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:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user