Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions docs/guide/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
}
}
}
Expand All @@ -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"
}
}
}
Expand All @@ -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"
}
}
}
Expand All @@ -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"
}
}
}
Expand All @@ -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"
}
}
}
Expand All @@ -107,12 +107,12 @@ 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).
:::

### 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`.
73 changes: 64 additions & 9 deletions src-tauri/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CancellationToken>,
pub host: String,
Expand Down Expand Up @@ -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]
Expand All @@ -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,
})
Expand Down Expand Up @@ -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",
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 11 additions & 6 deletions src/js/views/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -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
);
Expand Down
Loading