feat(mcp): implement StdioMCPTransport for JSON-RPC 2.0 stdio communication
CI / helm (push) Successful in 38s
CI / build (push) Successful in 1m3s
CI / push-validation (push) Successful in 30s
CI / lint (push) Successful in 1m22s
CI / quality (push) Successful in 1m31s
CI / security (push) Successful in 1m46s
CI / typecheck (push) Successful in 1m53s
CI / integration_tests (push) Successful in 3m41s
CI / e2e_tests (push) Failing after 4m5s
CI / unit_tests (push) Successful in 4m58s
CI / docker (push) Successful in 1m30s
CI / benchmark-regression (push) Failing after 45s
CI / coverage (push) Successful in 11m32s
CI / status-check (push) Successful in 5s
CI / benchmark-publish (push) Successful in 1h20m26s

Implements concrete StdioMCPTransport class that:
- Spawns MCP server as subprocess and communicates via JSON-RPC 2.0 over stdio
- Performs MCP handshake (initialize + notifications/initialized)
- Supports tools/list and tools/call methods
- Uses RLock for thread-safe concurrent access
- Auto-selected when transport='stdio' in MCPServerConfig

Adds BDD tests for stdio transport covering:
- Connection lifecycle and error handling
- Tool discovery and invocation
- MCPToolAdapter integration

