fix(mcp): restore HTTP /mcp endpoint after 855 regression
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) <noreply@anthropic.com>
This commit is contained in:
+132
-4
@@ -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
|
//! Local Claude Code agents connect to `POST /mcp` with a JSON-RPC 2.0
|
||||||
//! is invoked directly by API-based runtimes (Gemini, OpenAI) and by
|
//! envelope. Tool dispatch is also invoked directly by API-based runtimes
|
||||||
//! the WebSocket-based `/crdt-sync` rendezvous channel.
|
//! (Gemini, OpenAI) via [`dispatch::dispatch_tool_call`].
|
||||||
|
|
||||||
/// MCP tools for agent start, stop, wait, list, and inspect.
|
/// MCP tools for agent start, stop, wait, list, and inspect.
|
||||||
pub mod agent_tools;
|
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`.
|
// Re-export for test code in submodules that references `super::super::handle_tools_list`.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use tools_list::handle_tools_list;
|
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<Value>,
|
||||||
|
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<AppContext>>,
|
||||||
|
) -> 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<Value>) -> 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<Value>, 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
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ pub fn build_routes(
|
|||||||
.at("/ws", get(ws::ws_handler))
|
.at("/ws", get(ws::ws_handler))
|
||||||
.at("/crdt-sync", get(crate::crdt_sync::crdt_sync_handler))
|
.at("/crdt-sync", get(crate::crdt_sync::crdt_sync_handler))
|
||||||
.at("/rpc", post(rpc_http_handler))
|
.at("/rpc", post(rpc_http_handler))
|
||||||
|
.at(
|
||||||
|
"/mcp",
|
||||||
|
post(mcp::mcp_post_handler).get(mcp::mcp_get_handler),
|
||||||
|
)
|
||||||
.at(
|
.at(
|
||||||
"/agents/:story_id/:agent_name/stream",
|
"/agents/:story_id/:agent_name/stream",
|
||||||
get(agents_sse::agent_stream),
|
get(agents_sse::agent_stream),
|
||||||
|
|||||||
@@ -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
|
// Always update .mcp.json with the current port so the bot connects to
|
||||||
// the right endpoint even when HUSKIES_PORT changes between restarts.
|
// the right endpoint even when HUSKIES_PORT changes between restarts.
|
||||||
let mcp_content = format!(
|
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)
|
fs::write(path.join(".mcp.json"), mcp_content)
|
||||||
.map_err(|e| format!("Failed to write .mcp.json: {}", e))?;
|
.map_err(|e| format!("Failed to write .mcp.json: {}", e))?;
|
||||||
|
|||||||
@@ -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
|
// 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
|
// Only written when missing — never overwrites an existing file, because
|
||||||
// the port is environment-specific and must not clobber a running instance.
|
// the port is environment-specific and must not clobber a running instance.
|
||||||
let mcp_content = format!(
|
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)?;
|
write_file_if_missing(&root.join(".mcp.json"), &mcp_content)?;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// 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> {
|
pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> {
|
||||||
let content = format!(
|
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}"))
|
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();
|
let tmp = TempDir::new().unwrap();
|
||||||
write_mcp_json(tmp.path(), 4242).unwrap();
|
write_mcp_json(tmp.path(), 4242).unwrap();
|
||||||
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).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]
|
#[test]
|
||||||
@@ -99,7 +99,7 @@ mod tests {
|
|||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
write_mcp_json(tmp.path(), 3001).unwrap();
|
write_mcp_json(tmp.path(), 3001).unwrap();
|
||||||
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user