mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 00:23:19 +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
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -141,21 +143,43 @@ def _default_timeout() -> float:
|
|||
return 120.0
|
||||
|
||||
|
||||
def _client(timeout: float) -> httpx.Client:
|
||||
app = get_app()
|
||||
_MAIN_LOOP: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
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:
|
||||
base = (
|
||||
os.getenv("NETX_API_URL")
|
||||
or os.getenv("OCLAW_NETX_BASE_URL")
|
||||
or "http://127.0.0.1:8890"
|
||||
).rstrip("/")
|
||||
return httpx.Client(base_url=base, timeout=timeout, trust_env=False)
|
||||
return httpx.Client(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://mcp.internal",
|
||||
timeout=timeout,
|
||||
trust_env=False,
|
||||
)
|
||||
with httpx.Client(base_url=base, timeout=timeout, trust_env=False) as 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),
|
||||
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(
|
||||
|
|
@ -183,29 +207,30 @@ def http_json(
|
|||
if tok:
|
||||
headers["authorization"] = f"Bearer {tok}"
|
||||
try:
|
||||
with _client(to) as client:
|
||||
resp = client.request(
|
||||
method,
|
||||
path,
|
||||
params=merged or None,
|
||||
json=body,
|
||||
headers=headers,
|
||||
)
|
||||
text = resp.text
|
||||
if not resp.is_success:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"netx_http_{resp.status_code}",
|
||||
"detail": text[:800],
|
||||
"hint": "检查 API 地址、令牌 scope 和资源 ID。",
|
||||
}
|
||||
data = resp.json() if text else {}
|
||||
if isinstance(data, dict):
|
||||
data = localize_payload(data)
|
||||
data = redact(data, tokens=(tok,))
|
||||
else:
|
||||
data = {"raw": data}
|
||||
return {"ok": True, "data": data}
|
||||
resp = _request(
|
||||
get_app(),
|
||||
method,
|
||||
path,
|
||||
timeout=to,
|
||||
params=merged or None,
|
||||
json=body,
|
||||
headers=headers,
|
||||
)
|
||||
text = resp.text
|
||||
if not resp.is_success:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"netx_http_{resp.status_code}",
|
||||
"detail": text[:800],
|
||||
"hint": "检查 API 地址、令牌 scope 和资源 ID。",
|
||||
}
|
||||
data = resp.json() if text else {}
|
||||
if isinstance(data, dict):
|
||||
data = localize_payload(data)
|
||||
data = redact(data, tokens=(tok,))
|
||||
else:
|
||||
data = {"raw": data}
|
||||
return {"ok": True, "data": data}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
|
|
@ -257,7 +282,8 @@ def http_json_many(
|
|||
return idx, out
|
||||
|
||||
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):
|
||||
idx, out = fut.result()
|
||||
results[idx] = out
|
||||
|
|
|
|||
|
|
@ -77,8 +77,13 @@ async def _on_call_tool(
|
|||
try:
|
||||
# Handlers are sync and may nest ASGI calls; run off the event loop thread
|
||||
# so BaseHTTPMiddleware on the outer MCP request cannot deadlock.
|
||||
import asyncio
|
||||
|
||||
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))
|
||||
except Exception as exc:
|
||||
result = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue