186 lines
7.6 KiB
Python
186 lines
7.6 KiB
Python
"""Task-owned external MCP transport. A timed-out write is never replayed."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from contextlib import AsyncExitStack
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from mcp import ClientSession, StdioServerParameters
|
|
from mcp.client.stdio import stdio_client
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
|
|
from artemis.config.settings import settings
|
|
from artemis.plugins.models import PluginManifest, PluginProfile, ServerConfig
|
|
|
|
|
|
def resolve_server(
|
|
manifest: PluginManifest, profile: PluginProfile, directory: Path
|
|
) -> ServerConfig:
|
|
if manifest.server is None:
|
|
raise ValueError("此插件不包含 MCP 服务")
|
|
values = {name: field.default for name, field in manifest.config.items() if not field.secret}
|
|
values.update(profile.values)
|
|
|
|
def substitute(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return {k: substitute(v) for k, v in value.items()}
|
|
if isinstance(value, list):
|
|
return [substitute(v) for v in value]
|
|
if not isinstance(value, str):
|
|
return value
|
|
|
|
def replace(match: re.Match) -> str:
|
|
key = match.group(1)
|
|
if key == "plugin_dir":
|
|
return str(directory)
|
|
if key.startswith("config.") and key[7:] in values:
|
|
return values[key[7:]]
|
|
if key.startswith("secret.") and key[7:] in profile.secrets:
|
|
return profile.secrets[key[7:]].get_secret_value()
|
|
raise ValueError(f"插件配置引用无法解析:{key}")
|
|
|
|
return re.sub(r"\$\{([^}]+)\}", replace, value)
|
|
|
|
return ServerConfig.model_validate(substitute(manifest.server.model_dump()))
|
|
|
|
|
|
class PluginSession:
|
|
"""One owner task enters/exits anyio transport scopes, even during cancellation."""
|
|
|
|
def __init__(self, server: ServerConfig, directory: Path):
|
|
self.server = server
|
|
self.directory = directory
|
|
self.tools: list[Any] = []
|
|
self._queue: asyncio.Queue = asyncio.Queue()
|
|
self._ready = asyncio.Event()
|
|
self._owner: asyncio.Task | None = None
|
|
self._error: BaseException | None = None
|
|
self.stop_uncertain = False
|
|
|
|
async def start(self) -> PluginSession:
|
|
self._owner = asyncio.create_task(self._run(), name="artemis-plugin-session")
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._ready.wait(), settings.ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS
|
|
)
|
|
if self._error:
|
|
raise RuntimeError(
|
|
"插件 MCP 启动失败,请检查命令、连接地址及必填配置"
|
|
) from self._error
|
|
return self
|
|
except BaseException:
|
|
await self.aclose()
|
|
raise
|
|
|
|
async def _run(self):
|
|
active = None
|
|
try:
|
|
async with AsyncExitStack() as stack:
|
|
if self.server.transport == "stdio":
|
|
# A plugin can print credentials to stderr; never forward it to task reports.
|
|
errlog = stack.enter_context(open(os.devnull, "w"))
|
|
streams = await stack.enter_async_context(
|
|
stdio_client(
|
|
StdioServerParameters(
|
|
command=self.server.command,
|
|
args=self.server.args,
|
|
env=self.server.env,
|
|
cwd=str(self.directory),
|
|
),
|
|
errlog=errlog,
|
|
)
|
|
)
|
|
else:
|
|
client = await stack.enter_async_context(
|
|
httpx.AsyncClient(
|
|
headers=self.server.headers,
|
|
timeout=settings.ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS,
|
|
follow_redirects=False,
|
|
)
|
|
)
|
|
streams = await stack.enter_async_context(
|
|
streamable_http_client(self.server.url, http_client=client)
|
|
)
|
|
session = await stack.enter_async_context(ClientSession(streams[0], streams[1]))
|
|
await session.initialize()
|
|
cursor = None
|
|
for _ in range(20):
|
|
response = await session.list_tools(cursor=cursor)
|
|
self.tools.extend(response.tools)
|
|
cursor = response.nextCursor
|
|
if not cursor:
|
|
break
|
|
else:
|
|
raise ValueError("插件工具列表分页超过限制")
|
|
self._ready.set()
|
|
while True:
|
|
item = await self._queue.get()
|
|
if item is None:
|
|
return
|
|
active, name, arguments = item
|
|
if active.cancelled():
|
|
active = None
|
|
continue
|
|
# On timeout retire the whole connection: MCP may deliver a late response.
|
|
result = await asyncio.wait_for(
|
|
session.call_tool(name, arguments),
|
|
settings.ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS,
|
|
)
|
|
if not active.done():
|
|
active.set_result(result)
|
|
active = None
|
|
except BaseException as exc:
|
|
self._error = exc
|
|
if active is not None and self.server.transport == "streamable_http":
|
|
self.stop_uncertain = True
|
|
finally:
|
|
self._ready.set()
|
|
if active is not None and not active.done():
|
|
active.set_exception(
|
|
RuntimeError(
|
|
"插件调用中断或超时,执行结果不确定;请先观察状态,不能盲目重放操作"
|
|
)
|
|
)
|
|
while not self._queue.empty():
|
|
item = self._queue.get_nowait()
|
|
if item is not None and not item[0].done():
|
|
item[0].set_exception(RuntimeError("插件连接已关闭,此调用尚未执行"))
|
|
|
|
async def call(self, name: str, arguments: dict):
|
|
if self._owner is None or self._owner.done() or self._error is not None:
|
|
raise RuntimeError("插件连接已关闭,不能继续执行")
|
|
future = asyncio.get_running_loop().create_future()
|
|
self._queue.put_nowait((future, name, arguments))
|
|
try:
|
|
# Bound the caller as well as the protocol operation: transport context
|
|
# cleanup may itself stall after the inner request times out.
|
|
return await asyncio.wait_for(
|
|
future,
|
|
settings.ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS
|
|
+ settings.ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS,
|
|
)
|
|
except TimeoutError:
|
|
await self.aclose()
|
|
raise RuntimeError("插件响应或清理超时,执行结果不确定") from None
|
|
except asyncio.CancelledError:
|
|
# Guidance/pause must stop the transport before the task releases shared resources.
|
|
await self.aclose()
|
|
raise
|
|
|
|
async def aclose(self):
|
|
owner = self._owner
|
|
if owner is None or owner.done():
|
|
return
|
|
owner.cancel()
|
|
done, _ = await asyncio.wait(
|
|
{owner}, timeout=settings.ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS
|
|
)
|
|
if not done:
|
|
self.stop_uncertain = True
|
|
raise RuntimeError("插件尚未确认停止,不能释放其占用资源")
|