Compare commits
2
Commits
v202611-00035
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12a3198e77 | ||
|
|
4e6f74d477 |
No files matched your search
@@ -3,6 +3,15 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [v202611-00036] – 2026-11-15
|
||||||
|
|
||||||
|
### 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__<name>`. 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
|
## [v202611-00035] – 2026-11-15
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00035" src="https://img.shields.io/badge/version-v202611--00035-d69e2e?style=flat-square">
|
<img alt="Version v202611-00036" src="https://img.shields.io/badge/version-v202611--00036-d69e2e?style=flat-square">
|
||||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/sessions/{id}/links", get(list_links).post(create_link))
|
.route("/sessions/{id}/links", get(list_links).post(create_link))
|
||||||
.route("/sessions/{id}/links/{token}", delete(delete_link))
|
.route("/sessions/{id}/links/{token}", delete(delete_link))
|
||||||
.route("/public/chat/{token}", get(public_chat))
|
.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}/shares", get(get_shares).put(put_shares))
|
||||||
.route("/sessions/{id}/queue", get(get_queue))
|
.route("/sessions/{id}/queue", get(get_queue))
|
||||||
.route("/sessions/{id}/queue/flush", post(flush_queue))
|
.route("/sessions/{id}/queue/flush", post(flush_queue))
|
||||||
|
|||||||
@@ -189,6 +189,9 @@ fn is_public(path: &str) -> bool {
|
|||||||
// Read-only chat links (the token is the credential).
|
// Read-only chat links (the token is the credential).
|
||||||
|| path.starts_with("/public/chat/")
|
|| path.starts_with("/public/chat/")
|
||||||
|| path.starts_with("/api/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<Arc<App>>, mut req: Request, next: Next) -> Response {
|
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
|
||||||
|
|||||||
@@ -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.
|
/// 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) {
|
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 mut v = input.clone();
|
||||||
let rename = |v: &mut Value, from: &str, to: &str| {
|
let rename = |v: &mut Value, from: &str, to: &str| {
|
||||||
if let Some(x) = v.get(from).cloned() {
|
if let Some(x) = v.get(from).cloned() {
|
||||||
@@ -149,6 +153,8 @@ impl ToolInfo for CcTool {
|
|||||||
}
|
}
|
||||||
fn read_only(&self) -> bool {
|
fn read_only(&self) -> bool {
|
||||||
matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list")
|
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 {
|
fn is_edit(&self) -> bool {
|
||||||
matches!(self.name.as_str(), "write" | "edit")
|
matches!(self.name.as_str(), "write" | "edit")
|
||||||
@@ -178,7 +184,10 @@ fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
|
|||||||
match name {
|
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()),
|
"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(),
|
"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<App>, session_id: &str, cancel: CancellationToken) ->
|
|||||||
if let Some(r) = &resume {
|
if let Some(r) = &resume {
|
||||||
args.extend(["--resume".into(), r.clone()]);
|
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();
|
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() {
|
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();
|
appended = format!("{appended}\n{extra}").trim().to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ mod lsp;
|
|||||||
mod mentions;
|
mod mentions;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
mod mcp_bridge;
|
||||||
mod media;
|
mod media;
|
||||||
mod mesh3d;
|
mod mesh3d;
|
||||||
mod permission;
|
mod permission;
|
||||||
|
|||||||
@@ -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:<port>/api/mcp-bridge/<token>`; the CLI then
|
||||||
|
//! lists and calls the tools like any MCP tool (`mcp__codecraft__<name>`). 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<Mutex<HashMap<String, String>>> = 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::<u16>().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<App>, session: &crate::types::Session) -> Vec<Arc<dyn Tool>> {
|
||||||
|
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<dyn Tool>]) -> Option<String> {
|
||||||
|
if tools.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let names: Vec<String> = 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:<name>`).\n",
|
||||||
|
names.join(", ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- protocol ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn tools_list(tools: &[Arc<dyn Tool>]) -> Value {
|
||||||
|
json!({"tools": tools.iter().map(|t| json!({"name": t.name(), "description": t.description(), "inputSchema": t.schema()})).collect::<Vec<_>>()})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_result(res: anyhow::Result<ToolOutput>) -> 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<Arc<App>>, Path(token): Path<String>, Json(req): Json<Value>) -> 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::<Vec<_>>());
|
||||||
|
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::<Vec<_>>().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::<Value>(&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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
"202611-00036": {
|
||||||
|
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": {
|
"202611-00035": {
|
||||||
en: ["Creating SSH keys works in the container now (fixes \"No user exists for uid\")"],
|
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\")"],
|
de: ["SSH-Schlüssel lassen sich im Container jetzt erzeugen (behebt \"No user exists for uid\")"],
|
||||||
|
|||||||
Reference in new issue
Block a user