mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 04:24:17 +02:00
Forward hop-safe request metadata for proxied resources, templates, and prompts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1f009a12fe
commit
a6cd3fdff1
2 changed files with 79 additions and 12 deletions
|
|
@ -161,6 +161,19 @@ def _forwardable_request_meta(ctx: Context | None) -> dict[str, Any] | None:
|
|||
return forwarded or None
|
||||
|
||||
|
||||
def _session_request_meta(
|
||||
meta: dict[str, Any] | None,
|
||||
) -> mcp_types.RequestParamsMeta | None:
|
||||
"""Adapt forwardable metadata for a direct backend-session call.
|
||||
|
||||
Direct session calls bypass the high-level client mixins, so trace context
|
||||
is injected here, matching what the mixins do on the legacy client paths.
|
||||
"""
|
||||
return cast(
|
||||
"mcp_types.RequestParamsMeta | None", inject_trace_context(meta) or None
|
||||
)
|
||||
|
||||
|
||||
async def _relay_read_resource(
|
||||
client: Client, uri: str, ctx: Context | None
|
||||
) -> (
|
||||
|
|
@ -174,15 +187,15 @@ async def _relay_read_resource(
|
|||
to forward, instead of the high-level client trying to answer it here — the
|
||||
proxy has no back-channel to the real user, so driving it fails outright.
|
||||
The inbound request's continuation state travels down so the backend guard
|
||||
sees the client's answers on its own `ctx.input_responses`. Trace context
|
||||
still propagates: the SDK's JSON-RPC dispatcher injects it on every outgoing
|
||||
request (SEP-414), below whichever client layer issued the call.
|
||||
sees the client's answers on its own `ctx.input_responses`.
|
||||
"""
|
||||
meta = _forwardable_request_meta(ctx)
|
||||
if client.protocol_version not in MODERN_PROTOCOL_VERSIONS:
|
||||
return await client.read_resource(uri)
|
||||
return await client.read_resource(uri, meta=meta)
|
||||
result = await client._await_with_session_monitoring(
|
||||
client.session.read_resource(
|
||||
uri,
|
||||
meta=_session_request_meta(meta),
|
||||
input_responses=ctx.input_responses if ctx else None,
|
||||
request_state=ctx.request_state if ctx else None,
|
||||
allow_input_required=True,
|
||||
|
|
@ -357,10 +370,7 @@ class ProxyTool(Tool):
|
|||
# round. Forward the inbound request's continuation state
|
||||
# down so the backend guard tool sees the client's answers
|
||||
# on its own `ctx.input_responses` / `ctx.request_state`.
|
||||
request_meta = cast(
|
||||
"mcp_types.RequestParamsMeta | None",
|
||||
inject_trace_context(meta) or None,
|
||||
)
|
||||
request_meta = _session_request_meta(meta)
|
||||
# SEP-2243: a modern backend rejects a `tools/call` whose
|
||||
# `x-mcp-header` argument is not mirrored into an `Mcp-Param-*`
|
||||
# header. The SDK client emits those headers only for tools it
|
||||
|
|
@ -731,6 +741,7 @@ class ProxyPrompt(Prompt):
|
|||
ctx = get_context()
|
||||
async with client:
|
||||
_stash_proxy_request_context(client, ctx)
|
||||
meta = _forwardable_request_meta(ctx)
|
||||
if client.protocol_version in MODERN_PROTOCOL_VERSIONS:
|
||||
# See `_relay_read_resource`: surface a backend guard's ask
|
||||
# instead of trying to answer it inside the proxy.
|
||||
|
|
@ -738,6 +749,7 @@ class ProxyPrompt(Prompt):
|
|||
client.session.get_prompt(
|
||||
backend_name,
|
||||
arguments,
|
||||
meta=_session_request_meta(meta),
|
||||
input_responses=ctx.input_responses if ctx else None,
|
||||
request_state=ctx.request_state if ctx else None,
|
||||
allow_input_required=True,
|
||||
|
|
@ -747,7 +759,7 @@ class ProxyPrompt(Prompt):
|
|||
return InputRequiredPromptResult(raw)
|
||||
result = raw
|
||||
else:
|
||||
result = await client.get_prompt(backend_name, arguments)
|
||||
result = await client.get_prompt(backend_name, arguments, meta=meta)
|
||||
# Convert GetPromptResult to PromptResult, preserving meta from result
|
||||
# (not the static prompt meta which includes fastmcp tags)
|
||||
# Convert PromptMessages to Messages
|
||||
|
|
|
|||
|
|
@ -46,14 +46,32 @@ class _FrontendExtension(ClientExtension):
|
|||
def _recording_backend(seen: dict[str, _RecordedRequest]) -> FastMCP:
|
||||
backend = FastMCP("metadata-backend")
|
||||
|
||||
@backend.tool
|
||||
def inspect_tool(ctx: Context) -> str:
|
||||
def record(operation: str, ctx: Context) -> None:
|
||||
request_context = ctx.request_context
|
||||
assert request_context is not None
|
||||
seen["tool"] = _RecordedRequest(
|
||||
seen[operation] = _RecordedRequest(
|
||||
protocol_version=request_context.protocol_version,
|
||||
meta=dict(request_context.meta or {}),
|
||||
)
|
||||
|
||||
@backend.tool
|
||||
def inspect_tool(ctx: Context) -> str:
|
||||
record("tool", ctx)
|
||||
return "ok"
|
||||
|
||||
@backend.resource("data://metadata")
|
||||
def inspect_resource(ctx: Context) -> str:
|
||||
record("resource", ctx)
|
||||
return "ok"
|
||||
|
||||
@backend.resource("data://items/{item_id}")
|
||||
def inspect_template(item_id: str, ctx: Context) -> str:
|
||||
record("template", ctx)
|
||||
return "ok"
|
||||
|
||||
@backend.prompt
|
||||
def inspect_prompt(ctx: Context) -> str:
|
||||
record("prompt", ctx)
|
||||
return "ok"
|
||||
|
||||
return backend
|
||||
|
|
@ -134,3 +152,40 @@ async def test_forwarded_tool_meta_stays_hop_safe(
|
|||
assert isinstance(record.meta["progressToken"], str | int)
|
||||
assert record.meta["example.com/vendor"] == {"request": "kept"}
|
||||
_assert_backend_connection_meta(record, backend_is_modern)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("client_class", [ProxyClient, Client])
|
||||
@pytest.mark.parametrize(
|
||||
("backend_mode", "backend_is_modern"),
|
||||
[("auto", True), ("legacy", False)],
|
||||
)
|
||||
@pytest.mark.parametrize("operation", ["resource", "template", "prompt"])
|
||||
async def test_non_tool_requests_forward_hop_safe_metadata(
|
||||
operation: str,
|
||||
backend_mode: str,
|
||||
backend_is_modern: bool,
|
||||
client_class: type[Client],
|
||||
):
|
||||
seen: dict[str, _RecordedRequest] = {}
|
||||
proxy = _proxy(
|
||||
_recording_backend(seen), backend_mode=backend_mode, client_class=client_class
|
||||
)
|
||||
meta = {"example.com/vendor": {"operation": operation}}
|
||||
|
||||
async with Client(
|
||||
proxy,
|
||||
mode="auto",
|
||||
client_info=FRONT_INFO,
|
||||
extensions=[_FrontendExtension()],
|
||||
) as client:
|
||||
if operation == "resource":
|
||||
await client.read_resource("data://metadata", meta=meta)
|
||||
elif operation == "template":
|
||||
await client.read_resource("data://items/42", meta=meta)
|
||||
else:
|
||||
await client.get_prompt("inspect_prompt", meta=meta)
|
||||
|
||||
record = seen[operation]
|
||||
assert (record.protocol_version in MODERN_PROTOCOL_VERSIONS) is backend_is_modern
|
||||
assert record.meta["example.com/vendor"] == {"operation": operation}
|
||||
_assert_backend_connection_meta(record, backend_is_modern)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue