Add dark theme, richer Markdown and readable plugin results. Improve Android input evidence, response integrity and diagnostics. Unify lifecycle, guidance receipts and evidence sources across clients. Keep direct action dispatch without an added AI quota approval layer. Align version metadata at 2.2.0 and bundle Helper 1.2.2. Validation: 3063 backend tests before the version bump; 44 after. Frontend: 317 tests and production build passed.
133 lines
4.5 KiB
Python
133 lines
4.5 KiB
Python
"""MCP tools hosted once inside the user-started web service."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from functools import wraps
|
|
import hmac
|
|
import inspect
|
|
import ipaddress
|
|
import logging
|
|
import threading
|
|
from typing import get_type_hints
|
|
|
|
from starlette.responses import JSONResponse
|
|
|
|
from artemis.mcp.tool_server import ArtemisFastMCP
|
|
from artemis.service_registry import TOKEN_HEADER
|
|
|
|
|
|
class ToolExecutor:
|
|
"""One shared tool loop; synchronous legacy HTTP calls cannot block the UI.
|
|
|
|
Device clients keep their loop affinity across MCP connections. Cancellation
|
|
propagates to async tools; dispatched synchronous writes may finish in their
|
|
worker thread, so callers must resolve the original request rather than retry it.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.loop = None
|
|
self.thread = None
|
|
|
|
def start(self):
|
|
ready = threading.Event()
|
|
|
|
def run():
|
|
self.loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(self.loop)
|
|
self.loop.call_soon(ready.set)
|
|
self.loop.run_forever()
|
|
self.loop.close()
|
|
|
|
self.thread = threading.Thread(target=run, name="artemis-shared-mcp", daemon=True)
|
|
self.thread.start()
|
|
ready.wait()
|
|
|
|
async def invoke(self, fn, *args, **kwargs):
|
|
if self.loop is None or not self.loop.is_running():
|
|
raise RuntimeError("ARTEMIS 主程序正在启动或关闭,请稍后重试。")
|
|
|
|
async def dispatch():
|
|
if inspect.iscoroutinefunction(fn):
|
|
return await fn(*args, **kwargs)
|
|
# Legacy tools use synchronous HTTP to call this same web service.
|
|
# Execute them off both event loops, and never call them speculatively
|
|
# to discover whether their return value is awaitable.
|
|
result = await asyncio.to_thread(fn, *args, **kwargs)
|
|
return await result if inspect.isawaitable(result) else result
|
|
|
|
future = asyncio.run_coroutine_threadsafe(dispatch(), self.loop)
|
|
return await asyncio.wrap_future(future)
|
|
|
|
async def stop(self):
|
|
if self.loop is None or not self.loop.is_running():
|
|
return
|
|
|
|
async def cancel_pending():
|
|
pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]
|
|
for task in pending:
|
|
task.cancel()
|
|
if pending:
|
|
await asyncio.gather(*pending, return_exceptions=True)
|
|
|
|
future = asyncio.run_coroutine_threadsafe(cancel_pending(), self.loop)
|
|
try:
|
|
await asyncio.wait_for(asyncio.wrap_future(future), timeout=5)
|
|
except TimeoutError:
|
|
logging.getLogger(__name__).warning("Timed out draining shared MCP tools on shutdown")
|
|
finally:
|
|
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
await asyncio.to_thread(self.thread.join, 2)
|
|
|
|
|
|
def create_shared_mcp():
|
|
from mcp_server.base import mcp as definitions
|
|
from mcp_server import tools
|
|
|
|
executor = ToolExecutor()
|
|
shared = ArtemisFastMCP(
|
|
"artemis",
|
|
strict_inputs=True,
|
|
instructions=definitions.instructions,
|
|
streamable_http_path="/",
|
|
json_response=True,
|
|
)
|
|
|
|
def wrap(fn):
|
|
@wraps(fn)
|
|
async def call(*args, **kwargs):
|
|
return await executor.invoke(fn, *args, **kwargs)
|
|
|
|
call.__annotations__ = get_type_hints(fn)
|
|
call.__signature__ = inspect.signature(fn, eval_str=True)
|
|
return call
|
|
|
|
for name in tools.__all__:
|
|
shared.add_tool(wrap(getattr(tools, name)))
|
|
return shared, executor
|
|
|
|
|
|
class LocalMcpBoundary:
|
|
"""Only authenticated loopback bridges may enter the shared tool runtime."""
|
|
|
|
def __init__(self, app, token: str):
|
|
self.app = app
|
|
self.token = token
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
if scope["type"] != "http":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
headers = dict(scope.get("headers", []))
|
|
supplied = headers.get(TOKEN_HEADER.lower().encode(), b"").decode("latin-1")
|
|
try:
|
|
local = ipaddress.ip_address((scope.get("client") or ("",))[0]).is_loopback
|
|
except ValueError:
|
|
local = False
|
|
if not local or not hmac.compare_digest(supplied, self.token):
|
|
await JSONResponse({"detail": "MCP 仅接受本机 ARTEMIS 连接。"}, status_code=403)(
|
|
scope, receive, send
|
|
)
|
|
return
|
|
await self.app(scope, receive, send)
|