feat(acp): accept HTTP MCP servers from the client
A client only lists Streamable HTTP servers in mcpServers for an agent that advertises mcpCapabilities.http. sigit answered false, so editors could pass stdio servers and nothing else. initialize now advertises http, and an HTTP entry is connected with the URL and headers the client sent. It goes through the same HTTP path the mcp.toml servers already use and is scoped to the session like the stdio ones. SSE stays unadvertised. The MCP spec deprecated that transport, and an SSE entry sent anyway is skipped with a warning. Fixes #151 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 5, 2026 at 00:15 UTC
67a22cdfd86af4e204c289d8eceec036f03588c1
5 files changed
+311
-60
.agents/AGENTS.md
+4
-3
@@ -232,9 +232,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
232
`SessionState` and installs with `set_session_servers` whenever a session becomes live, the
233
same way it swaps the roots. `tool_specs`, `call_tool` and `/mcp` read the startup servers and
234
then the live session's. A client-supplied server whose name is already taken by a startup
235
- server is not connected, since both would claim the same `mcp__<server>__` prefix. HTTP and
236
- SSE entries are skipped: `mcpCapabilities` is not advertised, so a conforming client never
237
- sends them.
235
+ server is not connected, since both would claim the same `mcp__<server>__` prefix. Streamable
236
+ HTTP entries are connected too, with the URL and headers the client sent, because
237
+ `handle_initialize` advertises `mcpCapabilities.http`. SSE is not advertised (the MCP spec
238
+ deprecated it), so a conforming client never sends one and a stray entry is skipped.
239
- **`src/permissions.rs`** — tool permission policy. Every tool call passes through
240
`decision_for` before executing: read-only tools always run; mutating tools (and all
241
`mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
CHANGELOG.md
+8
@@ -9,6 +9,14 @@
9
the wire. The `unstable_auth_methods` feature is gone because agent auth
10
methods are stable in 2.x.
11
12
+### Added
13
+
14
+- **Editors can pass HTTP MCP servers to a session.** siGit Code now
15
+ advertises `mcpCapabilities.http`, so an ACP client may list Streamable HTTP
16
+ servers in `mcpServers` next to stdio ones. They are connected with the URL
17
+ and headers the client sent and are scoped to that session, like the stdio
18
+ ones. SSE is still not advertised, since the MCP spec deprecated it (#151).
19
+
20
### Fixed
21
22
- **MCP servers the editor passes to a session are connected.** ACP clients
src/main.rs
+73
-48
@@ -74,16 +74,16 @@ use agent_client_protocol::schema::v1::{
74
AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
75
ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
76
ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse,
77
- ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, McpServer,
78
- Meta, NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, Plan,
79
- PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptRequest, PromptResponse,
80
- RequestPermissionOutcome, RequestPermissionRequest, SessionAdditionalDirectoriesCapabilities,
81
- SessionCapabilities, SessionConfigOption, SessionConfigOptionCategory,
82
- SessionConfigSelectOption, SessionConfigValueId, SessionForkCapabilities, SessionId,
83
- SessionInfo, SessionListCapabilities, SessionNotification, SessionUpdate,
84
- SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
85
- ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields,
86
- ToolKind, UnstructuredCommandInput,
77
+ ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
78
+ McpCapabilities, McpServer, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
79
+ PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptRequest,
80
+ PromptResponse, RequestPermissionOutcome, RequestPermissionRequest,
81
+ SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionConfigOption,
82
+ SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
83
+ SessionForkCapabilities, SessionId, SessionInfo, SessionListCapabilities, SessionNotification,
84
+ SessionUpdate, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason,
85
+ ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdate,
86
+ ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
87
};
88
use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
89
use onde::inference::{ChatEngine, GgufModelConfig};
@@ -1476,28 +1476,45 @@ impl SiGitAgent {
1476
}
1477
}
1478
1479
-/// The stdio servers out of a session request's `mcpServers`.
1479
+/// The servers out of a session request's `mcpServers` that siGit Code can
1480
+/// connect to.
1481
///
1481
-/// ACP requires every agent to connect to the stdio ones. HTTP and SSE are
1482
-/// optional and gated on `mcpCapabilities`, which siGit Code does not
1483
-/// advertise, so a client that sends one anyway is told in the log and the
1484
-/// entry is skipped.
1482
+/// ACP requires every agent to connect to the stdio ones. Streamable HTTP is
1483
+/// optional and advertised through `mcpCapabilities.http` in
1484
+/// `handle_initialize`. SSE is not advertised (the MCP spec deprecated it), so
1485
+/// a client that sends one anyway is told in the log and the entry is skipped.
1486
fn client_mcp_servers(servers: &[McpServer]) -> Vec<mcp::ClientServer> {
1487
servers
1488
.iter()
1489
.filter_map(|server| match server {
1490
McpServer::Stdio(stdio) => Some(mcp::ClientServer {
1491
name: stdio.name.clone(),
1491
- command: stdio.command.to_string_lossy().into_owned(),
1492
- args: stdio.args.clone(),
1493
- env: stdio
1494
- .env
1495
- .iter()
1496
- .map(|var| (var.name.clone(), var.value.clone()))
1497
- .collect(),
1492
+ transport: mcp::ClientTransport::Stdio {
1493
+ command: stdio.command.to_string_lossy().into_owned(),
1494
+ args: stdio.args.clone(),
1495
+ env: stdio
1496
+ .env
1497
+ .iter()
1498
+ .map(|var| (var.name.clone(), var.value.clone()))
1499
+ .collect(),
1500
+ },
1501
+ }),
1502
+ McpServer::Http(http) => Some(mcp::ClientServer {
1503
+ name: http.name.clone(),
1504
+ transport: mcp::ClientTransport::Http {
1505
+ url: http.url.clone(),
1506
+ headers: http
1507
+ .headers
1508
+ .iter()
1509
+ .map(|header| (header.name.clone(), header.value.clone()))
1510
+ .collect(),
1511
+ },
1512
}),
1513
other => {
1500
- log::warn!("ignoring a non-stdio MCP server from the client: {other:?}");
1514
+ log::warn!(
1515
+ "ignoring an MCP server from the client with an unsupported transport: \
1516
+ {other:?}"
1517
+ );
1518
None
1519
}
1520
})
@@ -1550,31 +1567,39 @@ impl SiGitAgent {
1567
.description("Opens sigit.si in your browser to authorize this device."),
1568
)];
1569
1553
- Ok(InitializeResponse::new(ProtocolVersion::V1)
1554
- .agent_info(
1555
- Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
1556
- .title("siGit Code - AI Coding Agent"),
1557
- )
1558
- .auth_methods(auth_methods)
1559
- .agent_capabilities(
1560
- AgentCapabilities::default()
1561
- .load_session(true)
1562
- .session_capabilities(
1563
- SessionCapabilities::new()
1564
- .fork(SessionForkCapabilities::new())
1565
- // Durable sessions (`session_store`) are what makes
1566
- // listing meaningful: without this the editor's
1567
- // "Import Threads" picker reports that the agent
1568
- // doesn't support ACP's session/list capability.
1569
- .list(SessionListCapabilities::new())
1570
- // Without this, a client that has several directories
1571
- // open never sends the extra ones: Zed drops every root
1572
- // but the first and tells the user the agent has no
1573
- // multi-root support. See `workspace.rs`.
1574
- .additional_directories(SessionAdditionalDirectoriesCapabilities::new()),
1575
- ),
1576
- )
1577
- .meta(initialize_meta()))
1570
+ Ok(
1571
+ InitializeResponse::new(ProtocolVersion::V1)
1572
+ .agent_info(
1573
+ Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
1574
+ .title("siGit Code - AI Coding Agent"),
1575
+ )
1576
+ .auth_methods(auth_methods)
1577
+ .agent_capabilities(
1578
+ AgentCapabilities::default()
1579
+ .load_session(true)
1580
+ // Clients only pass HTTP MCP servers in `mcpServers` to an
1581
+ // agent that says it can reach them. SSE stays off: the
1582
+ // MCP spec deprecated that transport.
1583
+ .mcp_capabilities(McpCapabilities::new().http(true))
1584
+ .session_capabilities(
1585
+ SessionCapabilities::new()
1586
+ .fork(SessionForkCapabilities::new())
1587
+ // Durable sessions (`session_store`) are what makes
1588
+ // listing meaningful: without this the editor's
1589
+ // "Import Threads" picker reports that the agent
1590
+ // doesn't support ACP's session/list capability.
1591
+ .list(SessionListCapabilities::new())
1592
+ // Without this, a client that has several directories
1593
+ // open never sends the extra ones: Zed drops every root
1594
+ // but the first and tells the user the agent has no
1595
+ // multi-root support. See `workspace.rs`.
1596
+ .additional_directories(
1597
+ SessionAdditionalDirectoriesCapabilities::new(),
1598
+ ),
1599
+ ),
1600
+ )
1601
+ .meta(initialize_meta()),
1602
+ )
1603
}
1604
1605
async fn handle_authenticate(
src/mcp.rs
+25
-9
@@ -752,13 +752,27 @@ async fn list_tools(http: &reqwest::Client, conn: &ServerConn) -> Result<Vec<Mcp
752
753
// ── Client-supplied servers (per session) ───────────────────────────────────
754
755
-/// A stdio server an ACP client passed in `mcpServers` on a session request.
755
+/// A server an ACP client passed in `mcpServers` on a session request.
756
#[derive(Debug, Clone)]
757
pub struct ClientServer {
758
pub name: String,
759
- pub command: String,
760
- pub args: Vec<String>,
761
- pub env: Vec<(String, String)>,
759
+ pub transport: ClientTransport,
760
+}
761
+
762
+/// How a client-supplied server is reached. stdio is the transport every ACP
763
+/// agent must support; Streamable HTTP is the optional one siGit Code
764
+/// advertises through `mcpCapabilities.http`.
765
+#[derive(Debug, Clone)]
766
+pub enum ClientTransport {
767
+ Stdio {
768
+ command: String,
769
+ args: Vec<String>,
770
+ env: Vec<(String, String)>,
771
+ },
772
+ Http {
773
+ url: String,
774
+ headers: Vec<(String, String)>,
775
+ },
776
}
777
778
/// The MCP servers one ACP session brought with it, connected.
@@ -766,7 +780,8 @@ pub struct ClientServer {
780
/// Unlike the startup servers in [`MCP`], these belong to a session: the
781
/// client names them on `session/new` (or load/fork) and they are offered to
782
/// that session only. Cheap to clone; the connections are shared. When the
769
-/// last clone goes away the children's stdin closes and they are killed.
783
+/// last clone goes away the stdio children's stdin closes and they are killed;
784
+/// an HTTP server holds nothing open between calls.
785
#[derive(Clone, Default)]
786
pub struct SessionServers(Arc<Vec<ServerConn>>);
787
@@ -812,10 +827,11 @@ pub async fn connect_session_servers(servers: Vec<ClientServer>) -> SessionServe
827
let mut defs = Vec::new();
828
for server in servers {
829
let name = sanitize(&server.name);
815
- let transport = TransportDef::Stdio {
816
- command: server.command,
817
- args: server.args,
818
- env: server.env,
830
+ let transport = match server.transport {
831
+ ClientTransport::Stdio { command, args, env } => {
832
+ TransportDef::Stdio { command, args, env }
833
+ }
834
+ ClientTransport::Http { url, headers } => TransportDef::Http { url, headers },
835
};
836
if name.is_empty() || taken.contains(&name) {
837
clashes.push(ServerConn {
tests/mcp_stdio.rs
+201
@@ -491,3 +491,204 @@ fn client_supplied_stdio_server_is_scoped_to_its_session() {
491
drop(agent);
492
let _ = std::fs::remove_dir_all(&scratch);
493
}
494
+
495
+// ── HTTP servers the client supplies ────────────────────────────────────────
496
+
497
+/// A Streamable HTTP MCP server with one `echo` tool. Records the
498
+/// `Authorization` header of every request so the test can check that the
499
+/// headers the client sent with the server definition reached it.
500
+struct FakeMcpServer {
501
+ port: u16,
502
+ authorizations: Arc<Mutex<Vec<String>>>,
503
+}
504
+
505
+fn start_fake_mcp_server() -> FakeMcpServer {
506
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake MCP server");
507
+ let port = listener.local_addr().unwrap().port();
508
+ let authorizations: Arc<Mutex<Vec<String>>> = Arc::default();
509
+ let recorded = Arc::clone(&authorizations);
510
+
511
+ std::thread::spawn(move || {
512
+ for stream in listener.incoming() {
513
+ let Ok(mut stream) = stream else { continue };
514
+ let mut reader = BufReader::new(match stream.try_clone() {
515
+ Ok(clone) => clone,
516
+ Err(_) => continue,
517
+ });
518
+ let mut content_length = 0usize;
519
+ loop {
520
+ let mut line = String::new();
521
+ if reader.read_line(&mut line).unwrap_or(0) == 0 {
522
+ break;
523
+ }
524
+ let line = line.trim();
525
+ if line.is_empty() {
526
+ break;
527
+ }
528
+ let lower = line.to_ascii_lowercase();
529
+ if let Some(length) = lower.strip_prefix("content-length:") {
530
+ content_length = length.trim().parse().unwrap_or(0);
531
+ }
532
+ if lower.starts_with("authorization:") {
533
+ let value = line["authorization:".len()..].trim().to_string();
534
+ recorded.lock().unwrap().push(value);
535
+ }
536
+ }
537
+ let mut body = vec![0u8; content_length];
538
+ if reader.read_exact(&mut body).is_err() {
539
+ continue;
540
+ }
541
+ let Ok(message) = serde_json::from_slice::<Value>(&body) else {
542
+ continue;
543
+ };
544
+
545
+ let result = match message["method"].as_str().unwrap_or_default() {
546
+ "initialize" => Some(json!({
547
+ "protocolVersion": "2025-06-18",
548
+ "capabilities": {"tools": {}},
549
+ "serverInfo": {"name": "fake-http-mcp", "version": "0.0.0"},
550
+ })),
551
+ "tools/list" => Some(json!({
552
+ "tools": [{
553
+ "name": "echo",
554
+ "description": "Echo the text back.",
555
+ "inputSchema": {
556
+ "type": "object",
557
+ "properties": {"text": {"type": "string"}},
558
+ "required": ["text"],
559
+ },
560
+ }],
561
+ })),
562
+ "tools/call" => {
563
+ let text = message["params"]["arguments"]["text"]
564
+ .as_str()
565
+ .unwrap_or_default();
566
+ Some(json!({
567
+ "content": [{"type": "text", "text": format!("http:{text}")}],
568
+ }))
569
+ }
570
+ _ => None,
571
+ };
572
+
573
+ let response = match (message.get("id"), result) {
574
+ (Some(id), Some(result)) => {
575
+ let payload = json!({"jsonrpc": "2.0", "id": id, "result": result}).to_string();
576
+ format!(
577
+ "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
578
+ content-length: {}\r\nconnection: close\r\n\r\n{}",
579
+ payload.len(),
580
+ payload
581
+ )
582
+ }
583
+ // Notifications get an empty 202.
584
+ _ => "HTTP/1.1 202 Accepted\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
585
+ .to_string(),
586
+ };
587
+ let _ = stream.write_all(response.as_bytes());
588
+ }
589
+ });
590
+
591
+ FakeMcpServer {
592
+ port,
593
+ authorizations,
594
+ }
595
+}
596
+
597
+/// A client only sends HTTP MCP servers to an agent that advertises
598
+/// `mcpCapabilities.http`, and then expects them connected with the headers it
599
+/// supplied (issue #151). SSE stays unadvertised.
600
+#[test]
601
+fn client_supplied_http_server_is_connected() {
602
+ let endpoint = start_fake_endpoint(vec![
603
+ sse_tool_call("call_1", "mcp__remote__echo", r#"{"text":"hello"}"#),
604
+ sse_text("done"),
605
+ ]);
606
+ let mcp_server = start_fake_mcp_server();
607
+
608
+ let scratch =
609
+ std::env::temp_dir().join(format!("sigit_mcp_client_http_{}", std::process::id()));
610
+ let config_dir = scratch.join("config");
611
+ let cwd = scratch.join("cwd");
612
+ std::fs::create_dir_all(&config_dir).unwrap();
613
+ std::fs::create_dir_all(&cwd).unwrap();
614
+ std::fs::write(
615
+ config_dir.join("mcp.toml"),
616
+ "official = false\nsmbcloud = false\n",
617
+ )
618
+ .unwrap();
619
+
620
+ let mut agent = spawn_agent(endpoint.port, &config_dir, &cwd);
621
+
622
+ let id = agent.request(
623
+ "initialize",
624
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
625
+ );
626
+ let initialize = agent.wait_for_response(id);
627
+ let capabilities = &initialize["result"]["agentCapabilities"]["mcpCapabilities"];
628
+ assert_eq!(
629
+ capabilities["http"], true,
630
+ "HTTP MCP servers must be advertised: {initialize}"
631
+ );
632
+ assert_eq!(
633
+ capabilities["sse"], false,
634
+ "the deprecated SSE transport must stay off: {initialize}"
635
+ );
636
+
637
+ let id = agent.request(
638
+ "session/new",
639
+ json!({
640
+ "cwd": cwd,
641
+ "mcpServers": [{
642
+ "type": "http",
643
+ "name": "remote",
644
+ "url": format!("http://127.0.0.1:{}/mcp", mcp_server.port),
645
+ "headers": [{"name": "Authorization", "value": "Bearer from-the-editor"}],
646
+ }],
647
+ }),
648
+ );
649
+ let session_id = agent.wait_for_response(id)["result"]["sessionId"]
650
+ .as_str()
651
+ .expect("session id")
652
+ .to_string();
653
+
654
+ let id = agent.request(
655
+ "session/prompt",
656
+ json!({
657
+ "sessionId": session_id,
658
+ "prompt": [{"type": "text", "text": "use the remote tool"}],
659
+ }),
660
+ );
661
+ let response = agent.wait_for_response(id);
662
+ assert_eq!(response["result"]["stopReason"], "end_turn");
663
+
664
+ let requests = endpoint.requests.lock().unwrap();
665
+ assert_eq!(requests.len(), 2, "expected exactly two completions");
666
+ let tools = requests[0]["tools"].to_string();
667
+ assert!(
668
+ tools.contains("mcp__remote__echo"),
669
+ "the HTTP server's tool missing from specs: {tools}"
670
+ );
671
+ let messages = requests[1]["messages"].as_array().expect("messages");
672
+ let result = messages
673
+ .iter()
674
+ .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
675
+ .expect("tool result for the echo call");
676
+ assert_eq!(result["content"].as_str().unwrap_or_default(), "http:hello");
677
+
678
+ // Every request to the server, handshake and call alike, carried the
679
+ // header from the server definition.
680
+ let authorizations = mcp_server.authorizations.lock().unwrap();
681
+ assert!(
682
+ authorizations.len() >= 4,
683
+ "expected initialize, initialized, tools/list and tools/call, got {authorizations:?}"
684
+ );
685
+ assert!(
686
+ authorizations
687
+ .iter()
688
+ .all(|value| value == "Bearer from-the-editor"),
689
+ "the client's headers must reach the server: {authorizations:?}"
690
+ );
691
+
692
+ drop(agent);
693
+ let _ = std::fs::remove_dir_all(&scratch);
694
+}