fastmcp/tests/client/minimal_stdio_server.py

192 lines
6.3 KiB
Python

"""A minimal MCP server spoken over stdio, using only the standard library.
This is a **test fixture**, not a real server. It exists so that subprocess
lifecycle tests (keep-alive, crash recovery, PID identity) can spawn many
short-lived servers without paying for `import fastmcp` in every child
process. Importing fastmcp and constructing a `FastMCP` instance costs
roughly 0.7s per spawn; this script starts in roughly 0.03s.
It implements only what those tests exercise: the `initialize` handshake,
`tools/list`, and `tools/call` for two trivial tools. Anything that needs
real FastMCP semantics (tool serialization, error handling, structured
output shapes) must use a real FastMCP server instead.
The response shapes below were captured from the wire of a real FastMCP
stdio server so that `CallToolResult.data` deserializes identically.
Usage:
python minimal_stdio_server.py [--exit-after-calls N]
With `--exit-after-calls N`, the `pid` tool schedules a clean `os._exit(0)`
shortly after its Nth invocation, simulating a server that shuts itself
down mid-session.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import threading
from typing import Any
INT_OUTPUT_SCHEMA: dict[str, Any] = {
"properties": {"result": {"type": "integer"}},
"required": ["result"],
"type": "object",
"x-fastmcp-wrap-result": True,
}
STR_OUTPUT_SCHEMA: dict[str, Any] = {
"properties": {"result": {"type": "string"}},
"required": ["result"],
"type": "object",
"x-fastmcp-wrap-result": True,
}
TOOLS: list[dict[str, Any]] = [
{
"name": "pid",
"description": "Gets PID of server",
"inputSchema": {
"properties": {},
"type": "object",
"additionalProperties": False,
},
"outputSchema": INT_OUTPUT_SCHEMA,
},
{
"name": "echo",
"description": "Echoes the message back",
"inputSchema": {
"properties": {"message": {"type": "string"}},
"required": ["message"],
"type": "object",
"additionalProperties": False,
},
"outputSchema": STR_OUTPUT_SCHEMA,
},
]
METHOD_NOT_FOUND = -32601
INVALID_PARAMS = -32602
def _wrapped_result(value: int | str) -> dict[str, Any]:
"""Mirror how FastMCP reports a scalar return value on the wire."""
return {
"_meta": {"fastmcp": {"wrap_result": True}},
"content": [{"type": "text", "text": str(value)}],
"isError": False,
"structuredContent": {"result": value},
}
class MinimalServer:
def __init__(self, exit_after_calls: int | None) -> None:
self.exit_after_calls = exit_after_calls
self.pid_call_count = 0
def send(self, message: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(message) + "\n")
sys.stdout.flush()
def reply(self, request_id: Any, result: dict[str, Any]) -> None:
self.send({"jsonrpc": "2.0", "id": request_id, "result": result})
def reply_error(self, request_id: Any, code: int, message: str) -> None:
self.send(
{
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": code, "message": message},
}
)
def handle_initialize(self, request_id: Any, params: dict[str, Any]) -> None:
# Echo the client's requested version back. The client rejects any
# version it did not ask for, and echoing keeps this fixture working
# across SDK protocol bumps without edits.
protocol_version = params.get("protocolVersion")
self.reply(
request_id,
{
"protocolVersion": protocol_version,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "MinimalStdioServer", "version": "1.0.0"},
},
)
def handle_tools_call(self, request_id: Any, params: dict[str, Any]) -> None:
name = params.get("name")
arguments = params.get("arguments") or {}
if name == "pid":
self.pid_call_count += 1
pid = os.getpid()
if (
self.exit_after_calls is not None
and self.pid_call_count >= self.exit_after_calls
):
# Reply first, then exit shortly after, so the client sees a
# successful call followed by an unannounced clean shutdown.
self.reply(request_id, _wrapped_result(pid))
threading.Timer(0.1, lambda: os._exit(0)).start()
return
self.reply(request_id, _wrapped_result(pid))
return
if name == "echo":
message = arguments.get("message")
if not isinstance(message, str):
self.reply_error(request_id, INVALID_PARAMS, "message must be a string")
return
self.reply(request_id, _wrapped_result(message))
return
self.reply_error(request_id, INVALID_PARAMS, f"Unknown tool: {name}")
def handle(self, message: dict[str, Any]) -> None:
method = message.get("method")
request_id = message.get("id")
params = message.get("params") or {}
if request_id is None:
# Notification (e.g. notifications/initialized) — nothing to send.
return
if method == "initialize":
self.handle_initialize(request_id, params)
elif method == "ping":
self.reply(request_id, {})
elif method == "tools/list":
self.reply(request_id, {"tools": TOOLS})
elif method == "tools/call":
self.handle_tools_call(request_id, params)
else:
self.reply_error(request_id, METHOD_NOT_FOUND, f"Unknown method: {method}")
def run(self) -> None:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(message, dict):
self.handle(message)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--exit-after-calls", type=int, default=None)
parsed = parser.parse_args()
MinimalServer(exit_after_calls=parsed.exit_after_calls).run()
if __name__ == "__main__":
main()