719 lines
24 KiB
Python
719 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Tests for MCP server integration via the /tools endpoint.
|
|
|
|
Invariants verified:
|
|
1. MCP tools appear in /tools listing when configured
|
|
2. MCP tools use <server>_<tool> naming
|
|
3. MCP tools can be invoked and return correct results
|
|
4. Misconfigured MCP servers do not crash the server
|
|
5. Multiple MCP servers can be configured simultaneously
|
|
6. Warmup populates the tool list at startup
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from utils import *
|
|
|
|
# Path to the test MCP server fixture
|
|
FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "fixtures")
|
|
MCP_ECHO_SERVER = os.path.join(FIXTURES_DIR, "mcp_echo_server.py")
|
|
|
|
server: ServerProcess
|
|
|
|
|
|
def _mcp_config_json(servers: dict) -> str:
|
|
"""Create a JSON config string for --mcp-servers-json."""
|
|
return json.dumps({"mcpServers": servers})
|
|
|
|
|
|
def _start_server_with_mcp(mcp_json: str, **kwargs) -> ServerProcess:
|
|
"""Helper to start a router server with MCP config."""
|
|
srv = ServerPreset.router()
|
|
srv.server_tools = "all"
|
|
srv.no_ui = True
|
|
srv.server_port = 8085 # avoid conflict with load_all() which uses 8080
|
|
srv.mcp_servers_json = mcp_json
|
|
for k, v in kwargs.items():
|
|
setattr(srv, k, v)
|
|
srv.start()
|
|
return srv
|
|
|
|
|
|
def test_mcp_tools_listed_in_tools_endpoint():
|
|
"""MCP tools should appear in GET /tools with server:tool naming."""
|
|
global server
|
|
mcp_json = _mcp_config_json({
|
|
"echo": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
res = server.make_request("GET", "/tools")
|
|
assert res.status_code == 200, res.body
|
|
|
|
tools = res.body
|
|
assert isinstance(tools, list), f"Expected list, got {type(tools)}"
|
|
|
|
# Find MCP tools - name is in "tool" field or definition.function.name
|
|
def get_tool_name(t):
|
|
return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")
|
|
|
|
mcp_tools = [t for t in tools if get_tool_name(t).startswith("echo_")]
|
|
assert len(mcp_tools) >= 2, f"Expected at least 2 echo_ tools, got {len(mcp_tools)}: {mcp_tools}"
|
|
|
|
tool_names = {get_tool_name(t) for t in mcp_tools}
|
|
assert "echo_echo" in tool_names
|
|
assert "echo_add" in tool_names
|
|
|
|
# Verify tool structure
|
|
echo_tool = next(t for t in mcp_tools if get_tool_name(t) == "echo_echo")
|
|
assert "description" in echo_tool or "definition" in echo_tool
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_tool_invocation():
|
|
"""MCP tools should be callable via POST /tools and return correct results."""
|
|
global server
|
|
mcp_json = _mcp_config_json({
|
|
"echo": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
# Call echo_echo
|
|
res = server.make_request("POST", "/tools", data={
|
|
"tool": "echo_echo",
|
|
"params": {"message": "hello world"}
|
|
})
|
|
assert res.status_code == 200, res.body
|
|
body = res.body
|
|
assert "error" not in body, body
|
|
# The result format depends on the tool implementation
|
|
# For MCP tools, it should contain the tool result
|
|
assert "plain_text_response" in body or "result" in body or "content" in body, body
|
|
|
|
# Call echo_add
|
|
res = server.make_request("POST", "/tools", data={
|
|
"tool": "echo_add",
|
|
"params": {"a": 3, "b": 5}
|
|
})
|
|
assert res.status_code == 200, res.body
|
|
body = res.body
|
|
assert "error" not in body, body
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_bad_command_does_not_crash():
|
|
"""A misconfigured MCP server should not crash the llama-server."""
|
|
global server
|
|
mcp_json = _mcp_config_json({
|
|
"nonexistent": {
|
|
"command": "this_executable_does_not_exist_12345",
|
|
"args": [],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
# Server should still be healthy
|
|
res = server.make_request("GET", "/health")
|
|
assert res.status_code == 200, res.body
|
|
|
|
# Builtin tools should still work
|
|
res = server.make_request("GET", "/tools")
|
|
assert res.status_code == 200, res.body
|
|
tools = res.body
|
|
# Should have builtin tools but no MCP tools from the bad server
|
|
mcp_tools = [t for t in tools if t.get("name", "").startswith("nonexistent_")]
|
|
assert len(mcp_tools) == 0, f"Expected no nonexistent_ tools, got {mcp_tools}"
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_multiple_servers():
|
|
"""Multiple MCP servers can be configured simultaneously."""
|
|
global server
|
|
mcp_json = _mcp_config_json({
|
|
"echo": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
},
|
|
"echo2": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
res = server.make_request("GET", "/tools")
|
|
assert res.status_code == 200, res.body
|
|
|
|
tools = res.body
|
|
|
|
def get_tool_name(t):
|
|
return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")
|
|
|
|
echo_tools = [t for t in tools if get_tool_name(t).startswith("echo_")]
|
|
echo2_tools = [t for t in tools if get_tool_name(t).startswith("echo2_")]
|
|
|
|
assert len(echo_tools) >= 2, f"Expected echo_ tools, got {echo_tools}"
|
|
assert len(echo2_tools) >= 2, f"Expected echo2_ tools, got {echo2_tools}"
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_tools_not_listed_when_not_configured():
|
|
"""Without MCP config, no MCP tools should appear."""
|
|
global server
|
|
server = ServerPreset.router()
|
|
server.server_tools = "all"
|
|
server.no_ui = True
|
|
server.server_port = 8085
|
|
server.start()
|
|
|
|
try:
|
|
res = server.make_request("GET", "/tools")
|
|
assert res.status_code == 200, res.body
|
|
|
|
tools = res.body
|
|
|
|
def get_tool_name(t):
|
|
return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")
|
|
|
|
# Should only have builtin tools, no server: prefixed tools
|
|
mcp_tools = [t for t in tools if ":" in get_tool_name(t)]
|
|
assert len(mcp_tools) == 0, f"Expected no MCP tools, got {mcp_tools}"
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_fail_once_tool_eventual_success():
|
|
"""Test that a tool that fails once eventually succeeds (tests instance respawn)."""
|
|
global server
|
|
mcp_json = _mcp_config_json({
|
|
"echo": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
# First call should succeed (warmup already spawned and shut down the instance,
|
|
# but the first actual tool call will spawn a fresh instance)
|
|
res = server.make_request("POST", "/tools", data={
|
|
"tool": "echo_fail_once",
|
|
"params": {}
|
|
})
|
|
# It might fail on first call if the warmup instance was shut down
|
|
# and a new instance is spawned. The fail_once state is per-process,
|
|
# so a fresh process will fail once then succeed.
|
|
# Actually, warmup spawns, lists, then shuts down. So the first tool call
|
|
# spawns a new process which will fail once.
|
|
assert res.status_code in (200, 500), res.body
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_tools_via_json_config_file():
|
|
"""Test that --mcp-servers-config (file) works as well as --mcp-servers-json."""
|
|
global server
|
|
config = {
|
|
"mcpServers": {
|
|
"echo": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
}
|
|
}
|
|
}
|
|
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
json.dump(config, f)
|
|
config_path = f.name
|
|
|
|
try:
|
|
server = ServerPreset.router()
|
|
server.server_tools = "all"
|
|
server.no_ui = True
|
|
server.server_port = 8085
|
|
server.mcp_servers_config = config_path
|
|
server.start()
|
|
|
|
res = server.make_request("GET", "/tools")
|
|
assert res.status_code == 200, res.body
|
|
|
|
tools = res.body
|
|
|
|
def get_tool_name(t):
|
|
return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")
|
|
|
|
mcp_tools = [t for t in tools if get_tool_name(t).startswith("echo_")]
|
|
assert len(mcp_tools) >= 2, f"Expected echo_ tools, got {mcp_tools}"
|
|
finally:
|
|
os.unlink(config_path)
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_tools_slot_independent():
|
|
"""MCP tools should work without any slot concept; /tools is slot-independent."""
|
|
global server
|
|
mcp_json = _mcp_config_json({
|
|
"echo": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
# Call /tools without any slot binding - should succeed
|
|
res = server.make_request("POST", "/tools", data={
|
|
"tool": "echo_echo",
|
|
"params": {"message": "hello"}
|
|
})
|
|
assert res.status_code == 200, res.body
|
|
body = res.body
|
|
assert "error" not in body, body
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_concurrent_tool_calls():
|
|
"""Concurrent POST /tools to same MCP server should all succeed."""
|
|
global server
|
|
mcp_json = _mcp_config_json({
|
|
"echo": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
def call_tool():
|
|
return server.make_request("POST", "/tools", data={
|
|
"tool": "echo_echo",
|
|
"params": {"message": "hi"}
|
|
})
|
|
|
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
futures = [executor.submit(call_tool) for _ in range(10)]
|
|
results = [f.result() for f in futures]
|
|
|
|
for res in results:
|
|
assert res.status_code == 200, res.body
|
|
assert "error" not in res.body, res.body
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_tool_timeout():
|
|
"""Tool call should timeout if MCP server is too slow."""
|
|
global server
|
|
MCP_SLOW_SERVER = os.path.join(FIXTURES_DIR, "mcp_slow_server.py")
|
|
mcp_json = _mcp_config_json({
|
|
"slow": {
|
|
"command": sys.executable,
|
|
"args": [MCP_SLOW_SERVER, "--delay", "5"],
|
|
"timeout_ms": 500
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
res = server.make_request("POST", "/tools", data={
|
|
"tool": "slow_sleep",
|
|
"params": {"seconds": 5}
|
|
})
|
|
assert res.status_code == 200, res.body
|
|
body = res.body
|
|
assert "error" in body, body
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_warmup_partial_failure():
|
|
"""Good server's tools should appear even if bad server fails warmup."""
|
|
global server
|
|
mcp_json = _mcp_config_json({
|
|
"good": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
},
|
|
"bad": {
|
|
"command": "nonexistent",
|
|
"args": []
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
res = server.make_request("GET", "/tools")
|
|
assert res.status_code == 200, res.body
|
|
tools = res.body
|
|
|
|
def get_tool_name(t):
|
|
return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")
|
|
|
|
# good server tools should be present
|
|
assert any("good_" in get_tool_name(t) for t in tools), f"Expected good: tools in {tools}"
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_notification_during_request():
|
|
"""Notification during request should not be returned as response."""
|
|
global server
|
|
MCP_MALFORMED_SERVER = os.path.join(FIXTURES_DIR, "mcp_malformed_server.py")
|
|
mcp_json = _mcp_config_json({
|
|
"notifying": {
|
|
"command": sys.executable,
|
|
"args": [MCP_MALFORMED_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
res = server.make_request("POST", "/tools", data={
|
|
"tool": "notifying_echo",
|
|
"params": {"message": "hi"}
|
|
})
|
|
assert res.status_code == 200, res.body
|
|
body = res.body
|
|
assert "error" not in body, body
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_instance_respawn_after_crash():
|
|
"""Tool call after process crash should respawn and succeed."""
|
|
global server
|
|
MCP_CRASH_SERVER = os.path.join(FIXTURES_DIR, "mcp_crash_server.py")
|
|
mcp_json = _mcp_config_json({
|
|
"crash": {
|
|
"command": sys.executable,
|
|
"args": [MCP_CRASH_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
# First call succeeds
|
|
res1 = server.make_request("POST", "/tools", data={
|
|
"tool": "crash_echo",
|
|
"params": {"message": "hi"}
|
|
})
|
|
assert res1.status_code == 200, res1.body
|
|
assert "error" not in res1.body, res1.body
|
|
|
|
# Second call should also succeed (respawned instance)
|
|
res2 = server.make_request("POST", "/tools", data={
|
|
"tool": "crash_echo",
|
|
"params": {"message": "hi2"}
|
|
})
|
|
assert res2.status_code == 200, res2.body
|
|
assert "error" not in res2.body, res2.body
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
|
|
|
|
def test_mcp_fail_once_eventual_success_verified():
|
|
"""Verify that fail_once tool eventually succeeds after respawn."""
|
|
global server
|
|
mcp_json = _mcp_config_json({
|
|
"echo": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
# First call may fail (fresh process)
|
|
res1 = server.make_request("POST", "/tools", data={
|
|
"tool": "echo_fail_once",
|
|
"params": {}
|
|
})
|
|
# Second call should succeed
|
|
res2 = server.make_request("POST", "/tools", data={
|
|
"tool": "echo_fail_once",
|
|
"params": {}
|
|
})
|
|
assert res2.status_code == 200, res2.body
|
|
assert "error" not in res2.body, res2.body
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_config_file_errors():
|
|
"""Invalid JSON config and missing file should cause server to fail to start."""
|
|
# Invalid JSON - server should fail to start
|
|
server = ServerPreset.router()
|
|
server.server_tools = "all"
|
|
server.no_ui = True
|
|
server.server_port = 8085
|
|
server.mcp_servers_json = "not valid json"
|
|
try:
|
|
server.start()
|
|
assert False, "Server should not have started with invalid MCP JSON config"
|
|
except RuntimeError:
|
|
pass # Expected: server process dies due to bad config
|
|
|
|
# Missing file - server should fail to start
|
|
server = ServerPreset.router()
|
|
server.server_tools = "all"
|
|
server.no_ui = True
|
|
server.server_port = 8085
|
|
server.mcp_servers_config = "/nonexistent/path.json"
|
|
try:
|
|
server.start()
|
|
assert False, "Server should not have started with missing config file"
|
|
except RuntimeError:
|
|
pass # Expected: server process dies due to missing config
|
|
|
|
|
|
def test_mcp_empty_tool_list():
|
|
"""MCP server reporting zero tools should result in empty tool list."""
|
|
global server
|
|
# Create a minimal server that returns empty tools list
|
|
empty_server = os.path.join(FIXTURES_DIR, "_empty_mcp_server.py")
|
|
with open(empty_server, "w") as f:
|
|
f.write('''#!/usr/bin/env python3
|
|
import json, sys, os
|
|
def main():
|
|
sys.stdout = os.fdopen(sys.stdout.fileno(), "w", buffering=1)
|
|
for line in sys.stdin:
|
|
line = line.strip()
|
|
if not line: continue
|
|
try: request = json.loads(line)
|
|
except: continue
|
|
method = request.get("method")
|
|
req_id = request.get("id")
|
|
if method == "initialize":
|
|
resp = {"jsonrpc": "2.0", "id": req_id, "result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "empty", "version": "1.0"}}}
|
|
elif method == "tools/list":
|
|
resp = {"jsonrpc": "2.0", "id": req_id, "result": {"tools": []}}
|
|
else:
|
|
resp = {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": "Method not found"}}
|
|
sys.stdout.write(json.dumps(resp) + "\\n")
|
|
sys.stdout.flush()
|
|
if __name__ == "__main__":
|
|
main()
|
|
''')
|
|
try:
|
|
mcp_json = _mcp_config_json({
|
|
"empty": {
|
|
"command": sys.executable,
|
|
"args": [empty_server],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
res = server.make_request("GET", "/tools")
|
|
assert res.status_code == 200, res.body
|
|
tools = res.body
|
|
def get_tool_name(t):
|
|
return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")
|
|
mcp_tools = [t for t in tools if get_tool_name(t).startswith("empty:")]
|
|
assert len(mcp_tools) == 0, f"Expected no empty: tools, got {mcp_tools}"
|
|
finally:
|
|
os.unlink(empty_server)
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_rapid_succession_calls():
|
|
"""Many rapid calls should increment next_id correctly and correlate responses."""
|
|
global server
|
|
mcp_json = _mcp_config_json({
|
|
"echo": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
for i in range(20):
|
|
res = server.make_request("POST", "/tools", data={
|
|
"tool": "echo_echo",
|
|
"params": {"message": f"msg{i}"}
|
|
})
|
|
assert res.status_code == 200, res.body
|
|
assert "error" not in res.body, res.body
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_notification_burst():
|
|
"""Notification + response in a single write() with no flush should not strand the response."""
|
|
global server
|
|
MCP_BURST_SERVER = os.path.join(FIXTURES_DIR, "mcp_burst_server.py")
|
|
mcp_json = _mcp_config_json({
|
|
"burst": {
|
|
"command": sys.executable,
|
|
"args": [MCP_BURST_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
res = server.make_request("POST", "/tools", data={
|
|
"tool": "burst_echo",
|
|
"params": {"message": "burst test"}
|
|
})
|
|
assert res.status_code == 200, res.body
|
|
body = res.body
|
|
assert "error" not in body, body
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_tool_definition_shape_via_chat_completions():
|
|
"""MCP tool definitions returned by GET /tools should have the correct shape for chat/completions."""
|
|
global server
|
|
mcp_json = _mcp_config_json({
|
|
"echo": {
|
|
"command": sys.executable,
|
|
"args": [MCP_ECHO_SERVER],
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
# Get MCP tool definitions
|
|
res = server.make_request("GET", "/tools")
|
|
assert res.status_code == 200, res.body
|
|
tools = res.body
|
|
|
|
def get_tool_name(t):
|
|
return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")
|
|
|
|
echo_tools = [t for t in tools if get_tool_name(t).startswith("echo_")]
|
|
assert len(echo_tools) >= 2, f"Expected echo_ tools, got {echo_tools}"
|
|
|
|
echo_tool = next(t for t in echo_tools if get_tool_name(t) == "echo_echo")
|
|
definition = echo_tool.get("definition", echo_tool)
|
|
|
|
# Verify the definition has the standard function-calling shape
|
|
assert definition.get("type") == "function", f"Expected type=function, got {definition.get('type')}"
|
|
func = definition.get("function", {})
|
|
assert "name" in func, "Missing function.name"
|
|
assert "description" in func, "Missing function.description"
|
|
assert "parameters" in func, f"Missing function.parameters, got keys: {list(func.keys())}"
|
|
params = func["parameters"]
|
|
assert params.get("type") == "object", f"Expected parameters.type=object, got {params.get('type')}"
|
|
assert "properties" in params, "Missing parameters.properties"
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_slow_tool_call_slot_release():
|
|
"""A slow tool call should not stall server shutdown for the full I/O timeout."""
|
|
global server
|
|
MCP_SLOW_SERVER = os.path.join(FIXTURES_DIR, "mcp_slow_server.py")
|
|
mcp_json = _mcp_config_json({
|
|
"slow": {
|
|
"command": sys.executable,
|
|
"args": [MCP_SLOW_SERVER, "--delay", "10"],
|
|
"timeout_ms": 30000
|
|
}
|
|
})
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
# Start a slow tool call in a background thread
|
|
def slow_call():
|
|
return server.make_request("POST", "/tools", data={
|
|
"tool": "slow_sleep",
|
|
"params": {"seconds": 10}
|
|
})
|
|
|
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
future = executor.submit(slow_call)
|
|
|
|
# Wait a moment for the call to start
|
|
time.sleep(2)
|
|
|
|
# Stop the server while the tool call is in progress.
|
|
# With global MCP instances, close_all() is called explicitly at shutdown
|
|
# (not from slot release), so shutdown should complete promptly.
|
|
start_time = time.time()
|
|
server.stop()
|
|
elapsed = time.time() - start_time
|
|
|
|
# The server should stop quickly, not wait for the full 30s I/O timeout.
|
|
# With the terminating flag, send_rpc() bails out within one select()
|
|
# slice (~50ms). This threshold MUST stay below the 5s force-kill
|
|
# fallback in ServerProcess.stop(): without the flag, shutdown stalls
|
|
# on the instance mutex and only completes when stop() sends SIGKILL
|
|
# at ~5s -- which any threshold above 5 would still accept.
|
|
assert elapsed < 3, f"Server stop took {elapsed:.1f}s, expected < 3s"
|
|
|
|
# Wait for the future to complete (it will get an error response or timeout)
|
|
try:
|
|
res = future.result(timeout=5)
|
|
# If we got a response, it should be an error since the server stopped
|
|
if hasattr(res, 'status_code'):
|
|
assert res.status_code in (200, 500, 502, 503, 504), f"Unexpected status: {res.status_code}"
|
|
except Exception:
|
|
# Thread may have raised due to connection error - that's acceptable
|
|
pass
|
|
finally:
|
|
server.stop()
|
|
|
|
|
|
def test_mcp_grandchild_holding_pipes_does_not_deadlock():
|
|
"""An MCP server that leaves a grandchild inheriting its stdout/stderr must not deadlock
|
|
teardown.
|
|
|
|
subprocess_terminate() only SIGKILLs the direct MCP child, so the inherited pipe write ends
|
|
stay open and a blocking read on them would never see EOF. That hung both warmup shutdown
|
|
(the server would never reach "ready") and process shutdown. The polled, running-aware reader
|
|
must exit regardless, so the server both starts and stops promptly here.
|
|
"""
|
|
global server
|
|
MCP_GRANDCHILD_SERVER = os.path.join(FIXTURES_DIR, "mcp_grandchild_server.py")
|
|
mcp_json = _mcp_config_json({
|
|
"gc": {
|
|
"command": sys.executable,
|
|
"args": [MCP_GRANDCHILD_SERVER],
|
|
}
|
|
})
|
|
|
|
# If warmup teardown deadlocked, the server would never become ready and start() would time out.
|
|
server = _start_server_with_mcp(mcp_json)
|
|
|
|
try:
|
|
# invoking the tool spawns a live transport whose reader thread holds the inherited pipe
|
|
res = server.make_request("POST", "/tools", data={
|
|
"tool": "gc_echo",
|
|
"params": {"message": "hello"}
|
|
})
|
|
assert res.status_code == 200, res.body
|
|
assert "error" not in res.body, res.body
|
|
|
|
# shutdown must be prompt: a deadlocked reader-join would stall until the 5s SIGKILL
|
|
# fallback in ServerProcess.stop(), so the threshold has to stay below that
|
|
start = time.time()
|
|
server.stop()
|
|
elapsed = time.time() - start
|
|
assert elapsed < 3, f"server shutdown took {elapsed:.1f}s (expected < 3s) — teardown likely deadlocked"
|
|
finally:
|
|
server.stop()
|