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
2828 "Install it with: pip install fastapi_mcp"
2929 )
3030 MCP_AVAILABLE = False
31- # Create placeholder classes for testing
3231 FastApiMCP = None
3332
33+ try :
34+ from mcp .types import CallToolRequest as _CallToolRequest
35+ except ImportError :
36+ _CallToolRequest = None # type: ignore[assignment,misc]
37+
3438
3539class McpTransportNotSupportedError (RuntimeError ):
3640 pass
@@ -48,7 +52,6 @@ def add_mcp_support_to_app(
4852 return None
4953
5054 try :
51- # Create MCP server from the FastAPI app
5255 mcp = FastApiMCP (
5356 app ,
5457 name = getattr (config , "mcp_server_name" , "feast-feature-store" ),
@@ -74,7 +77,6 @@ def add_mcp_support_to_app(
7477 )
7578 mcp .mount ()
7679 else :
77- # Defensive guard for programmatic callers.
7880 raise McpTransportNotSupportedError (
7981 f"Unsupported mcp_transport={ transport !r} . Expected 'sse' or 'http'."
8082 )
@@ -104,16 +106,29 @@ def add_mcp_support_to_app(
104106# ---------------------------------------------------------------------------
105107
106108
107- def _principal_from_mcp_context (ctx : Any ) -> Any :
108- """Extract an ``AuditPrincipal`` from the MCP request context's HTTP headers.
109+ def _get_call_tool_handler_key () -> Any :
110+ """Return the dict key used for CallToolRequest in ``server.request_handlers``.
111+
112+ mcp 1.x uses the ``CallToolRequest`` *class* as the key in
113+ ``server.request_handlers``.
114+ """
115+ if _CallToolRequest is not None :
116+ return _CallToolRequest
117+ return None
118+
119+
120+ def _principal_from_mcp_context (server : Any ) -> Any :
121+ """Extract an ``AuditPrincipal`` from the MCP server's request context.
109122
110- Unlike REST endpoints the ``SecurityManager`` ``ContextVar`` is never
111- populated for MCP requests, so we read directly from the HTTP headers
112- that ``fastapi_mcp`` forwards into the request context.
123+ In mcp 1.x the request context is a ``ContextVar`` accessed via
124+ ``server.request_context``. The ``.request`` attribute carries the
125+ original Starlette/FastAPI ``Request`` that ``fastapi_mcp`` injects
126+ through ``ServerMessageMetadata(request_context=request)``.
113127 """
114128 from feast .audit .audit_logger import AuditPrincipal
115129
116130 try :
131+ ctx = server .request_context
117132 request = getattr (ctx , "request" , None )
118133 if request is None :
119134 return AuditPrincipal ()
@@ -131,40 +146,48 @@ def _principal_from_mcp_context(ctx: Any) -> Any:
131146
132147
133148def _wrap_call_tool_handler (mcp : "FastApiMCP" , audit : Any ) -> None :
134- """Wrap the MCP server's ``tools/call `` handler with audit logging.
149+ """Wrap the MCP server's ``CallToolRequest `` handler with audit logging.
135150
136- Operates at the protocol layer so that ``tool_name`` and error status
137- come as typed Python objects — no JSON-RPC body parsing required.
151+ In mcp 1.x the handler lives at
152+ ``server.request_handlers[CallToolRequest]`` and has the signature
153+ ``async def handler(req: CallToolRequest) -> ServerResult``. The
154+ JSON-RPC request_id is available on ``server.request_context``.
138155 """
139156 from feast .audit .audit_logger import AuditAction , AuditEvent , AuditSource
140157
141- handlers = getattr (mcp .server , "_request_handlers" , None )
158+ handler_key = _get_call_tool_handler_key ()
159+ handlers = getattr (mcp .server , "request_handlers" , None )
142160 if handlers is None :
143- logger .warning ("Cannot wrap MCP call_tool handler: _request_handlers not found" )
161+ logger .warning ("Cannot wrap MCP call_tool handler: request_handlers not found" )
144162 return
145163
146- original = handlers .get ("tools/call" )
147- if original is None :
148- logger .debug ("No tools/call handler registered; skipping audit wrapper" )
164+ if handler_key is None or handler_key not in handlers :
165+ logger .debug ("No CallToolRequest handler registered; skipping audit wrapper" )
149166 return
150167
151- async def audited_call_tool (ctx : Any , params : Any ) -> Any :
168+ original = handlers [handler_key ]
169+
170+ async def audited_call_tool (req : Any ) -> Any :
152171 from feast .audit .audit_logger import mcp_audit_request_id
153172
173+ params = getattr (req , "params" , None )
154174 tool_name = getattr (params , "name" , "" ) if params else ""
155175 request_id = audit .new_request_id ()
176+
156177 jsonrpc_id : Optional [str ] = None
157- if hasattr (ctx , "request_id" ):
158- jsonrpc_id = str (ctx .request_id )
178+ try :
179+ ctx = mcp .server .request_context
180+ if hasattr (ctx , "request_id" ):
181+ jsonrpc_id = str (ctx .request_id )
182+ except LookupError :
183+ pass
159184
160- # Propagate request_id so the internal REST call logged by
161- # AuditLoggingMiddleware uses the same identifier.
162185 token = mcp_audit_request_id .set (request_id )
163186 start = time .monotonic ()
164187 outcome = "success"
165188 error_detail = ""
166189 try :
167- result = await original (ctx , params )
190+ result = await original (req )
168191 if hasattr (result , "isError" ) and result .isError :
169192 outcome = "mcp_error"
170193 return result
@@ -175,12 +198,13 @@ async def audited_call_tool(ctx: Any, params: Any) -> Any:
175198 finally :
176199 duration_ms = (time .monotonic () - start ) * 1000.0
177200 mcp_audit_request_id .reset (token )
201+ principal = _principal_from_mcp_context (mcp .server )
178202 audit .log (
179203 AuditEvent (
180204 event_type = "mcp.tools.call" ,
181205 request_id = request_id ,
182206 jsonrpc_id = jsonrpc_id ,
183- principal = _principal_from_mcp_context ( ctx ) ,
207+ principal = principal ,
184208 source = AuditSource (transport = "mcp-http" ),
185209 action = AuditAction (mcp_tool = tool_name ),
186210 outcome = outcome ,
@@ -189,4 +213,4 @@ async def audited_call_tool(ctx: Any, params: Any) -> Any:
189213 )
190214 )
191215
192- handlers ["tools/call" ] = audited_call_tool
216+ handlers [handler_key ] = audited_call_tool
0 commit comments