193 lines
6.3 KiB
Python
193 lines
6.3 KiB
Python
"""确定性的 MCP stdio 测试 Server;仅使用标准库,不依赖产品代码。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import Any
|
|
|
|
WRITE_LOCK = threading.Lock()
|
|
CANCELLED: dict[int, threading.Event] = {}
|
|
MODE = sys.argv[1] if len(sys.argv) > 1 else "normal"
|
|
|
|
|
|
def send(message: dict[str, Any]) -> None:
|
|
with WRITE_LOCK:
|
|
sys.stdout.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def respond(request_id: int, result: dict[str, Any]) -> None:
|
|
send({"jsonrpc": "2.0", "id": request_id, "result": result})
|
|
|
|
|
|
def tool(name: str, description: str, properties: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
return {
|
|
"name": name,
|
|
"description": description,
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": properties or {},
|
|
"required": list(properties or {}),
|
|
"additionalProperties": False,
|
|
},
|
|
}
|
|
|
|
|
|
TOOLS = {
|
|
"echo": {
|
|
**tool("echo", "Return the provided text.", {"text": {"type": "string"}}),
|
|
"_meta": {"notesagent/permission": "notes.read"},
|
|
},
|
|
"fail": tool("fail", "Return an MCP business error."),
|
|
"sleep": tool("sleep", "Wait until completed or cancelled.", {"seconds": {"type": "number"}}),
|
|
"large": tool("large", "Return a result larger than the host limit."),
|
|
"environment": tool("environment", "Report whether host secrets leaked into the process."),
|
|
"exit": tool("exit", "Terminate the fixture process."),
|
|
}
|
|
|
|
|
|
def call_tool(request_id: int, params: dict[str, Any]) -> None:
|
|
name = params.get("name")
|
|
arguments = params.get("arguments") or {}
|
|
if name == "echo":
|
|
text = str(arguments.get("text", ""))
|
|
respond(
|
|
request_id,
|
|
{
|
|
"content": [{"type": "text", "text": text}],
|
|
"structuredContent": {"echo": text},
|
|
"isError": False,
|
|
},
|
|
)
|
|
return
|
|
if name == "fail":
|
|
respond(
|
|
request_id,
|
|
{
|
|
"content": [{"type": "text", "text": "fixture failure"}],
|
|
"isError": True,
|
|
},
|
|
)
|
|
return
|
|
if name == "large":
|
|
respond(
|
|
request_id,
|
|
{
|
|
"content": [{"type": "text", "text": "x" * 300_000}],
|
|
"isError": False,
|
|
},
|
|
)
|
|
return
|
|
if name == "environment":
|
|
respond(
|
|
request_id,
|
|
{
|
|
"content": [{"type": "text", "text": "environment checked"}],
|
|
"structuredContent": {
|
|
"has_openai_key": "OPENAI_API_KEY" in os.environ,
|
|
"has_app_db_path": "APP_DB_PATH" in os.environ,
|
|
},
|
|
"isError": False,
|
|
},
|
|
)
|
|
return
|
|
if name == "exit":
|
|
os._exit(17)
|
|
if name == "sleep":
|
|
cancelled = CANCELLED.setdefault(request_id, threading.Event())
|
|
seconds = max(0.0, min(float(arguments.get("seconds", 0)), 30.0))
|
|
if cancelled.wait(seconds):
|
|
respond(
|
|
request_id,
|
|
{
|
|
"content": [{"type": "text", "text": "cancelled"}],
|
|
"isError": True,
|
|
},
|
|
)
|
|
else:
|
|
respond(
|
|
request_id,
|
|
{
|
|
"content": [{"type": "text", "text": "completed"}],
|
|
"structuredContent": {"slept": seconds},
|
|
"isError": False,
|
|
},
|
|
)
|
|
CANCELLED.pop(request_id, None)
|
|
return
|
|
send(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"error": {"code": -32602, "message": f"Unknown tool: {name}"},
|
|
}
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
for line in sys.stdin:
|
|
message = json.loads(line)
|
|
method = message.get("method")
|
|
request_id = message.get("id")
|
|
params = message.get("params") or {}
|
|
if method == "initialize" and isinstance(request_id, int):
|
|
if MODE == "invalid-result":
|
|
send({"jsonrpc": "2.0", "id": request_id, "result": None})
|
|
continue
|
|
if MODE == "oversized-stdout":
|
|
# 不带换行,验证 Host 在读取完整内容前执行硬上限。
|
|
sys.stdout.write("x" * (2 * 1024 * 1024 + 1))
|
|
sys.stdout.flush()
|
|
time.sleep(10)
|
|
return
|
|
respond(
|
|
request_id,
|
|
{
|
|
"protocolVersion": params.get("protocolVersion"),
|
|
"capabilities": (
|
|
{} if MODE == "no-tools" else {"tools": {"listChanged": False}}
|
|
),
|
|
"serverInfo": {"name": "notesagent-mcp-fixture", "version": "1.0.0"},
|
|
},
|
|
)
|
|
elif method == "tools/list" and isinstance(request_id, int):
|
|
if MODE == "invalid-schema":
|
|
respond(
|
|
request_id,
|
|
{
|
|
"tools": [
|
|
{
|
|
"name": "broken",
|
|
"description": "invalid schema",
|
|
"inputSchema": {"type": "string"},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
elif params.get("cursor") == "page-2":
|
|
respond(
|
|
request_id,
|
|
{"tools": [TOOLS["large"], TOOLS["environment"], TOOLS["exit"]]},
|
|
)
|
|
else:
|
|
respond(
|
|
request_id,
|
|
{"tools": [TOOLS["echo"], TOOLS["fail"], TOOLS["sleep"]], "nextCursor": "page-2"},
|
|
)
|
|
elif method == "tools/call" and isinstance(request_id, int):
|
|
threading.Thread(target=call_tool, args=(request_id, params), daemon=True).start()
|
|
elif method == "notifications/cancelled":
|
|
cancelled_id = params.get("requestId")
|
|
if isinstance(cancelled_id, int):
|
|
CANCELLED.setdefault(cancelled_id, threading.Event()).set()
|
|
elif method == "ping" and isinstance(request_id, int):
|
|
respond(request_id, {})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|