feat: add guided exploration and share user-started MCP backend
This commit is contained in:
1 parent
f86db1ced6
commit
ee256ad7ce
144 files changed
+8625
-2075
No files matched your search
@@ -0,0 +1,120 @@
|
||||
"""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 mcp.server.fastmcp import FastMCP
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
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 the tool coroutine instead of abandoning a thread per request.
|
||||
"""
|
||||
|
||||
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 主程序正在启动或关闭,请稍后重试。")
|
||||
future = asyncio.run_coroutine_threadsafe(fn(*args, **kwargs), 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 = FastMCP(
|
||||
"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)
|
||||
Reference in new issue
Block a user