From 3a9ff5e740ce59b5a7f94df3893f5681e6c16eb0 Mon Sep 17 00:00:00 2001 From: Timmy Date: Thu, 30 Apr 2026 14:03:16 +0100 Subject: [PATCH] fix(mcp): restore HTTP /mcp endpoint after 855 regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 855 deleted the HTTP /mcp route and pointed agents at ws://...crdt-sync, but Claude Code's .mcp.json doesn't speak ws:// and the rendezvous WS never had MCP method handlers wired up — so every spawned Claude Code agent (gateway-routed and local) booted with zero huskies tools and died on --permission-prompt-tool=mcp__huskies__prompt_permission. Restore mcp_post_handler / mcp_get_handler / handle_initialize, re-add the /mcp route, and revert all three .mcp.json writers to emit http://localhost:{port}/mcp with explicit "type": "http". Reuses the already-extracted gateway::jsonrpc types and the surviving dispatch_tool_call / list_tools surfaces — net add ~140 lines. Federation work is unaffected: /crdt-sync continues to do CRDT sync, which is what it was actually doing. MCP-over-WebSocket for cross-LAN agents was never wired up by 855 and can be done as a proper follow-up with a regression test that boots a real claude and verifies tool registration. Verified end-to-end: /mcp initialize, tools/list (74 tools incl. prompt_permission), and tools/call all respond correctly from inside the rebuilt container. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/http/mcp/mod.rs | 136 ++++++++++++++++++++++++++++++- server/src/http/mod.rs | 4 + server/src/io/fs/project.rs | 2 +- server/src/io/fs/scaffold/mod.rs | 4 +- server/src/worktree/mod.rs | 8 +- 5 files changed, 143 insertions(+), 11 deletions(-) diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index b7d7901c..c993ec3a 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -1,8 +1,8 @@ -//! MCP tool dispatch and schema module. +//! MCP HTTP endpoint and tool dispatch module. //! -//! Agents no longer connect via an HTTP `/mcp` endpoint. Tool dispatch -//! is invoked directly by API-based runtimes (Gemini, OpenAI) and by -//! the WebSocket-based `/crdt-sync` rendezvous channel. +//! Local Claude Code agents connect to `POST /mcp` with a JSON-RPC 2.0 +//! envelope. Tool dispatch is also invoked directly by API-based runtimes +//! (Gemini, OpenAI) via [`dispatch::dispatch_tool_call`]. /// MCP tools for agent start, stop, wait, list, and inspect. pub mod agent_tools; @@ -30,3 +30,131 @@ pub mod wizard_tools; // Re-export for test code in submodules that references `super::super::handle_tools_list`. #[cfg(test)] pub(crate) use tools_list::handle_tools_list; + +use crate::http::context::AppContext; +use crate::http::gateway::jsonrpc::JsonRpcResponse; +use poem::handler; +use poem::http::StatusCode; +use poem::web::Data; +use poem::{Body, Request, Response}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::sync::Arc; + +#[derive(Deserialize)] +struct JsonRpcRequest { + jsonrpc: String, + id: Option, + method: String, + #[serde(default)] + params: Value, +} + +/// `GET /mcp` — method not allowed (MCP uses POST). +#[handler] +pub async fn mcp_get_handler() -> Response { + Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Body::empty()) +} + +/// `POST /mcp` — JSON-RPC 2.0 entry point for `initialize`, `tools/list`, +/// `tools/call`, and `notifications/*`. +#[handler] +pub async fn mcp_post_handler( + req: &Request, + body: Body, + ctx: Data<&Arc>, +) -> Response { + let content_type = req.header("content-type").unwrap_or(""); + if !content_type.is_empty() && !content_type.contains("application/json") { + return json_response(JsonRpcResponse::error( + None, + -32700, + "Unsupported Content-Type; expected application/json".into(), + )); + } + + let bytes = match body.into_bytes().await { + Ok(b) => b, + Err(_) => { + return json_response(JsonRpcResponse::error(None, -32700, "Parse error".into())); + } + }; + + let rpc: JsonRpcRequest = match serde_json::from_slice(&bytes) { + Ok(r) => r, + Err(_) => { + return json_response(JsonRpcResponse::error(None, -32700, "Parse error".into())); + } + }; + + if rpc.jsonrpc != "2.0" { + return json_response(JsonRpcResponse::error( + rpc.id, + -32600, + "Invalid JSON-RPC version".into(), + )); + } + + if rpc.id.is_none() || rpc.id.as_ref() == Some(&Value::Null) { + if rpc.method.starts_with("notifications/") { + return Response::builder() + .status(StatusCode::ACCEPTED) + .body(Body::empty()); + } + return json_response(JsonRpcResponse::error(None, -32600, "Missing id".into())); + } + + let resp = match rpc.method.as_str() { + "initialize" => handle_initialize(rpc.id), + "tools/list" => { + JsonRpcResponse::success(rpc.id, json!({ "tools": tools_list::list_tools() })) + } + "tools/call" => handle_tools_call(rpc.id, &rpc.params, &ctx).await, + _ => JsonRpcResponse::error(rpc.id, -32601, format!("Unknown method: {}", rpc.method)), + }; + + json_response(resp) +} + +fn json_response(resp: JsonRpcResponse) -> Response { + let body = serde_json::to_vec(&resp).unwrap_or_default(); + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(body)) +} + +fn handle_initialize(id: Option) -> JsonRpcResponse { + JsonRpcResponse::success( + id, + json!({ + "protocolVersion": "2025-03-26", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "huskies", "version": "1.0.0" } + }), + ) +} + +async fn handle_tools_call(id: Option, params: &Value, ctx: &AppContext) -> JsonRpcResponse { + let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + + match dispatch::dispatch_tool_call(tool_name, args, ctx).await { + Ok(content) => JsonRpcResponse::success( + id, + json!({ "content": [{ "type": "text", "text": content }] }), + ), + Err(msg) => { + crate::slog_warn!("[mcp] Tool call failed: tool={tool_name} error={msg}"); + JsonRpcResponse::success( + id, + json!({ + "content": [{ "type": "text", "text": msg }], + "isError": true + }), + ) + } + } +} diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 17d68861..7febb2f8 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -108,6 +108,10 @@ pub fn build_routes( .at("/ws", get(ws::ws_handler)) .at("/crdt-sync", get(crate::crdt_sync::crdt_sync_handler)) .at("/rpc", post(rpc_http_handler)) + .at( + "/mcp", + post(mcp::mcp_post_handler).get(mcp::mcp_get_handler), + ) .at( "/agents/:story_id/:agent_name/stream", get(agents_sse::agent_stream), diff --git a/server/src/io/fs/project.rs b/server/src/io/fs/project.rs index 2fa78d32..91ef1015 100644 --- a/server/src/io/fs/project.rs +++ b/server/src/io/fs/project.rs @@ -40,7 +40,7 @@ pub(crate) async fn ensure_project_root_with_story_kit( // Always update .mcp.json with the current port so the bot connects to // the right endpoint even when HUSKIES_PORT changes between restarts. let mcp_content = format!( - "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"url\": \"ws://localhost:{port}/crdt-sync\"\n }}\n }}\n}}\n" + "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" ); fs::write(path.join(".mcp.json"), mcp_content) .map_err(|e| format!("Failed to write .mcp.json: {}", e))?; diff --git a/server/src/io/fs/scaffold/mod.rs b/server/src/io/fs/scaffold/mod.rs index 99299be5..b2acc1c8 100644 --- a/server/src/io/fs/scaffold/mod.rs +++ b/server/src/io/fs/scaffold/mod.rs @@ -69,11 +69,11 @@ pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> { )?; // Write .mcp.json at the project root so agents can find the MCP server - // via the rendezvous WebSocket endpoint. + // via the HTTP MCP endpoint. // Only written when missing — never overwrites an existing file, because // the port is environment-specific and must not clobber a running instance. let mcp_content = format!( - "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"url\": \"ws://localhost:{port}/crdt-sync\"\n }}\n }}\n}}\n" + "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" ); write_file_if_missing(&root.join(".mcp.json"), &mcp_content)?; diff --git a/server/src/worktree/mod.rs b/server/src/worktree/mod.rs index 9e922944..eb32b647 100644 --- a/server/src/worktree/mod.rs +++ b/server/src/worktree/mod.rs @@ -37,10 +37,10 @@ pub fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf { } /// Write a `.mcp.json` file in the given directory pointing to the huskies -/// rendezvous WebSocket endpoint at the given port. +/// HTTP MCP endpoint at the given port. pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> { let content = format!( - "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"url\": \"ws://localhost:{port}/crdt-sync\"\n }}\n }}\n}}\n" + "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" ); std::fs::write(dir.join(".mcp.json"), content).map_err(|e| format!("Write .mcp.json: {e}")) } @@ -91,7 +91,7 @@ mod tests { let tmp = TempDir::new().unwrap(); write_mcp_json(tmp.path(), 4242).unwrap(); let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap(); - assert!(content.contains("ws://localhost:4242/crdt-sync")); + assert!(content.contains("http://localhost:4242/mcp")); } #[test] @@ -99,7 +99,7 @@ mod tests { let tmp = TempDir::new().unwrap(); write_mcp_json(tmp.path(), 3001).unwrap(); let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap(); - assert!(content.contains("ws://localhost:3001/crdt-sync")); + assert!(content.contains("http://localhost:3001/mcp")); } #[test]