Skip to content

Commit 4973219

Browse files
authored
fix(mcp): serve-path and bind failures were both invisible (#37)
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.
1 parent d2a0fb9 commit 4973219

3 files changed

Lines changed: 84 additions & 24 deletions

File tree

docs/guide/mcp.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Start the MCP server from the app's MCP page, then configure your AI tool:
2828
### Claude Code
2929

3030
```bash
31-
claude mcp add --transport http thinkutils http://127.0.0.1:8765/sse
31+
claude mcp add --transport http thinkutils http://127.0.0.1:8765/mcp
3232
```
3333

3434
Or add to `.mcp.json` in your project:
@@ -37,8 +37,8 @@ Or add to `.mcp.json` in your project:
3737
{
3838
"mcpServers": {
3939
"thinkutils": {
40-
"type": "sse",
41-
"url": "http://127.0.0.1:8765/sse"
40+
"type": "http",
41+
"url": "http://127.0.0.1:8765/mcp"
4242
}
4343
}
4444
}
@@ -52,7 +52,7 @@ Add to `~/.config/Claude/claude_desktop_config.json`:
5252
{
5353
"mcpServers": {
5454
"thinkutils": {
55-
"url": "http://127.0.0.1:8765/sse"
55+
"url": "http://127.0.0.1:8765/mcp"
5656
}
5757
}
5858
}
@@ -66,7 +66,7 @@ Add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):
6666
{
6767
"mcpServers": {
6868
"thinkutils": {
69-
"url": "http://127.0.0.1:8765/sse"
69+
"url": "http://127.0.0.1:8765/mcp"
7070
}
7171
}
7272
}
@@ -80,7 +80,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
8080
{
8181
"mcpServers": {
8282
"thinkutils": {
83-
"url": "http://127.0.0.1:8765/sse"
83+
"url": "http://127.0.0.1:8765/mcp"
8484
}
8585
}
8686
}
@@ -94,7 +94,7 @@ Add to `~/.lmstudio/mcp.json`:
9494
{
9595
"mcpServers": {
9696
"thinkutils": {
97-
"url": "http://127.0.0.1:8765/sse"
97+
"url": "http://127.0.0.1:8765/mcp"
9898
}
9999
}
100100
}
@@ -107,12 +107,12 @@ Or in the app: switch to the **Program** tab, click **Install**, then **Edit mcp
107107
In ChatGPT Desktop, click your profile > **Settings** > **Connectors** > **Advanced settings**, enable **Developer mode**, then go back to Connectors and click **Create**:
108108

109109
- **Name**: ThinkUtils
110-
- **Server URL**: `http://127.0.0.1:8765/sse`
110+
- **Server URL**: `http://127.0.0.1:8765/mcp`
111111

112112
::: info
113113
Requires ChatGPT Desktop with MCP support (Plus/Team/Enterprise).
114114
:::
115115

116116
### Other Tools
117117

118-
For any MCP-compatible client, configure an SSE server with URL `http://127.0.0.1:8765/sse`.
118+
For any MCP-compatible client, configure a Streamable HTTP server with URL `http://127.0.0.1:8765/mcp`.

