Files
Artemis-Eapil/mcp_server/shared.py
T

132 lines
4.4 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",
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)