mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
runtime: MCP stdio client and per-process connection manager
This commit is contained in:
@@ -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