Files
Artemis-Eapil/mcp_server/bridge.py

129 lines
5.3 KiB
Python

"""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()