diff --git a/server/src/agents/pool/start/mod.rs b/server/src/agents/pool/start/mod.rs index 9ead18dc..e4f03dc4 100644 --- a/server/src/agents/pool/start/mod.rs +++ b/server/src/agents/pool/start/mod.rs @@ -45,6 +45,48 @@ impl AgentPool { agent_name: Option<&str>, resume_context: Option<&str>, session_id_to_resume: Option, + ) -> Result { + self.start_agent_inner( + project_root, + story_id, + agent_name, + resume_context, + session_id_to_resume, + None, + ) + } + + /// Start an agent with an `AppContext` for direct MCP tool dispatch. + /// + /// API-based runtimes (Gemini, OpenAI) need the `AppContext` to invoke MCP + /// tools without an HTTP round-trip. CLI-based runtimes (Claude Code) do not. + pub fn start_agent_with_ctx( + &self, + project_root: &Path, + story_id: &str, + agent_name: Option<&str>, + resume_context: Option<&str>, + session_id_to_resume: Option, + app_ctx: Arc, + ) -> Result { + self.start_agent_inner( + project_root, + story_id, + agent_name, + resume_context, + session_id_to_resume, + Some(app_ctx), + ) + } + + fn start_agent_inner( + &self, + project_root: &Path, + story_id: &str, + agent_name: Option<&str>, + resume_context: Option<&str>, + session_id_to_resume: Option, + app_ctx: Option>, ) -> Result { let config = ProjectConfig::load(project_root)?; @@ -352,6 +394,7 @@ impl AgentPool { self.watcher_tx.clone(), inactivity_timeout_secs, prior_events, + app_ctx, )); // Store the task handle while the agent is still Pending. diff --git a/server/src/agents/pool/start/spawn.rs b/server/src/agents/pool/start/spawn.rs index b8e4c663..b6e96f6a 100644 --- a/server/src/agents/pool/start/spawn.rs +++ b/server/src/agents/pool/start/spawn.rs @@ -13,6 +13,7 @@ use tokio::sync::broadcast; use crate::agent_log::AgentLogWriter; use crate::config::ProjectConfig; +use crate::http::context::AppContext; use crate::io::watcher::WatcherEvent; use crate::slog_error; @@ -51,6 +52,7 @@ pub(super) async fn run_agent_spawn( // happened while it was idle (story 736). `None` when there were no // buffered events. buffered_events_block: Option, + app_ctx: Option>, ) { // Re-bind to the legacy `_clone` / `_owned` names so the body below remains // a verbatim copy of the original closure (story 157). @@ -240,7 +242,7 @@ pub(super) async fn run_agent_spawn( prompt: effective_prompt, cwd: wt_path_str, inactivity_timeout_secs, - mcp_port: port_for_task, + app_ctx: app_ctx.clone(), session_id_to_resume: session_id_to_resume_owned.clone(), fresh_prompt: fresh_prompt.clone(), }; @@ -258,7 +260,7 @@ pub(super) async fn run_agent_spawn( prompt: effective_prompt, cwd: wt_path_str, inactivity_timeout_secs, - mcp_port: port_for_task, + app_ctx: app_ctx.clone(), session_id_to_resume: session_id_to_resume_owned.clone(), fresh_prompt: fresh_prompt.clone(), }; @@ -276,7 +278,7 @@ pub(super) async fn run_agent_spawn( prompt: effective_prompt, cwd: wt_path_str, inactivity_timeout_secs, - mcp_port: port_for_task, + app_ctx: app_ctx.clone(), session_id_to_resume: session_id_to_resume_owned, fresh_prompt, }; diff --git a/server/src/agents/runtime/gemini/api.rs b/server/src/agents/runtime/gemini/api.rs index a927f7ee..7b26de47 100644 --- a/server/src/agents/runtime/gemini/api.rs +++ b/server/src/agents/runtime/gemini/api.rs @@ -93,6 +93,13 @@ pub(super) fn parse_usage_metadata(response: &Value) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::http::context::AppContext; + use std::sync::Arc; + + fn test_app_ctx() -> Arc { + let tmp = tempfile::tempdir().unwrap(); + Arc::new(AppContext::new_test(tmp.path().to_path_buf())) + } #[test] fn build_system_instruction_uses_args() { @@ -107,7 +114,7 @@ mod tests { prompt: "Do the thing".to_string(), cwd: "/tmp/wt".to_string(), inactivity_timeout_secs: 300, - mcp_port: 3001, + app_ctx: Some(test_app_ctx()), session_id_to_resume: None, fresh_prompt: None, }; @@ -126,7 +133,7 @@ mod tests { prompt: "Do the thing".to_string(), cwd: "/tmp/wt".to_string(), inactivity_timeout_secs: 300, - mcp_port: 3001, + app_ctx: Some(test_app_ctx()), session_id_to_resume: None, fresh_prompt: None, }; diff --git a/server/src/agents/runtime/gemini/mcp.rs b/server/src/agents/runtime/gemini/mcp.rs index 658d37da..5b119f56 100644 --- a/server/src/agents/runtime/gemini/mcp.rs +++ b/server/src/agents/runtime/gemini/mcp.rs @@ -1,45 +1,25 @@ -//! MCP tool fetching, schema conversion, and tool invocation for the Gemini runtime. -use reqwest::Client; +//! MCP tool schema conversion for the Gemini runtime. +//! +//! Tool definitions are loaded directly from `list_tools()` and tool +//! invocations go through `dispatch_tool_call()` — no HTTP round-trip. use serde_json::{Value, json}; use crate::slog; +use crate::http::mcp::tools_list::list_tools; + use super::api::GeminiFunctionDeclaration; -// ── MCP tool fetching ──────────────────────────────────────────────── +// ── MCP tool loading ──────────────────────────────────────────────── -/// Fetch MCP tool definitions from huskies' MCP server and convert -/// them to Gemini function declaration format. -pub(super) async fn fetch_and_convert_mcp_tools( - client: &Client, - mcp_base: &str, -) -> Result, String> { - let request = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/list", - "params": {} - }); - - let response = client - .post(mcp_base) - .json(&request) - .send() - .await - .map_err(|e| format!("Failed to fetch MCP tools: {e}"))?; - - let body: Value = response - .json() - .await - .map_err(|e| format!("Failed to parse MCP tools response: {e}"))?; - - let tools = body["result"]["tools"] - .as_array() - .ok_or_else(|| "No tools array in MCP response".to_string())?; +/// Load MCP tool definitions directly and convert to Gemini function +/// declaration format. +pub(super) fn convert_mcp_tools_to_gemini() -> Vec { + let tools = list_tools(); let mut declarations = Vec::new(); - for tool in tools { + for tool in &tools { let name = tool["name"].as_str().unwrap_or("").to_string(); let description = tool["description"].as_str().unwrap_or("").to_string(); @@ -47,9 +27,6 @@ pub(super) async fn fetch_and_convert_mcp_tools( continue; } - // Convert MCP inputSchema (JSON Schema) to Gemini parameters - // (OpenAPI-subset schema). They are structurally compatible for - // simple object schemas. let parameters = convert_mcp_schema_to_gemini(tool.get("inputSchema")); declarations.push(GeminiFunctionDeclaration { @@ -63,54 +40,7 @@ pub(super) async fn fetch_and_convert_mcp_tools( "[gemini] Loaded {} MCP tools as function declarations", declarations.len() ); - Ok(declarations) -} - -/// Call an MCP tool via huskies' MCP server. -pub(super) async fn call_mcp_tool( - client: &Client, - mcp_base: &str, - tool_name: &str, - args: &Value, -) -> Result { - let request = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": args - } - }); - - let response = client - .post(mcp_base) - .json(&request) - .send() - .await - .map_err(|e| format!("MCP tool call failed: {e}"))?; - - let body: Value = response - .json() - .await - .map_err(|e| format!("Failed to parse MCP tool response: {e}"))?; - - if let Some(error) = body.get("error") { - let msg = error["message"].as_str().unwrap_or("Unknown MCP error"); - return Err(format!("MCP tool '{tool_name}' error: {msg}")); - } - - // MCP tools/call returns { result: { content: [{ type: "text", text: "..." }] } } - let content = &body["result"]["content"]; - if let Some(arr) = content.as_array() { - let texts: Vec<&str> = arr.iter().filter_map(|c| c["text"].as_str()).collect(); - if !texts.is_empty() { - return Ok(texts.join("\n")); - } - } - - // Fall back to serializing the entire result. - Ok(body["result"].to_string()) + declarations } // ── Schema conversion ──────────────────────────────────────────────── diff --git a/server/src/agents/runtime/gemini/mod.rs b/server/src/agents/runtime/gemini/mod.rs index 9e1183af..d7c8a963 100644 --- a/server/src/agents/runtime/gemini/mod.rs +++ b/server/src/agents/runtime/gemini/mod.rs @@ -7,6 +7,7 @@ use serde_json::json; use tokio::sync::broadcast; use crate::agent_log::AgentLogWriter; +use crate::http::mcp::dispatch::dispatch_tool_call; use crate::slog; use super::super::{AgentEvent, TokenUsage}; @@ -16,7 +17,7 @@ mod api; mod mcp; use api::{build_generate_content_request, build_system_instruction, parse_usage_metadata}; -use mcp::{call_mcp_tool, fetch_and_convert_mcp_tools}; +use mcp::convert_mcp_tools_to_gemini; // ── Internal types ─────────────────────────────────────────────────── @@ -79,14 +80,16 @@ impl AgentRuntime for GeminiRuntime { .unwrap_or_else(|| "gemini-2.5-pro".to_string()) }; - let mcp_port = ctx.mcp_port; - let mcp_base = format!("http://localhost:{mcp_port}/mcp"); + let app_ctx = ctx + .app_ctx + .clone() + .ok_or_else(|| "Gemini runtime requires app_ctx to be set".to_string())?; let client = Client::new(); let cancelled = Arc::clone(&self.cancelled); - // Step 1: Fetch MCP tool definitions and convert to Gemini format. - let gemini_tools = fetch_and_convert_mcp_tools(&client, &mcp_base).await?; + // Step 1: Load MCP tool definitions and convert to Gemini format. + let gemini_tools = convert_mcp_tools_to_gemini(); // Step 2: Build the initial conversation contents. let system_instruction = build_system_instruction(&ctx); @@ -276,7 +279,7 @@ impl AgentRuntime for GeminiRuntime { text: format!("\n[Tool call: {}]\n", fc.name), }); - let tool_result = call_mcp_tool(&client, &mcp_base, &fc.name, &fc.args).await; + let tool_result = dispatch_tool_call(&fc.name, fc.args.clone(), &app_ctx).await; let response_value = match &tool_result { Ok(result) => { @@ -348,6 +351,12 @@ impl AgentRuntime for GeminiRuntime { #[cfg(test)] mod tests { use super::*; + use crate::http::context::AppContext; + + fn test_app_ctx() -> Arc { + let tmp = tempfile::tempdir().unwrap(); + Arc::new(AppContext::new_test(tmp.path().to_path_buf())) + } #[test] fn gemini_runtime_stop_sets_cancelled() { @@ -368,7 +377,7 @@ mod tests { prompt: "test".to_string(), cwd: "/tmp".to_string(), inactivity_timeout_secs: 300, - mcp_port: 3001, + app_ctx: Some(test_app_ctx()), session_id_to_resume: None, fresh_prompt: None, }; diff --git a/server/src/agents/runtime/mod.rs b/server/src/agents/runtime/mod.rs index 2704f057..9d5cfd60 100644 --- a/server/src/agents/runtime/mod.rs +++ b/server/src/agents/runtime/mod.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; use crate::agent_log::AgentLogWriter; +use crate::http::context::AppContext; use super::{AgentEvent, TokenUsage}; @@ -23,9 +24,10 @@ pub struct RuntimeContext { pub prompt: String, pub cwd: String, pub inactivity_timeout_secs: u64, - /// Port of the huskies MCP server, used by API-based runtimes (Gemini, OpenAI) - /// to call back for tool execution. - pub mcp_port: u16, + /// Shared application context, used by API-based runtimes (Gemini, OpenAI) + /// to invoke MCP tool dispatch directly without an HTTP round-trip. + /// `None` in tests or when the pool is created before `AppContext` exists. + pub app_ctx: Option>, /// When set, resume a previous Claude Code session instead of starting fresh. /// /// The CLI is invoked as `claude --resume [-p ]` rather @@ -95,6 +97,12 @@ pub trait AgentRuntime: Send + Sync { #[cfg(test)] mod tests { use super::*; + use crate::http::context::AppContext; + + fn test_app_ctx() -> Arc { + let tmp = tempfile::tempdir().unwrap(); + Arc::new(AppContext::new_test(tmp.path().to_path_buf())) + } #[test] fn runtime_context_fields() { @@ -106,7 +114,7 @@ mod tests { prompt: "Do the thing".to_string(), cwd: "/tmp/wt".to_string(), inactivity_timeout_secs: 300, - mcp_port: 3001, + app_ctx: Some(test_app_ctx()), session_id_to_resume: None, fresh_prompt: None, }; @@ -117,7 +125,6 @@ mod tests { assert_eq!(ctx.prompt, "Do the thing"); assert_eq!(ctx.cwd, "/tmp/wt"); assert_eq!(ctx.inactivity_timeout_secs, 300); - assert_eq!(ctx.mcp_port, 3001); } #[test] diff --git a/server/src/agents/runtime/openai.rs b/server/src/agents/runtime/openai.rs index 8ff58007..cf267f8d 100644 --- a/server/src/agents/runtime/openai.rs +++ b/server/src/agents/runtime/openai.rs @@ -7,6 +7,8 @@ use serde_json::{Value, json}; use tokio::sync::broadcast; use crate::agent_log::AgentLogWriter; +use crate::http::mcp::dispatch::dispatch_tool_call; +use crate::http::mcp::tools_list::list_tools; use crate::slog; use super::super::{AgentEvent, TokenUsage}; @@ -65,14 +67,16 @@ impl AgentRuntime for OpenAiRuntime { .unwrap_or_else(|| "gpt-4o".to_string()) }; - let mcp_port = ctx.mcp_port; - let mcp_base = format!("http://localhost:{mcp_port}/mcp"); + let app_ctx = ctx + .app_ctx + .clone() + .ok_or_else(|| "OpenAI runtime requires app_ctx to be set".to_string())?; let client = Client::new(); let cancelled = Arc::clone(&self.cancelled); // Step 1: Fetch MCP tool definitions and convert to OpenAI format. - let openai_tools = fetch_and_convert_mcp_tools(&client, &mcp_base).await?; + let openai_tools = convert_mcp_tools_to_openai(); // Step 2: Build the initial conversation messages. let system_text = build_system_text(&ctx); @@ -248,7 +252,7 @@ impl AgentRuntime for OpenAiRuntime { text: format!("\n[Tool call: {tool_name}]\n"), }); - let tool_result = call_mcp_tool(&client, &mcp_base, tool_name, &args).await; + let tool_result = dispatch_tool_call(tool_name, args.clone(), &app_ctx).await; let result_content = match &tool_result { Ok(result) => { @@ -313,38 +317,13 @@ fn build_system_text(ctx: &RuntimeContext) -> String { }) } -/// Fetch MCP tool definitions from huskies' MCP server and convert -/// them to OpenAI function-calling format. -async fn fetch_and_convert_mcp_tools( - client: &Client, - mcp_base: &str, -) -> Result, String> { - let request = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/list", - "params": {} - }); - - let response = client - .post(mcp_base) - .json(&request) - .send() - .await - .map_err(|e| format!("Failed to fetch MCP tools: {e}"))?; - - let body: Value = response - .json() - .await - .map_err(|e| format!("Failed to parse MCP tools response: {e}"))?; - - let tools = body["result"]["tools"] - .as_array() - .ok_or_else(|| "No tools array in MCP response".to_string())?; +/// Load MCP tool definitions directly and convert to OpenAI function-calling format. +fn convert_mcp_tools_to_openai() -> Vec { + let tools = list_tools(); let mut openai_tools = Vec::new(); - for tool in tools { + for tool in &tools { let name = tool["name"].as_str().unwrap_or("").to_string(); let description = tool["description"].as_str().unwrap_or("").to_string(); @@ -370,7 +349,7 @@ async fn fetch_and_convert_mcp_tools( "[openai] Loaded {} MCP tools as function definitions", openai_tools.len() ); - Ok(openai_tools) + openai_tools } /// Convert an MCP inputSchema (JSON Schema) to OpenAI-compatible @@ -435,53 +414,6 @@ fn clean_schema_properties(properties: &Value) -> Value { Value::Object(cleaned) } -/// Call an MCP tool via huskies' MCP server. -async fn call_mcp_tool( - client: &Client, - mcp_base: &str, - tool_name: &str, - args: &Value, -) -> Result { - let request = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": args - } - }); - - let response = client - .post(mcp_base) - .json(&request) - .send() - .await - .map_err(|e| format!("MCP tool call failed: {e}"))?; - - let body: Value = response - .json() - .await - .map_err(|e| format!("Failed to parse MCP tool response: {e}"))?; - - if let Some(error) = body.get("error") { - let msg = error["message"].as_str().unwrap_or("Unknown MCP error"); - return Err(format!("MCP tool '{tool_name}' error: {msg}")); - } - - // MCP tools/call returns { result: { content: [{ type: "text", text: "..." }] } } - let content = &body["result"]["content"]; - if let Some(arr) = content.as_array() { - let texts: Vec<&str> = arr.iter().filter_map(|c| c["text"].as_str()).collect(); - if !texts.is_empty() { - return Ok(texts.join("\n")); - } - } - - // Fall back to serializing the entire result. - Ok(body["result"].to_string()) -} - /// Parse token usage from an OpenAI API response. fn parse_usage(response: &Value) -> Option { let usage = response.get("usage")?; @@ -506,6 +438,12 @@ fn parse_usage(response: &Value) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::http::context::AppContext; + + fn test_app_ctx() -> Arc { + let tmp = tempfile::tempdir().unwrap(); + Arc::new(AppContext::new_test(tmp.path().to_path_buf())) + } #[test] fn convert_mcp_schema_simple_object() { @@ -616,7 +554,7 @@ mod tests { prompt: "Do the thing".to_string(), cwd: "/tmp/wt".to_string(), inactivity_timeout_secs: 300, - mcp_port: 3001, + app_ctx: Some(test_app_ctx()), session_id_to_resume: None, fresh_prompt: None, }; @@ -634,7 +572,7 @@ mod tests { prompt: "Do the thing".to_string(), cwd: "/tmp/wt".to_string(), inactivity_timeout_secs: 300, - mcp_port: 3001, + app_ctx: Some(test_app_ctx()), session_id_to_resume: None, fresh_prompt: None, }; @@ -685,7 +623,7 @@ mod tests { prompt: "test".to_string(), cwd: "/tmp".to_string(), inactivity_timeout_secs: 300, - mcp_port: 3001, + app_ctx: Some(test_app_ctx()), session_id_to_resume: None, fresh_prompt: None, }; @@ -702,7 +640,7 @@ mod tests { prompt: "test".to_string(), cwd: "/tmp".to_string(), inactivity_timeout_secs: 300, - mcp_port: 3001, + app_ctx: Some(test_app_ctx()), session_id_to_resume: None, fresh_prompt: None, }; diff --git a/server/src/http/gateway/mod.rs b/server/src/http/gateway/mod.rs index 303b8d67..43475d6d 100644 --- a/server/src/http/gateway/mod.rs +++ b/server/src/http/gateway/mod.rs @@ -9,7 +9,8 @@ //! - [`websocket`] — WebSocket handlers (CRDT-sync, event push) //! - [`rest`] — REST API handlers (agents, projects, bot config, pipeline) -mod jsonrpc; +/// JSON-RPC 2.0 request/response types shared across gateway handlers. +pub(crate) mod jsonrpc; mod mcp; mod rest; mod websocket; diff --git a/server/src/http/mcp/dispatch.rs b/server/src/http/mcp/dispatch.rs index df63460c..ab9e55bc 100644 --- a/server/src/http/mcp/dispatch.rs +++ b/server/src/http/mcp/dispatch.rs @@ -1,26 +1,25 @@ //! `tools/call` MCP method — dispatches a tool name to the appropriate `*_tools` module. -use serde_json::{Value, json}; +use serde_json::Value; -use super::JsonRpcResponse; use super::{ agent_tools, diagnostics, git_tools, merge_tools, qa_tools, shell_tools, status_tools, story_tools, wizard_tools, }; use crate::http::context::AppContext; -use crate::slog_warn; // ── Tool dispatch ───────────────────────────────────────────────── -pub(super) async fn handle_tools_call( - id: Option, - params: &Value, +/// Execute an MCP tool by name, returning the text result or an error string. +/// +/// This is the shared dispatch entry point used by both the WebSocket +/// rendezvous channel and API-based agent runtimes (Gemini, OpenAI). +pub async fn dispatch_tool_call( + tool_name: &str, + args: 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!({})); - - let result = match tool_name { +) -> Result { + match tool_name { // Workflow tools "create_story" => story_tools::tool_create_story(&args, ctx), "validate_stories" => story_tools::tool_validate_stories(ctx), @@ -120,31 +119,43 @@ pub(super) async fn handle_tools_call( "wizard_skip" => wizard_tools::tool_wizard_skip(ctx), "wizard_retry" => wizard_tools::tool_wizard_retry(ctx), _ => Err(format!("Unknown tool: {tool_name}")), - }; - - match result { - Ok(content) => JsonRpcResponse::success( - id, - json!({ - "content": [{ "type": "text", "text": content }] - }), - ), - Err(msg) => { - slog_warn!("[mcp] Tool call failed: tool={tool_name} error={msg}"); - JsonRpcResponse::success( - id, - json!({ - "content": [{ "type": "text", "text": msg }], - "isError": true - }), - ) - } } } #[cfg(test)] mod tests { - use super::*; + use crate::http::gateway::jsonrpc::JsonRpcResponse; + use crate::slog_warn; + use serde_json::json; + + /// Test helper: invoke a `tools/call` JSON-RPC request and return the response. + pub(in crate::http::mcp) async fn handle_tools_call( + id: Option, + params: &serde_json::Value, + ctx: &crate::http::context::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 super::dispatch_tool_call(tool_name, args, ctx).await { + Ok(content) => JsonRpcResponse::success( + id, + json!({ + "content": [{ "type": "text", "text": content }] + }), + ), + Err(msg) => { + slog_warn!("[mcp] Tool call failed: tool={tool_name} error={msg}"); + JsonRpcResponse::success( + id, + json!({ + "content": [{ "type": "text", "text": msg }], + "isError": true + }), + ) + } + } + } use crate::http::test_helpers::test_ctx; #[test] diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index e836cc6d..b7d7901c 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -1,18 +1,15 @@ -//! HTTP MCP server module. - -use crate::http::context::AppContext; -use poem::handler; -use poem::http::StatusCode; -use poem::web::Data; -use poem::{Body, Request, Response}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::sync::Arc; +//! MCP tool dispatch and schema 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. /// MCP tools for agent start, stop, wait, list, and inspect. pub mod agent_tools; /// MCP tools for server logs, CRDT dump, version, and story movement. pub mod diagnostics; +/// MCP tool dispatch — routes a tool name to the appropriate handler module. +pub mod dispatch; /// MCP tools for git operations scoped to agent worktrees. pub mod git_tools; /// MCP tools for merge status and merge-to-master operations. @@ -25,568 +22,11 @@ pub mod shell_tools; pub mod status_tools; /// MCP tools for creating, updating, and managing stories and bugs. pub mod story_tools; +/// MCP tool schema definitions for `tools/list`. +pub mod tools_list; /// MCP tools for the project setup wizard. pub mod wizard_tools; -mod dispatch; -mod tools_list; - -use dispatch::handle_tools_call; -use tools_list::handle_tools_list; - -/// Returns true when the Accept header includes text/event-stream. -fn wants_sse(req: &Request) -> bool { - req.header("accept") - .unwrap_or("") - .contains("text/event-stream") -} - -// ── JSON-RPC structs ────────────────────────────────────────────── - -#[derive(Deserialize)] -struct JsonRpcRequest { - jsonrpc: String, - id: Option, - method: String, - #[serde(default)] - params: Value, -} - -#[derive(Serialize)] -pub(super) struct JsonRpcResponse { - jsonrpc: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Serialize)] -struct JsonRpcError { - code: i64, - message: String, - #[serde(skip_serializing_if = "Option::is_none")] - data: Option, -} - -impl JsonRpcResponse { - pub(super) fn success(id: Option, result: Value) -> Self { - Self { - jsonrpc: "2.0", - id, - result: Some(result), - error: None, - } - } - - pub(super) fn error(id: Option, code: i64, message: String) -> Self { - Self { - jsonrpc: "2.0", - id, - result: None, - error: Some(JsonRpcError { - code, - message, - data: None, - }), - } - } -} - -// ── Poem handlers ───────────────────────────────────────────────── - -#[handler] -pub async fn mcp_get_handler() -> Response { - Response::builder() - .status(StatusCode::METHOD_NOT_ALLOWED) - .body(Body::empty()) -} - -#[handler] -pub async fn mcp_post_handler(req: &Request, body: Body, ctx: Data<&Arc>) -> Response { - // Validate Content-Type - let content_type = req.header("content-type").unwrap_or(""); - if !content_type.is_empty() && !content_type.contains("application/json") { - return json_rpc_error_response( - None, - -32700, - "Unsupported Content-Type; expected application/json".into(), - ); - } - - let bytes = match body.into_bytes().await { - Ok(b) => b, - Err(_) => return json_rpc_error_response(None, -32700, "Parse error".into()), - }; - - let rpc: JsonRpcRequest = match serde_json::from_slice(&bytes) { - Ok(r) => r, - Err(_) => return json_rpc_error_response(None, -32700, "Parse error".into()), - }; - - if rpc.jsonrpc != "2.0" { - return json_rpc_error_response(rpc.id, -32600, "Invalid JSON-RPC version".into()); - } - - // Notifications (no id) — accept silently - 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_rpc_error_response(None, -32600, "Missing id".into()); - } - - let sse = wants_sse(req); - - // Streaming agent output over SSE - if sse && rpc.method == "tools/call" { - let tool_name = rpc - .params - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if tool_name == "run_command" { - return shell_tools::handle_run_command_sse(rpc.id, &rpc.params, &ctx); - } - } - - let resp = match rpc.method.as_str() { - "initialize" => handle_initialize(rpc.id, &rpc.params), - "tools/list" => handle_tools_list(rpc.id), - "tools/call" => handle_tools_call(rpc.id, &rpc.params, &ctx).await, - _ => JsonRpcResponse::error(rpc.id, -32601, format!("Unknown method: {}", rpc.method)), - }; - - if sse { - to_sse_response(resp) - } else { - to_json_response(resp) - } -} - -fn json_rpc_error_response(id: Option, code: i64, message: String) -> Response { - to_json_response(JsonRpcResponse::error(id, code, message)) -} - -fn to_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)) -} - -pub(super) fn to_sse_response(resp: JsonRpcResponse) -> Response { - let json = serde_json::to_string(&resp).unwrap_or_default(); - let sse_body = format!("data: {json}\n\n"); - Response::builder() - .status(StatusCode::OK) - .header("Content-Type", "text/event-stream") - .header("Cache-Control", "no-cache") - .body(Body::from_string(sse_body)) -} - -// ── MCP protocol handlers ───────────────────────────────────────── - -fn handle_initialize(id: Option, params: &Value) -> JsonRpcResponse { - let _protocol_version = params - .get("protocolVersion") - .and_then(|v| v.as_str()) - .unwrap_or("2025-03-26"); - - JsonRpcResponse::success( - id, - json!({ - "protocolVersion": "2025-03-26", - "capabilities": { - "tools": {} - }, - "serverInfo": { - "name": "huskies", - "version": "1.0.0" - } - }), - ) -} - +// Re-export for test code in submodules that references `super::super::handle_tools_list`. #[cfg(test)] -mod tests { - use super::*; - use crate::http::test_helpers::test_ctx; - - #[test] - fn json_rpc_response_serializes_success() { - let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true})); - let s = serde_json::to_string(&resp).unwrap(); - assert!(s.contains("\"result\"")); - assert!(!s.contains("\"error\"")); - } - - #[test] - fn json_rpc_response_serializes_error() { - let resp = JsonRpcResponse::error(Some(json!(1)), -32600, "bad".into()); - let s = serde_json::to_string(&resp).unwrap(); - assert!(s.contains("\"error\"")); - assert!(!s.contains("\"result\"")); - } - - #[test] - fn initialize_returns_capabilities() { - let resp = handle_initialize( - Some(json!(1)), - &json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}), - ); - let result = resp.result.unwrap(); - assert_eq!(result["protocolVersion"], "2025-03-26"); - assert!(result["capabilities"]["tools"].is_object()); - assert_eq!(result["serverInfo"]["name"], "huskies"); - } - - #[test] - fn to_sse_response_wraps_in_data_prefix() { - let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true})); - let http_resp = to_sse_response(resp); - assert_eq!( - http_resp.headers().get("content-type").unwrap(), - "text/event-stream" - ); - } - - #[test] - fn wants_sse_detects_accept_header() { - // Can't easily construct a Request in tests without TestClient, - // so test the logic indirectly via to_sse_response format - let resp = JsonRpcResponse::success(Some(json!(1)), json!("ok")); - let json_resp = to_json_response(resp); - assert_eq!( - json_resp.headers().get("content-type").unwrap(), - "application/json" - ); - } - - #[test] - fn json_rpc_error_response_builds_json_response() { - let resp = json_rpc_error_response(Some(json!(42)), -32600, "test error".into()); - assert_eq!(resp.status(), poem::http::StatusCode::OK); - assert_eq!( - resp.headers().get("content-type").unwrap(), - "application/json" - ); - } - - // ── HTTP handler tests (TestClient) ─────────────────────────── - - fn test_mcp_app(ctx: std::sync::Arc) -> impl poem::Endpoint { - use poem::EndpointExt; - poem::Route::new() - .at("/mcp", poem::post(mcp_post_handler).get(mcp_get_handler)) - .data(ctx) - } - - async fn read_body_json(resp: poem::test::TestResponse) -> Value { - let body = resp.0.into_body().into_string().await.unwrap(); - serde_json::from_str(&body).unwrap() - } - - async fn post_json_mcp( - cli: &poem::test::TestClient, - payload: &str, - ) -> Value { - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .body(payload.to_string()) - .send() - .await; - read_body_json(resp).await - } - - #[tokio::test] - async fn mcp_get_handler_returns_405() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli.get("/mcp").send().await; - assert_eq!(resp.0.status(), poem::http::StatusCode::METHOD_NOT_ALLOWED); - } - - #[tokio::test] - async fn mcp_post_invalid_content_type_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "text/plain") - .body("{}") - .send() - .await; - let body = read_body_json(resp).await; - assert!(body.get("error").is_some(), "expected error field: {body}"); - } - - #[tokio::test] - async fn mcp_post_invalid_json_returns_parse_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .body("not-valid-json") - .send() - .await; - let body = read_body_json(resp).await; - assert!(body.get("error").is_some(), "expected error field: {body}"); - } - - #[tokio::test] - async fn mcp_post_wrong_jsonrpc_version_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let body = post_json_mcp( - &cli, - r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{}}"#, - ) - .await; - assert!( - body["error"]["message"] - .as_str() - .unwrap_or("") - .contains("version"), - "expected version error: {body}" - ); - } - - #[tokio::test] - async fn mcp_post_notification_with_null_id_returns_accepted() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#) - .send() - .await; - assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED); - } - - #[tokio::test] - async fn mcp_post_notification_with_explicit_null_id_returns_accepted() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .body(r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized","params":{}}"#) - .send() - .await; - assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED); - } - - #[tokio::test] - async fn mcp_post_missing_id_non_notification_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let body = post_json_mcp( - &cli, - r#"{"jsonrpc":"2.0","method":"initialize","params":{}}"#, - ) - .await; - assert!(body.get("error").is_some(), "expected error: {body}"); - } - - #[tokio::test] - async fn mcp_post_unknown_method_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let body = post_json_mcp( - &cli, - r#"{"jsonrpc":"2.0","id":1,"method":"bogus/method","params":{}}"#, - ) - .await; - assert!( - body["error"]["message"] - .as_str() - .unwrap_or("") - .contains("Unknown method"), - "expected unknown method error: {body}" - ); - } - - #[tokio::test] - async fn mcp_post_initialize_returns_capabilities() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let body = post_json_mcp( - &cli, - r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#, - ) - .await; - assert_eq!(body["result"]["protocolVersion"], "2025-03-26"); - assert_eq!(body["result"]["serverInfo"]["name"], "huskies"); - } - - #[tokio::test] - async fn mcp_post_tools_list_returns_tools() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let body = post_json_mcp( - &cli, - r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#, - ) - .await; - assert!(body["result"]["tools"].is_array()); - } - - #[tokio::test] - async fn mcp_post_sse_returns_event_stream_content_type() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#) - .send() - .await; - assert_eq!( - resp.0.headers().get("content-type").unwrap(), - "text/event-stream" - ); - } - - #[tokio::test] - async fn mcp_post_sse_get_agent_output_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{}}}"#) - .send() - .await; - assert_eq!( - resp.0.headers().get("content-type").unwrap(), - "text/event-stream", - "expected SSE content-type" - ); - } - - #[tokio::test] - async fn mcp_post_sse_get_agent_output_without_agent_name_returns_disk_content() { - // Without agent_name the SSE live-streaming intercept is skipped and - // the disk-based handler runs. The transport still wraps the result in - // SSE format (data: …\n\n) because the client sent Accept: text/event-stream, - // but the content should be a valid JSON-RPC result, not a subscribe error. - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"1_test"}}}"#) - .send() - .await; - let body = resp.0.into_body().into_string().await.unwrap(); - // Body is SSE-wrapped: "data: {…}\n\n" — strip the prefix and verify it's - // a valid JSON-RPC result (not an error about missing agent_name). - let json_part = body - .trim_start_matches("data: ") - .trim_end_matches("\n\n") - .trim(); - let parsed: serde_json::Value = serde_json::from_str(json_part) - .unwrap_or_else(|_| panic!("expected JSON-RPC in SSE body, got: {body}")); - assert!( - parsed.get("result").is_some(), - "expected JSON-RPC result (disk-based handler ran): {parsed}" - ); - // Must NOT be an error about missing agent_name (agent_name is now optional) - assert!( - parsed.get("error").is_none(), - "unexpected error when agent_name omitted: {parsed}" - ); - } - - #[tokio::test] - async fn mcp_post_sse_get_agent_output_no_agent_no_logs_returns_not_found() { - // Agent not in pool and no log files → SSE success with "No log files found" message. - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"99_nope","agent_name":"bot"}}}"#) - .send() - .await; - assert_eq!( - resp.0.headers().get("content-type").unwrap(), - "text/event-stream" - ); - let body = resp.0.into_body().into_string().await.unwrap(); - assert!(body.contains("data:"), "expected SSE data prefix: {body}"); - // Must NOT return isError — should be a success result with "No log files found" - assert!( - !body.contains("isError"), - "expected no isError for missing agent: {body}" - ); - assert!( - body.contains("No log files found"), - "expected not-found message: {body}" - ); - } - - #[tokio::test] - async fn mcp_post_sse_get_agent_output_exited_agent_reads_disk_logs() { - use crate::agent_log::AgentLogWriter; - use crate::agents::AgentEvent; - // Agent has exited (not in pool) but wrote logs to disk. - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-sse").unwrap(); - writer - .write_event(&AgentEvent::Output { - story_id: "42_story_foo".to_string(), - agent_name: "coder-1".to_string(), - text: "disk output".to_string(), - }) - .unwrap(); - drop(writer); - - let ctx = std::sync::Arc::new(test_ctx(root)); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"42_story_foo","agent_name":"coder-1"}}}"#) - .send() - .await; - let body = resp.0.into_body().into_string().await.unwrap(); - assert!( - body.contains("disk output"), - "expected disk log content in SSE response: {body}" - ); - assert!( - !body.contains("isError"), - "expected no error for exited agent with logs: {body}" - ); - } -} +pub(crate) use tools_list::handle_tools_list; diff --git a/server/src/http/mcp/shell_tools/exec.rs b/server/src/http/mcp/shell_tools/exec.rs index b530dd08..95c8edba 100644 --- a/server/src/http/mcp/shell_tools/exec.rs +++ b/server/src/http/mcp/shell_tools/exec.rs @@ -1,8 +1,5 @@ -//! MCP shell command execution: tool_run_command + SSE streaming variant. +//! MCP shell command execution: `tool_run_command`. -use bytes::Bytes; -use futures::StreamExt; -use poem::{Body, Response}; use serde_json::{Value, json}; use std::path::PathBuf; @@ -89,169 +86,6 @@ pub(crate) async fn tool_run_command(args: &Value, ctx: &AppContext) -> Result, - params: &Value, - ctx: &AppContext, -) -> Response { - use super::super::{JsonRpcResponse, to_sse_response}; - - let args = params.get("arguments").cloned().unwrap_or(json!({})); - - let command = match args.get("command").and_then(|v| v.as_str()) { - Some(c) => c.to_string(), - None => { - return to_sse_response(JsonRpcResponse::error( - id, - -32602, - "Missing required argument: command".into(), - )); - } - }; - - let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { - Some(d) => d.to_string(), - None => { - return to_sse_response(JsonRpcResponse::error( - id, - -32602, - "Missing required argument: working_dir".into(), - )); - } - }; - - let timeout_secs = args - .get("timeout") - .and_then(|v| v.as_u64()) - .unwrap_or(DEFAULT_TIMEOUT_SECS) - .min(MAX_TIMEOUT_SECS); - - if let Some(reason) = is_dangerous(&command) { - return to_sse_response(JsonRpcResponse::error(id, -32602, reason)); - } - - let canonical_dir = match validate_working_dir(&working_dir, ctx) { - Ok(d) => d, - Err(e) => return to_sse_response(JsonRpcResponse::error(id, -32602, e)), - }; - - let final_id = id; - - let stream = async_stream::stream! { - use tokio::io::AsyncBufReadExt; - - let mut child = match tokio::process::Command::new("bash") - .arg("-c") - .arg(&command) - .current_dir(&canonical_dir) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - { - Ok(c) => c, - Err(e) => { - let resp = JsonRpcResponse::success( - final_id, - json!({ - "content": [{"type": "text", "text": format!("Failed to spawn process: {e}")}], - "isError": true - }), - ); - if let Ok(s) = serde_json::to_string(&resp) { - yield Ok::<_, std::io::Error>(format!("data: {s}\n\n")); - } - return; - } - }; - - let stdout = child.stdout.take().expect("stdout piped"); - let stderr = child.stderr.take().expect("stderr piped"); - let mut stdout_lines = tokio::io::BufReader::new(stdout).lines(); - let mut stderr_lines = tokio::io::BufReader::new(stderr).lines(); - - let deadline = tokio::time::Instant::now() - + std::time::Duration::from_secs(timeout_secs); - let mut stdout_done = false; - let mut stderr_done = false; - let mut timed_out = false; - - loop { - if stdout_done && stderr_done { - break; - } - - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - timed_out = true; - let _ = child.kill().await; - break; - } - - tokio::select! { - line = stdout_lines.next_line(), if !stdout_done => { - match line { - Ok(Some(l)) => { - let notif = json!({ - "jsonrpc": "2.0", - "method": "notifications/tools/progress", - "params": { "stream": "stdout", "line": l } - }); - if let Ok(s) = serde_json::to_string(¬if) { - yield Ok::<_, std::io::Error>(format!("data: {s}\n\n")); - } - } - _ => { stdout_done = true; } - } - } - line = stderr_lines.next_line(), if !stderr_done => { - match line { - Ok(Some(l)) => { - let notif = json!({ - "jsonrpc": "2.0", - "method": "notifications/tools/progress", - "params": { "stream": "stderr", "line": l } - }); - if let Ok(s) = serde_json::to_string(¬if) { - yield Ok::<_, std::io::Error>(format!("data: {s}\n\n")); - } - } - _ => { stderr_done = true; } - } - } - _ = tokio::time::sleep(remaining) => { - timed_out = true; - let _ = child.kill().await; - break; - } - } - } - - let exit_code = child.wait().await.ok().and_then(|s| s.code()).unwrap_or(-1); - - let summary = json!({ - "exit_code": exit_code, - "timed_out": timed_out, - }); - - let final_resp = JsonRpcResponse::success( - final_id, - json!({ - "content": [{"type": "text", "text": summary.to_string()}] - }), - ); - if let Ok(s) = serde_json::to_string(&final_resp) { - yield Ok::<_, std::io::Error>(format!("data: {s}\n\n")); - } - }; - - Response::builder() - .status(poem::http::StatusCode::OK) - .header("Content-Type", "text/event-stream") - .header("Cache-Control", "no-cache") - .body(Body::from_bytes_stream(stream.map(|r| r.map(Bytes::from)))) -} - /// Run the project's test suite (`script/test`) and block until complete. /// /// Spawns the test process, then polls every second server-side until the diff --git a/server/src/http/mcp/shell_tools/mod.rs b/server/src/http/mcp/shell_tools/mod.rs index 85588157..9d93e9da 100644 --- a/server/src/http/mcp/shell_tools/mod.rs +++ b/server/src/http/mcp/shell_tools/mod.rs @@ -6,7 +6,7 @@ mod exec; mod script; -pub(crate) use exec::{handle_run_command_sse, tool_run_command}; +pub(crate) use exec::tool_run_command; pub(crate) use script::{ tool_get_test_result, tool_run_build, tool_run_check, tool_run_lint, tool_run_tests, }; diff --git a/server/src/http/mcp/tools_list/mod.rs b/server/src/http/mcp/tools_list/mod.rs index b5ac5509..7efdfb38 100644 --- a/server/src/http/mcp/tools_list/mod.rs +++ b/server/src/http/mcp/tools_list/mod.rs @@ -1,24 +1,36 @@ //! `tools/list` MCP method — returns the static schema for every tool the server exposes. -use serde_json::{Value, json}; - -use super::JsonRpcResponse; +use serde_json::Value; mod agent_tools; mod story_tools; mod system_tools; -pub(super) fn handle_tools_list(id: Option) -> JsonRpcResponse { +/// Return the full list of MCP tool definitions (name, description, inputSchema). +/// +/// Used by API-based runtimes (Gemini, OpenAI) that need tool schemas +/// without going through the network. +pub fn list_tools() -> Vec { let mut tools = Vec::new(); tools.extend(story_tools::story_tools()); tools.extend(agent_tools::agent_tools()); tools.extend(system_tools::system_tools()); - JsonRpcResponse::success(id, json!({ "tools": tools })) + tools +} + +/// Wrap `list_tools()` in a JSON-RPC response (test-only helper). +#[cfg(test)] +pub(crate) fn handle_tools_list( + id: Option, +) -> crate::http::gateway::jsonrpc::JsonRpcResponse { + use serde_json::json; + crate::http::gateway::jsonrpc::JsonRpcResponse::success(id, json!({ "tools": list_tools() })) } #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn tools_list_returns_all_tools() { diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 398c007b..17d68861 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -112,10 +112,6 @@ pub fn build_routes( "/agents/:story_id/:agent_name/stream", get(agents_sse::agent_stream), ) - .at( - "/mcp", - post(mcp::mcp_post_handler).get(mcp::mcp_get_handler), - ) .at("/identity", get(identity::identity_handler)) .at( "/oauth/authorize", diff --git a/server/src/io/fs/project.rs b/server/src/io/fs/project.rs index 91ef1015..2fa78d32 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 \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" + "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"url\": \"ws://localhost:{port}/crdt-sync\"\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 55a85201..99299be5 100644 --- a/server/src/io/fs/scaffold/mod.rs +++ b/server/src/io/fs/scaffold/mod.rs @@ -68,11 +68,12 @@ pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> { BOT_TOML_SLACK_EXAMPLE, )?; - // 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. // 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 \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" + "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"url\": \"ws://localhost:{port}/crdt-sync\"\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 9648a5d8..9e922944 100644 --- a/server/src/worktree/mod.rs +++ b/server/src/worktree/mod.rs @@ -36,11 +36,11 @@ pub fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf { .join(story_id) } -/// Write a `.mcp.json` file in the given directory pointing to the MCP server -/// at the given port. +/// Write a `.mcp.json` file in the given directory pointing to the huskies +/// rendezvous WebSocket endpoint at the given port. pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> { let content = format!( - "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" + "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"url\": \"ws://localhost:{port}/crdt-sync\"\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("http://localhost:4242/mcp")); + assert!(content.contains("ws://localhost:4242/crdt-sync")); } #[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("http://localhost:3001/mcp")); + assert!(content.contains("ws://localhost:3001/crdt-sync")); } #[test]