okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
4359cba
main
1"""MCP stdio discovery, credentials, namespaced calls, and teardown through the daemon.""" 2 3from typing import Any 4from concurrent .futures import ThreadPoolExecutor 5import errno 6import json 7import os 8import shutil 9import sys 10import time 11import unittest 12from pathlib import Path 13 14from harness import Albedo ,Provider ,exclusive ,operation_id ,Reply ,text 15 16SERVER = Path (__file__ ).with_name ("fake_mcp_server.py" ) 17 18 19# exclusive: fixture configures global MCP servers, credentials, and daemon environment 20@exclusive 21class McpTests (unittest .TestCase ): 22def setUp (self ): 23self .message = "ping from albedo" 24self .python_mode = False 25 26def script (request ): 27tools = sorted (tool ["name" ]for tool in request .get ("tools" , [])) 28answered = any ( 29item .get ("type" )== "function_call_output" for item in request ["input" ] 30 ) 31mcp_tool = next ((name for name in tools if name .startswith ("mcp_" )),None ) 32if mcp_tool and not answered : 33if self .python_mode : 34code = ( 35"import asyncio\n" 36'r = await mcp.call("fake/echo", message="ping from python")\n' 37"print(r.content[0].text)\n" 38"print(await mcp.tools())\n" 39"results = await asyncio.gather(\n" 40' mcp.call("fake/echo", message="batch one"),\n' 41' mcp.call("fake/echo", message="batch two"),\n' 42" return_exceptions=True,\n" 43")\n" 44"print(results)" 45 ) 46return Reply ("python" ,tool_arguments = {"code" :code }) 47return Reply ( 48"python" , 49tool_name = mcp_tool , 50tool_arguments = {"message" :self .message }, 51 ) 52return text ("done" ) 53 54self .provider = Provider (script ) 55self .addCleanup (self .provider .close ) 56 57def prepare (app ): 58app .env .update ( 59ALBEDO_MCP_SECRET = "configured-secret" , 60ALBEDO_MCP_CLOSED = str (app .root / "closed" ), 61ALBEDO_MCP_AMBIENT = "must-not-reach-the-server" , 62ALBEDO_MCP_CONTROL = str (app .root / "control" ), 63 ) 64 (app .root / "control" ).mkdir () 65self .configure (app , [str (SERVER )]) 66app .store_secrets ( 67"mcp" , {"fake" : {"env" : {"FAKE_SECRET" :"stored-secret" }}} 68 ) 69 70self .app = Albedo (self .provider ,protocol = "responses" ,prepare = prepare ) 71self .app .__enter__ () 72self .addCleanup (self .app .__exit__ ,None ,None ,None ) 73self .session = self .app .session () 74 75def configure (self ,app ,args ,startup = 20000 ,retry = None ): 76path = app .home / "extensions.json" 77settings :dict [str ,Any ]= ( 78json .loads (path .read_text ()) 79if path .exists () 80else {"models" : {"refreshHours" :0 }} 81 ) 82settings ["mcp" ]= { 83"servers" : { 84"fake" : { 85"type" :"stdio" , 86"command" :sys .executable , 87"args" :args , 88"env" : { 89"FAKE_SECRET" : {"env" :"ALBEDO_MCP_SECRET" }, 90"FAKE_MCP_CLOSED" : {"env" :"ALBEDO_MCP_CLOSED" }, 91"FAKE_MCP_CONTROL" : {"env" :"ALBEDO_MCP_CONTROL" }, 92 }, 93"startupTimeoutMs" :startup , 94 } 95 } 96 } 97if retry is not None : 98settings ["mcp" ]["retryMs" ]= retry 99path .write_text (json .dumps (settings )) 100 101def tools (self ,requests ): 102return [ 103tool ["name" ] 104for tool in requests [0 ]["tools" ] 105if tool ["name" ].startswith ("mcp_" ) 106 ] 107 108def mcp (self ): 109with self .app .api (f"/sessions/ { self . session } /catalog" )as response : 110catalog = json .load (response ) 111return next ( 112row 113for row in catalog ["discovery" ]["candidates" ] 114if row ["kind" ]== "extension" and row ["preference_key" ]== "mcp" 115 ) 116 117def select_mcp (self ,session ,enabled ): 118route = f"/sessions/ { session } ?view=configuration" 119with self .app .api (route )as response : 120json .load (response ) 121etag = response .headers ["ETag" ] 122self .app .api ( 123route , 124 {"selection" : {"extensions" : {"mcp" :enabled }}}, 125method = "PATCH" , 126headers = {"If-Match" :etag }, 127 ).close () 128 129def set_mcp (self ,enabled ): 130self .select_mcp (self .session ,enabled ) 131with self .app .api ( 132f"/sessions/ { self . session } /reload" , {"target" :"session" } 133 )as response : 134return json .load (response ) 135 136def turn (self ,prompt ,session = None ): 137session = session or self .session 138before = len (self .provider .requests ) 139self .app .prompt (session ,prompt ).close () 140self .app .idle (session ,timeout = 60 ) 141return [entry ["request" ]for entry in self .provider .requests [before :]] 142 143def test_cold_command_and_recorded_recovery_do_not_block_another_session (self ): 144other = self .app .session () 145for session ,enabled in ((self .session ,True ), (other ,False )): 146route = f"/sessions/ { session } ?view=configuration" 147with self .app .api (route )as response : 148json .load (response ) 149etag = response .headers ["ETag" ] 150self .app .api ( 151route , 152 {"selection" : {"extensions" : {"mcp" :enabled }}}, 153method = "PATCH" , 154headers = {"If-Match" :etag }, 155 ).close () 156with self .app .api (f"/sessions/ { self . session } ?tail=0" )as response : 157self .assertEqual (json .load (response )["kernel" ]["state" ],"none" ) 158marker = self .app .workspace / "cold-mcp-started" 159gate = self .app .workspace / "cold-mcp-release" 160os .mkfifo (gate ) 161script = ( 162"import pathlib; " 163+ f"pathlib.Path( { str ( marker )!r } ).touch(); " 164+ f"open( { str ( gate )!r } , 'rb').read(1); " 165+ f"exec(pathlib.Path( { str ( SERVER )!r } ).read_text())" 166 ) 167self .configure (self .app , ["-c" ,script ],startup = 30000 ) 168input_id = operation_id () 169 170def submit_command (): 171with self .app .api ( 172f"/sessions/ { self . session } /inputs/ { input_id } " , 173 { 174"kind" :"command" , 175"command_id" :"/research" , 176"arguments" : {"arguments" :"cold composition fixture" }, 177 }, 178method = "PUT" , 179 )as response : 180return json .load (response ) 181 182def release_preparation (): 183deadline = time .monotonic ()+ 10 184while True : 185try : 186descriptor = os .open (gate ,os .O_WRONLY | os .O_NONBLOCK ) 187break 188except OSError as error : 189if error .errno != errno .ENXIO or time .monotonic ()>= deadline : 190raise 191time .sleep (0.01 ) 192try : 193os .write (descriptor ,b"x" ) 194finally : 195os .close (descriptor ) 196 197def complete_independent_turn (): 198with self .app .api (f"/sessions/ { other } ?tail=0" )as response : 199self .assertEqual (json .load (response )["id" ],other ) 200with self .app .prompt (other ,"independent turn" )as response : 201other_input = json .load (response )["id" ] 202self .app .idle (other ) 203with self .app .api (f"/sessions/ { other } /inputs/ { other_input } " )as response : 204self .assertEqual (json .load (response )["turn" ]["state" ],"completed" ) 205 206with ThreadPoolExecutor (max_workers = 2 )as pool : 207command = pool .submit (submit_command ) 208deadline = time .monotonic ()+ 10 209while not marker .exists ()and time .monotonic ()< deadline : 210time .sleep (0.01 ) 211self .assertTrue (marker .exists (),"cold MCP preparation did not start" ) 212try : 213complete_independent_turn () 214self .assertFalse (command .done (),"command passed the held preparation" ) 215finally : 216release_preparation () 217self .assertEqual (command .result (timeout = 40 )["id" ],input_id ) 218self .app .idle (self .session ) 219with self .app .api (f"/sessions/ { self . session } /inputs/ { input_id } " )as response : 220self .assertEqual (json .load (response )["turn" ]["state" ],"completed" ) 221with self .app .api (f"/sessions/ { self . session } ?tail=0" )as response : 222original_kernel = json .load (response )["kernel" ]["instance_id" ] 223self .assertIsNotNone (original_kernel ) 224original_history = self .app .history (self .session )["items" ] 225marker .unlink () 226self .app .restart () 227deadline = time .monotonic ()+ 10 228while not marker .exists ()and time .monotonic ()< deadline : 229time .sleep (0.01 ) 230self .assertTrue (marker .exists (),"recorded kernel preparation did not start" ) 231try : 232complete_independent_turn () 233finally : 234release_preparation () 235deadline = time .monotonic ()+ 20 236while True : 237with self .app .api (f"/sessions/ { self . session } ?tail=0" )as response : 238recovered = json .load (response )["kernel" ] 239if recovered ["state" ]== "attached" or time .monotonic ()>= deadline : 240break 241time .sleep (0.01 ) 242self .assertEqual (recovered ["state" ],"attached" ) 243self .assertEqual (recovered ["instance_id" ],original_kernel ) 244self .assertEqual (self .app .history (self .session )["items" ],original_history ) 245 246def test_discovery_namespaced_call_and_scrubbed_credentials (self ): 247self .assertEqual (self .set_mcp (True )["session" ]["state" ],"applied" ) 248self .assertTrue (self .mcp ()["effective_enabled" ]) 249self .assertEqual (self .mcp ()["extension" ]["plugins" ], ["managed" ]) 250requests = self .turn ("use the mcp server" ) 251self .assertEqual (len (requests ),2 ) 252context = requests [0 ]["instructions" ] 253self .assertNotIn ("<extension-context" ,json .dumps (requests [0 ]["input" ])) 254self .assertIn ('name="mcp"' ,context ) 255self .assertIn ("echo" ,context ) 256advertised = [ 257tool ["name" ] 258for tool in requests [0 ]["tools" ] 259if tool ["name" ].startswith ("mcp_" ) 260 ] 261self .assertEqual (len (advertised ),1 ) 262self .assertTrue (advertised [0 ].startswith ("mcp_fake_echo_" )) 263output = next ( 264item ["output" ] 265for item in requests [1 ]["input" ] 266if item .get ("type" )== "function_call_output" 267 ) 268self .assertEqual ( 269json .loads (json .loads (output )["content" ][0 ]["text" ]), 270 {"echoed" :"ping from albedo" ,"secret" :"stored-secret" ,"ambient" :None }, 271 ) 272 273def test_python_binding_calls_and_batches_mcp_operations (self ): 274self .python_mode = True 275self .set_mcp (True ) 276requests = self .turn ("compose mcp calls in python" ) 277self .assertEqual (len (requests ),2 ) 278output = next ( 279item ["output" ] 280for item in requests [1 ]["input" ] 281if item .get ("type" )== "function_call_output" 282 ) 283rendered = json .dumps (output ) 284self .assertIn ("ping from python" ,rendered ) 285self .assertIn ("fake" ,rendered ) 286self .assertIn ("echo" ,rendered ) 287self .assertIn ("batch one" ,rendered ) 288self .assertIn ("batch two" ,rendered ) 289 290def test_a_failed_tool_call_is_refused_without_ending_the_turn (self ): 291self .set_mcp (True ) 292self .message = "fail" 293requests = self .turn ("the server will refuse" ) 294self .assertEqual (len (requests ),2 ) 295output = next ( 296item ["output" ] 297for item in requests [1 ]["input" ] 298if item .get ("type" )== "function_call_output" 299 ) 300self .assertIn ("MCP request failed" ,json .loads (output )["error" ]) 301self .assertIn ("done" ,json .dumps (self .app .history (self .session ))) 302 303def test_disable_closes_server_and_removes_tools (self ): 304self .set_mcp (True ) 305self .assertEqual (self .set_mcp (False )["session" ]["state" ],"applied" ) 306self .assertFalse (self .mcp ()["effective_enabled" ]) 307closed = self .app .root / "closed" 308deadline = time .monotonic ()+ 15 309while time .monotonic ()< deadline and not closed .exists (): 310time .sleep (0.05 ) 311self .assertTrue (closed .exists (),"disabling must close the server" ) 312requests = self .turn ("the server is gone" ) 313self .assertFalse ( 314any (tool ["name" ].startswith ("mcp_" )for tool in requests [0 ]["tools" ]) 315 ) 316 317def test_insecure_credentials_fail_closed (self ): 318credentials = self .app .home / "creds.json" 319credentials .chmod (0o644 ) 320self .addCleanup (credentials .chmod ,0o600 ) 321outcome = self .set_mcp (True ) 322self .assertEqual (outcome ["session" ]["state" ],"failed" ) 323self .assertIn ("0600" ,outcome ["session" ]["failure" ]["detail" ]) 324self .assertFalse (self .provider .requests ) 325credentials .chmod (0o600 ) 326self .assertEqual (self .set_mcp (True )["session" ]["state" ],"applied" ) 327 328def test_an_unavailable_server_is_skipped_and_the_rest_load (self ): 329path = self .app .home / "extensions.json" 330settings = json .loads (path .read_text ()) 331settings ["mcp" ]["servers" ]["dead" ]= { 332"type" :"stdio" , 333"command" :str (self .app .root / "missing-server" ), 334"startupTimeoutMs" :3000 , 335 } 336path .write_text (json .dumps (settings )) 337self .assertEqual (self .set_mcp (True )["session" ]["state" ],"applied" ) 338advertised = self .tools (self .turn ("one server is down" )) 339self .assertEqual (len (advertised ),1 ) 340self .assertTrue (advertised [0 ].startswith ("mcp_fake_echo_" )) 341 342def test_a_server_that_comes_online_joins_the_session_after_a_turn (self ): 343late = self .app .root / "late_server.py" 344self .configure (self .app , [str (late )],startup = 3000 ,retry = 200 ) 345self .assertEqual (self .set_mcp (True )["session" ]["state" ],"applied" ) 346self .assertEqual (self .tools (self .turn ("nothing is up yet" )), []) 347shutil .copy (SERVER ,late ) 348deadline = time .monotonic ()+ 40 349advertised = [] 350while time .monotonic ()< deadline and not advertised : 351time .sleep (0.5 ) 352advertised = self .tools (self .turn ("is it up now?" )) 353self .assertEqual (len (advertised ),1 ,"the server never joined the session" ) 354self .assertTrue (advertised [0 ].startswith ("mcp_fake_echo_" )) 355history = json .dumps (self .app .history (self .session )) 356self .assertIn ("capabilities changed" ,history ) 357self .assertIn ("fake" ,history ) 358 359def test_a_known_server_does_not_hold_up_opening_a_session (self ): 360self .set_mcp (True ) 361self .assertEqual (len (self .tools (self .turn ("learn the catalogue" ))),1 ) 362 (self .app .root / "control" / "delay" ).write_text ("3" ) 363 364session = self .app .session () 365self .select_mcp (session ,True ) 366started = time .monotonic () 367with self .app .api ( 368f"/sessions/ { session } /reload" , {"target" :"session" } 369 )as response : 370self .assertEqual (json .load (response )["session" ]["state" ],"applied" ) 371self .assertLess (time .monotonic ()- started ,1.5 ) 372 373# The saved tools are advertised at once; the call waits for the dial. 374requests = self .turn ("use the mcp server" ,session ) 375self .assertEqual (len (self .tools (requests )),1 ) 376output = next ( 377item ["output" ] 378for item in requests [- 1 ]["input" ] 379if item .get ("type" )== "function_call_output" 380 ) 381self .assertIn (self .message ,output ) 382self .assertNotIn ("capabilities changed" ,json .dumps (self .app .history (session ))) 383 384def test_a_changed_catalogue_refreshes_the_session_after_its_turn (self ): 385self .set_mcp (True ) 386self .turn ("learn the catalogue" ) 387schema = { 388"type" :"object" , 389"required" : ["message" ], 390"properties" : {"message" : {"type" :"string" }}, 391 } 392tools = [ 393 {"name" :name ,"description" :name ,"inputSchema" :schema } 394for name in ("echo" ,"shout" ) 395 ] 396 (self .app .root / "control" / "tools" ).write_text (json .dumps (tools )) 397 398session = self .app .session () 399self .select_mcp (session ,True ) 400self .assertEqual ( 401len (self .tools (self .turn ("still the saved tools" ,session ))),1 402 ) 403deadline = time .monotonic ()+ 20 404while "capabilities changed" not in json .dumps (self .app .history (session )): 405self .assertLess (time .monotonic (),deadline ,"the session never refreshed" ) 406time .sleep (0.1 ) 407advertised = self .tools (self .turn ("now the new ones" ,session )) 408self .assertEqual (len (advertised ),2 ) 409self .assertTrue (any (name .startswith ("mcp_fake_shout_" )for name in advertised ))