Team Ai
Modelpublic

Felipe97/llama-cpp-compiled

sourceHugging Faceupdated 21d agoView on Hugging Face
0likes1.2kdownloads
test_mcp_servers.py714 linesDownload Raw Back to unit
1#!/usr/bin/env python32"""3Tests for MCP server integration via the /tools endpoint.4 5Invariants verified:61. MCP tools appear in /tools listing when configured72. MCP tools use <server>_<tool> naming83. MCP tools can be invoked and return correct results94. Misconfigured MCP servers do not crash the server105. Multiple MCP servers can be configured simultaneously116. Warmup populates the tool list at startup12"""13import json14import os15import sys16import tempfile17import time18 19import pytest20 21from utils import *22 23# Path to the test MCP server fixture24FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "fixtures")25MCP_ECHO_SERVER = os.path.join(FIXTURES_DIR, "mcp_echo_server.py")26 27server: ServerProcess28 29 30def _mcp_config_json(servers: dict) -> str:31    """Create a JSON config string for --mcp-servers-json."""32    return json.dumps({"mcpServers": servers})33 34 35def _start_server_with_mcp(mcp_json: str, **kwargs) -> ServerProcess:36    """Helper to start a router server with MCP config."""37    srv = ServerPreset.router()38    srv.server_tools = "all"39    srv.no_ui = True40    srv.mcp_servers_json = mcp_json41    for k, v in kwargs.items():42        setattr(srv, k, v)43    srv.start()44    return srv45 46 47def test_mcp_tools_listed_in_tools_endpoint():48    """MCP tools should appear in GET /tools with server:tool naming."""49    global server50    mcp_json = _mcp_config_json({51        "echo": {52            "command": sys.executable,53            "args": [MCP_ECHO_SERVER],54        }55    })56    server = _start_server_with_mcp(mcp_json)57 58    try:59        res = server.make_request("GET", "/tools")60        assert res.status_code == 200, res.body61 62        tools = res.body63        assert isinstance(tools, list), f"Expected list, got {type(tools)}"64 65        # Find MCP tools - name is in "tool" field or definition.function.name66        def get_tool_name(t):67            return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")68 69        mcp_tools = [t for t in tools if get_tool_name(t).startswith("echo_")]70        assert len(mcp_tools) >= 2, f"Expected at least 2 echo_ tools, got {len(mcp_tools)}: {mcp_tools}"71 72        tool_names = {get_tool_name(t) for t in mcp_tools}73        assert "echo_echo" in tool_names74        assert "echo_add" in tool_names75 76        # Verify tool structure77        echo_tool = next(t for t in mcp_tools if get_tool_name(t) == "echo_echo")78        assert "description" in echo_tool or "definition" in echo_tool79    finally:80        server.stop()81 82 83def test_mcp_tool_invocation():84    """MCP tools should be callable via POST /tools and return correct results."""85    global server86    mcp_json = _mcp_config_json({87        "echo": {88            "command": sys.executable,89            "args": [MCP_ECHO_SERVER],90        }91    })92    server = _start_server_with_mcp(mcp_json)93 94    try:95        # Call echo_echo96        res = server.make_request("POST", "/tools", data={97            "tool": "echo_echo",98            "params": {"message": "hello world"}99        })100        assert res.status_code == 200, res.body101        body = res.body102        assert "error" not in body, body103        # The result format depends on the tool implementation104        # For MCP tools, it should contain the tool result105        assert "plain_text_response" in body or "result" in body or "content" in body, body106 107        # Call echo_add108        res = server.make_request("POST", "/tools", data={109            "tool": "echo_add",110            "params": {"a": 3, "b": 5}111        })112        assert res.status_code == 200, res.body113        body = res.body114        assert "error" not in body, body115    finally:116        server.stop()117 118 119def test_mcp_bad_command_does_not_crash():120    """A misconfigured MCP server should not crash the llama-server."""121    global server122    mcp_json = _mcp_config_json({123        "nonexistent": {124            "command": "this_executable_does_not_exist_12345",125            "args": [],126        }127    })128    server = _start_server_with_mcp(mcp_json)129 130    try:131        # Server should still be healthy132        res = server.make_request("GET", "/health")133        assert res.status_code == 200, res.body134 135        # Builtin tools should still work136        res = server.make_request("GET", "/tools")137        assert res.status_code == 200, res.body138        tools = res.body139        # Should have builtin tools but no MCP tools from the bad server140        mcp_tools = [t for t in tools if t.get("name", "").startswith("nonexistent_")]141        assert len(mcp_tools) == 0, f"Expected no nonexistent_ tools, got {mcp_tools}"142    finally:143        server.stop()144 145 146def test_mcp_multiple_servers():147    """Multiple MCP servers can be configured simultaneously."""148    global server149    mcp_json = _mcp_config_json({150        "echo": {151            "command": sys.executable,152            "args": [MCP_ECHO_SERVER],153        },154        "echo2": {155            "command": sys.executable,156            "args": [MCP_ECHO_SERVER],157        }158    })159    server = _start_server_with_mcp(mcp_json)160 161    try:162        res = server.make_request("GET", "/tools")163        assert res.status_code == 200, res.body164 165        tools = res.body166 167        def get_tool_name(t):168            return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")169 170        echo_tools = [t for t in tools if get_tool_name(t).startswith("echo_")]171        echo2_tools = [t for t in tools if get_tool_name(t).startswith("echo2_")]172 173        assert len(echo_tools) >= 2, f"Expected echo_ tools, got {echo_tools}"174        assert len(echo2_tools) >= 2, f"Expected echo2_ tools, got {echo2_tools}"175    finally:176        server.stop()177 178 179def test_mcp_tools_not_listed_when_not_configured():180    """Without MCP config, no MCP tools should appear."""181    global server182    server = ServerPreset.router()183    server.server_tools = "all"184    server.no_ui = True185    server.start()186 187    try:188        res = server.make_request("GET", "/tools")189        assert res.status_code == 200, res.body190 191        tools = res.body192 193        def get_tool_name(t):194            return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")195 196        # Should only have builtin tools, no server: prefixed tools197        mcp_tools = [t for t in tools if ":" in get_tool_name(t)]198        assert len(mcp_tools) == 0, f"Expected no MCP tools, got {mcp_tools}"199    finally:200        server.stop()201 202 203def test_mcp_fail_once_tool_eventual_success():204    """Test that a tool that fails once eventually succeeds (tests instance respawn)."""205    global server206    mcp_json = _mcp_config_json({207        "echo": {208            "command": sys.executable,209            "args": [MCP_ECHO_SERVER],210        }211    })212    server = _start_server_with_mcp(mcp_json)213 214    try:215        # First call should succeed (warmup already spawned and shut down the instance,216        # but the first actual tool call will spawn a fresh instance)217        res = server.make_request("POST", "/tools", data={218            "tool": "echo_fail_once",219            "params": {}220        })221        # It might fail on first call if the warmup instance was shut down222        # and a new instance is spawned. The fail_once state is per-process,223        # so a fresh process will fail once then succeed.224        # Actually, warmup spawns, lists, then shuts down. So the first tool call225        # spawns a new process which will fail once.226        assert res.status_code in (200, 500), res.body227    finally:228        server.stop()229 230 231def test_mcp_tools_via_json_config_file():232    """Test that --mcp-servers-config (file) works as well as --mcp-servers-json."""233    global server234    config = {235        "mcpServers": {236            "echo": {237                "command": sys.executable,238                "args": [MCP_ECHO_SERVER],239            }240        }241    }242 243    with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:244        json.dump(config, f)245        config_path = f.name246 247    try:248        server = ServerPreset.router()249        server.server_tools = "all"250        server.no_ui = True251        server.mcp_servers_config = config_path252        server.start()253 254        res = server.make_request("GET", "/tools")255        assert res.status_code == 200, res.body256 257        tools = res.body258 259        def get_tool_name(t):260            return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")261 262        mcp_tools = [t for t in tools if get_tool_name(t).startswith("echo_")]263        assert len(mcp_tools) >= 2, f"Expected echo_ tools, got {mcp_tools}"264    finally:265        os.unlink(config_path)266        server.stop()267 268 269def test_mcp_tools_slot_independent():270    """MCP tools should work without any slot concept; /tools is slot-independent."""271    global server272    mcp_json = _mcp_config_json({273        "echo": {274            "command": sys.executable,275            "args": [MCP_ECHO_SERVER],276        }277    })278    server = _start_server_with_mcp(mcp_json)279 280    try:281        # Call /tools without any slot binding - should succeed282        res = server.make_request("POST", "/tools", data={283            "tool": "echo_echo",284            "params": {"message": "hello"}285        })286        assert res.status_code == 200, res.body287        body = res.body288        assert "error" not in body, body289    finally:290        server.stop()291 292 293def test_mcp_concurrent_tool_calls():294    """Concurrent POST /tools to same MCP server should all succeed."""295    global server296    mcp_json = _mcp_config_json({297        "echo": {298            "command": sys.executable,299            "args": [MCP_ECHO_SERVER],300        }301    })302    server = _start_server_with_mcp(mcp_json)303 304    try:305        def call_tool():306            return server.make_request("POST", "/tools", data={307                "tool": "echo_echo",308                "params": {"message": "hi"}309            })310 311        with ThreadPoolExecutor(max_workers=10) as executor:312            futures = [executor.submit(call_tool) for _ in range(10)]313            results = [f.result() for f in futures]314 315        for res in results:316            assert res.status_code == 200, res.body317            assert "error" not in res.body, res.body318    finally:319        server.stop()320 321 322def test_mcp_tool_timeout():323    """Tool call should timeout if MCP server is too slow."""324    global server325    MCP_SLOW_SERVER = os.path.join(FIXTURES_DIR, "mcp_slow_server.py")326    mcp_json = _mcp_config_json({327        "slow": {328            "command": sys.executable,329            "args": [MCP_SLOW_SERVER, "--delay", "5"],330            "timeout_ms": 500331        }332    })333    server = _start_server_with_mcp(mcp_json)334 335    try:336        res = server.make_request("POST", "/tools", data={337            "tool": "slow_sleep",338            "params": {"seconds": 5}339        })340        assert res.status_code == 200, res.body341        body = res.body342        assert "error" in body, body343    finally:344        server.stop()345 346 347def test_mcp_warmup_partial_failure():348    """Good server's tools should appear even if bad server fails warmup."""349    global server350    mcp_json = _mcp_config_json({351        "good": {352            "command": sys.executable,353            "args": [MCP_ECHO_SERVER],354        },355        "bad": {356            "command": "nonexistent",357            "args": []358        }359    })360    server = _start_server_with_mcp(mcp_json)361 362    try:363        res = server.make_request("GET", "/tools")364        assert res.status_code == 200, res.body365        tools = res.body366 367        def get_tool_name(t):368            return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")369 370        # good server tools should be present371        assert any("good_" in get_tool_name(t) for t in tools), f"Expected good: tools in {tools}"372    finally:373        server.stop()374 375 376def test_mcp_notification_during_request():377    """Notification during request should not be returned as response."""378    global server379    MCP_MALFORMED_SERVER = os.path.join(FIXTURES_DIR, "mcp_malformed_server.py")380    mcp_json = _mcp_config_json({381        "notifying": {382            "command": sys.executable,383            "args": [MCP_MALFORMED_SERVER],384        }385    })386    server = _start_server_with_mcp(mcp_json)387 388    try:389        res = server.make_request("POST", "/tools", data={390            "tool": "notifying_echo",391            "params": {"message": "hi"}392        })393        assert res.status_code == 200, res.body394        body = res.body395        assert "error" not in body, body396    finally:397        server.stop()398 399 400def test_mcp_instance_respawn_after_crash():401    """Tool call after process crash should respawn and succeed."""402    global server403    MCP_CRASH_SERVER = os.path.join(FIXTURES_DIR, "mcp_crash_server.py")404    mcp_json = _mcp_config_json({405        "crash": {406            "command": sys.executable,407            "args": [MCP_CRASH_SERVER],408        }409    })410    server = _start_server_with_mcp(mcp_json)411 412    try:413        # First call succeeds414        res1 = server.make_request("POST", "/tools", data={415            "tool": "crash_echo",416            "params": {"message": "hi"}417        })418        assert res1.status_code == 200, res1.body419        assert "error" not in res1.body, res1.body420 421        # Second call should also succeed (respawned instance)422        res2 = server.make_request("POST", "/tools", data={423            "tool": "crash_echo",424            "params": {"message": "hi2"}425        })426        assert res2.status_code == 200, res2.body427        assert "error" not in res2.body, res2.body428    finally:429        server.stop()430 431 432 433 434def test_mcp_fail_once_eventual_success_verified():435    """Verify that fail_once tool eventually succeeds after respawn."""436    global server437    mcp_json = _mcp_config_json({438        "echo": {439            "command": sys.executable,440            "args": [MCP_ECHO_SERVER],441        }442    })443    server = _start_server_with_mcp(mcp_json)444 445    try:446        # First call may fail (fresh process)447        res1 = server.make_request("POST", "/tools", data={448            "tool": "echo_fail_once",449            "params": {}450        })451        # Second call should succeed452        res2 = server.make_request("POST", "/tools", data={453            "tool": "echo_fail_once",454            "params": {}455        })456        assert res2.status_code == 200, res2.body457        assert "error" not in res2.body, res2.body458    finally:459        server.stop()460 461 462def test_mcp_config_file_errors():463    """Invalid JSON config and missing file should cause server to fail to start."""464    # Invalid JSON - server should fail to start465    server = ServerPreset.router()466    server.server_tools = "all"467    server.no_ui = True468    server.mcp_servers_json = "not valid json"469    try:470        server.start()471        assert False, "Server should not have started with invalid MCP JSON config"472    except RuntimeError:473        pass  # Expected: server process dies due to bad config474 475    # Missing file - server should fail to start476    server = ServerPreset.router()477    server.server_tools = "all"478    server.no_ui = True479    server.mcp_servers_config = "/nonexistent/path.json"480    try:481        server.start()482        assert False, "Server should not have started with missing config file"483    except RuntimeError:484        pass  # Expected: server process dies due to missing config485 486 487def test_mcp_empty_tool_list():488    """MCP server reporting zero tools should result in empty tool list."""489    global server490    # Create a minimal server that returns empty tools list491    empty_server = os.path.join(FIXTURES_DIR, "_empty_mcp_server.py")492    with open(empty_server, "w") as f:493        f.write('''#!/usr/bin/env python3494import json, sys, os495def main():496    sys.stdout = os.fdopen(sys.stdout.fileno(), "w", buffering=1)497    for line in sys.stdin:498        line = line.strip()499        if not line: continue500        try: request = json.loads(line)501        except: continue502        method = request.get("method")503        req_id = request.get("id")504        if method == "initialize":505            resp = {"jsonrpc": "2.0", "id": req_id, "result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "empty", "version": "1.0"}}}506        elif method == "tools/list":507            resp = {"jsonrpc": "2.0", "id": req_id, "result": {"tools": []}}508        else:509            resp = {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": "Method not found"}}510        sys.stdout.write(json.dumps(resp) + "\\n")511        sys.stdout.flush()512if __name__ == "__main__":513    main()514''')515    try:516        mcp_json = _mcp_config_json({517            "empty": {518                "command": sys.executable,519                "args": [empty_server],520            }521        })522        server = _start_server_with_mcp(mcp_json)523        res = server.make_request("GET", "/tools")524        assert res.status_code == 200, res.body525        tools = res.body526        def get_tool_name(t):527            return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")528        mcp_tools = [t for t in tools if get_tool_name(t).startswith("empty:")]529        assert len(mcp_tools) == 0, f"Expected no empty: tools, got {mcp_tools}"530    finally:531        os.unlink(empty_server)532        server.stop()533 534 535def test_mcp_rapid_succession_calls():536    """Many rapid calls should increment next_id correctly and correlate responses."""537    global server538    mcp_json = _mcp_config_json({539        "echo": {540            "command": sys.executable,541            "args": [MCP_ECHO_SERVER],542        }543    })544    server = _start_server_with_mcp(mcp_json)545 546    try:547        for i in range(20):548            res = server.make_request("POST", "/tools", data={549                "tool": "echo_echo",550                "params": {"message": f"msg{i}"}551            })552            assert res.status_code == 200, res.body553            assert "error" not in res.body, res.body554    finally:555        server.stop()556 557 558def test_mcp_notification_burst():559    """Notification + response in a single write() with no flush should not strand the response."""560    global server561    MCP_BURST_SERVER = os.path.join(FIXTURES_DIR, "mcp_burst_server.py")562    mcp_json = _mcp_config_json({563        "burst": {564            "command": sys.executable,565            "args": [MCP_BURST_SERVER],566        }567    })568    server = _start_server_with_mcp(mcp_json)569 570    try:571        res = server.make_request("POST", "/tools", data={572            "tool": "burst_echo",573            "params": {"message": "burst test"}574        })575        assert res.status_code == 200, res.body576        body = res.body577        assert "error" not in body, body578    finally:579        server.stop()580 581 582def test_mcp_tool_definition_shape_via_chat_completions():583    """MCP tool definitions returned by GET /tools should have the correct shape for chat/completions."""584    global server585    mcp_json = _mcp_config_json({586        "echo": {587            "command": sys.executable,588            "args": [MCP_ECHO_SERVER],589        }590    })591    server = _start_server_with_mcp(mcp_json)592 593    try:594        # Get MCP tool definitions595        res = server.make_request("GET", "/tools")596        assert res.status_code == 200, res.body597        tools = res.body598 599        def get_tool_name(t):600            return t.get("tool", "") or t.get("definition", {}).get("function", {}).get("name", "")601 602        echo_tools = [t for t in tools if get_tool_name(t).startswith("echo_")]603        assert len(echo_tools) >= 2, f"Expected echo_ tools, got {echo_tools}"604 605        echo_tool = next(t for t in echo_tools if get_tool_name(t) == "echo_echo")606        definition = echo_tool.get("definition", echo_tool)607 608        # Verify the definition has the standard function-calling shape609        assert definition.get("type") == "function", f"Expected type=function, got {definition.get('type')}"610        func = definition.get("function", {})611        assert "name" in func, "Missing function.name"612        assert "description" in func, "Missing function.description"613        assert "parameters" in func, f"Missing function.parameters, got keys: {list(func.keys())}"614        params = func["parameters"]615        assert params.get("type") == "object", f"Expected parameters.type=object, got {params.get('type')}"616        assert "properties" in params, "Missing parameters.properties"617    finally:618        server.stop()619 620 621def test_mcp_slow_tool_call_slot_release():622    """A slow tool call should not stall server shutdown for the full I/O timeout."""623    global server624    MCP_SLOW_SERVER = os.path.join(FIXTURES_DIR, "mcp_slow_server.py")625    mcp_json = _mcp_config_json({626        "slow": {627            "command": sys.executable,628            "args": [MCP_SLOW_SERVER, "--delay", "10"],629            "timeout_ms": 30000630        }631    })632    server = _start_server_with_mcp(mcp_json)633 634    try:635        # Start a slow tool call in a background thread636        def slow_call():637            return server.make_request("POST", "/tools", data={638                "tool": "slow_sleep",639                "params": {"seconds": 10}640            })641 642        with ThreadPoolExecutor(max_workers=1) as executor:643            future = executor.submit(slow_call)644 645            # Wait a moment for the call to start646            time.sleep(2)647 648            # Stop the server while the tool call is in progress.649            # With global MCP instances, close_all() is called explicitly at shutdown650            # (not from slot release), so shutdown should complete promptly.651            start_time = time.time()652            server.stop()653            elapsed = time.time() - start_time654 655            # The server should stop quickly, not wait for the full 30s I/O timeout.656            # With the terminating flag, send_rpc() bails out within one select()657            # slice (~50ms). This threshold MUST stay below the 5s force-kill658            # fallback in ServerProcess.stop(): without the flag, shutdown stalls659            # on the instance mutex and only completes when stop() sends SIGKILL660            # at ~5s -- which any threshold above 5 would still accept.661            assert elapsed < 3, f"Server stop took {elapsed:.1f}s, expected < 3s"662 663            # Wait for the future to complete (it will get an error response or timeout)664            try:665                res = future.result(timeout=5)666                # If we got a response, it should be an error since the server stopped667                if hasattr(res, 'status_code'):668                    assert res.status_code in (200, 500, 502, 503, 504), f"Unexpected status: {res.status_code}"669            except Exception:670                # Thread may have raised due to connection error - that's acceptable671                pass672    finally:673        server.stop()674 675 676def test_mcp_grandchild_holding_pipes_does_not_deadlock():677    """An MCP server that leaves a grandchild inheriting its stdout/stderr must not deadlock678    teardown.679 680    subprocess_terminate() only SIGKILLs the direct MCP child, so the inherited pipe write ends681    stay open and a blocking read on them would never see EOF. That hung both warmup shutdown682    (the server would never reach "ready") and process shutdown. The polled, running-aware reader683    must exit regardless, so the server both starts and stops promptly here.684    """685    global server686    MCP_GRANDCHILD_SERVER = os.path.join(FIXTURES_DIR, "mcp_grandchild_server.py")687    mcp_json = _mcp_config_json({688        "gc": {689            "command": sys.executable,690            "args": [MCP_GRANDCHILD_SERVER],691        }692    })693 694    # If warmup teardown deadlocked, the server would never become ready and start() would time out.695    server = _start_server_with_mcp(mcp_json)696 697    try:698        # invoking the tool spawns a live transport whose reader thread holds the inherited pipe699        res = server.make_request("POST", "/tools", data={700            "tool": "gc_echo",701            "params": {"message": "hello"}702        })703        assert res.status_code == 200, res.body704        assert "error" not in res.body, res.body705 706        # shutdown must be prompt: a deadlocked reader-join would stall until the 5s SIGKILL707        # fallback in ServerProcess.stop(), so the threshold has to stay below that708        start = time.time()709        server.stop()710        elapsed = time.time() - start711        assert elapsed < 3, f"server shutdown took {elapsed:.1f}s (expected < 3s) — teardown likely deadlocked"712    finally:713        server.stop()714