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,128 @@
|
||||
"""A connection-only stdio MCP bridge to a user-started Artemis web service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
from mcp import ClientSession, McpError, types
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.models import InitializationOptions
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
from artemis.service_registry import MCP_PATH, TOKEN_HEADER, discover_service
|
||||
|
||||
|
||||
def load_catalog():
|
||||
return json.loads(Path(__file__).with_name("catalog.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def backend_session():
|
||||
info = await asyncio.to_thread(discover_service)
|
||||
async with httpx.AsyncClient(
|
||||
headers={TOKEN_HEADER: info["token"]},
|
||||
timeout=httpx.Timeout(3600, connect=5),
|
||||
trust_env=False,
|
||||
) as client:
|
||||
async with streamable_http_client(
|
||||
f"http://127.0.0.1:{info['port']}{MCP_PATH}", http_client=client
|
||||
) as (reader, writer, _):
|
||||
async with ClientSession(reader, writer) as session:
|
||||
initialized = await session.initialize()
|
||||
yield session, initialized
|
||||
|
||||
|
||||
def create_bridge() -> Server:
|
||||
server = Server("artemis")
|
||||
pairs = (
|
||||
(types.ListToolsRequest, types.ListToolsResult),
|
||||
(types.CallToolRequest, types.CallToolResult),
|
||||
(types.ListResourcesRequest, types.ListResourcesResult),
|
||||
(types.ListResourceTemplatesRequest, types.ListResourceTemplatesResult),
|
||||
(types.ReadResourceRequest, types.ReadResourceResult),
|
||||
(types.ListPromptsRequest, types.ListPromptsResult),
|
||||
(types.GetPromptRequest, types.GetPromptResult),
|
||||
)
|
||||
|
||||
def forward(result_type):
|
||||
async def handler(request):
|
||||
# A fresh HTTP session per request makes the next request reconnect
|
||||
# after a backend restart. Never replay a possibly applied tool call.
|
||||
try:
|
||||
async with backend_session() as (session, _):
|
||||
# Incoming SDK requests retain the outer JSON-RPC id/jsonrpc
|
||||
# as extras. The upstream session must allocate its own id.
|
||||
forwarded = type(request).model_validate(
|
||||
request.model_dump(include={"method", "params"}, exclude_none=True)
|
||||
)
|
||||
result = await session.send_request(types.ClientRequest(forwarded), result_type)
|
||||
return types.ServerResult(result)
|
||||
except (OSError, RuntimeError, httpx.HTTPError, ExceptionGroup, McpError) as exc:
|
||||
logging.getLogger(__name__).debug("Shared MCP request failed", exc_info=True)
|
||||
if isinstance(request, types.ListToolsRequest):
|
||||
return types.ServerResult(types.ListToolsResult(tools=load_catalog()["tools"]))
|
||||
if isinstance(request, types.CallToolRequest):
|
||||
return types.ServerResult(
|
||||
types.CallToolResult(
|
||||
isError=True,
|
||||
content=[
|
||||
types.TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
"ARTEMIS 主程序未启动、版本尚未重载或连接已断开。"
|
||||
"请由用户运行 start.bat / start-cn.bat 或 artemis ui。"
|
||||
"MCP 不会自动启动主程序。本次调用不会自动重试;"
|
||||
"若已提交任务,请先查询状态,避免重复执行。"
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
raise RuntimeError(
|
||||
"ARTEMIS 主程序连接失败或已断开。请确认网页服务正在运行。"
|
||||
"本次调用不会自动重试;若提交过任务,请先查询任务状态,避免重复执行。"
|
||||
) from exc
|
||||
|
||||
return handler
|
||||
|
||||
for request_type, result_type in pairs:
|
||||
server.request_handlers[request_type] = forward(result_type)
|
||||
return server
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
from artemis import __version__
|
||||
|
||||
# An offline backend must never prevent the host agent session from opening.
|
||||
# The shipped catalog is schema-checked against the real tools by CI.
|
||||
options = InitializationOptions(
|
||||
server_name="artemis",
|
||||
server_version=__version__,
|
||||
capabilities=types.ServerCapabilities(tools=types.ToolsCapability(listChanged=False)),
|
||||
instructions=load_catalog()["instructions"],
|
||||
)
|
||||
server = create_bridge()
|
||||
async with stdio_server() as (reader, writer):
|
||||
await server.run(reader, writer, options)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
asyncio.run(run())
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
except (OSError, ValueError, RuntimeError, httpx.HTTPError, ExceptionGroup, McpError) as exc:
|
||||
# stdout is exclusively MCP JSON-RPC. No agent/device runtime fallback.
|
||||
print(f"ARTEMIS MCP 无法连接:{exc}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user