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
88use 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.
1211const 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) .
1617fn 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).
6732macro_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 .
9762const GROUP : & str = "11111111-1111-1111-1111-111111111111" ;
9863const SRV_GROUPED : & str = "22222222-2222-2222-2222-222222222222" ;
9964const 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" ) ]
11782async 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" ) ]
147141async 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() {
200194async 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() {
256239async 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() {
292269async 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() {
323294async 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() {
338308async 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