mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 06:30:48 +08:00
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:
parent
52679a7cfc
commit
92bfad3e34
3 changed files with 89 additions and 33 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
# httpx.ASGITransport is async-only: run the in-process call as a coroutine.
|
||||||
|
async def _go() -> httpx.Response:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=app),
|
transport=httpx.ASGITransport(app=app),
|
||||||
base_url="http://mcp.internal",
|
base_url="http://mcp.internal",
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
trust_env=False,
|
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,10 +207,11 @@ 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,
|
||||||
|
timeout=to,
|
||||||
params=merged or None,
|
params=merged or None,
|
||||||
json=body,
|
json=body,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue