From 4e6f74d47710782982889d51f3a1a61b8623548b Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sun, 15 Nov 2026 13:04:00 +0100 Subject: [PATCH] Offer all CodeCraft tools to the Claude Code CLI over an MCP bridge Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 9 ++ server/src/api.rs | 1 + server/src/auth.rs | 3 + server/src/claude_code.rs | 17 ++- server/src/main.rs | 1 + server/src/mcp_bridge.rs | 252 ++++++++++++++++++++++++++++++++++++++ web/src/whatsnew.ts | 4 + 7 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 server/src/mcp_bridge.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 42712c6..97cb6e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Claude Code gets all CodeCraft tools** (image generation, 3D, OpenSCAD, browser, scheduling, + diagnostics, reverse engineering, skills, ask_user, the server's MCP tools …) over an MCP bridge, + as `mcp__codecraft__`. Tools the CLI has itself (read, write, edit, bash, grep …) are not + offered twice. Calls go through the usual approval cards and rules. + ## [v202611-00035] – 2026-11-15 ### Fixed diff --git a/server/src/api.rs b/server/src/api.rs index c923e5f..b4a75e4 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -103,6 +103,7 @@ pub fn router(app: AppState) -> Router { .route("/sessions/{id}/links", get(list_links).post(create_link)) .route("/sessions/{id}/links/{token}", delete(delete_link)) .route("/public/chat/{token}", get(public_chat)) + .route("/mcp-bridge/{token}", post(crate::mcp_bridge::handle)) .route("/sessions/{id}/shares", get(get_shares).put(put_shares)) .route("/sessions/{id}/queue", get(get_queue)) .route("/sessions/{id}/queue/flush", post(flush_queue)) diff --git a/server/src/auth.rs b/server/src/auth.rs index 169f6ac..ce0fe80 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -189,6 +189,9 @@ fn is_public(path: &str) -> bool { // Read-only chat links (the token is the credential). || path.starts_with("/public/chat/") || path.starts_with("/api/public/chat/") + // MCP bridge for the Claude Code CLI (per-run token). + || path.starts_with("/mcp-bridge/") + || path.starts_with("/api/mcp-bridge/") } pub async fn require_user(State(app): State>, mut req: Request, next: Next) -> Response { diff --git a/server/src/claude_code.rs b/server/src/claude_code.rs index cd600e9..52ab1e2 100644 --- a/server/src/claude_code.rs +++ b/server/src/claude_code.rs @@ -113,6 +113,10 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul /// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours. fn map_tool(name: &str, input: &Value) -> (String, Value) { + // Our own tools reach the CLI over MCP: shown and judged under their usual names. + if let Some(ours) = name.strip_prefix(crate::mcp_bridge::PREFIX) { + return (ours.to_string(), input.clone()); + } let mut v = input.clone(); let rename = |v: &mut Value, from: &str, to: &str| { if let Some(x) = v.get(from).cloned() { @@ -149,6 +153,8 @@ impl ToolInfo for CcTool { } fn read_only(&self) -> bool { matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list") + // One of our tools (MCP bridge): as it declares itself. + || crate::tools::builtin_tools().iter().any(|t| t.name() == self.name && t.read_only()) } fn is_edit(&self) -> bool { matches!(self.name.as_str(), "write" | "edit") @@ -178,7 +184,10 @@ fn title_of(name: &str, input: &Value, claude_input: &Value) -> String { match name { "bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()), "write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(), - _ => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()), + _ => match crate::tools::builtin_tools().iter().find(|t| t.name() == name) { + Some(t) => t.title(input), + None => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()), + }, } } @@ -365,7 +374,13 @@ pub async fn run(app: Arc, session_id: &str, cancel: CancellationToken) -> if let Some(r) = &resume { args.extend(["--resume".into(), r.clone()]); } + // CodeCraft's own tools for the CLI (MCP, per-run token). + let bridge = crate::mcp_bridge::register(session_id); + args.extend(["--mcp-config".into(), crate::mcp_bridge::config_json(&app, &bridge.token)]); let mut appended = session.instructions.trim().to_string(); + if let Some(note) = crate::mcp_bridge::prompt_section(&crate::mcp_bridge::tools_for(&app, &session)) { + appended = format!("{appended}\n{note}").trim().to_string(); + } for extra in owner.as_deref().into_iter().flat_map(|u| [crate::gitcreds::prompt_section(&app, u), crate::sshkeys::prompt_section(&app, u)]).flatten() { appended = format!("{appended}\n{extra}").trim().to_string(); } diff --git a/server/src/main.rs b/server/src/main.rs index ce43eb9..0462f03 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -24,6 +24,7 @@ mod lsp; mod mentions; mod metrics; mod mcp; +mod mcp_bridge; mod media; mod mesh3d; mod permission; diff --git a/server/src/mcp_bridge.rs b/server/src/mcp_bridge.rs new file mode 100644 index 0000000..8379c93 --- /dev/null +++ b/server/src/mcp_bridge.rs @@ -0,0 +1,252 @@ +//! CodeCraft's tools for the Claude Code CLI, as an MCP server (streamable HTTP, JSON answers). +//! +//! The CLI only knows its own tools. For every run the server registers a secret token and +//! passes `--mcp-config` with `http://127.0.0.1:/api/mcp-bridge/`; the CLI then +//! lists and calls the tools like any MCP tool (`mcp__codecraft__`). The token is the +//! credential (the route is public) and binds the calls to the chat that started the run. +//! Permissions are asked by the CLI through the usual `can_use_tool` request, so the approval +//! cards, rules and project approvals work as for the CLI's own tools. + +use crate::app::App; +use crate::tools::{Tool, ToolCtx, ToolOutput}; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, LazyLock, Mutex}; +use tokio_util::sync::CancellationToken; + +pub const SERVER: &str = "codecraft"; +/// Prefix the CLI gives our tools. +pub const PREFIX: &str = "mcp__codecraft__"; + +/// Our tools the CLI brings itself (same job): not offered twice. +const CLI_HAS: &[&str] = &["read", "write", "edit", "list", "glob", "grep", "bash", "bash_output", "kill_shell", "web_fetch", "web_search", "todo_write", "task", "change_directory"]; + +static TOKENS: LazyLock>> = LazyLock::new(Default::default); + +/// A registered run; the token stops working when this is dropped. +pub struct Registration { + pub token: String, +} + +impl Drop for Registration { + fn drop(&mut self) { + TOKENS.lock().unwrap().remove(&self.token); + } +} + +pub fn register(session_id: &str) -> Registration { + let token = format!("{}{}", crate::types::new_id(), crate::types::new_id()); + TOKENS.lock().unwrap().insert(token.clone(), session_id.to_string()); + Registration { token } +} + +/// `--mcp-config` value for a run. +pub fn config_json(app: &App, token: &str) -> String { + let port = app.config().server.bind.rsplit(':').next().and_then(|p| p.parse::().ok()).unwrap_or(8787); + json!({"mcpServers": {SERVER: {"type": "http", "url": format!("http://127.0.0.1:{port}/api/mcp-bridge/{token}")}}}).to_string() +} + +/// The tools the CLI gets for a chat: what the chat's own agent would have (mode, images, +/// the server's MCP tools …) without the ones the CLI has itself. +pub fn tools_for(app: &Arc, session: &crate::types::Session) -> Vec> { + let cwd = PathBuf::from(&session.cwd); + let root = app.root_for(session.owner_id.as_deref(), &cwd); + let agent = crate::prompts::find_agent_for(app, &root, &cwd, &session.agent, session.owner_id.as_deref()); + let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten()); + crate::runner::available_tools(app, &agent, owner.as_ref()).into_iter().filter(|t| !CLI_HAS.contains(&t.name())).collect() +} + +/// Short note for the system prompt: what the extra tools are called. +pub fn prompt_section(tools: &[Arc]) -> Option { + if tools.is_empty() { + return None; + } + let names: Vec = tools.iter().map(|t| format!("{PREFIX}{}", t.name())).collect(); + Some(format!( + "\n# CodeCraft tools\nBesides your own tools you have the tools of the CodeCraft server (MCP server `{SERVER}`): {}. \ + Use them for what they do – e.g. image generation (generate_image), 3D models, OpenSCAD, scheduling automations, diagnostics, the browser. \ + If one is not in your tool list, load it with ToolSearch (query `select:`).\n", + names.join(", ") + )) +} + +// --- protocol ---------------------------------------------------------------------------- + +fn tools_list(tools: &[Arc]) -> Value { + json!({"tools": tools.iter().map(|t| json!({"name": t.name(), "description": t.description(), "inputSchema": t.schema()})).collect::>()}) +} + +fn call_result(res: anyhow::Result) -> Value { + match res { + Ok(out) => { + let mut content = vec![json!({"type": "text", "text": out.content})]; + for img in out.images.iter().filter(|i| !i.data.is_empty()) { + content.push(json!({"type": "image", "data": img.data, "mimeType": img.media_type})); + } + json!({"content": content, "isError": out.is_error}) + } + Err(e) => json!({"content": [{"type": "text", "text": format!("Error: {e:#}")}], "isError": true}), + } +} + +fn answer(id: &Value, result: Value) -> Response { + Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response() +} + +fn failure(id: &Value, code: i64, message: &str) -> Response { + Json(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})).into_response() +} + +pub async fn handle(State(app): State>, Path(token): Path, Json(req): Json) -> Response { + let Some(session_id) = TOKENS.lock().unwrap().get(&token).cloned() else { + return (StatusCode::NOT_FOUND, "unknown or finished run").into_response(); + }; + let id = req["id"].clone(); + let method = req["method"].as_str().unwrap_or(""); + // Notifications have no answer. + if req.get("id").is_none() || method.starts_with("notifications/") { + return StatusCode::ACCEPTED.into_response(); + } + match method { + "initialize" => answer( + &id, + json!({ + "protocolVersion": req["params"]["protocolVersion"].as_str().unwrap_or("2025-03-26"), + "capabilities": {"tools": {"listChanged": false}}, + "serverInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")}, + }), + ), + "ping" => answer(&id, json!({})), + "tools/list" | "tools/call" => { + let Ok(session) = app.session(&session_id) else { return failure(&id, -32000, "the chat is gone") }; + let tools = tools_for(&app, &session); + if method == "tools/list" { + return answer(&id, tools_list(&tools)); + } + let name = req["params"]["name"].as_str().unwrap_or(""); + let Some(tool) = tools.iter().find(|t| t.name() == name).cloned() else { + return failure(&id, -32602, &format!("unknown tool '{name}'")); + }; + let cwd = PathBuf::from(&session.cwd); + let agent = crate::prompts::find_agent_for(&app, &app.root_for(session.owner_id.as_deref(), &cwd), &cwd, &session.agent, session.owner_id.as_deref()); + let ctx = ToolCtx { + app: app.clone(), + session_id: session_id.clone(), + call_id: crate::types::new_id(), + cwd, + cancel: CancellationToken::new(), + agent: agent.name.clone(), + roots: app.user_roots(session.owner_id.as_deref()), + sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()), + clean_room: session.clean_room(app.config().craft.clean_room) && agent.name == "craft", + owner_id: None, + }; + let input = req["params"]["arguments"].clone(); + let input = if input.is_null() { json!({}) } else { input }; + answer(&id, call_result(tool.run(input, &ctx).await)) + } + _ => failure(&id, -32601, "method not found"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::ImageData; + + #[test] + fn registration_lives_as_long_as_the_run() { + let r = register("s1"); + assert_eq!(TOKENS.lock().unwrap().get(&r.token).map(String::as_str), Some("s1")); + let t = r.token.clone(); + drop(r); + assert!(!TOKENS.lock().unwrap().contains_key(&t)); + } + + #[test] + fn tool_results_carry_text_and_images() { + let out = ToolOutput { + content: "saved".into(), + images: vec![ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None }, ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None }], + ..Default::default() + }; + let v = call_result(Ok(out)); + assert_eq!(v["isError"], false); + let c = v["content"].as_array().unwrap(); + assert_eq!(c.len(), 2); // the image without data is left out + assert_eq!((c[1]["type"].as_str(), c[1]["mimeType"].as_str()), (Some("image"), Some("image/png"))); + assert_eq!(call_result(Err(anyhow::anyhow!("boom")))["isError"], true); + } + + #[test] + fn cli_builtins_are_not_offered_twice() { + let all = crate::tools::builtin_tools(); + let ours: Vec<&str> = all.iter().map(|t| t.name()).filter(|n| !CLI_HAS.contains(n)).collect(); + for n in ["generate_image", "generate_3d", "openscad", "browser", "schedule", "remember", "diagnostics", "ask_user", "skill"] { + assert!(ours.contains(&n), "{n} missing"); + } + assert!(!ours.contains(&"read") && !ours.contains(&"bash")); + let list = tools_list(&all.into_iter().filter(|t| t.name() == "remember").collect::>()); + assert!(list["tools"][0]["inputSchema"].is_object()); + } + + /// The whole protocol against a real App: initialize, list, call, unknown token. + #[tokio::test] + async fn protocol_roundtrip_with_a_real_app() { + let dir = std::env::temp_dir().join(format!("cc-bridge-{}", std::process::id())); + std::fs::create_dir_all(dir.join("ws")).unwrap(); + let toml = crate::config::EXAMPLE_CONFIG.lines().map(|l| { + if l.starts_with("workspace = ") { format!("workspace = \"{}\"", dir.join("ws").display()) } + else if l.starts_with("data_dir = ") { format!("data_dir = \"{}\"", dir.join("data").display()) } + else { l.to_string() } + }).collect::>().join("\n"); + let cfg = crate::config::Config::parse(&toml).unwrap(); + let app = App::new(dir.join("config.toml"), cfg).await.unwrap(); + let now = crate::types::now_ms(); + let session = crate::types::Session { + id: crate::types::new_id(), title: String::new(), cwd: dir.join("ws").display().to_string(), model: "x/y".into(), agent: "build".into(), permission_mode: "bypass".into(), + parent_id: None, owner_id: None, created_at: now, updated_at: now, todos: Value::Null, last_usage: None, clean_room: None, phase: None, worktree: None, + pinned: false, archived: false, folder: None, instructions: String::new(), telegram_quiet: true, summary: String::new(), color: None, icon: None, + }; + let session = app.save_session(session).unwrap(); + let reg = register(&session.id); + let rpc = |req: Value| { + let app = app.clone(); + let token = reg.token.clone(); + async move { + let resp = handle(State(app), Path(token), Json(req)).await; + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + (status, serde_json::from_slice::(&bytes).unwrap_or(Value::Null)) + } + }; + let (st, init) = rpc(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})).await; + assert_eq!(st, StatusCode::OK); + assert_eq!(init["result"]["protocolVersion"], "2025-06-18"); + assert!(init["result"]["capabilities"]["tools"].is_object()); + let (st, _) = rpc(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).await; + assert_eq!(st, StatusCode::ACCEPTED); + let (_, list) = rpc(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})).await; + let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert!(names.contains(&"remember") && names.contains(&"openscad") && names.contains(&"browser"), "{names:?}"); + assert!(!names.contains(&"read") && !names.contains(&"bash"), "{names:?}"); + // A real tool call: `remember` writes the project memory. + let (_, call) = rpc(json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "remember", "arguments": {"note": "bridge works"}}})).await; + assert_eq!(call["result"]["isError"], false, "{call}"); + let mem = std::fs::read_to_string(dir.join("ws/.codecraft/memory.md")).unwrap_or_default(); + assert!(mem.contains("bridge works"), "{mem}"); + let (_, bad) = rpc(json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "bash", "arguments": {"command": "id"}}})).await; + assert!(bad["error"].is_object()); + // A finished run's token is dead. + let token = reg.token.clone(); + drop(reg); + let resp = handle(State(app.clone()), Path(token), Json(json!({"jsonrpc": "2.0", "id": 5, "method": "ping"}))).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index 76b3b15..766666e 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,10 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: ["Claude Code can now use all CodeCraft tools (image generation, 3D, browser, scheduling …)"], + de: ["Claude Code kann jetzt alle CodeCraft-Tools nutzen (Bildgenerierung, 3D, Browser, Zeitpläne …)"], + }, "202611-00035": { en: ["Creating SSH keys works in the container now (fixes \"No user exists for uid\")"], de: ["SSH-Schlüssel lassen sich im Container jetzt erzeugen (behebt \"No user exists for uid\")"],