char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
42f0225fe
char/aliceai-t5-moe
1#!/usr/bin/env python3 2""" 3Tests for MCP server integration via the /tools endpoint. 4 5Invariants verified: 61. MCP tools appear in /tools listing when configured 72. MCP tools use <server>_<tool> naming 83. MCP tools can be invoked and return correct results 94. Misconfigured MCP servers do not crash the server 105. Multiple MCP servers can be configured simultaneously 116. Warmup populates the tool list at startup 12""" 13import json 14import os 15import sys 16import tempfile 17import time 18 19import pytest 20 21from utils import * 22 23# Path to the test MCP server fixture 24FIXTURES_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 :ServerProcess 28 29 30def _mcp_config_json (servers :dict )-> str : 31"""Create a JSON config string for --mcp-servers-json.""" 32return 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.""" 37srv = ServerPreset .router () 38srv .server_tools = "all" 39srv .no_ui = True 40srv .mcp_servers_json = mcp_json 41for k ,v in kwargs .items (): 42setattr (srv ,k ,v ) 43srv .start () 44return srv 45 46 47def test_mcp_tools_listed_in_tools_endpoint (): 48"""MCP tools should appear in GET /tools with server:tool naming.""" 49global server 50mcp_json = _mcp_config_json ({ 51"echo" : { 52"command" :sys .executable , 53"args" : [MCP_ECHO_SERVER ], 54 } 55 }) 56server = _start_server_with_mcp (mcp_json ) 57 58try : 59res = server .make_request ("GET" ,"/tools" ) 60assert res .status_code == 200 ,res .body 61 62tools = res .body 63assert isinstance (tools ,list ),f"Expected list, got { type ( tools ) } " 64 65# Find MCP tools - name is in "tool" field or definition.function.name 66def get_tool_name (t ): 67return t .get ("tool" ,"" )or t .get ("definition" , {}).get ("function" , {}).get ("name" ,"" ) 68 69mcp_tools = [t for t in tools if get_tool_name (t ).startswith ("echo_" )] 70assert len (mcp_tools )>= 2 ,f"Expected at least 2 echo_ tools, got { len ( mcp_tools ) } : { mcp_tools } " 71 72tool_names = {get_tool_name (t )for t in mcp_tools } 73assert "echo_echo" in tool_names 74assert "echo_add" in tool_names 75 76# Verify tool structure 77echo_tool = next (t for t in mcp_tools if get_tool_name (t )== "echo_echo" ) 78assert "description" in echo_tool or "definition" in echo_tool 79finally : 80server .stop () 81 82 83def test_mcp_tool_invocation (): 84"""MCP tools should be callable via POST /tools and return correct results.""" 85global server 86mcp_json = _mcp_config_json ({ 87"echo" : { 88"command" :sys .executable , 89"args" : [MCP_ECHO_SERVER ], 90 } 91 }) 92server = _start_server_with_mcp (mcp_json ) 93 94try : 95# Call echo_echo 96res = server .make_request ("POST" ,"/tools" ,data = { 97"tool" :"echo_echo" , 98"params" : {"message" :"hello world" } 99 }) 100assert res .status_code == 200 ,res .body 101body = res .body 102assert "error" not in body ,body 103# The result format depends on the tool implementation 104# For MCP tools, it should contain the tool result 105assert "plain_text_response" in body or "result" in body or "content" in body ,body 106 107# Call echo_add 108res = server .make_request ("POST" ,"/tools" ,data = { 109"tool" :"echo_add" , 110"params" : {"a" :3 ,"b" :5 } 111 }) 112assert res .status_code == 200 ,res .body 113body = res .body 114assert "error" not in body ,body 115finally : 116server .stop () 117 118 119def test_mcp_bad_command_does_not_crash (): 120"""A misconfigured MCP server should not crash the llama-server.""" 121global server 122mcp_json = _mcp_config_json ({ 123"nonexistent" : { 124"command" :"this_executable_does_not_exist_12345" , 125"args" : [], 126 } 127 }) 128server = _start_server_with_mcp (mcp_json ) 129 130try : 131# Server should still be healthy 132res = server .make_request ("GET" ,"/health" ) 133assert res .status_code == 200 ,res .body 134 135# Builtin tools should still work 136res = server .make_request ("GET" ,"/tools" ) 137assert res .status_code == 200 ,res .body 138tools = res .body 139# Should have builtin tools but no MCP tools from the bad server 140mcp_tools = [t for t in tools if t .get ("name" ,"" ).startswith ("nonexistent_" )] 141assert len (mcp_tools )== 0 ,f"Expected no nonexistent_ tools, got { mcp_tools } " 142finally : 143server .stop () 144 145 146def test_mcp_multiple_servers (): 147"""Multiple MCP servers can be configured simultaneously.""" 148global server 149mcp_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 }) 159server = _start_server_with_mcp (mcp_json ) 160 161try : 162res = server .make_request ("GET" ,"/tools" ) 163assert res .status_code == 200 ,res .body 164 165tools = res .body 166 167def get_tool_name (t ): 168return t .get ("tool" ,"" )or t .get ("definition" , {}).get ("function" , {}).get ("name" ,"" ) 169 170echo_tools = [t for t in tools if get_tool_name (t ).startswith ("echo_" )] 171echo2_tools = [t for t in tools if get_tool_name (t ).startswith ("echo2_" )] 172 173assert len (echo_tools )>= 2 ,f"Expected echo_ tools, got { echo_tools } " 174assert len (echo2_tools )>= 2 ,f"Expected echo2_ tools, got { echo2_tools } " 175finally : 176server .stop () 177 178 179def test_mcp_tools_not_listed_when_not_configured (): 180"""Without MCP config, no MCP tools should appear.""" 181global server 182server = ServerPreset .router () 183server .server_tools = "all" 184server .no_ui = True 185server .start () 186 187try : 188res = server .make_request ("GET" ,"/tools" ) 189assert res .status_code == 200 ,res .body 190 191tools = res .body 192 193def get_tool_name (t ): 194return t .get ("tool" ,"" )or t .get ("definition" , {}).get ("function" , {}).get ("name" ,"" ) 195 196# Should only have builtin tools, no server: prefixed tools 197mcp_tools = [t for t in tools if ":" in get_tool_name (t )] 198assert len (mcp_tools )== 0 ,f"Expected no MCP tools, got { mcp_tools } " 199finally : 200server .stop () 201 202 203def test_mcp_fail_once_tool_eventual_success (): 204"""Test that a tool that fails once eventually succeeds (tests instance respawn).""" 205global server 206mcp_json = _mcp_config_json ({ 207"echo" : { 208"command" :sys .executable , 209"args" : [MCP_ECHO_SERVER ], 210 } 211 }) 212server = _start_server_with_mcp (mcp_json ) 213 214try : 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) 217res = 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 down 222# 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 call 225# spawns a new process which will fail once. 226assert res .status_code in (200 ,500 ),res .body 227finally : 228server .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.""" 233global server 234config = { 235"mcpServers" : { 236"echo" : { 237"command" :sys .executable , 238"args" : [MCP_ECHO_SERVER ], 239 } 240 } 241 } 242 243with tempfile .NamedTemporaryFile (mode = "w" ,suffix = ".json" ,delete = False )as f : 244json .dump (config ,f ) 245config_path = f .name 246 247try : 248server = ServerPreset .router () 249server .server_tools = "all" 250server .no_ui = True 251server .mcp_servers_config = config_path 252server .start () 253 254res = server .make_request ("GET" ,"/tools" ) 255assert res .status_code == 200 ,res .body 256 257tools = res .body 258 259def get_tool_name (t ): 260return t .get ("tool" ,"" )or t .get ("definition" , {}).get ("function" , {}).get ("name" ,"" ) 261 262mcp_tools = [t for t in tools if get_tool_name (t ).startswith ("echo_" )] 263assert len (mcp_tools )>= 2 ,f"Expected echo_ tools, got { mcp_tools } " 264finally : 265os .unlink (config_path ) 266server .stop () 267 268 269def test_mcp_tools_slot_independent (): 270"""MCP tools should work without any slot concept; /tools is slot-independent.""" 271global server 272mcp_json = _mcp_config_json ({ 273"echo" : { 274"command" :sys .executable , 275"args" : [MCP_ECHO_SERVER ], 276 } 277 }) 278server = _start_server_with_mcp (mcp_json ) 279 280try : 281# Call /tools without any slot binding - should succeed 282res = server .make_request ("POST" ,"/tools" ,data = { 283"tool" :"echo_echo" , 284"params" : {"message" :"hello" } 285 }) 286assert res .status_code == 200 ,res .body 287body = res .body 288assert "error" not in body ,body 289finally : 290server .stop () 291 292 293def test_mcp_concurrent_tool_calls (): 294"""Concurrent POST /tools to same MCP server should all succeed.""" 295global server 296mcp_json = _mcp_config_json ({ 297"echo" : { 298"command" :sys .executable , 299"args" : [MCP_ECHO_SERVER ], 300 } 301 }) 302server = _start_server_with_mcp (mcp_json ) 303 304try : 305def call_tool (): 306return server .make_request ("POST" ,"/tools" ,data = { 307"tool" :"echo_echo" , 308"params" : {"message" :"hi" } 309 }) 310 311with ThreadPoolExecutor (max_workers = 10 )as executor : 312futures = [executor .submit (call_tool )for _ in range (10 )] 313results = [f .result ()for f in futures ] 314 315for res in results : 316assert res .status_code == 200 ,res .body 317assert "error" not in res .body ,res .body 318finally : 319server .stop () 320 321 322def test_mcp_tool_timeout (): 323"""Tool call should timeout if MCP server is too slow.""" 324global server 325MCP_SLOW_SERVER = os .path .join (FIXTURES_DIR ,"mcp_slow_server.py" ) 326mcp_json = _mcp_config_json ({ 327"slow" : { 328"command" :sys .executable , 329"args" : [MCP_SLOW_SERVER ,"--delay" ,"5" ], 330"timeout_ms" :500 331 } 332 }) 333server = _start_server_with_mcp (mcp_json ) 334 335try : 336res = server .make_request ("POST" ,"/tools" ,data = { 337"tool" :"slow_sleep" , 338"params" : {"seconds" :5 } 339 }) 340assert res .status_code == 200 ,res .body 341body = res .body 342assert "error" in body ,body 343finally : 344server .stop () 345 346 347def test_mcp_warmup_partial_failure (): 348"""Good server's tools should appear even if bad server fails warmup.""" 349global server 350mcp_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 }) 360server = _start_server_with_mcp (mcp_json ) 361 362try : 363res = server .make_request ("GET" ,"/tools" ) 364assert res .status_code == 200 ,res .body 365tools = res .body 366 367def get_tool_name (t ): 368return t .get ("tool" ,"" )or t .get ("definition" , {}).get ("function" , {}).get ("name" ,"" ) 369 370# good server tools should be present 371assert any ("good_" in get_tool_name (t )for t in tools ),f"Expected good: tools in { tools } " 372finally : 373server .stop () 374 375 376def test_mcp_notification_during_request (): 377"""Notification during request should not be returned as response.""" 378global server 379MCP_MALFORMED_SERVER = os .path .join (FIXTURES_DIR ,"mcp_malformed_server.py" ) 380mcp_json = _mcp_config_json ({ 381"notifying" : { 382"command" :sys .executable , 383"args" : [MCP_MALFORMED_SERVER ], 384 } 385 }) 386server = _start_server_with_mcp (mcp_json ) 387 388try : 389res = server .make_request ("POST" ,"/tools" ,data = { 390"tool" :"notifying_echo" , 391"params" : {"message" :"hi" } 392 }) 393assert res .status_code == 200 ,res .body 394body = res .body 395assert "error" not in body ,body 396finally : 397server .stop () 398 399 400def test_mcp_instance_respawn_after_crash (): 401"""Tool call after process crash should respawn and succeed.""" 402global server 403MCP_CRASH_SERVER = os .path .join (FIXTURES_DIR ,"mcp_crash_server.py" ) 404mcp_json = _mcp_config_json ({ 405"crash" : { 406"command" :sys .executable , 407"args" : [MCP_CRASH_SERVER ], 408 } 409 }) 410server = _start_server_with_mcp (mcp_json ) 411 412try : 413# First call succeeds 414res1 = server .make_request ("POST" ,"/tools" ,data = { 415"tool" :"crash_echo" , 416"params" : {"message" :"hi" } 417 }) 418assert res1 .status_code == 200 ,res1 .body 419assert "error" not in res1 .body ,res1 .body 420 421# Second call should also succeed (respawned instance) 422res2 = server .make_request ("POST" ,"/tools" ,data = { 423"tool" :"crash_echo" , 424"params" : {"message" :"hi2" } 425 }) 426assert res2 .status_code == 200 ,res2 .body 427assert "error" not in res2 .body ,res2 .body 428finally : 429server .stop () 430 431 432 433 434def test_mcp_fail_once_eventual_success_verified (): 435"""Verify that fail_once tool eventually succeeds after respawn.""" 436global server 437mcp_json = _mcp_config_json ({ 438"echo" : { 439"command" :sys .executable , 440"args" : [MCP_ECHO_SERVER ], 441 } 442 }) 443server = _start_server_with_mcp (mcp_json ) 444 445try : 446# First call may fail (fresh process) 447res1 = server .make_request ("POST" ,"/tools" ,data = { 448"tool" :"echo_fail_once" , 449"params" : {} 450 }) 451# Second call should succeed 452res2 = server .make_request ("POST" ,"/tools" ,data = { 453"tool" :"echo_fail_once" , 454"params" : {} 455 }) 456assert res2 .status_code == 200 ,res2 .body 457assert "error" not in res2 .body ,res2 .body 458finally : 459server .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 start 465server = ServerPreset .router () 466server .server_tools = "all" 467server .no_ui = True 468server .mcp_servers_json = "not valid json" 469try : 470server .start () 471assert False ,"Server should not have started with invalid MCP JSON config" 472except RuntimeError : 473pass # Expected: server process dies due to bad config 474 475# Missing file - server should fail to start 476server = ServerPreset .router () 477server .server_tools = "all" 478server .no_ui = True 479server .mcp_servers_config = "/nonexistent/path.json" 480try : 481server .start () 482assert False ,"Server should not have started with missing config file" 483except RuntimeError : 484pass # Expected: server process dies due to missing config 485 486 487def test_mcp_empty_tool_list (): 488"""MCP server reporting zero tools should result in empty tool list.""" 489global server 490# Create a minimal server that returns empty tools list 491empty_server = os .path .join (FIXTURES_DIR ,"_empty_mcp_server.py" ) 492with open (empty_server ,"w" )as f : 493f .write ('''#!/usr/bin/env python3 494import json, sys, os 495def main(): 496sys.stdout = os.fdopen(sys.stdout.fileno(), "w", buffering=1) 497for line in sys.stdin: 498line = line.strip() 499if not line: continue 500try: request = json.loads(line) 501except: continue 502method = request.get("method") 503req_id = request.get("id") 504if method == "initialize": 505resp = {"jsonrpc": "2.0", "id": req_id, "result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "empty", "version": "1.0"}}} 506elif method == "tools/list": 507resp = {"jsonrpc": "2.0", "id": req_id, "result": {"tools": []}} 508else: 509resp = {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": "Method not found"}} 510sys.stdout.write(json.dumps(resp) + "\\n") 511sys.stdout.flush() 512if __name__ == "__main__": 513main() 514''' ) 515try : 516mcp_json = _mcp_config_json ({ 517"empty" : { 518"command" :sys .executable , 519"args" : [empty_server ], 520 } 521 }) 522server = _start_server_with_mcp (mcp_json ) 523res = server .make_request ("GET" ,"/tools" ) 524assert res .status_code == 200 ,res .body 525tools = res .body 526def get_tool_name (t ): 527return t .get ("tool" ,"" )or t .get ("definition" , {}).get ("function" , {}).get ("name" ,"" ) 528mcp_tools = [t for t in tools if get_tool_name (t ).startswith ("empty:" )] 529assert len (mcp_tools )== 0 ,f"Expected no empty: tools, got { mcp_tools } " 530finally : 531os .unlink (empty_server ) 532server .stop () 533 534 535def test_mcp_rapid_succession_calls (): 536"""Many rapid calls should increment next_id correctly and correlate responses.""" 537global server 538mcp_json = _mcp_config_json ({ 539"echo" : { 540"command" :sys .executable , 541"args" : [MCP_ECHO_SERVER ], 542 } 543 }) 544server = _start_server_with_mcp (mcp_json ) 545 546try : 547for i in range (20 ): 548res = server .make_request ("POST" ,"/tools" ,data = { 549"tool" :"echo_echo" , 550"params" : {"message" :f"msg { i } " } 551 }) 552assert res .status_code == 200 ,res .body 553assert "error" not in res .body ,res .body 554finally : 555server .stop () 556 557 558def test_mcp_notification_burst (): 559"""Notification + response in a single write() with no flush should not strand the response.""" 560global server 561MCP_BURST_SERVER = os .path .join (FIXTURES_DIR ,"mcp_burst_server.py" ) 562mcp_json = _mcp_config_json ({ 563"burst" : { 564"command" :sys .executable , 565"args" : [MCP_BURST_SERVER ], 566 } 567 }) 568server = _start_server_with_mcp (mcp_json ) 569 570try : 571res = server .make_request ("POST" ,"/tools" ,data = { 572"tool" :"burst_echo" , 573"params" : {"message" :"burst test" } 574 }) 575assert res .status_code == 200 ,res .body 576body = res .body 577assert "error" not in body ,body 578finally : 579server .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.""" 584global server 585mcp_json = _mcp_config_json ({ 586"echo" : { 587"command" :sys .executable , 588"args" : [MCP_ECHO_SERVER ], 589 } 590 }) 591server = _start_server_with_mcp (mcp_json ) 592 593try : 594# Get MCP tool definitions 595res = server .make_request ("GET" ,"/tools" ) 596assert res .status_code == 200 ,res .body 597tools = res .body 598 599def get_tool_name (t ): 600return t .get ("tool" ,"" )or t .get ("definition" , {}).get ("function" , {}).get ("name" ,"" ) 601 602echo_tools = [t for t in tools if get_tool_name (t ).startswith ("echo_" )] 603assert len (echo_tools )>= 2 ,f"Expected echo_ tools, got { echo_tools } " 604 605echo_tool = next (t for t in echo_tools if get_tool_name (t )== "echo_echo" ) 606definition = echo_tool .get ("definition" ,echo_tool ) 607 608# Verify the definition has the standard function-calling shape 609assert definition .get ("type" )== "function" ,f"Expected type=function, got { definition . get ( 'type' ) } " 610func = definition .get ("function" , {}) 611assert "name" in func ,"Missing function.name" 612assert "description" in func ,"Missing function.description" 613assert "parameters" in func ,f"Missing function.parameters, got keys: { list ( func . keys ()) } " 614params = func ["parameters" ] 615assert params .get ("type" )== "object" ,f"Expected parameters.type=object, got { params . get ( 'type' ) } " 616assert "properties" in params ,"Missing parameters.properties" 617finally : 618server .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.""" 623global server 624MCP_SLOW_SERVER = os .path .join (FIXTURES_DIR ,"mcp_slow_server.py" ) 625mcp_json = _mcp_config_json ({ 626"slow" : { 627"command" :sys .executable , 628"args" : [MCP_SLOW_SERVER ,"--delay" ,"10" ], 629"timeout_ms" :30000 630 } 631 }) 632server = _start_server_with_mcp (mcp_json ) 633 634try : 635# Start a slow tool call in a background thread 636def slow_call (): 637return server .make_request ("POST" ,"/tools" ,data = { 638"tool" :"slow_sleep" , 639"params" : {"seconds" :10 } 640 }) 641 642with ThreadPoolExecutor (max_workers = 1 )as executor : 643future = executor .submit (slow_call ) 644 645# Wait a moment for the call to start 646time .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 shutdown 650# (not from slot release), so shutdown should complete promptly. 651start_time = time .time () 652server .stop () 653elapsed = time .time ()- start_time 654 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-kill 658# fallback in ServerProcess.stop(): without the flag, shutdown stalls 659# on the instance mutex and only completes when stop() sends SIGKILL 660# at ~5s -- which any threshold above 5 would still accept. 661assert 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) 664try : 665res = future .result (timeout = 5 ) 666# If we got a response, it should be an error since the server stopped 667if hasattr (res ,'status_code' ): 668assert res .status_code in (200 ,500 ,502 ,503 ,504 ),f"Unexpected status: { res . status_code } " 669except Exception : 670# Thread may have raised due to connection error - that's acceptable 671pass 672finally : 673server .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 deadlock 678teardown. 679 680subprocess_terminate() only SIGKILLs the direct MCP child, so the inherited pipe write ends 681stay open and a blocking read on them would never see EOF. That hung both warmup shutdown 682(the server would never reach "ready") and process shutdown. The polled, running-aware reader 683must exit regardless, so the server both starts and stops promptly here. 684""" 685global server 686MCP_GRANDCHILD_SERVER = os .path .join (FIXTURES_DIR ,"mcp_grandchild_server.py" ) 687mcp_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. 695server = _start_server_with_mcp (mcp_json ) 696 697try : 698# invoking the tool spawns a live transport whose reader thread holds the inherited pipe 699res = server .make_request ("POST" ,"/tools" ,data = { 700"tool" :"gc_echo" , 701"params" : {"message" :"hello" } 702 }) 703assert res .status_code == 200 ,res .body 704assert "error" not in res .body ,res .body 705 706# shutdown must be prompt: a deadlocked reader-join would stall until the 5s SIGKILL 707# fallback in ServerProcess.stop(), so the threshold has to stay below that 708start = time .time () 709server .stop () 710elapsed = time .time ()- start 711assert elapsed < 3 ,f"server shutdown took { elapsed :.1f } s (expected < 3s) — teardown likely deadlocked" 712finally : 713server .stop ()