diff --git a/AGENTS.md b/AGENTS.md index 73dfe3d..6ce6d83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,70 @@ behaviors are active: Full Godot static-analysis scope, static-vs-runtime boundary, and honesty signals: [`docs/godot.md`](docs/godot.md). +## MCP protocol surface (`codegraph-mcp`, rmcp 3.0.1) + +`codegraph-mcp` builds on **rmcp 3.0.1** (`crates/codegraph-mcp/Cargo.toml`, dep and dev-dep; +features `server`, `client`, `transport-io`, `transport-streamable-http-server`). The 2.1 → 3.0.1 +upgrade cost three lines in `rmcp_handler.rs`: `call_tool` returns the `#[non_exhaustive]` +`CallToolResponse` enum instead of `CallToolResult` (we only ever build `Complete`, via `.into()`), +`with_stateful_mode` became `with_legacy_session_mode`, and a stale `get_info()` comment was +corrected. The 3.x breaking changes that bit third-party code were all CLIENT-side +(`InitializeResult` → `ServerPeerInfo`, optional `server_info`, OAuth `resolve_metadata()`), none of +which touch a server `ServerHandler`. `Peer::list_roots` is still `#[deprecated]` (SEP-2577) with +still no replacement, so the `#[allow(deprecated)]` at `rmcp_handler.rs:334` stays. + +**We serve five protocol revisions, and that is the SDK default rather than a choice.** +`ProtocolVersion` is `struct ProtocolVersion(Cow<'static, str>)` plus associated constants (NOT an +enum); rmcp's `negotiate_protocol_version` echoes back any client-requested version present in +`KNOWN_VERSIONS`. So `get_info()`'s `.with_protocol_version(V_2024_11_05)` is only the FALLBACK for +an unknown client version — it is not a ceiling, and we do not pin or force 2024-11-05: + +| client requests | negotiated | `resultType` in results | HTTP session | +| --------------- | ---------- | ----------------------- | ---------------------- | +| 2024-11-05 | 2024-11-05 | absent | no header (our config) | +| 2025-03-26 | 2025-03-26 | absent | no header (our config) | +| 2025-06-18 | 2025-06-18 | absent | no header (our config) | +| 2025-11-25 | 2025-11-25 | absent | no header (our config) | +| 2026-07-28 | 2026-07-28 | `"complete"` | no header (per spec) | + +`resultType` (SEP-2322) is absent for pre-2026 peers because upstream +[#1038](https://github.com/modelcontextprotocol/rust-sdk/pull/1038) strips it for legacy peers, and +2026-07-28 is stateless per SEP-2567 — no `Mcp-Session-Id` — unconditionally, regardless of +`with_legacy_session_mode`, which now governs legacy versions only. We pass +`with_legacy_session_mode(false)` (`rmcp_handler.rs:737`), so the legacy versions issue no +`Mcp-Session-Id` either: THIS server never sends one at any revision. The two absences differ in +cause, though — legacy is our configuration and would come back if that flag flipped to `true`, +while 2026-07-28 is mandated and cannot be turned back on. SEP-2243 standard headers are +validated in both directions: matching `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` reach the +tool, while a missing protocol header or a mismatched `Mcp-Method` / `Mcp-Name` is rejected with +HTTP 400 + JSON-RPC code `-32020`. Coverage lives in `tests/rmcp_l3.rs` (version echo, `resultType` +presence/absence) and `tests/rmcp_http.rs` (no session header, SEP-2243 both branches). + +**Not implemented, deliberately:** Tasks (SEP-2663), MRTR / elicitation-in-tool, and subscriptions. +We advertise only the tools capability and always return `CallToolResponse::Complete`; we never +construct `Task` or `InputRequired`. `accepted_subscription_filter` keeps its `None` default, while +legacy `subscribe` / `unsubscribe` return method-not-found. The request surface is not literally +tools-only, however. Beyond the mandatory `initialize` handshake — itself an inherited default +(`handler/server.rs:314-326`), which is why negotiation runs automatically over our `get_info()` and +why the `V_2024_11_05` there is only a fallback — these inherited defaults also answer `Ok`. +`discover` returns real content +(`supported_protocol_versions()` plus `get_info()`); `complete` returns an empty `CompleteResult`; +`list_prompts` / `list_resources` / `list_resource_templates` (`handler/server.rs:362-383`) return +EMPTY results, so `prompts/list`, `resources/list`, and `resources/templates/list` answer with +nothing rather than method-not-found; and `ping` succeeds on the legacy revisions only +(method-not-found at 2026-07-28, `handler/server.rs:112-118`). The `set_level`, `get_prompt`, +`read_resource`, and task-lifecycle defaults return method-not-found. These are SDK defaults, not +our implementations, and they add no advertised capabilities. + +**MCP golden fixtures are STRUCTURAL, not byte-stable.** `tests/support/parity.rs:244-300` and +`tests/golden_mcp.rs` compare only NAMED fields — initialize: `protocolVersion`, `capabilities`, +`serverInfo.name`, `serverInfo.version`, `instructions`; tools/list: names + order + `inputSchema` + +annotations; tool result: `content[0].type`, `isError`, and the sorted set of non-empty text lines; +error: `code` + `message` — never whole-object equality. That is why an added top-level `resultType` +could not drift the 15 existing fixtures, and equally why it needed its own explicit test. (The +EXTRACTION goldens under `reference/golden/` are a different contract and genuinely ARE +byte-stable — see "Hard invariants" above.) + ## HTTP MCP server: background mode + addr-keyed registry `serve --mcp` (stdio) uses the PER-PROJECT daemon (`.codegraph-v2/daemon.pid` + socket; the whole @@ -176,9 +240,16 @@ make coverage # workspace coverage summary (informational; `make coverage-htm non-blocking** — the `coverage` job is kept out of the `CI Success` gate and the Codecov status is `informational: true` (`codecov.yml`), so a below-target % never turns CI red. This honors the iron rule "local green ⇒ CI green". -- **Baseline ~72% line coverage** — a known gap to close. Biggest gaps: - `codegraph-resolve/src/import_resolver.rs`, `codegraph-resolve/src/name_matcher.rs`, - and the 0%-covered `codegraph-watch/src/{git,worktree}.rs`. +- **Measured 94.42% line / 94.12% region / 93.41% function** — close to the 95% + target but not yet there. Figure measured at `4fabc0b` (rmcp 3.0.1 round) via + `make coverage`; re-measure before quoting it, since it moves with every round. + The remaining gap is now concentrated in the **per-language extractors** rather + than the resolver or watch internals: 7 of the 10 lowest-covered files are + `codegraph-extract/src/lang/*` — `r.rs` 70.59%, `dart.rs` 72.75%, + `erlang.rs` 75.77%, `arkts.rs` 81.01%, `nix.rs` 82.21%, `jsx.rs` 82.61%, + `lua.rs` 85.94% — alongside `codegraph-watch/src/migrate.rs` 74.77%, + `codegraph-store/src/index_state.rs` 80.45%, and + `codegraph-store/src/connection.rs` 82.57%. - **Enabling Codecov:** enable the repo at codecov.io. This repo is public, so tokenless upload works (no `CODECOV_TOKEN` needed); a private repo would need `CODECOV_TOKEN` in GitHub repo Secrets. diff --git a/Cargo.lock b/Cargo.lock index af82c04..3c62c7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "base64ct" version = "1.8.3" @@ -1447,7 +1453,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2339,7 +2345,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -2371,7 +2377,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -2421,12 +2427,12 @@ dependencies = [ [[package]] name = "rmcp" -version = "2.1.0" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00a32c3b81b7b254076a65abd5ab2551209146713ba38f73818657e865e9433" +checksum = "3797d226787327b35b0b6f9e878fe55f3af2bb4daf916c0411079885454042f0" dependencies = [ "async-trait", - "base64", + "base64 0.23.0", "bytes", "chrono", "futures", @@ -2452,9 +2458,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "2.1.0" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee70afb7956da9f30d5348a2539b5eb90d9038f834463657ab717076ac3b1ad" +checksum = "a8f5bc0c6851a20cd627ef4b90defb79f39a261c467de52af5d38000d7979c52" dependencies = [ "darling", "proc-macro2", @@ -2932,9 +2938,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" dependencies = [ "bytes", "futures-util", @@ -3815,7 +3821,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64", + "base64 0.22.1", "cookie_store", "encoding_rs", "flate2", @@ -3837,7 +3843,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64", + "base64 0.22.1", "http", "httparse", "log", @@ -4461,7 +4467,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "thiserror 2.0.18", ] diff --git a/crates/codegraph-mcp/Cargo.toml b/crates/codegraph-mcp/Cargo.toml index 060c05b..79ff9b1 100644 --- a/crates/codegraph-mcp/Cargo.toml +++ b/crates/codegraph-mcp/Cargo.toml @@ -24,7 +24,7 @@ codegraph-resolve = { path = "../codegraph-resolve" } regex = "1.10" # rmcp is now the SOLE MCP transport (Phase E): a regular dependency, no longer # feature-gated. The shipped binary links rmcp + tokio + axum unconditionally. -rmcp = { version = "2.1", features = [ +rmcp = { version = "3.0.1", features = [ "server", "transport-io", "transport-streamable-http-server", @@ -60,7 +60,7 @@ test-hooks = ["codegraph-store/test-hooks"] codegraph-mcp = { path = ".", features = ["test-hooks"] } codegraph-extract = { path = "../codegraph-extract" } # rmcp `client` feature: dev-only, drives the handler over duplex in parity tests. -rmcp = { version = "2.1", features = ["server", "client", "transport-io", "transport-streamable-http-server"] } +rmcp = { version = "3.0.1", features = ["server", "client", "transport-io", "transport-streamable-http-server"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "net", "sync", "time"] } # reqwest: dev-only HTTP client for the streamable-HTTP integration test. reqwest = { version = "0.12", default-features = false, features = ["json"] } diff --git a/crates/codegraph-mcp/src/rmcp_handler.rs b/crates/codegraph-mcp/src/rmcp_handler.rs index fba383c..db71cd0 100644 --- a/crates/codegraph-mcp/src/rmcp_handler.rs +++ b/crates/codegraph-mcp/src/rmcp_handler.rs @@ -26,9 +26,9 @@ use std::time::Duration; use rmcp::handler::server::ServerHandler; use rmcp::model::{ - CallToolRequestParams, CallToolResult, ContentBlock, ErrorData, Implementation, - InitializeResult, JsonObject, ListToolsResult, PaginatedRequestParams, ProtocolVersion, - ServerCapabilities, ServerInfo, Tool, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ErrorData, + Implementation, InitializeResult, JsonObject, ListToolsResult, PaginatedRequestParams, + ProtocolVersion, ServerCapabilities, ServerInfo, Tool, }; use rmcp::service::{NotificationContext, RequestContext, RoleServer}; use serde_json::{Value, json}; @@ -295,7 +295,8 @@ fn execute_owned(project_path: &Path, tool_name: &str, args: &Value) -> ToolResu impl ServerHandler for CodeGraphHandler { fn get_info(&self) -> ServerInfo { // capabilities = exactly {"tools":{}} (enable_tools, NO list_changed); - // protocolVersion forced to V_2024_11_05 (rmcp defaults to LATEST); + // protocolVersion falls back to V_2024_11_05 for unknown client versions; + // rmcp negotiates and echoes known versions verbatim. // serverInfo{name,version=crate}; instructions reused verbatim. InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) .with_protocol_version(ProtocolVersion::V_2024_11_05) @@ -329,9 +330,9 @@ impl ServerHandler for CodeGraphHandler { } // `Peer::list_roots` is `#[deprecated]` (SEP-2577); it is still THE - // mechanism in rmcp 2.1 for a server to ask the client for its roots and - // has no non-deprecated replacement, so the deprecation is allowed at - // this one call site (rmcp pinned to 2.1.x; revisit on upgrade). + // mechanism in rmcp 3.0.1 for a server to ask the client for its roots and + // still has no non-deprecated replacement, so the deprecation is allowed + // at this one call site. #[allow(deprecated)] let roots = match context.peer.list_roots().await { Ok(result) => result, @@ -358,7 +359,7 @@ impl ServerHandler for CodeGraphHandler { &self, request: CallToolRequestParams, _context: RequestContext, - ) -> Result { + ) -> Result { let tool_name = request.name.to_string(); // Unknown tool → JSON-RPC -32602 (keep our own lookup so rmcp's built-in @@ -384,7 +385,8 @@ impl ServerHandler for CodeGraphHandler { crate::roots::ProjectArg::InvalidConfig(detail) => { return Ok(tool_result_to_call_result(&ToolResult::error( crate::roots::invalid_config_message(&detail), - ))); + )) + .into()); } crate::roots::ProjectArg::NotIndexed => { let message = match raw_project { @@ -393,7 +395,7 @@ impl ServerHandler for CodeGraphHandler { ), None => "No indexed project resolved. Pass a `projectPath` argument, run `codegraph init` in the project, or start the server with `--path `.".to_string(), }; - return Ok(tool_result_to_call_result(&ToolResult::error(message))); + return Ok(tool_result_to_call_result(&ToolResult::error(message)).into()); } }; @@ -469,7 +471,7 @@ impl ServerHandler for CodeGraphHandler { timed_out, "tool call complete" ); - Ok(tool_result_to_call_result(&result)) + Ok(tool_result_to_call_result(&result).into()) } } @@ -729,8 +731,10 @@ pub fn build_http_config( ) -> rmcp::transport::streamable_http_server::StreamableHttpServerConfig { use rmcp::transport::streamable_http_server::StreamableHttpServerConfig; + // This switch governs legacy protocol versions only; MCP 2026-07-28 is + // stateless unconditionally per SEP-2567. let base = StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) .with_json_response(true) .with_sse_keep_alive(None); diff --git a/crates/codegraph-mcp/tests/rmcp_http.rs b/crates/codegraph-mcp/tests/rmcp_http.rs index c852b93..f3e9f8a 100644 --- a/crates/codegraph-mcp/tests/rmcp_http.rs +++ b/crates/codegraph-mcp/tests/rmcp_http.rs @@ -125,6 +125,53 @@ async fn post_json_with_host( .expect("http post") } +async fn post_json_with_mcp_headers( + client: &reqwest::Client, + url: &str, + body: &Value, + protocol_version: Option<&str>, + method: Option<&str>, + name: Option<&str>, +) -> reqwest::Response { + let mut request = client + .post(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream"); + if let Some(value) = protocol_version { + request = request.header("MCP-Protocol-Version", value); + } + if let Some(value) = method { + request = request.header("Mcp-Method", value); + } + if let Some(value) = name { + request = request.header("Mcp-Name", value); + } + request + .body(serde_json::to_string(body).unwrap()) + .send() + .await + .expect("http post") +} + +async fn assert_header_mismatch(response: reqwest::Response, expected_message: &str) { + assert_eq!( + response.status(), + 400, + "header mismatch must return HTTP 400" + ); + let body: Value = response.json().await.expect("header mismatch JSON body"); + assert_eq!( + body["error"]["code"], + json!(-32020), + "header mismatch must use rmcp code -32020: {body}" + ); + assert_eq!( + body["error"]["message"], + json!(expected_message), + "unexpected header mismatch message: {body}" + ); +} + fn initialize_body() -> Value { json!({ "jsonrpc": "2.0", @@ -206,6 +253,131 @@ fn http_initialize_negotiates_2024_11_05_and_no_roots() { }); } +/// Structural goldens cannot observe HTTP response headers, so this locks the +/// 2026 stateless initialize path to the absence of `Mcp-Session-Id`. +#[test] +fn http_2026_initialize_has_no_session_id() { + rt().block_on(async { + let project = setup_mini_project(); + let (url, _addr, ct) = spawn_http_server(project.path().to_path_buf()).await; + let client = reqwest::Client::new(); + let init = json!({ + "jsonrpc": "2.0", + "id": 2026, + "method": "initialize", + "params": { + "protocolVersion": "2026-07-28", + "capabilities": {}, + "clientInfo": { "name": "http-test", "version": "0" } + } + }); + + let response = post_json(&client, &url, init).await; + assert_eq!(response.status(), 200, "initialize must return 200"); + assert!( + response.headers().get("Mcp-Session-Id").is_none(), + "2026-07-28 HTTP must be stateless and omit Mcp-Session-Id: {:?}", + response.headers() + ); + let body: Value = response.json().await.expect("json body"); + assert_eq!( + body["result"]["protocolVersion"], + json!("2026-07-28"), + "initialize must negotiate 2026-07-28: {body}" + ); + + ct.cancel(); + }); +} + +/// Structural goldens compare neither HTTP request headers nor top-level +/// `resultType`, so exercise SEP-2243 success and rejection on the real wire. +#[test] +fn http_sep_2243_headers_accept_match_and_reject_missing_or_mismatched() { + rt().block_on(async { + let project = setup_mini_project(); + let (url, _addr, ct) = spawn_http_server(project.path().to_path_buf()).await; + let client = reqwest::Client::new(); + let call = json!({ + "jsonrpc": "2.0", + "id": 2243, + "method": "tools/call", + "params": { + "name": "codegraph_search", + "arguments": { "query": "McpServer" }, + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + } + } + }); + + let matching = post_json_with_mcp_headers( + &client, + &url, + &call, + Some("2026-07-28"), + Some("tools/call"), + Some("codegraph_search"), + ) + .await; + assert_eq!(matching.status(), 200, "matching headers must succeed"); + let matching_body: Value = matching.json().await.expect("matching JSON body"); + assert_eq!( + matching_body["result"]["resultType"], + json!("complete"), + "matching 2026 headers must reach tools/call: {matching_body}" + ); + + let missing_protocol = post_json_with_mcp_headers( + &client, + &url, + &call, + None, + Some("tools/call"), + Some("codegraph_search"), + ) + .await; + assert_header_mismatch( + missing_protocol, + "request _meta protocolVersion requires MCP-Protocol-Version header", + ) + .await; + + let mismatched_method = post_json_with_mcp_headers( + &client, + &url, + &call, + Some("2026-07-28"), + Some("tools/list"), + Some("codegraph_search"), + ) + .await; + assert_header_mismatch( + mismatched_method, + "Mcp-Method header `tools/list` does not match body method `tools/call`", + ) + .await; + + let mismatched_name = post_json_with_mcp_headers( + &client, + &url, + &call, + Some("2026-07-28"), + Some("tools/call"), + Some("other_tool"), + ) + .await; + assert_header_mismatch( + mismatched_name, + "Mcp-Name header `other_tool` does not match body value `codegraph_search`", + ) + .await; + + ct.cancel(); + }); +} + /// (b) tools/call codegraph_search with NO projectPath resolves against the /// http-pinned default project → NON-EMPTY, non-error result. #[test] diff --git a/crates/codegraph-mcp/tests/rmcp_l3.rs b/crates/codegraph-mcp/tests/rmcp_l3.rs index 0bf45a0..1217bc0 100644 --- a/crates/codegraph-mcp/tests/rmcp_l3.rs +++ b/crates/codegraph-mcp/tests/rmcp_l3.rs @@ -16,7 +16,7 @@ mod parity; use codegraph_mcp::rmcp_handler::CodeGraphHandler; use rmcp::ServiceExt; use rmcp::handler::server::ServerHandler; -use rmcp::model::{CallToolRequestParams, PaginatedRequestParams}; +use rmcp::model::{CallToolRequestParams, PaginatedRequestParams, ProtocolVersion}; use serde_json::json; use parity::{TestProject, golden_client, setup_mini_project, workspace_root}; @@ -26,6 +26,16 @@ async fn connect( ) -> ( rmcp::service::RunningService, tokio::task::JoinHandle<()>, +) { + connect_with_protocol(handler, ProtocolVersion::V_2024_11_05).await +} + +async fn connect_with_protocol( + handler: CodeGraphHandler, + protocol_version: ProtocolVersion, +) -> ( + rmcp::service::RunningService, + tokio::task::JoinHandle<()>, ) { let (client_io, server_io) = tokio::io::duplex(1024 * 1024); let server_task = tokio::spawn(async move { @@ -34,6 +44,7 @@ async fn connect( } }); let client = golden_client() + .with_protocol_version(protocol_version) .serve(client_io) .await .expect("rmcp client handshake"); @@ -97,6 +108,88 @@ fn negotiated_protocol_version_is_2024_11_05() { }); } +#[test] +fn known_protocol_versions_are_echoed_by_handshake() { + rt().block_on(async { + for (requested, expected) in [ + (ProtocolVersion::V_2024_11_05, "2024-11-05"), + (ProtocolVersion::V_2025_03_26, "2025-03-26"), + (ProtocolVersion::V_2025_06_18, "2025-06-18"), + (ProtocolVersion::V_2025_11_25, "2025-11-25"), + (ProtocolVersion::V_2026_07_28, "2026-07-28"), + ] { + let project = setup_mini_project(); + let handler = CodeGraphHandler::new(Some(project.path().to_path_buf())); + let (client, task) = connect_with_protocol(handler, requested).await; + let info = client.peer_info().expect("negotiated server info"); + let value = serde_json::to_value(&*info).expect("serialize"); + assert_eq!( + value["protocolVersion"], + json!(expected), + "negotiated protocolVersion must echo {expected}, got {}", + value["protocolVersion"] + ); + let _ = client.cancel().await; + let _ = task.await; + } + }); +} + +/// The structural goldens never compare top-level `resultType`, so explicitly +/// lock its 2026 presence and its pre-2026 omission for both tool result shapes. +#[test] +fn result_type_is_versioned_for_tools_list_and_call() { + rt().block_on(async { + for (requested, expected) in [ + (ProtocolVersion::V_2024_11_05, None), + (ProtocolVersion::V_2025_03_26, None), + (ProtocolVersion::V_2025_06_18, None), + (ProtocolVersion::V_2025_11_25, None), + (ProtocolVersion::V_2026_07_28, Some("complete")), + ] { + let project = setup_mini_project(); + let handler = CodeGraphHandler::new(Some(project.path().to_path_buf())); + let (client, task) = connect_with_protocol(handler, requested.clone()).await; + + let listed = client.list_tools(paginated()).await.expect("list_tools"); + let listed = serde_json::to_value(&listed).expect("serialize tools/list result"); + assert_result_type(&listed, expected, &requested, "tools/list"); + + let called = client + .call_tool(call( + "codegraph_search", + json!({ "query": "add" }).as_object().cloned(), + )) + .await + .expect("tools/call"); + let called = serde_json::to_value(&called).expect("serialize tools/call result"); + assert_result_type(&called, expected, &requested, "tools/call"); + + let _ = client.cancel().await; + let _ = task.await; + } + }); +} + +fn assert_result_type( + result: &serde_json::Value, + expected: Option<&str>, + protocol_version: &ProtocolVersion, + method: &str, +) { + match expected { + Some(expected) => assert_eq!( + result.get("resultType"), + Some(&json!(expected)), + "{method} at {protocol_version} must carry resultType={expected}: {result}" + ), + None => assert!( + result.get("resultType").is_none(), + "{method} at {protocol_version} must omit resultType entirely: {result}" + ), + } +} + /// (c) an unknown tool → JSON-RPC `-32602` (invalid_params), NOT the built-in /// `-32601` method-not-found. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index e738b6b..b7fca12 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3,12 +3,56 @@ `codegraph serve --mcp` runs a newline-delimited JSON-RPC MCP server over stdin/stdout. It does **not** use LSP `Content-Length` framing. -Protocol handshake: `initialize` returns -`protocolVersion: "2024-11-05"`, `serverInfo.name: "codegraph"`. +Protocol handshake: `initialize` returns `serverInfo.name: "codegraph"`. `serverInfo.version` reports the running binary's crate version (from `CARGO_PKG_VERSION`), so it tracks releases automatically rather than being hardcoded. +`protocolVersion` is negotiated, not fixed. The server (built on `rmcp` 3.0.1) +echoes back whatever revision the client asks for, as long as it is one it knows: +**2024-11-05**, **2025-03-26**, **2025-06-18**, **2025-11-25**, or +**2026-07-28**. An unrecognized request falls back to `2024-11-05`. What the +negotiated revision changes: + +| client requests | negotiated | `resultType` in results | streamable-HTTP session | +| --------------- | ---------- | ----------------------- | -------------------------------- | +| 2024-11-05 | 2024-11-05 | absent | no `Mcp-Session-Id` (our config) | +| 2025-03-26 | 2025-03-26 | absent | no `Mcp-Session-Id` (our config) | +| 2025-06-18 | 2025-06-18 | absent | no `Mcp-Session-Id` (our config) | +| 2025-11-25 | 2025-11-25 | absent | no `Mcp-Session-Id` (our config) | +| 2026-07-28 | 2026-07-28 | `"complete"` | no `Mcp-Session-Id` (per spec) | + +Results carry the SEP-2322 discriminator `resultType: "complete"` only for a +2026-07-28 peer; older peers get the key stripped, and per spec a missing +`resultType` means `"complete"`. At 2026-07-28 the streamable-HTTP transport is +stateless (SEP-2567): no `Mcp-Session-Id`, no standalone GET/DELETE stream, no +`Last-Event-ID` resumption. At the four pre-2026 revisions the spec would allow a +session id, but this server is configured with legacy session mode off, so it +sends none there either: don't write session-resumption handling against it. Only +the cause differs — legacy statelessness is our configuration and could be +reversed, while 2026-07-28 statelessness is mandated and cannot. That transport also +validates the SEP-2243 standard headers — a request whose `MCP-Protocol-Version` +is missing, or whose `Mcp-Method` +or `Mcp-Name` disagrees with the body, is rejected with HTTP 400 and JSON-RPC +error code `-32020`. + +The server advertises only the **tools** capability, and every tool call returns +a complete result; it never constructs the task or input-required response forms, +so Tasks (SEP-2663) and MRTR / elicitation-in-tool are not implemented. +Subscriptions are also unimplemented: the handler accepts no subscription filter, +and the legacy subscribe methods return method-not-found. Beyond the mandatory +`initialize` handshake — itself an inherited default, which is why version +negotiation happens automatically on top of our `get_info()` and why the +`2024-11-05` there is only a fallback — these inherited defaults also answer +successfully despite that narrow capability advertisement: discovery returns rmcp's known protocol versions plus this server's `get_info()`; +completion returns an empty default result; `prompts/list`, `resources/list`, and +`resources/templates/list` each return an empty list rather than an error, so +probing for prompts or resources gets a successful response with nothing in it; +and `ping` succeeds on the legacy revisions (it is method-not-found at +2026-07-28). None of these are our implementations — they are inherited SDK +defaults we do not override, and they add no advertised capability. Logging +`setLevel`, `prompts/get`, and `resources/read` return method-not-found. + --- ## Quick-Register