src-tauri/src/mcp.rs

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ fn resolve_bind_host(host: &str) -> &str {
6060

6161
// -- Shared state for managing the MCP server lifecycle --
6262

63+
/// The path the Streamable HTTP transport is mounted at.
64+
///
65+
/// Exposed so the client-config snippets shown in the UI are generated from the
66+
/// same value the router is built with. They were hardcoded to `/sse`, left over
67+
/// from rmcp 0.1.5's SSE transport, and rmcp 2 serves nothing there — so anyone
68+
/// pasting the displayed config got a 404 on every connection.
69+
pub const MCP_PATH: &str = "/mcp";
70+
6371
pub struct McpServerState {
6472
cancel_token: Option<CancellationToken>,
6573
pub host: String,
@@ -338,6 +346,9 @@ pub struct McpStatus {
338346
pub running: bool,
339347
pub host: String,
340348
pub port: u16,
349+
/// Reported so the UI builds its client-config snippets from the path the
350+
/// router actually serves, rather than a second copy that can drift from it.
351+
pub path: String,
341352
}
342353

343354
#[tauri::command]
@@ -351,6 +362,7 @@ pub async fn get_mcp_status(
351362
running: s.cancel_token.is_some(),
352363
host: s.host.clone(),
353364
port: s.port,
365+
path: MCP_PATH.to_string(),
354366
}),
355367
error: None,
356368
})
@@ -405,6 +417,23 @@ pub async fn start_mcp_server(
405417
// at all) still works, while anything originating in a browser tab is rejected
406418
// unless it is genuinely same-origin.
407419

420+
// Bind BEFORE spawning. Binding inside the task left its error with nowhere
421+
// to go but eprintln!, while this function unconditionally reported success
422+
// and recorded a cancel token -- so an unusable port (already taken, or
423+
// privileged and EACCES) showed "Running" in the UI, and every retry was
424+
// refused with "already running" until the user pressed Stop.
425+
let listener = match tokio::net::TcpListener::bind(addr).await {
426+
Ok(l) => l,
427+
Err(e) => {
428+
eprintln!("[MCP] Failed to bind {}: {}", addr, e);
429+
return Ok(ApiResponse {
430+
success: false,
431+
data: None,
432+
error: Some(format!("Could not listen on {}: {}", addr, e)),
433+
});
434+
}
435+
};
436+
408437
tokio::spawn(async move {
409438
println!(
410439
"[MCP] Starting Streamable HTTP server on http://{}/mcp",
@@ -417,15 +446,7 @@ pub async fn start_mcp_server(
417446
config,
418447
);
419448

420-
let router = axum::Router::new().nest_service("/mcp", service);
421-
422-
let listener = match tokio::net::TcpListener::bind(addr).await {
423-
Ok(l) => l,
424-
Err(e) => {
425-
eprintln!("[MCP] Failed to bind {}: {}", addr, e);
426-
return;
427-
}
428-
};
449+
let router = axum::Router::new().nest_service(MCP_PATH, service);
429450

430451
let server = axum::serve(listener, router).with_graceful_shutdown(async move {
431452
ct_clone.cancelled().await;
@@ -479,6 +500,40 @@ pub async fn stop_mcp_server(
479500
mod tests {
480501
use super::*;
481502

503+
// -- Client-config endpoint --
504+
505+
/// The UI and the docs tell users which URL to point their MCP client at.
506+
/// Both used to hardcode `/sse`, left over from rmcp 0.1.5's SSE transport,
507+
/// while rmcp 2 serves Streamable HTTP at `/mcp` and nothing at `/sse` — so
508+
/// every config copied out of the app 404'd on connect.
509+
///
510+
/// The status payload now carries the path, and this pins that payload to
511+
/// the constant the router is built from.
512+
#[test]
513+
fn advertised_path_is_the_one_the_router_serves() {
514+
assert_eq!(MCP_PATH, "/mcp");
515+
assert!(
516+
MCP_PATH.starts_with('/'),
517+
"nest_service requires a leading slash"
518+
);
519+
assert_ne!(
520+
MCP_PATH, "/sse",
521+
"rmcp 2 removed the SSE server transport entirely"
522+
);
523+
}
524+
525+
/// The source file must not reintroduce a second, hardcoded copy of the
526+
/// path. Scoped to code above the test module so this cannot match itself.
527+
#[test]
528+
fn router_path_is_not_hardcoded_alongside_the_constant() {
529+
let src = include_str!("mcp.rs");
530+
let code = src.split("#[cfg(test)]").next().unwrap();
531+
assert!(
532+
!code.contains("nest_service(\""),
533+
"nest_service should be given MCP_PATH, not a literal"
534+
);
535+
}
536+
482537
// -- Host and Origin allowlists (the point of the rmcp 2 migration) --
483538

484539
/// Any port; the allowlists are built from whatever the server binds.

src/js/views/mcp.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export async function loadMcpStatus() {
6060
try {
6161
const response = await invoke('get_mcp_status');
6262
if (response.success && response.data) {
63-
const { running, host, port } = response.data;
63+
const { running, host, port, path } = response.data;
6464
if (running) {
6565
dot.className = 'status-dot installed';
6666
text.textContent = `Running on ${host}:${port}`;
@@ -80,19 +80,24 @@ export async function loadMcpStatus() {
8080
}
8181

8282
// Update config snippets with current host/port
83-
updateConfigSnippets(host, port);
83+
updateConfigSnippets(host, port, path);
8484
}
8585
} catch (error) {
8686
console.error('[MCP] Status check failed:', error);
8787
}
8888
}
8989

90-
function updateConfigSnippets(host, port) {
91-
const url = `http://${host}:${port}/sse`;
90+
// The path comes from the backend (McpStatus.path) so these snippets cannot
91+
// drift from the route the router actually serves. They were hardcoded to
92+
// `/sse` from the rmcp 0.1.5 days; rmcp 2 serves Streamable HTTP at `/mcp` and
93+
// nothing at `/sse`, so every pasted config 404'd.
94+
function updateConfigSnippets(host, port, path = '/mcp') {
95+
const url = `http://${host}:${port}${path}`;
9296

93-
// Claude Code uses "type": "sse"
97+
// Streamable HTTP is "type": "http" -- "sse" selects the transport rmcp 2
98+
// removed, which fails even against the correct URL.
9499
const claudeCodeStr = JSON.stringify(
95-
{ mcpServers: { thinkutils: { type: 'sse', url } } },
100+
{ mcpServers: { thinkutils: { type: 'http', url } } },
96101
null,
97102
2
98103
);

0 commit comments

Comments
 (0)