ISSUES CLOSED: #4918
This commit was merged in pull request #11129.
This commit is contained in:
CoreRasurae
2026-05-11 16:31:26 +00:00
committed by Forgejo
parent 3368cba38f
commit 3fcfaee02d
6 changed files with 816 additions and 1 deletions
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""MCP stub server for integration testing of StdioMCPTransport.
Implements the MCP JSON-RPC 2.0 stdio protocol for deterministic testing.
This server speaks MCP 2024-11-05 protocol version.
"""
from __future__ import annotations
import json
import sys
import threading
import time
from typing import Any
MCP_PROTOCOL_VERSION = "2024-11-05"
TOOLS = [
{
"name": "test/echo",
"description": "Echoes back the input arguments",
"inputSchema": {
"type": "object",
"properties": {
"message": {"type": "string", "description": "Message to echo"},
},
"required": ["message"],
},
},
{
"name": "test/multiply",
"description": "Multiplies two numbers",
"inputSchema": {
"type": "object",
"properties": {
"a": {"type": "number", "description": "First number"},
"b": {"type": "number", "description": "Second number"},
},
"required": ["a", "b"],
},
},
{
"name": "test/slow",
"description": "A slow tool that takes time to respond",
"inputSchema": {
"type": "object",
"properties": {
"delay": {"type": "number", "description": "Delay in seconds"},
},
},
},
]
request_id = 0
capabilities: dict[str, Any] = {}
initialized = False
slow_tool_delay = 0.0
lock = threading.Lock()
def send_response(resp_id: int | str | None, result: Any) -> None:
response = {
"jsonrpc": "2.0",
"id": resp_id,
"result": result,
}
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
def send_error(resp_id: int | str | None, code: int, message: str) -> None:
response = {
"jsonrpc": "2.0",
"id": resp_id,
"error": {"code": code, "message": message},
}
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
def handle_initialize(params: dict[str, Any]) -> dict[str, Any]:
global capabilities, initialized
capabilities = params.get("capabilities", {})
initialized = True
return {
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {"tools": True, "resources": {}},
"serverInfo": {
"name": "test-mcp-stdio-server",
"version": "1.0.0",
},
}
def handle_tools_list(params: dict[str, Any]) -> dict[str, Any]:
return {"tools": TOOLS}
def handle_tools_call(req_id: int | str | None, params: dict[str, Any]) -> None:
global slow_tool_delay
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
if tool_name == "test/echo":
message = arguments.get("message", "")
send_response(
req_id,
{"content": [{"type": "text", "text": json.dumps({"echoed": message})}]},
)
return
if tool_name == "test/multiply":
a = arguments.get("a", 0)
b = arguments.get("b", 0)
send_response(
req_id,
{"content": [{"type": "text", "text": json.dumps({"result": a * b})}]},
)
return
if tool_name == "test/slow":
delay = arguments.get("delay", 0.1)
if delay > slow_tool_delay:
time.sleep(delay)
send_response(
req_id,
{"content": [{"type": "text", "text": json.dumps({"delayed": delay})}]},
)
return
send_error(req_id, -32602, f"Unknown tool: {tool_name}")
def handle_request(request: dict[str, Any]) -> None:
global request_id, slow_tool_delay
method = request.get("method", "")
params = request.get("params", {})
req_id = request.get("id")
if method == "initialize":
result = handle_initialize(params)
send_response(req_id, result)
elif method == "notifications/initialized":
pass
elif method == "tools/list":
if not initialized:
send_error(req_id, -32602, "Not initialized")
return
result = handle_tools_list(params)
send_response(req_id, result)
elif method == "tools/call":
if not initialized:
send_error(req_id, -32602, "Not initialized")
return
handle_tools_call(req_id, params)
elif method == "test/set_slow_delay":
slow_tool_delay = params.get("delay", 0.0)
send_response(req_id, {"ok": True})
else:
send_error(req_id, -32601, f"Unknown method: {method}")
def main() -> None:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(request, dict) and request.get("jsonrpc") == "2.0":
handle_request(request)
if __name__ == "__main__":
main()
+242
View File
@@ -0,0 +1,242 @@
"""Step definitions for features/tdd_stdio_transport.feature."""
from __future__ import annotations
import json
import os
import sys
from behave import given, then, when
from behave.runner import Context
from cleveragents.mcp.adapter import MCPServerConfig
from cleveragents.mcp.stdio_transport import StdioMCPTransport
def _get_stdio_server_path() -> str:
return os.path.join(
os.path.dirname(__file__),
"..",
"mocks",
"mcp_stub_server_stdio.py",
)
@given("the MCP stdio stub server is available")
def step_stub_server_available(context: Context) -> None:
server_path = _get_stdio_server_path()
if not os.path.exists(server_path):
pytest_path = sys.executable
msg = (
f"MCP stdio stub server not found at {server_path} (python: {pytest_path})"
)
raise RuntimeError(msg)
context.mcp_stdio_server_path = server_path
@given("an MCP server config using the stdio stub server")
def step_config_with_stub_server(context: Context) -> None:
server_path = _get_stdio_server_path()
context.mcp_config = MCPServerConfig(
name="test-stdio-server",
transport="stdio",
command=sys.executable,
args=[server_path],
)
@given("an MCP server config with stdio transport and no command")
def step_config_stdio_no_command(context: Context) -> None:
context.mcp_config = MCPServerConfig(
name="bad-server",
transport="stdio",
)
@given("an MCP server config with stdio transport and command {command}")
def step_config_stdio_bad_command(context: Context, command: str) -> None:
context.mcp_config = MCPServerConfig(
name="bad-server",
transport="stdio",
command=command,
)
@given("the config command is set to the stdio stub server path")
def step_config_command_set(context: Context) -> None:
server_path = _get_stdio_server_path()
context.mcp_config = MCPServerConfig(
name="test-server",
transport="stdio",
command=sys.executable,
args=[server_path],
)
@given("a connected stdio transport")
def step_connected_stdio_transport(context: Context) -> None:
server_path = _get_stdio_server_path()
config = MCPServerConfig(
name="test-stdio-server",
transport="stdio",
command=sys.executable,
args=[server_path],
)
context.mcp_stdio_transport = StdioMCPTransport()
context.mcp_stdio_capabilities = context.mcp_stdio_transport.connect(config)
@when("I create a stdio transport")
def step_create_stdio_transport(context: Context) -> None:
context.mcp_stdio_transport = StdioMCPTransport()
@when("I connect the transport")
def step_connect_transport(context: Context) -> None:
context.mcp_stdio_error = None
try:
context.mcp_stdio_capabilities = context.mcp_stdio_transport.connect(
context.mcp_config
)
except Exception as exc:
context.mcp_stdio_error = str(exc)
@when("I create a stdio transport and connect")
def step_create_and_connect_transport(context: Context) -> None:
context.mcp_stdio_error = None
try:
transport = StdioMCPTransport()
context.mcp_stdio_capabilities = transport.connect(context.mcp_config)
context.mcp_stdio_transport = transport
except Exception as exc:
context.mcp_stdio_error = str(exc)
@when("I connect the transport again")
def step_reconnect_transport(context: Context) -> None:
context.mcp_stdio_capabilities = context.mcp_stdio_transport.connect(
context.mcp_config
)
@when("I close the transport")
def step_close_transport(context: Context) -> None:
context.mcp_stdio_transport.close()
@when('I call "{method}" on the transport')
def step_call_method(context: Context, method: str) -> None:
context.mcp_stdio_call_error = None
context.mcp_stdio_response = None
try:
context.mcp_stdio_response = context.mcp_stdio_transport.call(method, {})
except Exception as exc:
context.mcp_stdio_call_error = str(exc)
@when('I call "{method}" with arguments {args_json}')
def step_call_method_with_args(context: Context, method: str, args_json: str) -> None:
arguments = json.loads(args_json)
context.mcp_stdio_call_error = None
context.mcp_stdio_response = None
try:
context.mcp_stdio_response = context.mcp_stdio_transport.call(method, arguments)
except Exception as exc:
context.mcp_stdio_call_error = str(exc)
@when('I send a "{method}" notification with params {params_json}')
def step_send_notification(context: Context, method: str, params_json: str) -> None:
params = json.loads(params_json)
context.mcp_stdio_transport._send_notification(method, params)
# -------------------------------------------------------------------
# Then Steps
# -------------------------------------------------------------------
@then("the MCP stdio transport should be connected")
def step_mcp_stdio_transport_connected(context: Context) -> None:
assert context.mcp_stdio_transport is not None
assert context.mcp_stdio_transport._connected
@then("the MCP stdio transport should not be connected")
def step_mcp_stdio_transport_not_connected(context: Context) -> None:
assert context.mcp_stdio_transport is not None
assert not context.mcp_stdio_transport._connected
@then("the server capabilities should include tools support")
def step_capabilities_have_tools(context: Context) -> None:
caps = context.mcp_stdio_capabilities
assert caps is not None
assert "capabilities" in caps
assert caps["capabilities"].get("tools") is not None
@then("the connection should fail with error containing {text}")
def step_connection_error_contains(context: Context, text: str) -> None:
assert context.mcp_stdio_error is not None
text = text.strip('"')
assert text.lower() in context.mcp_stdio_error.lower(), (
f"Expected error containing '{text}', got: {context.mcp_stdio_error}"
)
@then('the response should contain "{key}" key')
def step_response_contains_key(context: Context, key: str) -> None:
assert context.mcp_stdio_response is not None
assert key in context.mcp_stdio_response, (
f"Key '{key}' not in response: {context.mcp_stdio_response}"
)
@then("the tools list should have {count:d} tools")
def step_tools_list_count(context: Context, count: int) -> None:
tools = context.mcp_stdio_response.get("tools", [])
assert len(tools) == count, f"Expected {count} tools, got {len(tools)}: {tools}"
@then('tool {idx:d} should have name "{name}"')
def step_tool_has_name(context: Context, idx: int, name: str) -> None:
tools = context.mcp_stdio_response.get("tools", [])
assert tools[idx]["name"] == name
@then("the call should succeed")
def step_call_succeeded(context: Context) -> None:
assert context.mcp_stdio_call_error is None
assert context.mcp_stdio_response is not None
@then("the call should fail with error containing {text}")
def step_call_failed(context: Context, text: str) -> None:
text = text.strip('"')
error_msg = context.mcp_stdio_call_error or ""
assert text.lower() in error_msg.lower(), (
f"Expected error containing '{text}', got: {error_msg}"
)
@then('the response content should contain "{text}"')
def step_response_content_contains(context: Context, text: str) -> None:
content = context.mcp_stdio_response.get("content", [])
content_text = json.dumps(content)
assert text in content_text, f"Expected '{text}' in content, got: {content_text}"
@then("the adapter transport type should be {transport}")
def step_adapter_transport_type(context: Context, transport: str) -> None:
actual = context.mcp_adapter.transport_type
transport = transport.strip('"')
assert actual == transport, f"Expected transport type '{transport}', got '{actual}'"
@then("the MCP stdio transport should still be connected")
def step_mcp_stdio_transport_still_connected(context: Context) -> None:
assert context.mcp_stdio_transport is not None
assert context.mcp_stdio_transport._connected
+123
View File
@@ -0,0 +1,123 @@
Feature: Stdio MCP Transport
As an MCP client using the stdio transport
I want to connect to MCP servers via subprocess stdio
So that I can discover and invoke tools from real MCP servers
Background:
Given the MCP stdio stub server is available
# -------------------------------------------------------------------
# Connection Lifecycle
# -------------------------------------------------------------------
Scenario: Connect to MCP stdio server performs handshake
Given an MCP server config using the stdio stub server
When I create a stdio transport
And I connect the transport
Then the MCP stdio transport should be connected
And the server capabilities should include tools support
Scenario: Connect with missing command raises error
Given an MCP server config with stdio transport and no command
When I create a stdio transport and connect
Then the connection should fail with error containing "command"
Scenario: Connect with non-existent command raises error
Given an MCP server config with stdio transport and command "/nonexistent/binary"
When I create a stdio transport and connect
Then the connection should fail with error containing "not found"
Scenario: Double connect is idempotent
Given an MCP server config using the stdio stub server
And a connected stdio transport
When I connect the transport again
Then the MCP stdio transport should be connected
Scenario: Close transport terminates subprocess
Given an MCP server config using the stdio stub server
And a connected stdio transport
When I close the transport
Then the MCP stdio transport should not be connected
# -------------------------------------------------------------------
# Tool Discovery via Transport
# -------------------------------------------------------------------
Scenario: Discover tools via stdio transport
Given an MCP server config using the stdio stub server
And a connected stdio transport
When I call "tools/list" on the transport
Then the response should contain "tools" key
And the tools list should have 3 tools
And tool 0 should have name "test/echo"
Scenario: Call unknown method raises error
Given an MCP server config using the stdio stub server
And a connected stdio transport
When I call "nonexistent/method" on the transport
Then the call should fail with error containing "Unknown method"
# -------------------------------------------------------------------
# Tool Invocation via Transport
# -------------------------------------------------------------------
Scenario: Invoke test/echo tool via stdio transport
Given an MCP server config using the stdio stub server
And a connected stdio transport
When I call "tools/call" with arguments {"name": "test/echo", "arguments": {"message": "hello"}}
Then the call should succeed
And the response should contain "content" key
Scenario: Invoke test/multiply tool via stdio transport
Given an MCP server config using the stdio stub server
And a connected stdio transport
When I call "tools/call" with arguments {"name": "test/multiply", "arguments": {"a": 6, "b": 7}}
Then the call should succeed
And the response content should contain "42"
Scenario: Invoke unknown tool returns error response
Given an MCP server config using the stdio stub server
And a connected stdio transport
When I call "tools/call" with arguments {"name": "test/nonexistent", "arguments": {}}
Then the call should fail with error containing "Unknown tool"
# -------------------------------------------------------------------
# MCPToolAdapter Integration with Stdio Transport
# -------------------------------------------------------------------
Scenario: MCPToolAdapter uses StdioMCPTransport automatically for stdio config
Given an MCP server config for "test-server" with stdio transport
And the config command is set to the stdio stub server path
When I create an MCP adapter from the config
And I connect the adapter
Then the adapter transport type should be "stdio"
And the adapter should be connected
And the adapter server capabilities should be set
Scenario: MCPToolAdapter discovers tools via StdioMCPTransport
Given an MCP server config for "test-server" with stdio transport
And the config command is set to the stdio stub server path
When I create an MCP adapter from the config
And I connect the adapter
And I discover tools from the adapter
Then the adapter should have 3 discovered tools
Scenario: MCPToolAdapter invokes tools via StdioMCPTransport
Given an MCP server config for "test-server" with stdio transport
And the config command is set to the stdio stub server path
When I create an MCP adapter from the config
And I connect the adapter
And I discover tools from the adapter
And I invoke "test/echo" with arguments {"message": "test"}
Then the invocation should succeed
And the invocation result should contain key "content"
# -------------------------------------------------------------------
# Notifications
# -------------------------------------------------------------------
Scenario: Send notification to server
Given an MCP server config using the stdio stub server
And a connected stdio transport
When I send a "test/custom_notification" notification with params {}
Then the MCP stdio transport should still be connected
+6
View File
@@ -14,12 +14,15 @@ from cleveragents.mcp.adapter import (
MCPServerConfig,
MCPToolAdapter,
MCPToolDescriptor,
MCPToolFilter,
MCPToolResult,
MCPTransport,
)
from cleveragents.mcp.client import McpClient, McpClientConfig, McpClientState
from cleveragents.mcp.refresh_hook import MCPRefreshHook
from cleveragents.mcp.registry import McpRegistry
from cleveragents.mcp.sandbox import SandboxPathRewriter, SandboxPathRewriterConfig
from cleveragents.mcp.stdio_transport import StdioMCPTransport
__all__ = [
"MCPCapabilityMetadata",
@@ -27,11 +30,14 @@ __all__ = [
"MCPServerConfig",
"MCPToolAdapter",
"MCPToolDescriptor",
"MCPToolFilter",
"MCPToolResult",
"MCPTransport",
"McpClient",
"McpClientConfig",
"McpClientState",
"McpRegistry",
"SandboxPathRewriter",
"SandboxPathRewriterConfig",
"StdioMCPTransport",
]
+12 -1
View File
@@ -48,6 +48,12 @@ __all__ = [
]
def _create_stdio_transport() -> MCPTransport:
from cleveragents.mcp.stdio_transport import StdioMCPTransport
return StdioMCPTransport()
class MCPServerConfig(BaseModel):
"""Configuration for connecting to an MCP server.
@@ -196,7 +202,12 @@ class MCPToolAdapter:
) -> None:
self._validate_config(config)
self._config = config
self._transport = transport or MCPTransport()
if transport is not None:
self._transport = transport
elif config.transport == "stdio":
self._transport = _create_stdio_transport()
else:
self._transport = MCPTransport()
self._connected = False
self._capabilities: dict[str, Any] = {}
self._tools: dict[str, MCPToolDescriptor] = {}
+252
View File
@@ -0,0 +1,252 @@
"""Stdio transport layer for MCP server communication.
Implements the MCP stdio transport protocol (JSON-RPC 2.0 over stdio)
as specified in the MCP specification version 2024-11-05.
"""
from __future__ import annotations
import json
import logging
import subprocess
import threading
import time
from typing import Any
from cleveragents.mcp.adapter import MCPServerConfig, MCPTransport
logger = logging.getLogger(__name__)
MCP_PROTOCOL_VERSION = "2024-11-05"
DEFAULT_TIMEOUT = 30.0
class StdioMCPTransport(MCPTransport):
"""Concrete stdio transport for MCP server communication.
Spawns an MCP server as a subprocess and communicates via JSON-RPC 2.0
messages over stdio using newline-delimited JSON (NDJSON).
Thread-safety: uses a lock to serialize concurrent calls to the
subprocess since stdio is not inherently thread-safe.
"""
def __init__(
self,
timeout: float = DEFAULT_TIMEOUT,
) -> None:
self._timeout = timeout
self._process: subprocess.Popen[bytes] | None = None
self._lock = threading.RLock()
self._request_id = 0
self._connected = False
self._capabilities: dict[str, Any] = {}
def connect(self, config: MCPServerConfig) -> dict[str, Any]:
"""Spawn the MCP server subprocess and perform the initialize handshake.
Parameters
----------
config:
Server connection configuration with stdio transport settings.
Returns
-------
dict[str, Any]
Server capabilities from the initialize response.
Raises
------
ConnectionError
If the subprocess fails to start or the handshake times out.
"""
with self._lock:
if self._connected:
return self._capabilities
if config.command is None:
msg = "stdio transport requires 'command' field"
raise ConnectionError(msg)
env = dict(config.env) if config.env else {}
env["NO_COLOR"] = "1"
try:
self._process = subprocess.Popen(
[config.command, *config.args],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
bufsize=0,
)
except FileNotFoundError as exc:
msg = f"Command not found: {config.command}"
raise ConnectionError(msg) from exc
except PermissionError as exc:
msg = f"Permission denied: {config.command}"
raise ConnectionError(msg) from exc
self._request_id = 0
capabilities = self._send_request_sync(
"initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {"roots": {"listChanged": True}},
"clientInfo": {
"name": "cleveragents-mcp-adapter",
"version": "1.0.0",
},
},
)
if capabilities is None:
self._terminate()
msg = "Server did not return capabilities during handshake"
raise ConnectionError(msg)
self._capabilities = capabilities.get("capabilities", {})
self._connected = True
self._send_notification("notifications/initialized", {})
logger.info("MCP stdio transport connected to '%s'", config.name)
return capabilities
def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
"""Send a JSON-RPC method call and return the result.
Parameters
----------
method:
The JSON-RPC method name.
params:
Method parameters.
Returns
-------
dict[str, Any]
The JSON-RPC response result.
Raises
------
TimeoutError
If the response times out.
ConnectionError
If the transport is not connected.
"""
with self._lock:
if not self._connected or self._process is None:
msg = "Transport not connected. Call connect() first."
raise ConnectionError(msg)
result = self._send_request_sync(method, params)
if result is None:
msg = f"Server did not respond to {method} request"
raise ConnectionError(msg)
return result
def close(self) -> None:
"""Shut down the transport connection and terminate the subprocess."""
with self._lock:
if not self._connected:
return
self._terminate()
logger.info("MCP stdio transport closed")
def _terminate(self) -> None:
"""Terminate the subprocess and reset state."""
self._connected = False
self._capabilities = {}
if self._process is not None:
try:
self._process.terminate()
try:
self._process.wait(timeout=5.0)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait()
except Exception:
pass
self._process = None
def _send_request_sync(
self,
method: str,
params: dict[str, Any],
) -> dict[str, Any] | None:
"""Send a JSON-RPC request synchronously and wait for response.
Must be called with the lock held.
"""
if self._process is None:
return None
self._request_id += 1
req_id = self._request_id
request = {
"jsonrpc": "2.0",
"id": req_id,
"method": method,
"params": params,
}
try:
if self._process.stdin is None:
return None
self._process.stdin.write(json.dumps(request).encode("utf-8") + b"\n")
self._process.stdin.flush()
except BrokenPipeError as exc:
msg = "Subprocess stdin pipe broken"
raise ConnectionError(msg) from exc
start = time.monotonic()
while True:
if self._process.stdout is None:
return None
line = self._process.stdout.readline()
if not line:
return None
if time.monotonic() - start > self._timeout:
msg = f"Response timeout after {self._timeout}s"
raise TimeoutError(msg)
try:
response = json.loads(line.decode("utf-8"))
except json.JSONDecodeError:
continue
if not isinstance(response, dict):
continue
if response.get("id") == req_id:
if "error" in response:
error = response["error"]
msg = error.get("message", "Unknown error")
raise ConnectionError(f"MCP error: {msg}")
return response.get("result")
def _send_notification(self, method: str, params: dict[str, Any]) -> None:
"""Send a JSON-RPC notification (no response expected).
Must be called with the lock held.
"""
if self._process is None:
return
notification = {
"jsonrpc": "2.0",
"method": method,
"params": params,
}
if self._process.stdin is None:
return
try:
self._process.stdin.write(json.dumps(notification).encode("utf-8") + b"\n")
self._process.stdin.flush()
except BrokenPipeError:
pass