Skip to content

Commit ba2d1ca

Browse files
committed
feat(mcp): add stdio MCP tool loader + fake stdio server test (D2.3)
cubepi.mcp.load_mcp_tools_stdio(command, args, env, cwd) spawns an MCP server subprocess via stdio, lists tools, returns cubepi.AgentTool list. Each tool's execute opens a fresh subprocess per call (v1 simplicity). Tested via tests/mcp/_fake_stdio_server.py — a minimal MCP server using the mcp SDK's own server primitives, runnable as 'python -m tests.mcp._fake_stdio_server'.
1 parent 7fb6c74 commit ba2d1ca

4 files changed

Lines changed: 156 additions & 1 deletion

File tree

cubepi/mcp/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
cubepi[mcp] extra required.
44
"""
55
from cubepi.mcp.http_loader import load_mcp_tools_http
6+
from cubepi.mcp.stdio_loader import load_mcp_tools_stdio
67

7-
__all__ = ["load_mcp_tools_http"]
8+
__all__ = ["load_mcp_tools_http", "load_mcp_tools_stdio"]

cubepi/mcp/stdio_loader.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""stdio transport MCP tool loader."""
2+
from __future__ import annotations
3+
4+
from typing import Any
5+
6+
from cubepi.agent.types import AgentTool
7+
from cubepi.mcp._adapter import make_mcp_agent_tool
8+
9+
10+
async def load_mcp_tools_stdio(
11+
command: str,
12+
args: list[str],
13+
*,
14+
env: dict[str, str] | None = None,
15+
cwd: str | None = None,
16+
timeout: float = 30.0,
17+
) -> list[AgentTool]:
18+
"""Spawn a stdio MCP server subprocess, discover tools, return AgentTools.
19+
20+
Each returned tool's execute opens a fresh subprocess per call (v1
21+
simplicity, no process pooling).
22+
23+
Args:
24+
command: executable to run (e.g. "npx" or sys.executable)
25+
args: argv for the server process
26+
env: environment variables (passed to subprocess)
27+
cwd: working directory for the subprocess
28+
timeout: per-call timeout (not currently enforced strictly)
29+
"""
30+
from mcp import ClientSession, StdioServerParameters
31+
from mcp.client.stdio import stdio_client
32+
33+
server_params = StdioServerParameters(
34+
command=command, args=args, env=env, cwd=cwd,
35+
)
36+
37+
async def _call_remote(tool_name: str, args_dict: dict[str, Any]) -> dict[str, Any]:
38+
async with stdio_client(server_params) as streams:
39+
async with ClientSession(*streams) as session:
40+
await session.initialize()
41+
resp = await session.call_tool(tool_name, args_dict)
42+
return _serialize_call_tool_response(resp)
43+
44+
async with stdio_client(server_params) as streams:
45+
async with ClientSession(*streams) as session:
46+
await session.initialize()
47+
tools_resp = await session.list_tools()
48+
tool_descs = tools_resp.tools
49+
50+
return [
51+
make_mcp_agent_tool(
52+
name=desc.name,
53+
description=desc.description or "",
54+
input_schema=desc.inputSchema or {"type": "object", "properties": {}},
55+
call_remote=_call_remote,
56+
)
57+
for desc in tool_descs
58+
]
59+
60+
61+
def _serialize_call_tool_response(resp: Any) -> dict[str, Any]:
62+
content = []
63+
for c in (resp.content or []):
64+
if getattr(c, "type", None) == "text":
65+
content.append({"type": "text", "text": c.text})
66+
return {
67+
"content": content,
68+
"isError": bool(getattr(resp, "isError", False)),
69+
}

tests/mcp/_fake_stdio_server.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Minimal stdio MCP server for testing.
2+
3+
Uses the mcp SDK's server primitives. Advertises one 'echo' tool.
4+
5+
Run as: python -m tests.mcp._fake_stdio_server
6+
"""
7+
import asyncio
8+
9+
10+
async def main() -> None:
11+
from mcp.server import NotificationOptions, Server
12+
from mcp.server.models import InitializationOptions
13+
from mcp.server.stdio import stdio_server
14+
from mcp.types import TextContent, Tool
15+
16+
server = Server("fake-cubepi-test-server")
17+
18+
@server.list_tools()
19+
async def _list_tools() -> list[Tool]:
20+
return [
21+
Tool(
22+
name="echo",
23+
description="Echo the input back",
24+
inputSchema={
25+
"type": "object",
26+
"properties": {"text": {"type": "string"}},
27+
"required": ["text"],
28+
},
29+
),
30+
]
31+
32+
@server.call_tool()
33+
async def _call_tool(name: str, arguments: dict) -> list[TextContent]:
34+
if name == "echo":
35+
return [TextContent(type="text", text=arguments.get("text", ""))]
36+
raise ValueError(f"unknown tool: {name}")
37+
38+
async with stdio_server() as (read_stream, write_stream):
39+
await server.run(
40+
read_stream,
41+
write_stream,
42+
InitializationOptions(
43+
server_name="fake-cubepi-test-server",
44+
server_version="0.0.1",
45+
capabilities=server.get_capabilities(
46+
notification_options=NotificationOptions(),
47+
experimental_capabilities={},
48+
),
49+
),
50+
)
51+
52+
53+
if __name__ == "__main__":
54+
asyncio.run(main())

tests/mcp/test_stdio_loader.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""stdio MCP loader integration tests (D2.3)."""
2+
3+
import sys
4+
5+
import pytest
6+
7+
8+
def test_import_stdio_loader() -> None:
9+
from cubepi.mcp import load_mcp_tools_stdio
10+
assert callable(load_mcp_tools_stdio)
11+
12+
13+
@pytest.mark.asyncio
14+
async def test_stdio_loader_against_fake_server() -> None:
15+
"""Spawn the fake stdio server, list tools, invoke 'echo'."""
16+
from cubepi.mcp import load_mcp_tools_stdio
17+
18+
tools = await load_mcp_tools_stdio(
19+
command=sys.executable,
20+
args=["-m", "tests.mcp._fake_stdio_server"],
21+
)
22+
assert len(tools) == 1
23+
echo = tools[0]
24+
assert echo.name == "echo"
25+
26+
args = echo.parameters(text="hello")
27+
result = await echo.execute(args)
28+
assert len(result.content) == 1
29+
from cubepi.providers.base import TextContent
30+
assert isinstance(result.content[0], TextContent)
31+
assert result.content[0].text == "hello"

0 commit comments

Comments
 (0)