From c18f5779dd1c748892b20102fecc04703a802067 Mon Sep 17 00:00:00 2001 From: Viet Anh Nguyen Date: Mon, 20 Jul 2026 10:04:32 +0700 Subject: [PATCH] fix(mcp): serve-path and bind failures were both invisible Two defects that each make the MCP feature look like it works when it does not. The client-config snippets in the UI, and every example in the docs, pointed at /sse with "type": "sse". That is rmcp 0.1.5's transport; the rmcp 2 migration mounts Streamable HTTP at /mcp and serves nothing at /sse. Anyone copying the config out of the app -- its own documented setup path -- got a 404 on every connection attempt. The docs were also self-contradictory, pairing --transport http with an /sse URL. The path is now a constant the router is built from and the status payload reports, so the UI renders whatever is actually served instead of a second copy that can drift. Separately, TcpListener::bind ran inside tokio::spawn, leaving its error nowhere to go but eprintln! while start_mcp_server unconditionally stored a cancel token and returned success. An unusable port -- taken, or privileged and EACCES -- reported "Running" in the UI, and every retry was then refused with "already running" until the user pressed Stop. Binding before the spawn puts the error back in the caller's hands. Both guards mutation-verified: reintroducing the nest_service literal fails router_path_is_not_hardcoded_alongside_the_constant. --- docs/guide/mcp.md | 18 +++++------ src-tauri/src/mcp.rs | 73 ++++++++++++++++++++++++++++++++++++++------ src/js/views/mcp.js | 17 +++++++---- 3 files changed, 84 insertions(+), 24 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 338146f..2270d8e 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -28,7 +28,7 @@ Start the MCP server from the app's MCP page, then configure your AI tool: ### Claude Code ```bash -claude mcp add --transport http thinkutils http://127.0.0.1:8765/sse +claude mcp add --transport http thinkutils http://127.0.0.1:8765/mcp ``` Or add to `.mcp.json` in your project: @@ -37,8 +37,8 @@ Or add to `.mcp.json` in your project: { "mcpServers": { "thinkutils": { - "type": "sse", - "url": "http://127.0.0.1:8765/sse" + "type": "http", + "url": "http://127.0.0.1:8765/mcp" } } } @@ -52,7 +52,7 @@ Add to `~/.config/Claude/claude_desktop_config.json`: { "mcpServers": { "thinkutils": { - "url": "http://127.0.0.1:8765/sse" + "url": "http://127.0.0.1:8765/mcp" } } } @@ -66,7 +66,7 @@ Add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global): { "mcpServers": { "thinkutils": { - "url": "http://127.0.0.1:8765/sse" + "url": "http://127.0.0.1:8765/mcp" } } } @@ -80,7 +80,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`: { "mcpServers": { "thinkutils": { - "url": "http://127.0.0.1:8765/sse" + "url": "http://127.0.0.1:8765/mcp" } } } @@ -94,7 +94,7 @@ Add to `~/.lmstudio/mcp.json`: { "mcpServers": { "thinkutils": { - "url": "http://127.0.0.1:8765/sse" + "url": "http://127.0.0.1:8765/mcp" } } } @@ -107,7 +107,7 @@ Or in the app: switch to the **Program** tab, click **Install**, then **Edit mcp In ChatGPT Desktop, click your profile > **Settings** > **Connectors** > **Advanced settings**, enable **Developer mode**, then go back to Connectors and click **Create**: - **Name**: ThinkUtils -- **Server URL**: `http://127.0.0.1:8765/sse` +- **Server URL**: `http://127.0.0.1:8765/mcp` ::: info Requires ChatGPT Desktop with MCP support (Plus/Team/Enterprise). @@ -115,4 +115,4 @@ Requires ChatGPT Desktop with MCP support (Plus/Team/Enterprise). ### Other Tools -For any MCP-compatible client, configure an SSE server with URL `http://127.0.0.1:8765/sse`. +For any MCP-compatible client, configure a Streamable HTTP server with URL `http://127.0.0.1:8765/mcp`. diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 24080ed..0714cf3 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -60,6 +60,14 @@ fn resolve_bind_host(host: &str) -> &str { // -- Shared state for managing the MCP server lifecycle -- +/// The path the Streamable HTTP transport is mounted at. +/// +/// Exposed so the client-config snippets shown in the UI are generated from the +/// same value the router is built with. They were hardcoded to `/sse`, left over +/// from rmcp 0.1.5's SSE transport, and rmcp 2 serves nothing there — so anyone +/// pasting the displayed config got a 404 on every connection. +pub const MCP_PATH: &str = "/mcp"; + pub struct McpServerState { cancel_token: Option, pub host: String, @@ -338,6 +346,9 @@ pub struct McpStatus { pub running: bool, pub host: String, pub port: u16, + /// Reported so the UI builds its client-config snippets from the path the + /// router actually serves, rather than a second copy that can drift from it. + pub path: String, } #[tauri::command] @@ -351,6 +362,7 @@ pub async fn get_mcp_status( running: s.cancel_token.is_some(), host: s.host.clone(), port: s.port, + path: MCP_PATH.to_string(), }), error: None, }) @@ -405,6 +417,23 @@ pub async fn start_mcp_server( // at all) still works, while anything originating in a browser tab is rejected // unless it is genuinely same-origin. + // Bind BEFORE spawning. Binding inside the task left its error with nowhere + // to go but eprintln!, while this function unconditionally reported success + // and recorded a cancel token -- so an unusable port (already taken, or + // privileged and EACCES) showed "Running" in the UI, and every retry was + // refused with "already running" until the user pressed Stop. + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("[MCP] Failed to bind {}: {}", addr, e); + return Ok(ApiResponse { + success: false, + data: None, + error: Some(format!("Could not listen on {}: {}", addr, e)), + }); + } + }; + tokio::spawn(async move { println!( "[MCP] Starting Streamable HTTP server on http://{}/mcp", @@ -417,15 +446,7 @@ pub async fn start_mcp_server( config, ); - let router = axum::Router::new().nest_service("/mcp", service); - - let listener = match tokio::net::TcpListener::bind(addr).await { - Ok(l) => l, - Err(e) => { - eprintln!("[MCP] Failed to bind {}: {}", addr, e); - return; - } - }; + let router = axum::Router::new().nest_service(MCP_PATH, service); let server = axum::serve(listener, router).with_graceful_shutdown(async move { ct_clone.cancelled().await; @@ -479,6 +500,40 @@ pub async fn stop_mcp_server( mod tests { use super::*; + // -- Client-config endpoint -- + + /// The UI and the docs tell users which URL to point their MCP client at. + /// Both used to hardcode `/sse`, left over from rmcp 0.1.5's SSE transport, + /// while rmcp 2 serves Streamable HTTP at `/mcp` and nothing at `/sse` — so + /// every config copied out of the app 404'd on connect. + /// + /// The status payload now carries the path, and this pins that payload to + /// the constant the router is built from. + #[test] + fn advertised_path_is_the_one_the_router_serves() { + assert_eq!(MCP_PATH, "/mcp"); + assert!( + MCP_PATH.starts_with('/'), + "nest_service requires a leading slash" + ); + assert_ne!( + MCP_PATH, "/sse", + "rmcp 2 removed the SSE server transport entirely" + ); + } + + /// The source file must not reintroduce a second, hardcoded copy of the + /// path. Scoped to code above the test module so this cannot match itself. + #[test] + fn router_path_is_not_hardcoded_alongside_the_constant() { + let src = include_str!("mcp.rs"); + let code = src.split("#[cfg(test)]").next().unwrap(); + assert!( + !code.contains("nest_service(\""), + "nest_service should be given MCP_PATH, not a literal" + ); + } + // -- Host and Origin allowlists (the point of the rmcp 2 migration) -- /// Any port; the allowlists are built from whatever the server binds. diff --git a/src/js/views/mcp.js b/src/js/views/mcp.js index 0646036..de4d727 100644 --- a/src/js/views/mcp.js +++ b/src/js/views/mcp.js @@ -60,7 +60,7 @@ export async function loadMcpStatus() { try { const response = await invoke('get_mcp_status'); if (response.success && response.data) { - const { running, host, port } = response.data; + const { running, host, port, path } = response.data; if (running) { dot.className = 'status-dot installed'; text.textContent = `Running on ${host}:${port}`; @@ -80,19 +80,24 @@ export async function loadMcpStatus() { } // Update config snippets with current host/port - updateConfigSnippets(host, port); + updateConfigSnippets(host, port, path); } } catch (error) { console.error('[MCP] Status check failed:', error); } } -function updateConfigSnippets(host, port) { - const url = `http://${host}:${port}/sse`; +// The path comes from the backend (McpStatus.path) so these snippets cannot +// drift from the route the router actually serves. They were hardcoded to +// `/sse` from the rmcp 0.1.5 days; rmcp 2 serves Streamable HTTP at `/mcp` and +// nothing at `/sse`, so every pasted config 404'd. +function updateConfigSnippets(host, port, path = '/mcp') { + const url = `http://${host}:${port}${path}`; - // Claude Code uses "type": "sse" + // Streamable HTTP is "type": "http" -- "sse" selects the transport rmcp 2 + // removed, which fails even against the correct URL. const claudeCodeStr = JSON.stringify( - { mcpServers: { thinkutils: { type: 'sse', url } } }, + { mcpServers: { thinkutils: { type: 'http', url } } }, null, 2 );