mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
runtime: MCP stdio client and per-process connection manager
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
|
from plyngent.config.models import McpConfig as McpConfig
|
||||||
|
|
||||||
from .client_factory import InvalidHttpTimeoutError as InvalidHttpTimeoutError
|
from .client_factory import InvalidHttpTimeoutError as InvalidHttpTimeoutError
|
||||||
from .client_factory import ProviderNotSupportedError as ProviderNotSupportedError
|
from .client_factory import ProviderNotSupportedError as ProviderNotSupportedError
|
||||||
from .client_factory import create_client as create_client
|
from .client_factory import create_client as create_client
|
||||||
from .client_factory import normalize_http_timeout as normalize_http_timeout
|
from .client_factory import normalize_http_timeout as normalize_http_timeout
|
||||||
from .client_factory import provider_to_openai_config as provider_to_openai_config
|
from .client_factory import provider_to_openai_config as provider_to_openai_config
|
||||||
|
from .mcp_client import McpConnectionError as McpConnectionError
|
||||||
|
from .mcp_client import McpManager as McpManager
|
||||||
|
from .mcp_client import McpServerConnection as McpServerConnection
|
||||||
|
from .mcp_client import McpTool as McpTool
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""MCP client: Model Context Protocol over stdio (newline-delimited JSON-RPC).
|
||||||
|
|
||||||
|
One :class:`McpServerConnection` owns one spawned server subprocess and its
|
||||||
|
initialize handshake. :class:`McpManager` owns all configured connections for
|
||||||
|
the process: start, tool listing, tool calls, reconnect, shutdown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from plyngent.config.models import McpConfig, McpServerConfig
|
||||||
|
|
||||||
|
MCP_PROTOCOL_VERSION = "2025-06-18"
|
||||||
|
MCP_CLIENT_NAME = "plyngent"
|
||||||
|
DEFAULT_MCP_TIMEOUT = 30.0
|
||||||
|
_STDERR_TAIL_LINES = 32
|
||||||
|
_CLOSE_GRACE_SECONDS = 2.0
|
||||||
|
_MAX_TOOL_PAGES = 1024
|
||||||
|
|
||||||
|
type Json = dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
def _json_dict(raw: object) -> Json:
|
||||||
|
"""Narrow an untyped JSON payload to a string-keyed object mapping."""
|
||||||
|
return cast("Json", raw) if isinstance(raw, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_list(raw: object) -> list[object]:
|
||||||
|
"""Narrow an untyped JSON payload to a list of objects."""
|
||||||
|
return cast("list[object]", raw) if isinstance(raw, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _client_version() -> str:
|
||||||
|
try:
|
||||||
|
return importlib.metadata.version("plyngent")
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
class McpConnectionError(RuntimeError):
|
||||||
|
"""Server process failed to spawn, handshake, or answer in time."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class McpTool:
|
||||||
|
"""One tool advertised by a server (``tools/list`` entry)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
input_schema: Json
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_raw(cls, raw: object) -> McpTool:
|
||||||
|
entry = _json_dict(raw)
|
||||||
|
return cls(
|
||||||
|
name=str(entry.get("name", "")),
|
||||||
|
description=str(entry.get("description", "") or ""),
|
||||||
|
input_schema=_json_dict(entry.get("inputSchema")) or {"type": "object"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_content(result: Json) -> str:
|
||||||
|
"""Flatten a ``tools/call`` result payload into model-facing text."""
|
||||||
|
parts: list[str] = []
|
||||||
|
for item in _json_list(result.get("content")):
|
||||||
|
entry = _json_dict(item)
|
||||||
|
kind = str(entry.get("type", ""))
|
||||||
|
if kind == "text":
|
||||||
|
text = entry.get("text")
|
||||||
|
if isinstance(text, str):
|
||||||
|
parts.append(text)
|
||||||
|
elif kind == "resource":
|
||||||
|
parts.append(f"[resource] {json.dumps(entry, ensure_ascii=False)}")
|
||||||
|
else:
|
||||||
|
mime = entry.get("mimeType", "?")
|
||||||
|
parts.append(f"[{kind or 'unknown'}: {mime}]")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerConnection:
|
||||||
|
"""One server subprocess with an active MCP session (stdio transport)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
error: str | None
|
||||||
|
tools: list[McpTool]
|
||||||
|
tools_stale: bool
|
||||||
|
|
||||||
|
_config: McpServerConfig
|
||||||
|
_proc: asyncio.subprocess.Process | None
|
||||||
|
_reader_task: asyncio.Task[None] | None
|
||||||
|
_pending: dict[int, asyncio.Future[Json]]
|
||||||
|
_next_id: int
|
||||||
|
_stderr_tail: deque[str]
|
||||||
|
|
||||||
|
def __init__(self, name: str, config: McpServerConfig) -> None:
|
||||||
|
self.name = name
|
||||||
|
self._config = config
|
||||||
|
self._proc = None
|
||||||
|
self._reader_task = None
|
||||||
|
self._pending = {}
|
||||||
|
self._next_id = 1
|
||||||
|
self._stderr_tail = deque(maxlen=_STDERR_TAIL_LINES)
|
||||||
|
self.error = None
|
||||||
|
self.tools = []
|
||||||
|
self.tools_stale = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
if self.error is not None:
|
||||||
|
return f"error: {self.error}"
|
||||||
|
if self._proc is not None:
|
||||||
|
return "connected (tools changed; /mcp reconnect)" if self.tools_stale else "connected"
|
||||||
|
return "not started"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self._proc is not None and self.error is None
|
||||||
|
|
||||||
|
def stderr_tail(self) -> list[str]:
|
||||||
|
return list(self._stderr_tail)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Spawn the server process and run the initialize handshake."""
|
||||||
|
if self._proc is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._proc = await asyncio.create_subprocess_exec(
|
||||||
|
self._config.command,
|
||||||
|
*self._config.args,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=os.environ | self._config.env,
|
||||||
|
cwd=self._config.cwd or None,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
self.error = f"spawn {self._config.command!r}: {exc}"
|
||||||
|
self._proc = None
|
||||||
|
return
|
||||||
|
self._reader_task = asyncio.create_task(self._read_loop())
|
||||||
|
try:
|
||||||
|
await self._initialize()
|
||||||
|
self.tools = await self.list_tools()
|
||||||
|
except (McpConnectionError, TimeoutError) as exc:
|
||||||
|
self.error = f"initialize: {exc}"
|
||||||
|
await self.aclose()
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Terminate the server subprocess and fail pending requests."""
|
||||||
|
proc = self._proc
|
||||||
|
self._proc = None
|
||||||
|
if self._reader_task is not None:
|
||||||
|
_ = self._reader_task.cancel()
|
||||||
|
self._reader_task = None
|
||||||
|
if proc is not None and proc.returncode is None:
|
||||||
|
_ = proc.terminate()
|
||||||
|
try:
|
||||||
|
_ = await asyncio.wait_for(proc.wait(), _CLOSE_GRACE_SECONDS)
|
||||||
|
except TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
_ = await proc.wait()
|
||||||
|
for future in self._pending.values():
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(McpConnectionError("connection closed"))
|
||||||
|
self._pending.clear()
|
||||||
|
|
||||||
|
async def _read_loop(self) -> None:
|
||||||
|
proc = self._proc
|
||||||
|
if proc is None or proc.stdout is None or proc.stderr is None:
|
||||||
|
return
|
||||||
|
stderr_task = asyncio.create_task(self._drain_stderr(proc.stderr))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = await proc.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
self._dispatch(line)
|
||||||
|
finally:
|
||||||
|
_ = stderr_task.cancel()
|
||||||
|
for future in self._pending.values():
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(McpConnectionError("server closed the stream"))
|
||||||
|
self._pending.clear()
|
||||||
|
|
||||||
|
async def _drain_stderr(self, stderr: asyncio.StreamReader) -> None:
|
||||||
|
while True:
|
||||||
|
line = await stderr.readline()
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
self._stderr_tail.append(line.decode(errors="replace").rstrip())
|
||||||
|
|
||||||
|
def _dispatch(self, line: bytes) -> None:
|
||||||
|
try:
|
||||||
|
payload = _json_dict(cast("object", json.loads(line)))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return
|
||||||
|
message_id = payload.get("id")
|
||||||
|
if message_id is not None:
|
||||||
|
if isinstance(message_id, bool) or not isinstance(message_id, (int, str)):
|
||||||
|
return
|
||||||
|
request_id = int(message_id)
|
||||||
|
future = self._pending.pop(request_id, None)
|
||||||
|
if future is None or future.done():
|
||||||
|
return
|
||||||
|
error = payload.get("error")
|
||||||
|
if error is not None:
|
||||||
|
err = _json_dict(error)
|
||||||
|
detail = str(err.get("message", "unknown error")) if err else str(error)
|
||||||
|
future.set_exception(McpConnectionError(detail))
|
||||||
|
else:
|
||||||
|
future.set_result(_json_dict(payload.get("result")))
|
||||||
|
return
|
||||||
|
# Notifications carry no id; only tool-list changes matter to the host.
|
||||||
|
if payload.get("method") == "notifications/tools/list_changed":
|
||||||
|
self.tools_stale = True
|
||||||
|
|
||||||
|
def _send(self, payload: Json) -> None:
|
||||||
|
proc = self._proc
|
||||||
|
stdin = proc.stdin if proc is not None else None
|
||||||
|
if stdin is None:
|
||||||
|
msg = "server process is not running"
|
||||||
|
raise McpConnectionError(msg)
|
||||||
|
_ = stdin.write(json.dumps(payload, ensure_ascii=False).encode() + b"\n")
|
||||||
|
|
||||||
|
async def _request(self, method: str, params: Json | None = None) -> Json:
|
||||||
|
timeout = self._config.timeout or DEFAULT_MCP_TIMEOUT
|
||||||
|
future: asyncio.Future[Json] = asyncio.get_running_loop().create_future()
|
||||||
|
request_id = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
self._pending[request_id] = future
|
||||||
|
payload: Json = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
||||||
|
if params is not None:
|
||||||
|
payload["params"] = params
|
||||||
|
try:
|
||||||
|
self._send(payload)
|
||||||
|
except McpConnectionError:
|
||||||
|
_ = self._pending.pop(request_id, None)
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(future, timeout)
|
||||||
|
except TimeoutError:
|
||||||
|
_ = self._pending.pop(request_id, None)
|
||||||
|
msg = f"{method} timed out after {timeout}s"
|
||||||
|
raise McpConnectionError(msg) from None
|
||||||
|
|
||||||
|
async def _notify(self, method: str, params: Json | None = None) -> None:
|
||||||
|
payload: Json = {"jsonrpc": "2.0", "method": method}
|
||||||
|
if params is not None:
|
||||||
|
payload["params"] = params
|
||||||
|
self._send(payload)
|
||||||
|
|
||||||
|
async def _initialize(self) -> None:
|
||||||
|
_ = await self._request(
|
||||||
|
"initialize",
|
||||||
|
{
|
||||||
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": MCP_CLIENT_NAME, "version": _client_version()},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self._notify("notifications/initialized")
|
||||||
|
|
||||||
|
async def list_tools(self) -> list[McpTool]:
|
||||||
|
"""Fetch the full tool list, following ``nextCursor`` pagination."""
|
||||||
|
tools: list[McpTool] = []
|
||||||
|
cursor: str | None = None
|
||||||
|
for _ in range(_MAX_TOOL_PAGES):
|
||||||
|
params: Json = {} if cursor is None else {"cursor": cursor}
|
||||||
|
result = await self._request("tools/list", params)
|
||||||
|
tools.extend(McpTool.from_raw(item) for item in _json_list(result.get("tools")))
|
||||||
|
next_cursor = result.get("nextCursor")
|
||||||
|
if not isinstance(next_cursor, str) or not next_cursor:
|
||||||
|
return tools
|
||||||
|
cursor = next_cursor
|
||||||
|
return tools
|
||||||
|
|
||||||
|
async def call_tool(self, name: str, arguments: Json) -> str:
|
||||||
|
"""Run ``tools/call`` and return the flattened text result.
|
||||||
|
|
||||||
|
Tool-level failures (``isError``) come back as ``error: …`` text, not
|
||||||
|
exceptions: the protocol round-trip itself succeeded.
|
||||||
|
"""
|
||||||
|
result = await self._request("tools/call", {"name": name, "arguments": arguments})
|
||||||
|
text = _format_content(result)
|
||||||
|
if result.get("isError") is True:
|
||||||
|
return f"error: {text or 'tool reported failure without content'}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
class McpManager:
|
||||||
|
"""All configured MCP server connections for this process."""
|
||||||
|
|
||||||
|
_config: McpConfig
|
||||||
|
_connections: dict[str, McpServerConnection]
|
||||||
|
|
||||||
|
def __init__(self, config: McpConfig) -> None:
|
||||||
|
self._config = config
|
||||||
|
self._connections = {}
|
||||||
|
|
||||||
|
def enabled_servers(self) -> dict[str, McpServerConfig]:
|
||||||
|
"""Configured servers minus the ``disable`` list, in config order."""
|
||||||
|
disabled = {name.strip() for name in self._config.disable}
|
||||||
|
return {name: cfg for name, cfg in self._config.servers.items() if name not in disabled}
|
||||||
|
|
||||||
|
async def ensure_started(self) -> None:
|
||||||
|
"""Connect every enabled server that has no live connection yet."""
|
||||||
|
for name, cfg in self.enabled_servers().items():
|
||||||
|
connection = self._connections.get(name)
|
||||||
|
if connection is not None and connection.connected:
|
||||||
|
continue
|
||||||
|
if connection is not None:
|
||||||
|
await connection.aclose()
|
||||||
|
connection = McpServerConnection(name, cfg)
|
||||||
|
self._connections[name] = connection
|
||||||
|
await connection.start()
|
||||||
|
|
||||||
|
async def restart(self, config: McpConfig) -> None:
|
||||||
|
"""Drop every connection and adopt a new config (e.g. /mcp reconnect)."""
|
||||||
|
await self.aclose()
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
for connection in self._connections.values():
|
||||||
|
await connection.aclose()
|
||||||
|
self._connections.clear()
|
||||||
|
|
||||||
|
def statuses(self) -> list[tuple[str, str, int]]:
|
||||||
|
"""(server, status, tool_count) rows for diagnostics surfaces."""
|
||||||
|
rows: list[tuple[str, str, int]] = []
|
||||||
|
for name in self.enabled_servers():
|
||||||
|
connection = self._connections.get(name)
|
||||||
|
if connection is None:
|
||||||
|
rows.append((name, "not started", 0))
|
||||||
|
else:
|
||||||
|
rows.append((name, connection.status, len(connection.tools)))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def connections(self) -> list[McpServerConnection]:
|
||||||
|
return list(self._connections.values())
|
||||||
|
|
||||||
|
async def refresh_tools(self) -> None:
|
||||||
|
"""Re-run tools/list on every connected server."""
|
||||||
|
for connection in self._connections.values():
|
||||||
|
if connection.connected:
|
||||||
|
connection.tools = await connection.list_tools()
|
||||||
|
connection.tools_stale = False
|
||||||
|
|
||||||
|
async def call(self, server: str, tool: str, arguments: Json) -> str:
|
||||||
|
connection = self._connections.get(server)
|
||||||
|
if connection is None or not connection.connected:
|
||||||
|
msg = f"server {server!r} is not connected"
|
||||||
|
raise McpConnectionError(msg)
|
||||||
|
return await connection.call_tool(tool, arguments)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Minimal MCP stdio server used by the client tests (echo + sleep tools)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def respond(request_id: int, result: object) -> None:
|
||||||
|
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
for line in sys.stdin:
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
method = payload.get("method", "")
|
||||||
|
request_id = payload.get("id")
|
||||||
|
if request_id is None:
|
||||||
|
continue # notification (notifications/initialized)
|
||||||
|
if method == "initialize":
|
||||||
|
respond(
|
||||||
|
int(request_id),
|
||||||
|
{
|
||||||
|
"protocolVersion": "2025-06-18",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {"name": "fake-mcp", "version": "0.1.0"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif method == "tools/list":
|
||||||
|
respond(
|
||||||
|
int(request_id),
|
||||||
|
{
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "echo",
|
||||||
|
"description": "Echo the given text back.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string"}},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fail",
|
||||||
|
"description": "Always reports a tool error.",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "slow",
|
||||||
|
"description": "Sleeps past any client timeout.",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif method == "tools/call":
|
||||||
|
name = (payload.get("params") or {}).get("name")
|
||||||
|
if name == "echo":
|
||||||
|
text = str((payload.get("params") or {}).get("arguments", {}).get("text", ""))
|
||||||
|
respond(
|
||||||
|
int(request_id),
|
||||||
|
{
|
||||||
|
"content": [{"type": "text", "text": f"echo: {text}"}],
|
||||||
|
"isError": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif name == "fail":
|
||||||
|
respond(
|
||||||
|
int(request_id),
|
||||||
|
{"content": [{"type": "text", "text": "boom"}], "isError": True},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
respond(int(request_id), {"content": [], "isError": False})
|
||||||
|
else:
|
||||||
|
sys.stdout.write(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"error": {"code": -32601, "message": f"unknown method {method}"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""MCP stdio client + manager against the bundled fake server subprocess."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plyngent.config.models import McpConfig, McpServerConfig
|
||||||
|
from plyngent.runtime.mcp_client import (
|
||||||
|
McpConnectionError,
|
||||||
|
McpManager,
|
||||||
|
McpServerConnection,
|
||||||
|
)
|
||||||
|
|
||||||
|
_FAKE_SERVER = Path(__file__).parent / "mcp_fake_server.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _server_config(**overrides: object) -> McpServerConfig:
|
||||||
|
base: dict[str, object] = {
|
||||||
|
"command": sys.executable,
|
||||||
|
"args": [str(_FAKE_SERVER)],
|
||||||
|
"timeout": 5.0,
|
||||||
|
}
|
||||||
|
return McpServerConfig(**{**base, **overrides}) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_connection_handshake_lists_and_calls_tools() -> None:
|
||||||
|
connection = McpServerConnection("fake", _server_config())
|
||||||
|
await connection.start()
|
||||||
|
try:
|
||||||
|
assert connection.connected
|
||||||
|
assert connection.error is None
|
||||||
|
assert [tool.name for tool in connection.tools] == ["echo", "fail", "slow"]
|
||||||
|
echo = connection.tools[0]
|
||||||
|
assert echo.description == "Echo the given text back."
|
||||||
|
assert echo.input_schema["type"] == "object"
|
||||||
|
|
||||||
|
result = await connection.call_tool("echo", {"text": "hello"})
|
||||||
|
assert result == "echo: hello"
|
||||||
|
finally:
|
||||||
|
await connection.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tool_error_returns_error_text() -> None:
|
||||||
|
connection = McpServerConnection("fake", _server_config())
|
||||||
|
await connection.start()
|
||||||
|
try:
|
||||||
|
result = await connection.call_tool("fail", {})
|
||||||
|
assert result == "error: boom"
|
||||||
|
finally:
|
||||||
|
await connection.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_timeout_raises() -> None:
|
||||||
|
connection = McpServerConnection("fake", _server_config(timeout=0.2))
|
||||||
|
await connection.start()
|
||||||
|
try:
|
||||||
|
with pytest.raises(McpConnectionError, match="timed out"):
|
||||||
|
await connection.call_tool("slow", {})
|
||||||
|
finally:
|
||||||
|
await connection.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawn_failure_records_error() -> None:
|
||||||
|
connection = McpServerConnection("bad", _server_config(command="/nonexistent/plyngent-mcp-binary"))
|
||||||
|
await connection.start()
|
||||||
|
assert not connection.connected
|
||||||
|
assert connection.error is not None
|
||||||
|
assert "spawn" in connection.status
|
||||||
|
await connection.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_close_terminates_subprocess() -> None:
|
||||||
|
connection = McpServerConnection("fake", _server_config())
|
||||||
|
await connection.start()
|
||||||
|
proc = connection._proc
|
||||||
|
assert proc is not None
|
||||||
|
await connection.aclose()
|
||||||
|
assert connection._proc is None
|
||||||
|
assert proc.returncode is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manager_start_status_and_call() -> None:
|
||||||
|
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=[]))
|
||||||
|
await manager.ensure_started()
|
||||||
|
try:
|
||||||
|
assert [(name, status, count) for name, status, count in manager.statuses()] == [("fake", "connected", 3)]
|
||||||
|
assert await manager.call("fake", "echo", {"text": "hi"}) == "echo: hi"
|
||||||
|
finally:
|
||||||
|
await manager.aclose()
|
||||||
|
assert manager.statuses() == [("fake", "not started", 0)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manager_disable_and_restart() -> None:
|
||||||
|
manager = McpManager(McpConfig(servers={"fake": _server_config()}, disable=["fake"]))
|
||||||
|
await manager.ensure_started()
|
||||||
|
assert manager.connections() == []
|
||||||
|
with pytest.raises(McpConnectionError, match="not connected"):
|
||||||
|
await manager.call("fake", "echo", {})
|
||||||
|
|
||||||
|
await manager.restart(McpConfig(servers={"fake": _server_config()}, disable=[]))
|
||||||
|
await manager.ensure_started()
|
||||||
|
assert manager.statuses() == [("fake", "connected", 3)]
|
||||||
|
await manager.aclose()
|
||||||
Reference in New Issue
Block a user