Skip to content

Commit a8fefb2

Browse files
committed
refactor(mcp): Update MCP server handler for audit logging
Signed-off-by: Siddhesh Khairnar <khairnarsiddhesh4057@gmail.com>
1 parent 2321056 commit a8fefb2

2 files changed

Lines changed: 203 additions & 127 deletions

File tree

sdk/python/feast/infra/mcp_servers/mcp_server.py

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
This module provides MCP support for Feast by integrating with fastapi_mcp
55
to 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

1313
import logging
@@ -29,9 +29,13 @@
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

3640
class 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

203219
def _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

Comments
 (0)