From f58f4199dcb2c56ff44fc45ddd01aaa8e3906652 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 18:39:19 +0800 Subject: [PATCH 1/7] feat(mcp): upgrade rmcp to 3.0.1 and serve the 2026-07-28 revision rmcp 3.0.1 implements the stable MCP 2026-07-28 specification. The server-side migration cost three lines: call_tool returns the non-exhaustive CallToolResponse enum instead of CallToolResult, of which we only ever build Complete; with_stateful_mode became with_legacy_session_mode; and the get_info comment claiming the protocol version is forced was wrong. That comment is the substantive part. negotiate_protocol_version echoes back any version the client asks for that rmcp knows, consulting the configured value only for an unrecognized request, so with_protocol_version was never a ceiling. The upgrade therefore starts serving 2026-07-28 by default rather than by choice, and the honest change is to say so instead of implying we pin 2024-11-05. with_legacy_session_mode also narrowed: it governs legacy revisions only, because 2026-07-28 is stateless unconditionally per SEP-2567. The list_roots deprecation note is refreshed rather than removed: SEP-2577 still deprecates it in 3.0.1 and still offers no replacement. --- Cargo.lock | 32 ++++++++++++++---------- crates/codegraph-mcp/Cargo.toml | 4 +-- crates/codegraph-mcp/src/rmcp_handler.rs | 28 ++++++++++++--------- 3 files changed, 37 insertions(+), 27 deletions(-) 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); From 40b63f00217e94c1d75a905a2dbafa8c5961ebf8 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 18:39:19 +0800 Subject: [PATCH 2/7] test(mcp): cover the 2026-07-28 wire format on both transports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structural goldens compare only named fields, so a top-level resultType is invisible to them — which is why the fifteen fixtures survived the 3.0.1 upgrade untouched, and equally why the new wire shape needed its own assertions. Lock the version-echo table across all three known revisions, the presence of resultType at 2026-07-28 against its absence at 2024-11-05 and 2025-11-25, the absence of Mcp-Session-Id on the stateless 2026 HTTP path, and SEP-2243 header validation in both directions: matching headers reach the tool, while a missing MCP-Protocol-Version or a mismatched Mcp-Method or Mcp-Name is rejected with HTTP 400 and JSON-RPC -32020. 2025-11-25 had no coverage anywhere before this. --- crates/codegraph-mcp/tests/rmcp_http.rs | 172 ++++++++++++++++++++++++ crates/codegraph-mcp/tests/rmcp_l3.rs | 91 ++++++++++++- 2 files changed, 262 insertions(+), 1 deletion(-) 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..50391a7 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,84 @@ 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_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_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] From 8979e325534d8d88cd26af40247d215e388b3e78 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 18:39:19 +0800 Subject: [PATCH 3/7] docs: record the rmcp 3.0.1 protocol surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files claimed the handshake returns a fixed 2024-11-05, which negotiation contradicts. Replace that with the three revisions actually served and what changes at each, and state plainly that the configured version is only an unknown-request fallback. Also correct the session-id claim the first draft got wrong: the tables promised Mcp-Session-Id for legacy revisions, but this server passes with_legacy_session_mode(false), so it never sends one at any revision. The two absences have different causes worth distinguishing — legacy is our configuration and reversible, 2026-07-28 is mandated by SEP-2567 and is not. Finally, state that the MCP goldens are structural rather than byte-stable. That is why resultType could not drift them and why it needed an explicit test. The extraction goldens are a separate contract and genuinely are byte-stable. --- AGENTS.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/mcp.md | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 73dfe3d..1d07253 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,56 @@ 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 three 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-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, subscriptions, and +discovery. These are capability-gated, so a 2026-07-28 server may legitimately omit them — we +declare tools only and always return `Complete`. + +**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 diff --git a/docs/mcp.md b/docs/mcp.md index e738b6b..c382c37 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3,12 +3,40 @@ `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-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-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 two legacy 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 declares **tools only**. Tasks (SEP-2663), MRTR / elicitation-in-tool, +subscriptions, and discovery are capability-gated in the spec and are not +implemented; every tool call returns a complete result. + --- ## Quick-Register From 34d874035b4405cd28c91d33f010bc81a148cd35 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 18:58:11 +0800 Subject: [PATCH 4/7] fix(mcp): correct the known-version count and the discovery claim KNOWN_VERSIONS holds five revisions, not three: 2025-03-26 and 2025-06-18 sit between the two I had documented. negotiate_protocol_version echoes any of them, so telling a reader that only three are known and anything else falls back to 2024-11-05 was a false statement about our own wire behavior, and the version tables had the same blind spot. Both tests now cover all five; the two added revisions strip resultType like the other pre-2026 peers, verified by running them rather than assuming. Discovery was listed as not implemented, which is wrong. rmcp's default discover answers with supported_protocol_versions() plus get_info(), and we do not override it, so a client can call it and get a real result. Same for complete, which returns an empty default. Move both out of the not-implemented list and say what actually happens, keeping Tasks, MRTR and subscriptions there, where it is true: we never construct Task or InputRequired, accepted_subscription_filter keeps its None default, and subscribe, unsubscribe and set_level all return method-not-found. Advertising only the tools capability and having a literally tools-only request surface are different things, and the docs now distinguish them. --- AGENTS.md | 15 +++++++++++---- crates/codegraph-mcp/tests/rmcp_l3.rs | 4 ++++ docs/mcp.md | 21 +++++++++++++++------ 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d07253..be9664d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ corrected. The 3.x breaking changes that bit third-party code were all CLIENT-si 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 three protocol revisions, and that is the SDK default rather than a choice.** +**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 @@ -100,6 +100,8 @@ an unknown client version — it is not a ceiling, and we do not pin or force 20 | 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) | @@ -116,9 +118,14 @@ tool, while a missing protocol header or a mismatched `Mcp-Method` / `Mcp-Name` 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, subscriptions, and -discovery. These are capability-gated, so a 2026-07-28 server may legitimately omit them — we -declare tools only and always return `Complete`. +**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: we do not override `discover`, so rmcp's default answers with +`supported_protocol_versions()` plus `get_info()`, and its default `complete` handler returns an +empty `CompleteResult`. The default `set_level` handler returns method-not-found. These SDK defaults +do not add 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`, diff --git a/crates/codegraph-mcp/tests/rmcp_l3.rs b/crates/codegraph-mcp/tests/rmcp_l3.rs index 50391a7..1217bc0 100644 --- a/crates/codegraph-mcp/tests/rmcp_l3.rs +++ b/crates/codegraph-mcp/tests/rmcp_l3.rs @@ -113,6 +113,8 @@ 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"), ] { @@ -140,6 +142,8 @@ 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")), ] { diff --git a/docs/mcp.md b/docs/mcp.md index c382c37..a5db643 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10,12 +10,15 @@ 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-11-25**, or **2026-07-28**. An unrecognized request falls -back to `2024-11-05`. What the negotiated revision changes: +**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) | @@ -23,7 +26,7 @@ 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 two legacy revisions the spec would allow a +`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 @@ -33,9 +36,15 @@ 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 declares **tools only**. Tasks (SEP-2663), MRTR / elicitation-in-tool, -subscriptions, and discovery are capability-gated in the spec and are not -implemented; every tool call returns a complete result. +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. Two request-level SDK +defaults remain callable despite that narrow capability advertisement: discovery +returns rmcp's known protocol versions plus this server's `get_info()`, and +completion returns an empty default result. Logging `setLevel` is not implemented +and returns method-not-found. --- From e418415cfce32003bfc1330e4659688aa122637c Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 19:36:01 +0800 Subject: [PATCH 5/7] docs(mcp): enumerate every inherited SDK request default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim that two request-level defaults remain callable was wrong: six answer successfully. Besides discover and complete, the three list methods return empty results rather than method-not-found, so probing for prompts or resources yields a success with nothing in it; and ping succeeds on the legacy revisions while being method-not-found at 2026-07-28. This is the third incomplete enumeration of rmcp defaults this round — first discovery was wrongly filed as unimplemented, then three list methods were missed, now ping. The pattern is that a claim about what a macro-generated trait does not implement has to be checked against the generated defaults, not against our own code, since we override none of them. Behavior is unchanged. Returning an empty list is spec-conformant, so overriding those methods to refuse would be a change nobody asked for. --- AGENTS.md | 12 ++++++++---- docs/mcp.md | 15 ++++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index be9664d..1779b25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,10 +122,14 @@ presence/absence) and `tests/rmcp_http.rs` (no session header, SEP-2243 both bra 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: we do not override `discover`, so rmcp's default answers with -`supported_protocol_versions()` plus `get_info()`, and its default `complete` handler returns an -empty `CompleteResult`. The default `set_level` handler returns method-not-found. These SDK defaults -do not add advertised capabilities. +tools-only, however: SIX inherited defaults still 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`, diff --git a/docs/mcp.md b/docs/mcp.md index a5db643..a1485ea 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -40,11 +40,16 @@ 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. Two request-level SDK -defaults remain callable despite that narrow capability advertisement: discovery -returns rmcp's known protocol versions plus this server's `get_info()`, and -completion returns an empty default result. Logging `setLevel` is not implemented -and returns method-not-found. +and the legacy subscribe methods return method-not-found. Six request-level SDK +defaults still 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. --- From 4fabc0ba7071d95579d861f5699a9814ef053c65 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 21:27:08 +0800 Subject: [PATCH 6/7] docs(mcp): scope the inherited-default claim instead of counting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sentence had been corrected four times in one round — discovery was wrongly filed as unimplemented, then three list methods were missed, then ping, then initialize itself. Each fix bumped a total that the next reading falsified, because an exact count of a macro-generated trait's defaults goes stale the moment upstream adds one. State the scope instead: beyond the mandatory initialize handshake, these inherited defaults also answer. A future addition then makes the list incomplete rather than false. Naming initialize as an inherited default also connects two facts the sections stated separately: its default applies negotiate_protocol_version on top of our get_info(), which is precisely why negotiation is automatic and why the version we pass there is only a fallback. --- AGENTS.md | 5 ++++- docs/mcp.md | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1779b25..4962675 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,10 @@ presence/absence) and `tests/rmcp_http.rs` (no session header, SEP-2243 both bra 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: SIX inherited defaults still answer `Ok`. `discover` returns real content +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 diff --git a/docs/mcp.md b/docs/mcp.md index a1485ea..b7fca12 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -40,9 +40,11 @@ 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. Six request-level SDK -defaults still answer successfully despite that narrow capability advertisement: -discovery returns rmcp's known protocol versions plus this server's `get_info()`; +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; From 9e378b278a6f1c01d03b376906b2ff5e7dd62c14 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 31 Jul 2026 22:05:30 +0800 Subject: [PATCH 7/7] docs: refresh the measured coverage figure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage bullet claimed a ~72% baseline and named three files as the biggest gaps, all four of which are now false: measured coverage is 94.42% line, 94.12% region, 93.41% function, and the named files sit at 95.16%, 97.12%, 94.26% and 91.11% — the last two having been described as 0%-covered. Record the measured figures with the commit they came from, since a bare number in a doc becomes folklore once nobody knows how old it is. Also state where the gap actually lives now: it moved category, from resolver and watch internals to the per-language extractors, which hold 7 of the 10 lowest-covered files. The 95% aspiration, the informational non-blocking gate, the three make targets and the bench exclusion are unchanged — this corrects a measurement, not a policy. --- AGENTS.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4962675..6ce6d83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,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.