diff --git a/tests/tools/test_mcp_structured_content.py b/tests/tools/test_mcp_structured_content.py index 94e89e736e..7ce59324de 100644 --- a/tests/tools/test_mcp_structured_content.py +++ b/tests/tools/test_mcp_structured_content.py @@ -25,10 +25,12 @@ class _FakeCallToolResult: MCP SDK Pydantic model (``mcp.types.CallToolResult``). """ - def __init__(self, content, is_error=False, structuredContent=None): + def __init__(self, content, is_error=False, structuredContent=None, meta=None): self.content = content self.isError = is_error self.structuredContent = structuredContent + # Real SDK exposes the wire ``_meta`` field as ``.meta`` (Pydantic alias). + self.meta = meta def _fake_run_on_mcp_loop(coro_or_factory, timeout=30): @@ -107,3 +109,109 @@ class TestStructuredContentPreservation: raw = handler({}) data = json.loads(raw) assert data["result"] == payload + + +class TestMetaPassthrough: + """Server ``_meta`` is surfaced, minus protocol-reserved keys. + + Ported from MoonshotAI/kimi-code#2596/#2600. + """ + + def test_vendor_meta_passes_through(self, _patch_mcp_server): + session = _patch_mcp_server + session.call_tool = AsyncMock( + return_value=_FakeCallToolResult( + content=[_FakeContentBlock("done")], + meta={"com.example/handoff": {"url": "https://x"}}, + ) + ) + handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0) + data = json.loads(handler({})) + assert data["result"] == "done" + assert data["_meta"] == {"com.example/handoff": {"url": "https://x"}} + + def test_reserved_meta_keys_dropped(self, _patch_mcp_server): + session = _patch_mcp_server + session.call_tool = AsyncMock( + return_value=_FakeCallToolResult( + content=[_FakeContentBlock("done")], + meta={ + "modelcontextprotocol.io/progress": 1, + "tools.mcp.com/trace": "x", + "com.example.mcp/vendor": "keep", # trailing mcp label = vendor ns + "unprefixed": "keep", + }, + ) + ) + handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0) + data = json.loads(handler({})) + assert data["_meta"] == { + "com.example.mcp/vendor": "keep", + "unprefixed": "keep", + } + + def test_all_reserved_meta_omits_field(self, _patch_mcp_server): + session = _patch_mcp_server + session.call_tool = AsyncMock( + return_value=_FakeCallToolResult( + content=[_FakeContentBlock("done")], + meta={"mcp.io/internal": True}, + ) + ) + handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0) + data = json.loads(handler({})) + assert data == {"result": "done"} + + def test_meta_with_structured_content(self, _patch_mcp_server): + session = _patch_mcp_server + session.call_tool = AsyncMock( + return_value=_FakeCallToolResult( + content=[_FakeContentBlock("txt")], + structuredContent={"ok": True}, + meta={"com.example/k": "v"}, + ) + ) + handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0) + data = json.loads(handler({})) + assert data == { + "result": "txt", + "structuredContent": {"ok": True}, + "_meta": {"com.example/k": "v"}, + } + + def test_non_serializable_meta_dropped(self, _patch_mcp_server): + session = _patch_mcp_server + session.call_tool = AsyncMock( + return_value=_FakeCallToolResult( + content=[_FakeContentBlock("done")], + meta={"com.example/obj": object()}, + ) + ) + handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0) + data = json.loads(handler({})) + assert data == {"result": "done"} + + def test_non_dict_meta_ignored(self, _patch_mcp_server): + session = _patch_mcp_server + session.call_tool = AsyncMock( + return_value=_FakeCallToolResult( + content=[_FakeContentBlock("done")], + meta="not-a-dict", + ) + ) + handler = mcp_tool._make_tool_handler("test-server", "my-tool", 30.0) + data = json.loads(handler({})) + assert data == {"result": "done"} + + +class TestReservedMetaKeyPredicate: + def test_reserved_prefixes(self): + assert mcp_tool._is_reserved_mcp_meta_key("modelcontextprotocol.io/x") + assert mcp_tool._is_reserved_mcp_meta_key("mcp.dev/x") + assert mcp_tool._is_reserved_mcp_meta_key("tools.mcp.com/x") + + def test_vendor_and_unprefixed_not_reserved(self): + assert not mcp_tool._is_reserved_mcp_meta_key("com.example.mcp/x") # trailing label + assert not mcp_tool._is_reserved_mcp_meta_key("com.example/x") + assert not mcp_tool._is_reserved_mcp_meta_key("plain-key") + assert not mcp_tool._is_reserved_mcp_meta_key("/leading-slash") diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 80db308460..3c2a5b4d9e 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -884,6 +884,39 @@ def _wrap_command_with_watchdog(command: str, args: list) -> tuple[str, list]: # --------------------------------------------------------------------------- +def _is_reserved_mcp_meta_key(key: str) -> bool: + """Return True if an MCP ``_meta`` key uses a protocol-reserved prefix. + + Per the MCP spec's key-name rules, a prefix is reserved when a + ``modelcontextprotocol`` or ``mcp`` label is followed by at least one + more label (``modelcontextprotocol.io/...``, ``tools.mcp.com/...``). + A trailing reserved word (``com.example.mcp/...``) is a legitimate + vendor namespace and passes through. Ported from + MoonshotAI/kimi-code#2600. + """ + slash = key.find("/") + if slash <= 0: + return False + labels = key[:slash].split(".") + return any( + label in ("modelcontextprotocol", "mcp") and i < len(labels) - 1 + for i, label in enumerate(labels) + ) + + +def _strip_reserved_meta_keys(meta) -> "Optional[Dict[str, Any]]": + """Drop protocol-reserved keys from a tool result's ``_meta`` mapping. + + Returns the filtered dict, or ``None`` when there is nothing + model-facing left (or the input wasn't a mapping). + """ + if not isinstance(meta, dict): + return None + out = {k: v for k, v in meta.items() + if isinstance(k, str) and not _is_reserved_mcp_meta_key(k)} + return out or None + + def _mcp_image_extension_for_mime_type(mime_type: str) -> str: """Return a reasonable file extension for an MCP image MIME type.""" import mimetypes @@ -5509,14 +5542,39 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float): # MCP spec: content is model-oriented (text), structuredContent # is machine-oriented (JSON metadata). For an AI agent, content # is the primary payload; structuredContent supplements it. + # + # Server-level `_meta` is also surfaced (ported from + # MoonshotAI/kimi-code#2596): servers return namespaced metadata + # there (validated contracts, browser-handoff payloads, ...) that + # was previously invisible to the agent. Protocol-reserved keys + # are dropped first (kimi-code#2600) — per the MCP spec's key-name + # rules a prefix is reserved when a `modelcontextprotocol` or + # `mcp` label is followed by at least one more label (e.g. + # `modelcontextprotocol.io/...`, `tools.mcp.com/...`); those carry + # host/protocol plumbing, not model-facing data. Unprefixed and + # vendor-namespaced keys (`com.example.mcp/...`) pass through — + # their semantics belong to the server. structured = getattr(result, "structuredContent", None) - if structured is not None: + meta = _strip_reserved_meta_keys(getattr(result, "meta", None)) + if structured is not None or meta is not None: + payload: Dict[str, Any] = {} if text_result: - return json.dumps({ - "result": text_result, - "structuredContent": structured, - }, ensure_ascii=False) - return json.dumps({"result": structured}, ensure_ascii=False) + payload["result"] = text_result + if structured is not None: + if text_result: + payload["structuredContent"] = structured + else: + payload["result"] = structured + if meta is not None: + payload["_meta"] = meta + if "result" not in payload: + payload["result"] = text_result + try: + return json.dumps(payload, ensure_ascii=False) + except (TypeError, ValueError): + # Non-serializable metadata: drop the extras rather than + # failing the whole tool call. + return json.dumps({"result": text_result}, ensure_ascii=False) return json.dumps({"result": text_result}, ensure_ascii=False) def _call_once():