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 );