Inital commit

This commit is contained in:
2026-07-29 01:00:10 -05:00
commit 23e8ea90e4
3301 changed files with 1376308 additions and 0 deletions
+718
View File
@@ -0,0 +1,718 @@
#!/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()