Skip to content

Custom MCP tool development guide

How to add new tools to DuDuClaw’s MCP Server Applies to: v0.12.0+


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.

Claude Code (client)
↕ JSON-RPC 2.0 (stdin/stdout)
DuDuClaw MCP Server (crates/duduclaw-cli/src/mcp.rs)
↕ Rust function calls
Tool handlers (gateway, agent, memory, inference, etc.)

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,
},
],
},
  • Use snake_case for tool names
  • Group related tools with a common prefix: odoo_*, model_*, cost_*
  • Keep names concise but descriptive
  • required: true — Claude Code must provide this parameter
  • required: false — optional, tool handler must supply a default
  • All parameters are passed as JSON values (serde_json::Value)

Add a match arm in the handle_tool_call() function:

"my_custom_tool" => {
let input = get_string_param(&params, "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
}))
}

Return errors as structured JSON, not panics:

// Good: structured error
if input.is_empty() {
return Ok(json!({
"status": "error",
"error": "input parameter cannot be empty"
}));
}
// Bad: panic
assert!(!input.is_empty()); // Never do this in a tool handler

All tool handlers run in a Tokio async context. Use .await for I/O:

"my_async_tool" => {
let url = get_string_param(&params, "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 }))
}

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", &params).await;
assert!(result.is_ok());
let value = result.unwrap();
assert_eq!(value["status"], "ok");
}
}
終端機視窗
# Start the MCP server
duduclaw mcp-server
# In another terminal, verify the tool appears in the tool list
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | duduclaw mcp-server

Then configure Claude Code to use DuDuClaw as an MCP server:

.mcp.json
{
"mcpServers": {
"duduclaw": {
"command": "duduclaw",
"args": ["mcp-server"]
}
}
}

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.

Most tools need access to agent config or state:

"agent_info_tool" => {
let agent_name = get_string_param(&params, "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),
}))
}
"memory_tool" => {
let agent_id = get_string_param(&params, "agent_id")?;
let query = get_string_param(&params, "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<_>>()
}))
}

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
}

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_fetch patterns)
  • 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.

{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "my_custom_tool",
"arguments": {
"input": "value",
"options": "config"
}
}
}
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "{\"status\":\"ok\",\"result\":\"...\"}"
}
]
}
}
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32602,
"message": "Missing required parameter: input"
}
}

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