44This module provides MCP support for Feast by integrating with fastapi_mcp
55to expose Feast functionality through the Model Context Protocol.
66
7- When audit logging is enabled, the ``tools/call `` handler on the low-level
8- MCP ``Server`` is wrapped so that every tool invocation is logged with
9- typed tool name, outcome, duration, and principal — without parsing raw
10- JSON-RPC bodies.
7+ When audit logging is enabled, the ``CallToolRequest `` handler on the
8+ low-level MCP ``Server`` is wrapped so that every tool invocation is
9+ logged with typed tool name, outcome, duration, and principal — without
10+ parsing raw JSON-RPC bodies.
1111"""
1212
1313import logging
2929 "Install it with: pip install fastapi_mcp"
3030 )
3131 MCP_AVAILABLE = False
32- # Create placeholder classes for testing
3332 FastApiMCP = None
3433
34+ try :
35+ from mcp .types import CallToolRequest as _CallToolRequest
36+ except ImportError :
37+ _CallToolRequest = None # type: ignore[assignment,misc]
38+
3539
3640class McpTransportNotSupportedError (RuntimeError ):
3741 pass
@@ -144,7 +148,6 @@ def add_mcp_support_to_app(
144148 )
145149 mcp .mount ()
146150 else :
147- # Defensive guard for programmatic callers.
148151 raise McpTransportNotSupportedError (
149152 f"Unsupported mcp_transport={ transport !r} . Expected 'sse' or 'http'."
150153 )
@@ -174,16 +177,29 @@ def add_mcp_support_to_app(
174177# ---------------------------------------------------------------------------
175178
176179
177- def _principal_from_mcp_context ( ctx : Any ) -> Any :
178- """Extract an ``AuditPrincipal`` from the MCP request context's HTTP headers .
180+ def _get_call_tool_handler_key ( ) -> Any :
181+ """Return the dict key used for CallToolRequest in ``server.request_handlers`` .
179182
180- Unlike REST endpoints the ``SecurityManager`` ``ContextVar`` is never
181- populated for MCP requests, so we read directly from the HTTP headers
182- that ``fastapi_mcp`` forwards into the request context.
183+ mcp 1.x uses the ``CallToolRequest`` *class* as the key in
184+ ``server.request_handlers``.
185+ """
186+ if _CallToolRequest is not None :
187+ return _CallToolRequest
188+ return None
189+
190+
191+ def _principal_from_mcp_context (server : Any ) -> Any :
192+ """Extract an ``AuditPrincipal`` from the MCP server's request context.
193+
194+ In mcp 1.x the request context is a ``ContextVar`` accessed via
195+ ``server.request_context``. The ``.request`` attribute carries the
196+ original Starlette/FastAPI ``Request`` that ``fastapi_mcp`` injects
197+ through ``ServerMessageMetadata(request_context=request)``.
183198 """
184199 from feast .audit .audit_logger import AuditPrincipal
185200
186201 try :
202+ ctx = server .request_context
187203 request = getattr (ctx , "request" , None )
188204 if request is None :
189205 return AuditPrincipal ()
@@ -201,42 +217,48 @@ def _principal_from_mcp_context(ctx: Any) -> Any:
201217
202218
203219def _wrap_call_tool_handler (mcp : "FastApiMCP" , audit : Any ) -> None :
204- """Wrap the MCP server's ``tools/call `` handler with audit logging.
220+ """Wrap the MCP server's ``CallToolRequest `` handler with audit logging.
205221
206- Operates at the protocol layer so that ``tool_name`` and error status
207- come as typed Python objects — no JSON-RPC body parsing required.
222+ In mcp 1.x the handler lives at
223+ ``server.request_handlers[CallToolRequest]`` and has the signature
224+ ``async def handler(req: CallToolRequest) -> ServerResult``. The
225+ JSON-RPC request_id is available on ``server.request_context``.
208226 """
209227 from feast .audit .audit_logger import AuditAction , AuditEvent , AuditSource
210228
211- handlers = getattr (mcp .server , "_request_handlers" , None )
229+ handler_key = _get_call_tool_handler_key ()
230+ handlers = getattr (mcp .server , "request_handlers" , None )
212231 if handlers is None :
213- logger .warning (
214- "Cannot wrap MCP call_tool handler: _request_handlers not found"
215- )
232+ logger .warning ("Cannot wrap MCP call_tool handler: request_handlers not found" )
216233 return
217234
218- original = handlers .get ("tools/call" )
219- if original is None :
220- logger .debug ("No tools/call handler registered; skipping audit wrapper" )
235+ if handler_key is None or handler_key not in handlers :
236+ logger .debug ("No CallToolRequest handler registered; skipping audit wrapper" )
221237 return
222238
223- async def audited_call_tool (ctx : Any , params : Any ) -> Any :
239+ original = handlers [handler_key ]
240+
241+ async def audited_call_tool (req : Any ) -> Any :
224242 from feast .audit .audit_logger import mcp_audit_request_id
225243
244+ params = getattr (req , "params" , None )
226245 tool_name = getattr (params , "name" , "" ) if params else ""
227246 request_id = audit .new_request_id ()
247+
228248 jsonrpc_id : Optional [str ] = None
229- if hasattr (ctx , "request_id" ):
230- jsonrpc_id = str (ctx .request_id )
249+ try :
250+ ctx = mcp .server .request_context
251+ if hasattr (ctx , "request_id" ):
252+ jsonrpc_id = str (ctx .request_id )
253+ except LookupError :
254+ pass
231255
232- # Propagate request_id so the internal REST call logged by
233- # AuditLoggingMiddleware uses the same identifier.
234256 token = mcp_audit_request_id .set (request_id )
235257 start = time .monotonic ()
236258 outcome = "success"
237259 error_detail = ""
238260 try :
239- result = await original (ctx , params )
261+ result = await original (req )
240262 if hasattr (result , "isError" ) and result .isError :
241263 outcome = "mcp_error"
242264 return result
@@ -247,12 +269,13 @@ async def audited_call_tool(ctx: Any, params: Any) -> Any:
247269 finally :
248270 duration_ms = (time .monotonic () - start ) * 1000.0
249271 mcp_audit_request_id .reset (token )
272+ principal = _principal_from_mcp_context (mcp .server )
250273 audit .log (
251274 AuditEvent (
252275 event_type = "mcp.tools.call" ,
253276 request_id = request_id ,
254277 jsonrpc_id = jsonrpc_id ,
255- principal = _principal_from_mcp_context ( ctx ) ,
278+ principal = principal ,
256279 source = AuditSource (transport = "mcp-http" ),
257280 action = AuditAction (mcp_tool = tool_name ),
258281 outcome = outcome ,
@@ -261,4 +284,4 @@ async def audited_call_tool(ctx: Any, params: Any) -> Any:
261284 )
262285 )
263286
264- handlers ["tools/call" ] = audited_call_tool
287+ handlers [handler_key ] = audited_call_tool
0 commit comments