MCP: fix in-process tool calls and keep caller token in batch calls

- httpx.ASGITransport is async-only; the sync client made every
  tools/call fail with "'ASGITransport' object has no attribute
  '__enter__'". Run the in-process request on the API loop from the
  tool worker thread instead.
- http_json_many fanned out on a plain thread pool without the caller's
  context, so batch requests fell back to the server MCP token file.
  Copy the context per request so RBAC uses the caller's token.
- Real-TCP regression: tools/call getTopologyTree must reach the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-16 22:09:28 +08:00
parent 52679a7cfc
commit 92bfad3e34
3 changed files with 89 additions and 33 deletions

View file

@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import contextvars
import json import json
import os import os
import re import re
@ -141,21 +143,43 @@ def _default_timeout() -> float:
return 120.0 return 120.0
def _client(timeout: float) -> httpx.Client: _MAIN_LOOP: asyncio.AbstractEventLoop | None = None
app = get_app()
def bind_main_loop(loop: asyncio.AbstractEventLoop) -> None:
"""Remember the API loop; tool handlers run in worker threads and submit ASGI calls to it."""
global _MAIN_LOOP
_MAIN_LOOP = loop
def _request(app: Any, method: str, path: str, *, timeout: float, **kwargs: Any) -> httpx.Response:
if app is None: if app is None:
base = ( base = (
os.getenv("NETX_API_URL") os.getenv("NETX_API_URL")
or os.getenv("OCLAW_NETX_BASE_URL") or os.getenv("OCLAW_NETX_BASE_URL")
or "http://127.0.0.1:8890" or "http://127.0.0.1:8890"
).rstrip("/") ).rstrip("/")
return httpx.Client(base_url=base, timeout=timeout, trust_env=False) with httpx.Client(base_url=base, timeout=timeout, trust_env=False) as client:
return httpx.Client( return client.request(method, path, **kwargs)
transport=httpx.ASGITransport(app=app),
base_url="http://mcp.internal", # httpx.ASGITransport is async-only: run the in-process call as a coroutine.
timeout=timeout, async def _go() -> httpx.Response:
trust_env=False, async with httpx.AsyncClient(
) transport=httpx.ASGITransport(app=app),
base_url="http://mcp.internal",
timeout=timeout,
trust_env=False,
) as client:
return await client.request(method, path, **kwargs)
loop = _MAIN_LOOP
if loop is not None and loop.is_running():
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run_coroutine_threadsafe(_go(), loop).result(timeout + 5)
raise RuntimeError("http_json must not be called on the event loop thread")
return asyncio.run(_go())
def http_json( def http_json(
@ -183,29 +207,30 @@ def http_json(
if tok: if tok:
headers["authorization"] = f"Bearer {tok}" headers["authorization"] = f"Bearer {tok}"
try: try:
with _client(to) as client: resp = _request(
resp = client.request( get_app(),
method, method,
path, path,
params=merged or None, timeout=to,
json=body, params=merged or None,
headers=headers, json=body,
) headers=headers,
text = resp.text )
if not resp.is_success: text = resp.text
return { if not resp.is_success:
"ok": False, return {
"error": f"netx_http_{resp.status_code}", "ok": False,
"detail": text[:800], "error": f"netx_http_{resp.status_code}",
"hint": "检查 API 地址、令牌 scope 和资源 ID。", "detail": text[:800],
} "hint": "检查 API 地址、令牌 scope 和资源 ID。",
data = resp.json() if text else {} }
if isinstance(data, dict): data = resp.json() if text else {}
data = localize_payload(data) if isinstance(data, dict):
data = redact(data, tokens=(tok,)) data = localize_payload(data)
else: data = redact(data, tokens=(tok,))
data = {"raw": data} else:
return {"ok": True, "data": data} data = {"raw": data}
return {"ok": True, "data": data}
except Exception as exc: except Exception as exc:
return { return {
"ok": False, "ok": False,
@ -257,7 +282,8 @@ def http_json_many(
return idx, out return idx, out
with ThreadPoolExecutor(max_workers=workers) as pool: with ThreadPoolExecutor(max_workers=workers) as pool:
futs = [pool.submit(_one, i, r) for i, r in enumerate(requests)] # Copy the caller's context so each request keeps the caller's token (RBAC).
futs = [pool.submit(contextvars.copy_context().run, _one, i, r) for i, r in enumerate(requests)]
for fut in as_completed(futs): for fut in as_completed(futs):
idx, out = fut.result() idx, out = fut.result()
results[idx] = out results[idx] = out

View file

@ -77,8 +77,13 @@ async def _on_call_tool(
try: try:
# Handlers are sync and may nest ASGI calls; run off the event loop thread # Handlers are sync and may nest ASGI calls; run off the event loop thread
# so BaseHTTPMiddleware on the outer MCP request cannot deadlock. # so BaseHTTPMiddleware on the outer MCP request cannot deadlock.
import asyncio
import anyio import anyio
from .asgi_client import bind_main_loop
bind_main_loop(asyncio.get_running_loop())
result = await anyio.to_thread.run_sync(call_tool, name, dict(args)) result = await anyio.to_thread.run_sync(call_tool, name, dict(args))
except Exception as exc: except Exception as exc:
result = { result = {

View file

@ -187,3 +187,28 @@ def test_mcp_tcp_httpx_initialize(live_mcp_port: int):
assert r.status_code == 200, (r.status_code, r.text[:300]) assert r.status_code == 200, (r.status_code, r.text[:300])
data = r.json() data = r.json()
assert data["result"]["serverInfo"]["name"] == "netx" assert data["result"]["serverInfo"]["name"] == "netx"
def test_mcp_tcp_tool_call_reaches_api(live_mcp_port: int):
"""tools/call must run the in-process API request (not just list tools)."""
body = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "getTopologyTree", "arguments": {}},
}
r = httpx.post(
f"http://127.0.0.1:{live_mcp_port}/mcp",
json=body,
headers={
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": "2024-11-05",
},
timeout=60.0,
trust_env=False,
)
assert r.status_code == 200, (r.status_code, r.text[:300])
result = r.json()["result"]
text = result["content"][0]["text"]
assert "netx_request_failed" not in text, text[:300]
assert not result.get("isError"), text[:300]