Skip to content

Commit d54e24d

Browse files
SIDDHESH1564ntkathole
authored andcommitted
refactor(mcp): Update MCP server handler for audit logging
Signed-off-by: Siddhesh Khairnar <khairnarsiddhesh4057@gmail.com>
1 parent e82daf5 commit d54e24d

2 files changed

Lines changed: 203 additions & 126 deletions

File tree

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

Lines changed: 52 additions & 28 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
@@ -28,9 +28,13 @@
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

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

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

Comments
 (0)