alexcastgonz 31e7b4ae4f
fix(mcp): keep a tool argument named runtime out of LangGraph's runtime injection (#7677)
* fix(mcp): keep a tool argument named runtime out of LangGraph's runtime injection

The pooled stdio wrapper and the background-submit wrapper took LangGraph's
ToolRuntime in a parameter called `runtime`. ToolNode injects by that name, so a
tool whose own input schema has a `runtime` property had the model's value
replaced by the ToolRuntime and then lost: the server received the other
arguments only, and TaskSubmitRequest.arguments missed it too.

Name the injected parameter `deerflow_runtime` in both wrappers (it is still
detected through its ToolRuntime annotation) and read it under that name in the
personal-MCP ownership guard. Existing tests that call the wrappers pass the new
name; two ToolNode tests cover a `runtime` business argument on each path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(mcp): read the injected runtime under the right name in the personal-tool guard

The personal-tool ownership guard read kwargs["deerflow_runtime"], but only
the pooled stdio wrapper was renamed; personal HTTP/SSE tools still carry
langchain-mcp-adapters' `runtime` parameter, so the guard never saw the
authoritative runtime user for them. Look the parameter name up per tool, and
update the mcp AGENTS.md note on how the runtime is injected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 12:45:14 +08:00

78 lines
3.0 KiB
Python

"""Load only the caller's personal tools; never publish them in the global cache."""
from __future__ import annotations
import asyncio
import functools
import inspect
import threading
from langchain_core.tools import ToolException
from deerflow.mcp.personal_access import require_personal_mcp_access
from deerflow.mcp.user_config import PersonalMcpConfigSnapshot, load_user_mcp_config, load_user_mcp_config_if_changed
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.tools.mcp_metadata import get_mcp_source
from deerflow.tools.sync import make_sync_tool_wrapper
def _runtime_parameter(coroutine) -> str:
"""Name of the parameter LangGraph injects the ``ToolRuntime`` into.
Pooled stdio wrappers expose ``deerflow_runtime`` so a business argument
named ``runtime`` survives; HTTP/SSE tools keep ``langchain-mcp-adapters``'
own ``runtime`` parameter.
"""
try:
parameters = inspect.signature(coroutine).parameters
except (TypeError, ValueError):
return "runtime"
return "deerflow_runtime" if "deerflow_runtime" in parameters else "runtime"
def _guard(tool, owner: str, server_name: str):
original = tool.coroutine
runtime_parameter = _runtime_parameter(original)
snapshot: PersonalMcpConfigSnapshot | None = None
snapshot_lock = threading.Lock()
def current_connection():
nonlocal snapshot
with snapshot_lock:
snapshot = load_user_mcp_config_if_changed(owner, snapshot)
server = snapshot.config.mcp_servers.get(server_name)
return server if server is not None and server.enabled else None
@functools.wraps(original)
async def invoke(*args, **kwargs):
if resolve_runtime_user_id(kwargs.get(runtime_parameter)) != owner:
raise ToolException("This MCP connection belongs to another user")
server = await asyncio.to_thread(current_connection)
if server is None:
raise ToolException("This personal MCP connection was changed, disabled or removed; start a new run")
await require_personal_mcp_access(owner, server)
return await original(*args, **kwargs)
return tool.model_copy(update={"coroutine": invoke, "func": make_sync_tool_wrapper(invoke, tool.name)})
async def _load(owner: str, config):
from deerflow.mcp.tools import get_mcp_tools
tools = await get_mcp_tools(config, personal_user_id=owner)
return [_guard(tool, owner, source["server_name"]) for tool in tools if (source := get_mcp_source(tool))]
def get_user_mcp_tools():
"""Discover from one literal snapshot, with an ownership check on every call.
Deliberately keep personal discovery out of the deployment's singleton
cache. Stdio session reuse remains owner/thread/revision-scoped.
"""
owner = resolve_runtime_user_id(None)
config = load_user_mcp_config(owner)
if not config.get_enabled_mcp_servers():
return [], config
tools = make_sync_tool_wrapper(_load, "personal MCP discovery")(owner, config)
return tools, config