Port from MoonshotAI/kimi-code#2596/#2600: surface MCP tool-result _meta to the model, minus protocol-reserved keys
MCP tool results carry a server _meta mapping (exposed as .meta by the Python SDK) alongside structuredContent. Servers return namespaced machine-readable contracts there (validated payloads, browser-handoff URLs); Hermes previously dropped the field entirely, so that data was invisible to the agent. Now _meta is included in the JSON tool output, after filtering protocol-reserved keys 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/...). Vendor namespaces with a trailing reserved word (com.example.mcp/...) and unprefixed keys pass through. Non-serializable metadata drops the extras rather than failing the call.
This commit is contained in:
@@ -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")
|
||||
|
||||
+64
-6
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user