Initial CodeCraft: web-based coding harness for AI models

Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash,
web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare),
permissions with modes and rules, Anthropic + OpenAI-compatible providers,
MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics,
compaction, commented TOML config editable via toml_edit.

React PWA: chat with live streaming, permission/question cards, modes
(build/plan/craft), settings for providers/models/prices/MCP/permissions,
raw config editor, statistics dashboard, i18n (en/de). Docker setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 14:39:24 +01:00
commit f3a2fe3816
62 files changed
+21835

No files matched your search

+552
View File
@@ -0,0 +1,552 @@
//! Minimal MCP client: JSON-RPC over stdio or Streamable HTTP.
//!
//! Supports tools (list/call, list_changed), roots and elicitation – server
//! questions are forwarded to the user through the same UI as agent questions.
use crate::app::App;
use crate::config::{McpConfig, McpKind, expand_env};
use crate::tools::{Tool, ToolCtx, ToolOutput};
use crate::types::{ImageData, Question, QuestionOption};
use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use serde::Serialize;
use serde_json::{Map, Value, json};
use std::collections::{BTreeMap, HashMap};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{Mutex as AsyncMutex, oneshot};
use tokio_util::sync::CancellationToken;
const PROTOCOL_VERSION: &str = "2025-06-18";
const CALL_TIMEOUT: Duration = Duration::from_secs(600);
#[derive(Debug, Clone, Serialize)]
pub struct McpToolInfo {
pub name: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "state")]
pub enum McpState {
Disabled,
Connecting,
Connected { tools: usize },
Failed { error: String },
}
type Pending = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value>>>>>;
enum Transport {
Stdio {
stdin: AsyncMutex<tokio::process::ChildStdin>,
pending: Pending,
_child: AsyncMutex<tokio::process::Child>,
},
Http {
client: reqwest::Client,
url: String,
headers: BTreeMap<String, String>,
session: Mutex<Option<String>>,
protocol: Mutex<Option<String>>,
},
}
pub struct McpServer {
pub name: String,
transport: Transport,
next_id: AtomicU64,
app: Weak<App>,
/// Session that triggered the current tool call (for elicitation routing).
current_session: Mutex<Option<String>>,
pub tools: Mutex<Vec<McpToolInfo>>,
}
fn rpc(id: u64, method: &str, params: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})
}
impl McpServer {
pub async fn connect(name: &str, cfg: &McpConfig, app: Weak<App>) -> Result<Arc<Self>> {
let transport = match cfg.kind {
McpKind::Stdio => {
if cfg.command.is_empty() {
bail!("command is required for stdio servers");
}
let mut cmd = tokio::process::Command::new(expand_env(&cfg.command));
cmd.args(cfg.args.iter().map(|a| expand_env(a)))
.envs(cfg.env.iter().map(|(k, v)| (k.clone(), expand_env(v))))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(app) = app.upgrade() {
cmd.current_dir(app.workspace());
}
let mut child = cmd.spawn().with_context(|| format!("failed to start {}", cfg.command))?;
let stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let server_name = name.to_string();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(l)) = lines.next_line().await {
tracing::debug!("mcp {server_name} stderr: {l}");
}
});
let pending: Pending = Arc::default();
let server = Arc::new(Self {
name: name.to_string(),
transport: Transport::Stdio {
stdin: AsyncMutex::new(stdin),
pending: pending.clone(),
_child: AsyncMutex::new(child),
},
next_id: AtomicU64::new(1),
app,
current_session: Mutex::new(None),
tools: Mutex::new(Vec::new()),
});
let weak = Arc::downgrade(&server);
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
let Some(server) = weak.upgrade() else { break };
// Server requests (elicitation) may wait for the user: never block the reader.
tokio::spawn(async move { server.handle_incoming(msg).await });
}
// Process ended: fail all pending requests.
for (_, tx) in pending.lock().unwrap().drain() {
let _ = tx.send(Err(anyhow!("MCP server exited")));
}
});
server
}
McpKind::Http => Arc::new(Self {
name: name.to_string(),
transport: Transport::Http {
client: crate::provider::http_client(),
url: expand_env(&cfg.url),
headers: cfg.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(),
session: Mutex::new(None),
protocol: Mutex::new(None),
},
next_id: AtomicU64::new(1),
app,
current_session: Mutex::new(None),
tools: Mutex::new(Vec::new()),
}),
};
let server = transport;
server.initialize().await?;
server.refresh_tools().await?;
Ok(server)
}
async fn initialize(&self) -> Result<()> {
let res = self
.request(
"initialize",
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"elicitation": {}, "roots": {"listChanged": false}},
"clientInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")}
}),
)
.await?;
if let Transport::Http { protocol, .. } = &self.transport {
*protocol.lock().unwrap() =
Some(res["protocolVersion"].as_str().unwrap_or(PROTOCOL_VERSION).to_string());
}
self.notify("notifications/initialized", json!({})).await?;
Ok(())
}
pub async fn refresh_tools(&self) -> Result<()> {
let mut tools = Vec::new();
let mut cursor: Option<String> = None;
loop {
let params = match &cursor {
Some(c) => json!({"cursor": c}),
None => json!({}),
};
let res = self.request("tools/list", params).await?;
for t in res["tools"].as_array().cloned().unwrap_or_default() {
tools.push(McpToolInfo {
name: t["name"].as_str().unwrap_or_default().to_string(),
description: t["description"].as_str().unwrap_or_default().to_string(),
input_schema: t.get("inputSchema").cloned().unwrap_or(json!({"type": "object"})),
});
}
cursor = res["nextCursor"].as_str().map(String::from);
if cursor.is_none() {
break;
}
}
*self.tools.lock().unwrap() = tools;
Ok(())
}
pub async fn call_tool(&self, session_id: &str, name: &str, args: Value, cancel: CancellationToken) -> Result<ToolOutput> {
*self.current_session.lock().unwrap() = Some(session_id.to_string());
let res = tokio::select! {
r = tokio::time::timeout(CALL_TIMEOUT, self.request("tools/call", json!({"name": name, "arguments": args}))) => {
r.map_err(|_| anyhow!("MCP tool call timed out"))?
}
_ = cancel.cancelled() => bail!("aborted"),
}?;
let mut out = ToolOutput { is_error: res["isError"].as_bool().unwrap_or(false), ..Default::default() };
let mut texts = Vec::new();
for c in res["content"].as_array().cloned().unwrap_or_default() {
match c["type"].as_str().unwrap_or_default() {
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
"image" => out.images.push(ImageData {
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
data: c["data"].as_str().unwrap_or_default().into(),
}),
"resource" => {
let r = &c["resource"];
texts.push(match r["text"].as_str() {
Some(t) => format!("[resource {}]\n{t}", r["uri"].as_str().unwrap_or("")),
None => format!("[binary resource {}]", r["uri"].as_str().unwrap_or("")),
});
}
"resource_link" => texts.push(format!("[resource link {}]", c["uri"].as_str().unwrap_or(""))),
other => texts.push(format!("[{other} content]")),
}
}
if texts.is_empty() {
if let Some(s) = res.get("structuredContent") {
texts.push(serde_json::to_string_pretty(s)?);
}
}
out.content = crate::tools::truncate(&texts.join("\n"), 80_000);
Ok(out)
}
// --- transport -------------------------------------------------------
async fn request(&self, method: &str, params: Value) -> Result<Value> {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let msg = rpc(id, method, params);
match &self.transport {
Transport::Stdio { stdin, pending, .. } => {
let (tx, rx) = oneshot::channel();
pending.lock().unwrap().insert(id, tx);
self.write_stdio(stdin, &msg).await?;
let res = tokio::time::timeout(CALL_TIMEOUT, rx)
.await
.map_err(|_| anyhow!("MCP request {method} timed out"))?
.map_err(|_| anyhow!("MCP server closed the connection"))?;
res
}
Transport::Http { .. } => self.http_send(&msg, Some(id)).await?.ok_or_else(|| anyhow!("no response to {method}")),
}
}
async fn notify(&self, method: &str, params: Value) -> Result<()> {
let msg = json!({"jsonrpc": "2.0", "method": method, "params": params});
self.send_raw(&msg).await
}
async fn send_raw(&self, msg: &Value) -> Result<()> {
match &self.transport {
Transport::Stdio { stdin, .. } => self.write_stdio(stdin, msg).await,
Transport::Http { .. } => self.http_send(msg, None).await.map(|_| ()),
}
}
async fn write_stdio(&self, stdin: &AsyncMutex<tokio::process::ChildStdin>, msg: &Value) -> Result<()> {
let mut line = serde_json::to_vec(msg)?;
line.push(b'\n');
let mut w = stdin.lock().await;
w.write_all(&line).await?;
w.flush().await?;
Ok(())
}
/// POST a message. For requests, waits for the matching response, handling
/// server requests (elicitation) that arrive on the same SSE stream.
async fn http_send(&self, msg: &Value, want_id: Option<u64>) -> Result<Option<Value>> {
let Transport::Http { client, url, headers, session, protocol } = &self.transport else { unreachable!() };
let mut rb = client
.post(url)
.header("Accept", "application/json, text/event-stream")
.header("Content-Type", "application/json")
.json(msg);
for (k, v) in headers {
rb = rb.header(k, v);
}
if let Some(s) = session.lock().unwrap().clone() {
rb = rb.header("Mcp-Session-Id", s);
}
if let Some(p) = protocol.lock().unwrap().clone() {
rb = rb.header("MCP-Protocol-Version", p);
}
let resp = rb.send().await?;
if let Some(s) = resp.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()) {
*session.lock().unwrap() = Some(s.to_string());
}
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
bail!("MCP HTTP {status}: {}", body.chars().take(400).collect::<String>());
}
let Some(want) = want_id else { return Ok(None) };
let ctype = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
if ctype.contains("text/event-stream") {
let mut events = resp.bytes_stream().eventsource();
while let Some(ev) = events.next().await {
let ev = ev.map_err(|e| anyhow!("MCP stream error: {e}"))?;
let Ok(v) = serde_json::from_str::<Value>(&ev.data) else { continue };
if v.get("id").and_then(|i| i.as_u64()) == Some(want) && v.get("method").is_none() {
return Ok(Some(Self::result_of(v)?));
}
// Server→client requests/notifications on the stream.
Box::pin(self.handle_incoming(v)).await;
}
bail!("MCP stream ended without a response");
}
let v: Value = resp.json().await?;
Ok(Some(Self::result_of(v)?))
}
fn result_of(v: Value) -> Result<Value> {
if let Some(e) = v.get("error") {
bail!("MCP error {}: {}", e["code"], e["message"].as_str().unwrap_or(""));
}
Ok(v.get("result").cloned().unwrap_or(Value::Null))
}
async fn handle_incoming(&self, msg: Value) {
let method = msg.get("method").and_then(|m| m.as_str()).map(String::from);
let id = msg.get("id").cloned();
match (method, id) {
// Response to one of our requests (stdio).
(None, Some(id)) => {
if let (Transport::Stdio { pending, .. }, Some(id)) = (&self.transport, id.as_u64()) {
if let Some(tx) = pending.lock().unwrap().remove(&id) {
let _ = tx.send(Self::result_of(msg));
}
}
}
// Server → client request.
(Some(method), Some(id)) => {
let result = match method.as_str() {
"ping" => Ok(json!({})),
"roots/list" => {
let root = self.app.upgrade().map(|a| a.workspace()).unwrap_or_default();
Ok(json!({"roots": [{"uri": format!("file://{}", root.display()), "name": "workspace"}]}))
}
"elicitation/create" => self.elicit(&msg["params"]).await,
other => Err(anyhow!("method not supported: {other}")),
};
let reply = match result {
Ok(r) => json!({"jsonrpc": "2.0", "id": id, "result": r}),
Err(e) => json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": e.to_string()}}),
};
if let Err(e) = self.send_raw(&reply).await {
tracing::warn!("mcp {}: failed to reply: {e:#}", self.name);
}
}
(Some(method), None) => {
if method == "notifications/tools/list_changed" {
if let Err(e) = self.refresh_tools().await {
tracing::warn!("mcp {}: tool refresh failed: {e:#}", self.name);
}
}
}
_ => {}
}
}
/// Turn an elicitation form into UI questions and the answers back into content.
async fn elicit(&self, params: &Value) -> Result<Value> {
let app = self.app.upgrade().ok_or_else(|| anyhow!("app gone"))?;
let session = self
.current_session
.lock()
.unwrap()
.clone()
.ok_or_else(|| anyhow!("no active session for elicitation"))?;
let message = params["message"].as_str().unwrap_or("").to_string();
let props = params["requestedSchema"]["properties"].as_object().cloned().unwrap_or_default();
let mut questions = Vec::new();
let mut keys = Vec::new();
for (key, schema) in &props {
let title = schema["title"].as_str().unwrap_or(key);
let desc = schema["description"].as_str().unwrap_or("");
let mut options: Vec<QuestionOption> = Vec::new();
if let Some(e) = schema["enum"].as_array() {
let names = schema["enumNames"].as_array();
for (i, v) in e.iter().enumerate() {
let label = v.as_str().map(String::from).unwrap_or_else(|| v.to_string());
let description = names.and_then(|n| n.get(i)).and_then(|n| n.as_str()).unwrap_or("").to_string();
options.push(QuestionOption { label, description });
}
} else if schema["type"] == "boolean" {
options = vec![
QuestionOption { label: "true".into(), description: "Yes".into() },
QuestionOption { label: "false".into(), description: "No".into() },
];
}
let question = if props.len() == 1 && !message.is_empty() {
format!("{message}\n\n{title}{}", if desc.is_empty() { String::new() } else { format!(" – {desc}") })
} else {
format!("{title}{}", if desc.is_empty() { String::new() } else { format!(" – {desc}") })
};
questions.push(Question { question, header: key.chars().take(12).collect(), options, multi_select: false });
keys.push((key.clone(), schema.clone()));
}
if questions.is_empty() {
questions.push(Question {
question: message.clone(),
header: "Confirm".into(),
options: vec![
QuestionOption { label: "accept".into(), description: String::new() },
QuestionOption { label: "decline".into(), description: String::new() },
],
multi_select: false,
});
}
let source = format!("mcp:{}", self.name);
let answers = app.ask_user(&session, &source, questions.clone(), CancellationToken::new()).await?;
let Some(answers) = answers else { return Ok(json!({"action": "cancel"})) };
if keys.is_empty() {
let a = answers.get(&questions[0].question).and_then(|v| v.as_str()).unwrap_or("decline");
return Ok(json!({"action": if a == "accept" { "accept" } else { "decline" }}));
}
let mut content = Map::new();
for ((key, schema), q) in keys.iter().zip(&questions) {
let Some(raw) = answers.get(&q.question).and_then(|v| v.as_str()) else { continue };
let v = match schema["type"].as_str() {
Some("boolean") => json!(raw == "true" || raw.eq_ignore_ascii_case("yes")),
Some("integer") => raw.trim().parse::<i64>().map(|n| json!(n)).unwrap_or(json!(raw)),
Some("number") => raw.trim().parse::<f64>().map(|n| json!(n)).unwrap_or(json!(raw)),
_ => json!(raw),
};
content.insert(key.clone(), v);
}
Ok(json!({"action": "accept", "content": content}))
}
}
// ---------------------------------------------------------------------------
/// All configured MCP servers with their connection state.
#[derive(Default)]
pub struct McpManager {
servers: Mutex<BTreeMap<String, Arc<McpServer>>>,
states: Mutex<BTreeMap<String, McpState>>,
}
impl McpManager {
pub fn status_json(&self) -> Value {
let states = self.states.lock().unwrap();
let servers = self.servers.lock().unwrap();
let mut out = Map::new();
for (name, st) in states.iter() {
let tools: Vec<Value> = servers
.get(name)
.map(|s| s.tools.lock().unwrap().iter().map(|t| json!({"name": t.name, "description": t.description})).collect())
.unwrap_or_default();
out.insert(name.clone(), json!({"status": st, "tools": tools}));
}
Value::Object(out)
}
/// (Re)connect all servers from config. Runs connections concurrently.
pub async fn start_all(self: &Arc<Self>, app: Weak<App>, configs: BTreeMap<String, McpConfig>) {
self.servers.lock().unwrap().clear();
self.states.lock().unwrap().clear();
let mut tasks = Vec::new();
for (name, cfg) in configs {
if !cfg.enabled {
self.states.lock().unwrap().insert(name, McpState::Disabled);
continue;
}
self.states.lock().unwrap().insert(name.clone(), McpState::Connecting);
let me = self.clone();
let app = app.clone();
tasks.push(tokio::spawn(async move {
let res = tokio::time::timeout(Duration::from_secs(60), McpServer::connect(&name, &cfg, app.clone())).await;
let state = match res {
Ok(Ok(server)) => {
let n = server.tools.lock().unwrap().len();
me.servers.lock().unwrap().insert(name.clone(), server);
McpState::Connected { tools: n }
}
Ok(Err(e)) => McpState::Failed { error: format!("{e:#}") },
Err(_) => McpState::Failed { error: "connection timed out".into() },
};
tracing::info!("mcp {name}: {state:?}");
me.states.lock().unwrap().insert(name, state);
if let Some(app) = app.upgrade() {
app.emit(crate::types::Event::McpStatus { servers: me.status_json() });
}
}));
}
for t in tasks {
let _ = t.await;
}
}
pub fn tools(&self) -> Vec<Arc<dyn Tool>> {
let servers = self.servers.lock().unwrap();
let mut out: Vec<Arc<dyn Tool>> = Vec::new();
for (sname, server) in servers.iter() {
for t in server.tools.lock().unwrap().iter() {
out.push(Arc::new(McpTool {
full_name: tool_name(sname, &t.name),
server: server.clone(),
info: t.clone(),
}));
}
}
out
}
}
/// Tool names must match ^[a-zA-Z0-9_-]{1,64}$ for most providers.
pub fn tool_name(server: &str, tool: &str) -> String {
let clean = |s: &str| s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect::<String>();
let mut n = format!("mcp__{}__{}", clean(server), clean(tool));
n.truncate(64);
n
}
pub struct McpTool {
full_name: String,
server: Arc<McpServer>,
info: McpToolInfo,
}
#[async_trait]
impl Tool for McpTool {
fn name(&self) -> &str {
&self.full_name
}
fn description(&self) -> String {
format!("[MCP {}] {}", self.server.name, self.info.description)
}
fn schema(&self) -> Value {
let mut s = self.info.input_schema.clone();
if s.get("type").is_none() {
s["type"] = json!("object");
}
s
}
fn title(&self, _input: &Value) -> String {
format!("{} → {}", self.server.name, self.info.name)
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
self.server.call_tool(&ctx.session_id, &self.info.name, input, ctx.cancel.clone()).await
}
}