129 lines
5.3 KiB
Python
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()
|