From ea5b8347337bc04182eebf90dbd953f92fd3c799 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 5 Oct 2026 23:31:47 +0100 Subject: [PATCH 1/2] feat(gateway): support session MCP servers and background cancellation --- docs/user/gateway.md | 17 ++- src/gateway.rs | 31 ++++- src/gateway/http_server.rs | 39 ++++-- tests/gateway.rs | 156 +++++++++++++++++++++- tests/gateway_background_cancel/mod.rs | 174 +++++++++++++++++++++++++ 5 files changed, 399 insertions(+), 18 deletions(-) create mode 100644 tests/gateway_background_cancel/mod.rs diff --git a/docs/user/gateway.md b/docs/user/gateway.md index d600d33..ed6fc76 100644 --- a/docs/user/gateway.md +++ b/docs/user/gateway.md @@ -6,7 +6,7 @@ This is an experimental, single-user, private-network facility, not a public ser ## Start the host -Configure the host's provider credentials, model, tools, and MCP servers as for a local Kit session. Children inherit the gateway host's environment and load its configuration; the remote terminal does not supply provider credentials or local MCP servers. +Configure the host's provider credentials, model, tools, and MCP servers as for a local Kit session. Children inherit the gateway host's environment and load its configuration; the remote terminal does not supply provider credentials. ACP clients may supply session-scoped stdio `mcpServers` when creating a session or restoring one after its child exits. These commands execute on the gateway host under the existing runtime's MCP validation and configuration rules, not on the client. Each ACP v2 stdio entry needs `"type":"stdio"` and an absolute host executable path. For Slick, use that host's `slick` executable with `"args":["--profile","kit","mcp"]`; the named Slick profile supplies its existing gateway grant. Kit's gateway bearer credential remains separate and is not copied into MCP configuration. Resident reattach retains the running session's MCP runtime: omit `mcpServers` or send an empty array; a nonempty list is rejected rather than ignored or used to replace live servers. Session-scoped MCP configuration is not persisted in the transcript, so clients must supply it again when restoring a stopped session. Create a high-entropy bearer credential without putting it in command-line arguments: @@ -120,10 +120,19 @@ that controller's pending requests with errors without detaching its other sessi | `session/resume` | Acquire or replace control of a session; optional replay from `start` only. | | `session/prompt` | Submit work to the resident child. | | `session/inject`, `session/replace_inject`, `session/revoke_inject` | Enqueue, replace, or revoke pending steering using the host-advertised ACP capability. | -| `session/cancel` | Explicitly interrupt work; this is distinct from closing a connection. | +| `session/cancel` | Interrupt the current response; ordinary top-level sessions retain detached background jobs. This is distinct from closing a connection. | +| `kit/background/cancel` | Request cancellation of one detached call using the existing Kit extension. | | `session/set_config_option` | Select configuration advertised by the child, without changing client-local defaults. | -Unsupported requests fail explicitly. Client-to-agent request cancellation (`$/cancel_request`) does not interrupt accepted session work. Integrations must use `session/cancel` for that purpose. Do not automatically resend a prompt after a transport failure. +Unsupported requests fail explicitly. Client-to-agent request cancellation (`$/cancel_request`) does not interrupt accepted session work. Integrations must use `session/cancel` for that purpose, and cancel detached jobs separately. Do not automatically resend a prompt after a transport failure. + +The Kit-private background operation uses **snake_case** parameters, unlike standard ACP session methods: + +```json +{"jsonrpc":"2.0","id":4,"method":"kit/background/cancel","params":{"session_id":"SESSION_ID","call_id":"CALL_ID"}} +``` + +Only the current controller can send it. The gateway forwards the native response (`{"cancelled":true}`); this acknowledges the cancellation request, not completion or proof that the call was running. The native operation also accepts unknown call IDs and repeated requests. Observe subsequent task updates to confirm completion. Neither this operation nor `session/cancel` is a gateway-wide “stop all.” ### Pinned wire fixture @@ -169,6 +178,6 @@ The error above illustrates a session-routed revoke of an unknown pending messag - These bounds exclude allocator overhead, transient JSON conversion, arbitrary application state, and HTTP/TLS/socket buffers. They are not a hard process-memory ceiling or a denial-of-service-hardening claim. The bounded transport supports HTTP/SSE, not WebSocket upgrades. SSE has no replay cursor: reopening a stream alone cannot recover lost events. Recovery uses fresh initialization and session-level resume, not SSE event IDs. There is no idle connection expiration or body-read deadline: disconnected peers that do not DELETE or otherwise terminate their transport can retain connection slots. Slot exhaustion may require restarting the gateway; do not expose it to untrusted clients. - At most 64 active or stopping actors occupy session slots. Listing or creating sessions reclaims completed actor slots without restarting the gateway or deleting durable transcripts. Detached actors, including idle actors, are not automatically terminated; accepted work continues. HTTP POST bodies are limited to 1 MiB inside SDK admission, including streamed bodies; supervisor command queues and pending child requests are also bounded. Child stdout frames are limited to 8 MiB before JSON parsing; oversized or malformed frames stop that child. - Graceful `Ctrl+C` shutdown stops serving, releases resident actor ownership, closes each ACP child’s input, drains its output, and waits for cleanup and exit. A 10-second timeout falls back to killing and reaping the child. That fallback can leave a stale transcript lock requiring operator intervention. Hard process termination has operating-system-dependent cleanup behavior; arbitrary tool descendants are not a managed process group. -- Client-side ACP services, arbitrary extra directories, remote MCP injection, browser callbacks, and arbitrary ACP methods are not supported. Files, tool execution, and provider authentication belong to the gateway host. +- Client-side ACP services, arbitrary extra directories, session-scoped HTTP/SSE MCP servers, browser callbacks, and arbitrary ACP methods are not supported. Files, tool execution, and provider authentication belong to the gateway host. The internal `gateway bridge` stdio command is a transport adapter for the bundled TUI, not a stable public protocol or general-purpose ACP proxy. The public transport is ACP HTTP at `/acp/v2`, implemented by the ACP SDK rather than a Kit-specific RPC protocol. diff --git a/src/gateway.rs b/src/gateway.rs index 3c8ba54..78c6caa 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -1069,6 +1069,22 @@ impl Actor { .. } = request { + // A live runtime owns its MCP configuration. Do not replace the + // controller and then silently ignore a new configuration. + if (self.session_result.is_some() + || self.pending.values().any(|pending| { + matches!(pending.method.as_str(), "session/new" | "session/resume") + })) + && startup.as_ref().is_some_and(|message| { + message["params"].get("mcpServers").is_some_and(|servers| { + !servers.is_null() && servers != &Value::Array(Vec::new()) + }) + }) + { + return Err(Failure::conflict( + "resident session retains its MCP servers; omit mcpServers or send an empty array", + )); + } let metadata = startup .as_ref() .map(|message| &message["params"]["_meta"]["kit/gateway"]); @@ -1256,6 +1272,9 @@ impl Actor { { return Err(Failure::bad("ACP session does not match gateway session")); } + if method == "kit/background/cancel" && message["params"]["session_id"] != self.id { + return Err(Failure::bad("ACP session does not match gateway session")); + } // Request-task cancellation is not an execution interrupt. In // particular, SDK teardown must not cancel detached work; use // explicit session/cancel (which is session-scoped) instead. @@ -1272,6 +1291,7 @@ impl Actor { | "session/revoke_inject" | "session/replace_inject" | "session/cancel" + | "kit/background/cancel" | "session/set_config_option" ) { return Err(Failure::bad( @@ -1314,6 +1334,13 @@ impl Actor { }; if let Some(result) = cached { if startup { + if message["params"].get("mcpServers").is_some_and(|servers| { + !servers.is_null() && servers != &Value::Array(Vec::new()) + }) { + return Err(Failure::conflict( + "resident session retains its MCP servers; omit mcpServers or send an empty array", + )); + } let Some(id) = original else { return Err(Failure::bad("session startup requires a request id")); }; @@ -1360,7 +1387,9 @@ impl Actor { message["params"]["cwd"] = Value::String(self.root.to_string_lossy().into_owned()); message["params"]["additionalDirectories"] = Value::Array(Vec::new()); - message["params"]["mcpServers"] = Value::Array(Vec::new()); + if message["params"]["mcpServers"].is_null() { + message["params"]["mcpServers"] = Value::Array(Vec::new()); + } if self.restore { message["method"] = Value::String("session/resume".into()); message["params"]["sessionId"] = Value::String(self.id.clone()); diff --git a/src/gateway/http_server.rs b/src/gateway/http_server.rs index b9f3501..211bf55 100644 --- a/src/gateway/http_server.rs +++ b/src/gateway/http_server.rs @@ -135,10 +135,11 @@ impl Connection { let mut capabilities = serde_json::to_value(agentkit_acp::v2::agent_capabilities()) .map_err(|e| Failure::unavailable(e.to_string()))?; if let Some(session) = capabilities["session"].as_object_mut() { - for name in ["close", "delete", "fork", "mcp"] { + for name in ["close", "delete", "fork"] { session.remove(name); } session.insert("list".into(), object([])); + session.insert("mcp".into(), object([("stdio", object([]))])); } self.initialize = Some((message.clone(), Arc::clone(charge))); return Ok(Some(object([ @@ -221,15 +222,23 @@ impl Connection { if id.as_ref().is_some_and(|id| self.controls.contains_key(id)) { return Err(Failure::conflict("connection already controls session")); } - for field in ["mcpServers", "additionalDirectories"] { - if message["params"] - .get(field) - .is_some_and(|v| !v.is_null() && v != &Value::Array(vec![])) - { - return Err(Failure::bad( - "client directories and MCP servers are not supported", - )); - } + if message["params"] + .get("additionalDirectories") + .is_some_and(|v| !v.is_null() && v != &Value::Array(vec![])) + { + return Err(Failure::bad("client directories are not supported")); + } + // ACP startup decoding skips invalid MCP entries by default. + // Validate the list directly so forwarded configuration is never + // silently dropped; the runtime still validates supported transports + // and server configuration. + if let Some(servers) = message["params"].get("mcpServers") + && !servers.is_null() + { + serde_json::from_value::>( + servers.clone(), + ) + .map_err(|_| Failure::bad("invalid mcpServers configuration"))?; } if message["params"] .get("replayFrom") @@ -347,9 +356,15 @@ impl Connection { self.controls.insert(session, control); return Ok(None); } - let id = message["params"]["sessionId"] + // Kit's existing background extension uses snake_case on the wire. + let session_field = if method == "kit/background/cancel" { + "session_id" + } else { + "sessionId" + }; + let id = message["params"][session_field] .as_str() - .ok_or_else(|| Failure::bad("sessionId required"))?; + .ok_or_else(|| Failure::bad(format!("{session_field} required")))?; let control = self .controls .get_mut(id) diff --git a/tests/gateway.rs b/tests/gateway.rs index c08d80c..7c1aef2 100644 --- a/tests/gateway.rs +++ b/tests/gateway.rs @@ -173,6 +173,10 @@ impl SdkClient { }; let response = client.request(1, "initialize", json!({"protocolVersion":2,"info":{"name":"standard-sdk-test","version":"0"},"capabilities":{}})).await; assert_eq!(response["result"]["protocolVersion"], 2, "{response}"); + assert_eq!( + response["result"]["capabilities"]["session"]["mcp"], + json!({"stdio":{}}) + ); assert_eq!( response["result"]["_meta"]["kit/gateway"]["transport"], "bounded-http" @@ -341,6 +345,153 @@ async fn stop_gateway(child: &mut Child) { ); } +#[tokio::test] +async fn sdk_session_mcp_validation_preserves_directory_boundaries() { + let fixture = Fixture::new(); + let (mut gateway, url) = fixture.gateway().await; + let stdio = + json!({"type":"stdio","name":"fixture","command":"/usr/bin/python3","args":[],"env":[]}); + let cases = [ + ( + json!({"cwd":fixture.root,"mcpServers":[{"name":"missing-type","command":"/usr/bin/python3"}]}), + "invalid mcpServers", + ), + ( + json!({"cwd":fixture.root,"mcpServers":[{"type":"http","name":"unsupported","url":"http://127.0.0.1:1/mcp","headers":[]}]}), + "stdio transport", + ), + ( + json!({"cwd":fixture.root,"mcpServers":[stdio.clone(),stdio.clone()]}), + "duplicate session MCP server", + ), + ( + json!({"cwd":fixture.root,"mcpServers":[stdio.clone()],"additionalDirectories":[fixture.home.path()]}), + "directories are not supported", + ), + ( + json!({"cwd":fixture.home.path(),"mcpServers":[stdio]}), + "project is not registered", + ), + ]; + for (params, expected) in cases { + let mut sdk = SdkClient::connect(&url).await; + let response = sdk.request(2, "session/new", params).await; + assert!(response.get("error").is_some(), "{response}"); + assert!(response.to_string().contains(expected), "{response}"); + sdk.detach().await; + } + stop_gateway(&mut gateway).await; +} + +#[tokio::test] +async fn sdk_session_mcp_new_reattach_and_restore() { + // MCP connections are lazy. A local provider boundary requests discovery + // through the real compose tool; no inference reaches a paid service. + async fn inference(axum::Json(body): axum::Json) -> impl axum::response::IntoResponse { + let finished = body["messages"].as_array().unwrap().last().unwrap()["role"] == "tool"; + let delta = if finished { + json!({"role":"assistant","content":"MCP discovery complete"}) + } else { + json!({"role":"assistant","tool_calls":[{"index":0,"id":"discover-mcp","type":"function","function":{"name":"compose","arguments":json!({"script":"return tool_search({ query: \"slick-test echo\" })"}).to_string()}}]}) + }; + let chunk = json!({"id":"local-mcp-test","choices":[{"index":0,"delta":delta,"finish_reason":if finished {"stop"} else {"tool_calls"}}]}); + ( + [("content-type", "text/event-stream")], + format!("data: {chunk}\n\ndata: [DONE]\n\n"), + ) + } + let mut fixture = Fixture::new(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + fixture.provider_url = format!("http://{}/stream", listener.local_addr().unwrap()); + let provider = tokio::spawn(async move { + axum::serve( + listener, + axum::Router::new().fallback(axum::routing::post(inference)), + ) + .await + .unwrap(); + }); + let marker = fixture.home.path().join("mcp-started"); + let script = fixture.home.path().join("mcp.py"); + fs::write(&script, format!( + "import os, runpy\nwith open(os.environ['MCP_MARKER'], 'a') as f: f.write(os.getcwd() + '\\n')\nrunpy.run_path({:?}, run_name='__main__')\n", + format!("{}/fixtures/mock-mcp.py", env!("CARGO_MANIFEST_DIR")) + )).unwrap(); + let servers = json!([{"type":"stdio","name":"slick-test","command":"/usr/bin/python3","args":[script],"env":[{"name":"MCP_MARKER","value":marker}]}]); + let (mut gateway, url) = fixture.gateway().await; + let mut sdk = SdkClient::connect(&url).await; + let created = sdk + .request( + 2, + "session/new", + json!({"cwd":fixture.root,"mcpServers":servers}), + ) + .await; + assert!(created.get("error").is_none(), "{created}"); + let id = created["result"]["sessionId"].as_str().unwrap().to_owned(); + sdk.start_prompt(3, &id, "Discover the session MCP fixture") + .await; + sdk.wait_idle().await; + assert_eq!( + fs::read_to_string(&marker).unwrap(), + format!("{}\n", fixture.root.display()) + ); + + // Nonempty MCP on a resident resume must not silently replace or ignore it, + // and rejection must preserve the current controller. + let mut other = SdkClient::connect(&url).await; + let rejected = other + .request( + 2, + "session/resume", + json!({"sessionId":id,"cwd":fixture.root,"mcpServers":servers}), + ) + .await; + assert!( + rejected["error"].to_string().contains("resident"), + "{rejected}" + ); + other.detach().await; + sdk.start_prompt(4, &id, "Confirm the original controller remains attached") + .await; + sdk.wait_idle().await; + sdk.detach().await; + let mut sdk = SdkClient::connect(&url).await; + let resumed = sdk + .request( + 2, + "session/resume", + json!({"sessionId":id,"cwd":fixture.root,"mcpServers":[]}), + ) + .await; + assert!(resumed.get("error").is_none(), "{resumed}"); + sdk.start_prompt(3, &id, "Discover the retained session MCP fixture") + .await; + sdk.wait_idle().await; + assert_eq!(fs::read_to_string(&marker).unwrap().lines().count(), 1); + sdk.detach().await; + stop_gateway(&mut gateway).await; + + // A restorable session starts a new child, so its incoming MCP is applied. + let (mut gateway, url) = fixture.gateway().await; + let mut sdk = SdkClient::connect(&url).await; + let resumed = sdk + .request( + 2, + "session/resume", + json!({"sessionId":id,"cwd":fixture.root,"mcpServers":servers}), + ) + .await; + assert!(resumed.get("error").is_none(), "{resumed}"); + sdk.start_prompt(3, &id, "Discover the restored session MCP fixture") + .await; + sdk.wait_idle().await; + assert_eq!(fs::read_to_string(&marker).unwrap().lines().count(), 2); + sdk.detach().await; + stop_gateway(&mut gateway).await; + provider.abort(); +} + #[tokio::test] async fn bridge_creates_persists_reconnects_and_restores_session() { let fixture = Fixture::new(); @@ -1440,7 +1591,7 @@ async fn pinned_sdk_http_initialize_capabilities_and_session_stream_contract() { initialized, json!({"jsonrpc":"2.0","id":1,"result":{ "protocolVersion":2,"info":{"name":"kit-gateway","version":env!("CARGO_PKG_VERSION")}, - "capabilities":{"session":{"prompt":{"image":{},"audio":{},"embeddedContext":{}},"inject":{"modes":["steer"],"steerInStream":["finish"],"pending":{"replace":true}},"list":{}}}, + "capabilities":{"session":{"prompt":{"image":{},"audio":{},"embeddedContext":{}},"inject":{"modes":["steer"],"steerInStream":["finish"],"pending":{"replace":true}},"list":{},"mcp":{"stdio":{}}}}, "_meta":{"kit/gateway":{"experimental":true,"transport":"bounded-http","maxFrameBytes":1048576,"coreBufferedBytesPerDirection":16777216,"httpEgressBytesPerConnection":4194304,"liveReplayLimitBytes":8388608,"liveReplayLimitEvents":4093}} }}) ); @@ -2411,3 +2562,6 @@ async fn assert_no_replay_snapshot(bridge: &mut Bridge, id: &str, state: &str) { ); assert_eq!(candidate[1]["params"]["update"]["state"], state); } + +#[path = "gateway_background_cancel/mod.rs"] +mod background_cancel; diff --git a/tests/gateway_background_cancel/mod.rs b/tests/gateway_background_cancel/mod.rs new file mode 100644 index 0000000..0c14d5b --- /dev/null +++ b/tests/gateway_background_cancel/mod.rs @@ -0,0 +1,174 @@ +use super::*; + +#[tokio::test] +async fn sdk_cancels_detached_background_call_after_controller_replacement() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + // Fake only the external model. The gateway, child ACP process, compose, + // detached job registry and shell cancellation are all real. + async fn inference( + axum::extract::State(turn): axum::extract::State>, + axum::Json(body): axum::Json, + ) -> impl axum::response::IntoResponse { + let (delta, reason) = if turn.fetch_add(1, Ordering::SeqCst) == 0 { + let args = json!({"background":true,"script":"return shell({command: \"echo $$ > background.pid; exec sleep 120\", timeout_seconds: 150})"}); + ( + json!({"role":"assistant","tool_calls":[{"index":0,"id":"gateway-background-call","type":"function","function":{"name":body["tools"][0]["function"]["name"],"arguments":args.to_string()}}]}), + "tool_calls", + ) + } else { + ( + json!({"role":"assistant","content":"Waiting for detached work."}), + "stop", + ) + }; + let chunk = json!({"id":"local-background-test","choices":[{"index":0,"delta":delta,"finish_reason":reason}]}); + ( + [("content-type", "text/event-stream")], + format!("data: {chunk}\n\ndata: [DONE]\n\n"), + ) + } + + let mut fixture = Fixture::new(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + fixture.provider_url = format!("http://{}/stream", listener.local_addr().unwrap()); + let provider = tokio::spawn(async move { + axum::serve( + listener, + axum::Router::new() + .fallback(axum::routing::post(inference)) + .with_state(Arc::new(AtomicUsize::new(0))), + ) + .await + .unwrap(); + }); + let (mut gateway, url) = fixture.gateway().await; + let mut client = SdkClient::connect(&url).await; + let created = client + .request( + 2, + "session/new", + json!({"cwd":fixture.root,"mcpServers":[]}), + ) + .await; + let id = created["result"]["sessionId"].as_str().unwrap(); + // The native operation acknowledges cancellation even for unknown calls. + // Pin its snake_case wire format without changing those semantics. + let missing = client + .request( + 3, + "kit/background/cancel", + json!({"session_id":id,"call_id":"missing"}), + ) + .await; + assert_eq!(missing["result"]["cancelled"], true, "{missing}"); + let malformed = client + .request( + 4, + "kit/background/cancel", + json!({"sessionId":id,"call_id":"missing"}), + ) + .await; + assert!(malformed.get("error").is_some(), "{malformed}"); + client.start_prompt(5, id, "Start a background task.").await; + client.wait_idle().await; + let pid = timeout(WAIT, async { + loop { + if let Ok(pid) = fs::read_to_string(fixture.root.join("background.pid")) + && let Ok(pid) = pid.trim().parse::() + { + break pid; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("background shell did not start"); + assert!( + client.send(json!({"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":id}})) + ); + + // A reply on the same transport fences the earlier cancel notification + // before replacing its controller. + let barrier = client + .request( + 6, + "kit/background/cancel", + json!({"session_id":id,"call_id":"barrier"}), + ) + .await; + assert_eq!(barrier["result"]["cancelled"], true, "{barrier}"); + + let mut replacement = SdkClient::connect(&url).await; + let resumed = replacement + .request( + 2, + "session/resume", + json!({"sessionId":id,"cwd":fixture.root,"mcpServers":[]}), + ) + .await; + assert!(resumed.get("error").is_none(), "{resumed}"); + client + .assert_rejected_or_closed( + 7, + "kit/background/cancel", + json!({"session_id":id,"call_id":"gateway-background-call"}), + ) + .await; + let wrong = replacement + .request( + 3, + "kit/background/cancel", + json!({"session_id":"another-session","call_id":"gateway-background-call"}), + ) + .await; + assert!(wrong.get("error").is_some(), "{wrong}"); + // Session cancellation and controller replacement must leave detached work alive. + assert!( + Command::new("/bin/kill") + .args(["-0", &pid.to_string()]) + .stderr(Stdio::null()) + .status() + .await + .unwrap() + .success() + ); + let stopped = replacement + .request( + 4, + "kit/background/cancel", + json!({"session_id":id,"call_id":"gateway-background-call"}), + ) + .await; + assert_eq!(stopped["result"]["cancelled"], true, "{stopped}"); + let repeated = replacement + .request( + 5, + "kit/background/cancel", + json!({"session_id":id,"call_id":"gateway-background-call"}), + ) + .await; + assert_eq!(repeated["result"]["cancelled"], true, "{repeated}"); + timeout(WAIT, async { + while Command::new("/bin/kill") + .args(["-0", &pid.to_string()]) + .stderr(Stdio::null()) + .status() + .await + .unwrap() + .success() + { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("background cancellation did not reap the shell"); + replacement.detach().await; + // A replaced transport may already have been closed by the server. + client.transport.abort(); + stop_gateway(&mut gateway).await; + provider.abort(); +} From 4b81724a533a604380bd9d0ca9eebd6191766613 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 5 Oct 2026 23:43:57 +0100 Subject: [PATCH 2/2] fix(gateway): keep documentation and fixtures application-neutral --- docs/user/gateway.md | 2 +- tests/gateway.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user/gateway.md b/docs/user/gateway.md index ed6fc76..2204fc6 100644 --- a/docs/user/gateway.md +++ b/docs/user/gateway.md @@ -6,7 +6,7 @@ This is an experimental, single-user, private-network facility, not a public ser ## Start the host -Configure the host's provider credentials, model, tools, and MCP servers as for a local Kit session. Children inherit the gateway host's environment and load its configuration; the remote terminal does not supply provider credentials. ACP clients may supply session-scoped stdio `mcpServers` when creating a session or restoring one after its child exits. These commands execute on the gateway host under the existing runtime's MCP validation and configuration rules, not on the client. Each ACP v2 stdio entry needs `"type":"stdio"` and an absolute host executable path. For Slick, use that host's `slick` executable with `"args":["--profile","kit","mcp"]`; the named Slick profile supplies its existing gateway grant. Kit's gateway bearer credential remains separate and is not copied into MCP configuration. Resident reattach retains the running session's MCP runtime: omit `mcpServers` or send an empty array; a nonempty list is rejected rather than ignored or used to replace live servers. Session-scoped MCP configuration is not persisted in the transcript, so clients must supply it again when restoring a stopped session. +Configure the host's provider credentials, model, tools, and MCP servers as for a local Kit session. Children inherit the gateway host's environment and load its configuration; the remote terminal does not supply provider credentials. ACP clients may supply session-scoped stdio `mcpServers` when creating a session or restoring one after its child exits. These commands execute on the gateway host under the existing runtime's MCP validation and configuration rules, not on the client. Each ACP v2 stdio entry needs `"type":"stdio"` and an absolute host executable path. Resident reattach retains the running session's MCP runtime: omit `mcpServers` or send an empty array; a nonempty list is rejected rather than ignored or used to replace live servers. Session-scoped MCP configuration is not persisted in the transcript, so clients must supply it again when restoring a stopped session. Create a high-entropy bearer credential without putting it in command-line arguments: diff --git a/tests/gateway.rs b/tests/gateway.rs index 7c1aef2..11328ef 100644 --- a/tests/gateway.rs +++ b/tests/gateway.rs @@ -392,7 +392,7 @@ async fn sdk_session_mcp_new_reattach_and_restore() { let delta = if finished { json!({"role":"assistant","content":"MCP discovery complete"}) } else { - json!({"role":"assistant","tool_calls":[{"index":0,"id":"discover-mcp","type":"function","function":{"name":"compose","arguments":json!({"script":"return tool_search({ query: \"slick-test echo\" })"}).to_string()}}]}) + json!({"role":"assistant","tool_calls":[{"index":0,"id":"discover-mcp","type":"function","function":{"name":"compose","arguments":json!({"script":"return tool_search({ query: \"session-fixture echo\" })"}).to_string()}}]}) }; let chunk = json!({"id":"local-mcp-test","choices":[{"index":0,"delta":delta,"finish_reason":if finished {"stop"} else {"tool_calls"}}]}); ( @@ -417,7 +417,7 @@ async fn sdk_session_mcp_new_reattach_and_restore() { "import os, runpy\nwith open(os.environ['MCP_MARKER'], 'a') as f: f.write(os.getcwd() + '\\n')\nrunpy.run_path({:?}, run_name='__main__')\n", format!("{}/fixtures/mock-mcp.py", env!("CARGO_MANIFEST_DIR")) )).unwrap(); - let servers = json!([{"type":"stdio","name":"slick-test","command":"/usr/bin/python3","args":[script],"env":[{"name":"MCP_MARKER","value":marker}]}]); + let servers = json!([{"type":"stdio","name":"session-fixture","command":"/usr/bin/python3","args":[script],"env":[{"name":"MCP_MARKER","value":marker}]}]); let (mut gateway, url) = fixture.gateway().await; let mut sdk = SdkClient::connect(&url).await; let created = sdk