Skip to content

Commit 2498df9

Browse files
passcodclaude
andcommitted
fix(private-server): run the MCP endpoint statelessly
Claude Code (and any client) connected, then got 'Not Found: Session not found' on the request after initialize. rmcp's default stateful mode keeps sessions in process memory; behind the multi-replica deployment a follow-up request routed to a different pod than the one that ran initialize, so the session was unknown and rmcp 404'd. Clients surface that as a dropped connection / 'tools fetch failed'. This is a read-only request/response API with no server-initiated push, so disable sessions (stateful_mode=false) and return plain application/json (json_response=true). Each POST is then self-contained and any replica can serve it; there's also no long-lived SSE stream for the ingress to drop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7664ead commit 2498df9

2 files changed

Lines changed: 70 additions & 95 deletions

File tree

crates/private-server/src/mcp.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,17 @@ fn mcp_err(e: impl std::fmt::Display) -> McpError {
10241024
/// Build the tower service nested into the axum router at `/api/mcp`.
10251025
pub fn service(state: AppState) -> StreamableHttpService<CanopyMcp, LocalSessionManager> {
10261026
let mut config = StreamableHttpServerConfig::default();
1027+
// Stateless: each request is self-contained, with no server-side session.
1028+
// The default stateful mode keeps sessions in process memory and 404s
1029+
// ("Session not found") any follow-up request that a load balancer routes to
1030+
// a different replica than the one that handled `initialize` — which is
1031+
// exactly what a multi-replica deployment behind the Tailscale ingress does.
1032+
// This is a read-only request/response API with no server-initiated push, so
1033+
// sessions buy us nothing.
1034+
config.stateful_mode = false;
1035+
// Return plain `application/json` per request instead of an SSE stream. With
1036+
// no streaming there's no long-lived response for a proxy to buffer or drop.
1037+
config.json_response = true;
10271038
// rmcp's `allowed_hosts` defaults to loopback only — a DNS-rebinding defense
10281039
// aimed at browser-facing localhost MCP servers. That threat doesn't apply
10291040
// here: the endpoint is reachable only through the Tailscale ingress (which

crates/private-server/tests/mcp.rs

Lines changed: 59 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
//! Tests for the read-only MCP query interface mounted at `/api/mcp`.
22
//!
3-
//! The debug build bypasses Tailscale auth, so no headers are needed. These
4-
//! drive the real protocol path (initialize → tools/call over Streamable HTTP)
5-
//! against seeded data and assert the structured results, so they cover both
6-
//! the wiring and each tool's data shaping.
3+
//! The endpoint runs in stateless mode (no server-side session), so each POST
4+
//! is self-contained: a `tools/call` needs no prior `initialize`, just an
5+
//! `MCP-Protocol-Version` header. Responses are plain `application/json`. The
6+
//! debug build bypasses Tailscale auth, so no identity headers are needed.
77
88
use commons_tests::diesel_async::SimpleAsyncConnection;
99

10-
/// The Streamable HTTP transport requires the client to accept both JSON and
11-
/// SSE on the POST leg.
10+
/// Clients must accept both JSON and SSE on the POST leg.
1211
const ACCEPT: &str = "application/json, text/event-stream";
12+
/// Required on every non-initialize request in stateless mode.
13+
const PROTO: &str = "2025-06-18";
1314

14-
/// Extract the JSON-RPC envelope from a Streamable HTTP response body, which is
15-
/// either a bare JSON object or an SSE stream whose `data:` line carries it.
15+
/// Extract the JSON-RPC envelope from a response body (plain JSON in
16+
/// json-response mode, or an SSE `data:` line otherwise).
1617
fn parse_envelope(body: &str) -> serde_json::Value {
1718
let trimmed = body.trim_start();
1819
if trimmed.starts_with('{') {
@@ -26,50 +27,14 @@ fn parse_envelope(body: &str) -> serde_json::Value {
2627
serde_json::from_str(data.trim()).expect("json in SSE data line")
2728
}
2829

29-
/// Initialize a session and return its id. Expanded inline so the test never
30-
/// has to name `axum_test::TestServer`.
31-
macro_rules! init_session {
32-
($private:expr) => {{
33-
let init = $private
34-
.post("/api/mcp")
35-
.add_header("accept", ACCEPT)
36-
.json(&serde_json::json!({
37-
"jsonrpc": "2.0", "id": 1, "method": "initialize",
38-
"params": {
39-
"protocolVersion": "2025-06-18",
40-
"capabilities": {},
41-
"clientInfo": { "name": "canopy-tests", "version": "0" }
42-
}
43-
}))
44-
.await;
45-
assert_eq!(init.status_code().as_u16(), 200, "initialize should 200");
46-
let session = init
47-
.headers()
48-
.get("mcp-session-id")
49-
.expect("session id")
50-
.to_str()
51-
.unwrap()
52-
.to_owned();
53-
$private
54-
.post("/api/mcp")
55-
.add_header("accept", ACCEPT)
56-
.add_header("mcp-session-id", &session)
57-
.json(&serde_json::json!({
58-
"jsonrpc": "2.0", "method": "notifications/initialized"
59-
}))
60-
.await;
61-
session
62-
}};
63-
}
64-
6530
/// Call a tool and return its `structuredContent`. Asserts the call succeeded
6631
/// (no JSON-RPC error and `isError` is not true).
6732
macro_rules! call_tool {
68-
($private:expr, $session:expr, $name:expr, $args:expr) => {{
33+
($private:expr, $name:expr, $args:expr) => {{
6934
let resp = $private
7035
.post("/api/mcp")
7136
.add_header("accept", ACCEPT)
72-
.add_header("mcp-session-id", &$session)
37+
.add_header("mcp-protocol-version", PROTO)
7338
.json(&serde_json::json!({
7439
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
7540
"params": { "name": $name, "arguments": $args }
@@ -93,7 +58,7 @@ macro_rules! call_tool {
9358
}
9459

9560
/// Seed a group, two servers (one grouped + monitored with a fresh healthy
96-
/// status, one ungrouped), and a backup config + capability + schedule.
61+
/// status, one ungrouped), and a status carrying a version + platform.
9762
const GROUP: &str = "11111111-1111-1111-1111-111111111111";
9863
const SRV_GROUPED: &str = "22222222-2222-2222-2222-222222222222";
9964
const SRV_UNGROUPED: &str = "33333333-3333-3333-3333-333333333333";
@@ -116,11 +81,26 @@ async fn seed(conn: &mut impl SimpleAsyncConnection) {
11681
#[tokio::test(flavor = "multi_thread")]
11782
async fn initialize_and_list_tools() {
11883
commons_tests::server::run(async |_conn, _public, private| {
119-
let session = init_session!(private);
84+
// initialize works (and returns tool capability), but is not required
85+
// before other calls in stateless mode.
86+
let init = private
87+
.post("/api/mcp")
88+
.add_header("accept", ACCEPT)
89+
.json(&serde_json::json!({
90+
"jsonrpc": "2.0", "id": 1, "method": "initialize",
91+
"params": {
92+
"protocolVersion": PROTO,
93+
"capabilities": {},
94+
"clientInfo": { "name": "canopy-tests", "version": "0" }
95+
}
96+
}))
97+
.await;
98+
assert_eq!(init.status_code().as_u16(), 200, "initialize should 200");
99+
120100
let list = private
121101
.post("/api/mcp")
122102
.add_header("accept", ACCEPT)
123-
.add_header("mcp-session-id", &session)
103+
.add_header("mcp-protocol-version", PROTO)
124104
.json(&serde_json::json!({
125105
"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}
126106
}))
@@ -143,12 +123,26 @@ async fn initialize_and_list_tools() {
143123
.await
144124
}
145125

126+
#[tokio::test(flavor = "multi_thread")]
127+
async fn tools_call_needs_no_session() {
128+
// Regression: the endpoint is stateless, so a `tools/call` must succeed on
129+
// its own — no `initialize` handshake and no session id. (The default
130+
// stateful mode 404s any request routed to a replica that didn't handle
131+
// `initialize`, which is what broke real clients behind the load balancer.)
132+
commons_tests::server::run(async |mut conn, _public, private| {
133+
seed(&mut conn).await;
134+
let summary = call_tool!(private, "fleet_summary", serde_json::json!({}));
135+
assert!(summary["total_servers"].as_u64().unwrap() >= 2);
136+
})
137+
.await
138+
}
139+
146140
#[tokio::test(flavor = "multi_thread")]
147141
async fn oauth_discovery_is_404_not_spa() {
148-
// Regression: MCP clients probe `/.well-known/oauth-*` for OAuth metadata.
149-
// The SPA fallback used to answer 200 text/html, which clients fail to parse
150-
// as JSON and report as "needs authentication". A 404 tells them there's no
151-
// OAuth, so they connect with the ambient (Tailscale) identity instead.
142+
// MCP clients probe `/.well-known/oauth-*` for OAuth metadata. The SPA
143+
// fallback used to answer 200 text/html, which clients fail to parse as JSON
144+
// and report as "needs authentication". A 404 tells them there's no OAuth, so
145+
// they connect with the ambient (Tailscale) identity instead.
152146
commons_tests::server::run(async |_conn, _public, private| {
153147
for path in [
154148
"/.well-known/oauth-protected-resource",
@@ -180,7 +174,7 @@ async fn accepts_non_loopback_host() {
180174
.json(&serde_json::json!({
181175
"jsonrpc": "2.0", "id": 1, "method": "initialize",
182176
"params": {
183-
"protocolVersion": "2025-06-18",
177+
"protocolVersion": PROTO,
184178
"capabilities": {},
185179
"clientInfo": { "name": "canopy-tests", "version": "0" }
186180
}
@@ -200,31 +194,20 @@ async fn accepts_non_loopback_host() {
200194
async fn find_servers_filters_and_decorates() {
201195
commons_tests::server::run(async |mut conn, _public, private| {
202196
seed(&mut conn).await;
203-
let session = init_session!(private);
204197

205198
// Unfiltered: both seeded servers.
206-
let all = call_tool!(private, session, "find_servers", serde_json::json!({}));
199+
let all = call_tool!(private, "find_servers", serde_json::json!({}));
207200
assert!(all["total_matched"].as_u64().unwrap() >= 2);
208201

209202
// Filter by kind=facility → only the ungrouped one.
210-
let facility = call_tool!(
211-
private,
212-
session,
213-
"find_servers",
214-
serde_json::json!({ "kind": "facility" })
215-
);
203+
let facility = call_tool!(private, "find_servers", serde_json::json!({ "kind": "facility" }));
216204
let servers = facility["servers"].as_array().unwrap();
217205
assert_eq!(servers.len(), 1);
218206
assert_eq!(servers[0]["id"], SRV_UNGROUPED);
219207
assert_eq!(servers[0]["health"], "healthy");
220208

221209
// Query matches by name.
222-
let q = call_tool!(
223-
private,
224-
session,
225-
"find_servers",
226-
serde_json::json!({ "query": "central" })
227-
);
210+
let q = call_tool!(private, "find_servers", serde_json::json!({ "query": "central" }));
228211
let names: Vec<&str> = q["servers"]
229212
.as_array()
230213
.unwrap()
@@ -233,11 +216,11 @@ async fn find_servers_filters_and_decorates() {
233216
.collect();
234217
assert_eq!(names, vec!["Prod Central"]);
235218

236-
// Bad enum → invalid params (protocol error).
219+
// Bad enum → error (protocol or tool-level).
237220
let bad = private
238221
.post("/api/mcp")
239222
.add_header("accept", ACCEPT)
240-
.add_header("mcp-session-id", &session)
223+
.add_header("mcp-protocol-version", PROTO)
241224
.json(&serde_json::json!({
242225
"jsonrpc": "2.0", "id": 9, "method": "tools/call",
243226
"params": { "name": "find_servers", "arguments": { "kind": "nonsense" } }
@@ -256,14 +239,8 @@ async fn find_servers_filters_and_decorates() {
256239
async fn get_server_detail_and_not_found() {
257240
commons_tests::server::run(async |mut conn, _public, private| {
258241
seed(&mut conn).await;
259-
let session = init_session!(private);
260242

261-
let detail = call_tool!(
262-
private,
263-
session,
264-
"get_server",
265-
serde_json::json!({ "server_id": SRV_GROUPED })
266-
);
243+
let detail = call_tool!(private, "get_server", serde_json::json!({ "server_id": SRV_GROUPED }));
267244
assert_eq!(detail["name"], "Prod Central");
268245
assert_eq!(detail["group_name"], "Prod Group");
269246
assert_eq!(detail["latest_status"]["version"], "2.34.1");
@@ -273,7 +250,7 @@ async fn get_server_detail_and_not_found() {
273250
let missing = private
274251
.post("/api/mcp")
275252
.add_header("accept", ACCEPT)
276-
.add_header("mcp-session-id", &session)
253+
.add_header("mcp-protocol-version", PROTO)
277254
.json(&serde_json::json!({
278255
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
279256
"params": {
@@ -292,9 +269,8 @@ async fn get_server_detail_and_not_found() {
292269
async fn group_listing_and_detail() {
293270
commons_tests::server::run(async |mut conn, _public, private| {
294271
seed(&mut conn).await;
295-
let session = init_session!(private);
296272

297-
let groups = call_tool!(private, session, "find_groups", serde_json::json!({}));
273+
let groups = call_tool!(private, "find_groups", serde_json::json!({}));
298274
let g = groups["groups"]
299275
.as_array()
300276
.unwrap()
@@ -305,12 +281,7 @@ async fn group_listing_and_detail() {
305281
assert_eq!(g["member_count"], 1);
306282
assert_eq!(g["highest_rank"], "production");
307283

308-
let detail = call_tool!(
309-
private,
310-
session,
311-
"get_group",
312-
serde_json::json!({ "group_id": GROUP })
313-
);
284+
let detail = call_tool!(private, "get_group", serde_json::json!({ "group_id": GROUP }));
314285
assert_eq!(detail["name"], "Prod Group");
315286
let members = detail["members"].as_array().unwrap();
316287
assert_eq!(members.len(), 1);
@@ -323,9 +294,8 @@ async fn group_listing_and_detail() {
323294
async fn fleet_summary_rolls_up() {
324295
commons_tests::server::run(async |mut conn, _public, private| {
325296
seed(&mut conn).await;
326-
let session = init_session!(private);
327297

328-
let s = call_tool!(private, session, "fleet_summary", serde_json::json!({}));
298+
let s = call_tool!(private, "fleet_summary", serde_json::json!({}));
329299
assert!(s["total_servers"].as_u64().unwrap() >= 2);
330300
assert_eq!(s["counts"]["by_kind"]["facility"], 1);
331301
assert_eq!(s["version_distribution"]["2.34.1"], 1);
@@ -338,14 +308,8 @@ async fn fleet_summary_rolls_up() {
338308
async fn backup_problems_scan_runs() {
339309
commons_tests::server::run(async |mut conn, _public, private| {
340310
seed(&mut conn).await;
341-
let session = init_session!(private);
342311
// No ready backup config seeded, so the scan returns an empty, well-formed set.
343-
let p = call_tool!(
344-
private,
345-
session,
346-
"find_backup_problems",
347-
serde_json::json!({})
348-
);
312+
let p = call_tool!(private, "find_backup_problems", serde_json::json!({}));
349313
assert!(p["count"].is_number());
350314
assert!(p["problems"].is_array());
351315
})

0 commit comments

Comments
 (0)