Custom MCP tool development guide
How to add new tools to DuDuClaw’s MCP Server Applies to: v0.12.0+
Overview
Section titled “Overview”DuDuClaw exposes 200+ MCP tools (206 as of v1.56) via JSON-RPC 2.0 over stdin/stdout. This guide explains how to add custom tools that integrate with Claude Code.
Architecture
Section titled “Architecture”Claude Code (client) ↕ JSON-RPC 2.0 (stdin/stdout)DuDuClaw MCP Server (crates/duduclaw-cli/src/mcp.rs) ↕ Rust function callsTool handlers (gateway, agent, memory, inference, etc.)Step 1: Define the tool
Section titled “Step 1: Define the tool”Add a new ToolDef entry to the TOOLS array in crates/duduclaw-cli/src/mcp.rs:
ToolDef { name: "my_custom_tool", description: "Brief description of what this tool does", params: &[ ParamDef { name: "input", description: "The input parameter", required: true, }, ParamDef { name: "options", description: "Optional configuration", required: false, }, ],},Naming conventions
Section titled “Naming conventions”- Use
snake_casefor tool names - Group related tools with a common prefix:
odoo_*,model_*,cost_* - Keep names concise but descriptive
Parameter rules
Section titled “Parameter rules”required: true— Claude Code must provide this parameterrequired: false— optional, tool handler must supply a default- All parameters are passed as JSON values (
serde_json::Value)
Step 2: Implement the handler
Section titled “Step 2: Implement the handler”Add a match arm in the handle_tool_call() function:
"my_custom_tool" => { let input = get_string_param(¶ms, "input")?; let options = params.get("options") .and_then(|v| v.as_str()) .unwrap_or("default");
// Your logic here let result = do_something(input, options).await?;
Ok(json!({ "status": "ok", "result": result }))}Error handling
Section titled “Error handling”Return errors as structured JSON, not panics:
// Good: structured errorif input.is_empty() { return Ok(json!({ "status": "error", "error": "input parameter cannot be empty" }));}
// Bad: panicassert!(!input.is_empty()); // Never do this in a tool handlerAsync operations
Section titled “Async operations”All tool handlers run in a Tokio async context. Use .await for I/O:
"my_async_tool" => { let url = get_string_param(¶ms, "url")?;
let response = reqwest::get(&url).await .map_err(|e| DuDuClawError::Network(e.to_string()))?;
let body = response.text().await .map_err(|e| DuDuClawError::Network(e.to_string()))?;
Ok(json!({ "status": "ok", "content": body }))}Step 3: Test the tool
Section titled “Step 3: Test the tool”Unit test
Section titled “Unit test”Add a test in the same file or a dedicated test module:
#[cfg(test)]mod tests { use super::*;
#[tokio::test] async fn test_my_custom_tool() { let params = json!({ "input": "test value", "options": "custom" });
let result = handle_tool_call("my_custom_tool", ¶ms).await; assert!(result.is_ok());
let value = result.unwrap(); assert_eq!(value["status"], "ok"); }}Manual test with Claude Code
Section titled “Manual test with Claude Code”# Start the MCP serverduduclaw mcp-server
# In another terminal, verify the tool appears in the tool listecho '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | duduclaw mcp-serverThen configure Claude Code to use DuDuClaw as an MCP server:
{ "mcpServers": { "duduclaw": { "command": "duduclaw", "args": ["mcp-server"] } }}Step 4: Document the tool
Section titled “Step 4: Document the tool”Add the tool to the tool listing in crates/duduclaw-cli/src/mcp.rs comments and update docs/CLAUDE.md if it represents a significant capability.
Patterns & best practices
Section titled “Patterns & best practices”Accessing agent state
Section titled “Accessing agent state”Most tools need access to agent config or state:
"agent_info_tool" => { let agent_name = get_string_param(¶ms, "agent")?; let agents_dir = duduclaw_agent::get_agents_dir(); let config = duduclaw_agent::load_agent_config(&agents_dir, &agent_name)?;
Ok(json!({ "status": "ok", "agent": config.identity.name, "role": format!("{:?}", config.identity.role), }))}Accessing memory
Section titled “Accessing memory”"memory_tool" => { let agent_id = get_string_param(¶ms, "agent_id")?; let query = get_string_param(¶ms, "query")?;
let engine = SqliteMemoryEngine::open(&memory_db_path(&agent_id))?; let results = engine.search(&query, 10).await?;
Ok(json!({ "status": "ok", "memories": results.iter().map(|m| json!({ "content": m.content, "tags": m.tags, "importance": m.importance, })).collect::<Vec<_>>() }))}Rate limiting
Section titled “Rate limiting”For tools that call external APIs, use rate limiting:
use duduclaw_security::rate_limiter::RateLimiter;
static LIMITER: OnceLock<RateLimiter> = OnceLock::new();
"external_api_tool" => { let limiter = LIMITER.get_or_init(|| RateLimiter::new(10, Duration::from_secs(60))); if !limiter.check("external_api") { return Ok(json!({ "status": "error", "error": "rate limit exceeded, try again in 60s" })); } // ... call external API}Security checklist
Section titled “Security checklist”Before merging a new tool:
- Input validation on all parameters
- No hardcoded secrets
- Rate limiting for external API calls
- SSRF protection for URL parameters (use
web_fetchpatterns) - Audit logging for sensitive operations
- Feature gate check if tool is Pro/Enterprise only
agent.toml [capabilities] allowed_tools / denied_tools no longer need a
per-tool check: every call — stdio, HTTP/SSE, and the openai-compat
tool-loop’s internal MCP client alike — is dispatched through the shared
McpDispatcher::dispatch_tool_call choke point (mcp_dispatch.rs), which
enforces the caller’s [capabilities] allow/deny list against the tool’s
base name (an mcp__<server>__ qualifier is stripped before matching, and
denied_tools always wins over allowed_tools) before your handler is ever
invoked. A new tool registered in handle_tool_call() is covered
automatically. This closed a real gap: before this enforcement moved to the
choke point, allowed_tools / denied_tools only reached the Claude CLI
spawn’s --allowedTools / --disallowedTools flags, so a caller that talked
to the MCP server directly (bypassing the CLI spawn) was unrestricted by
them.
JSON-RPC protocol reference
Section titled “JSON-RPC protocol reference”Request format
Section titled “Request format”{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "my_custom_tool", "arguments": { "input": "value", "options": "config" } }}Response format (success)
Section titled “Response format (success)”{ "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"status\":\"ok\",\"result\":\"...\"}" } ] }}Response format (error)
Section titled “Response format (error)”{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32602, "message": "Missing required parameter: input" }}Tool categories
Section titled “Tool categories”When adding tools, follow the existing category naming:
| Prefix | Category | Examples |
|---|---|---|
send_* |
Messaging | send_message, send_photo, send_sticker |
web_* |
Web/Search | web_search, web_fetch_cached, web_extract |
agent_* |
Agent management | agent_status, agent_update, agent_remove |
memory_* |
Memory operations | memory_search, memory_store |
model_* |
Model management | model_list, model_load, model_unload |
inference_* |
Inference control | inference_status, inference_mode |
llamafile_* |
Llamafile lifecycle | llamafile_start, llamafile_stop |
cost_* |
Cost telemetry | cost_summary, cost_agents, cost_recent |
odoo_* |
Odoo ERP | odoo_crm_leads, odoo_sale_orders |
skill_* |
Skill ecosystem | skill_search, skill_list |
| (none) | Standalone | emergency_stop, tool_approve, schedule_task |