@@ -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 \n Besides 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 ) , 3 D 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 ) ;
}
}