# Tool Creator

> Use when implementing new execution tools for the Mithril MCP server - covers file placement, trait implementation, argument design, validation rules, elicitation, availability checks, and router wiring

- Skill: `radimsem/tool-creator` (Agent Skill)
- Install (CLI): `npx skillmds@latest add radimsem/tool-creator`
- Raw SKILL.md: https://api.skillmd.com/api/skills/radimsem/tool-creator/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: radimsem (https://skillmd.com/u/radimsem)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/radimsem/tool-creator

---


# Tool Creator

Implementation guide for Mithril MCP server execution tools.

## Overview

Each tool wraps a single command (POSIX, git, etc.) as a structured MCP tool. Tools are never shell strings - they build typed argument vectors validated before execution.

## File Placement

Tool lives in `crates/tools/src/<category>/<tool_name>.rs` where category groups related commands:

| Category | Examples |
|----------|----------|
| `system/` | Ls, Cp, Rm, Grep, Find, Sed, Awk |
| `git/` | GitStatus, GitCommit, GitLog, GitDiff |

PascalCase tool name, snake_case file name: `GitStatus` -> `git/git_status.rs`.

Register in `crates/tools/src/<category>/mod.rs` with `pub mod <tool_name>;`.

## Pre-Implementation Verification

Before writing any code, verify the tool's CLI interface and sandbox compatibility. **This section applies only to tools that wrap a CLI command** — skip it for general-purpose tools like `RunChain` that implement execution logic rather than wrapping a binary.

### 1. CLI interface check

Run `<cmd> --help` (or `<cmd> <subcommand> --help`) to get the up-to-date flag reference. Use this output — not memory or documentation — to decide which arguments to expose in the Args struct.

```bash
# Examples
grep --help
git diff --help
cargo clippy --help
```

Cross-check the flag names, short forms, and value types against the help output before writing the `parameters()` JSON schema. Mismatches between the schema and the real CLI are the most common source of `CmdError` in tests.

### 2. Version check

Run `<cmd> --version` to confirm the developer has a recent version installed:

```bash
go version       # → go1.22.0 or newer
cargo --version  # → cargo 1.78.0 or newer
```

If the installed version is notably old (e.g. Go < 1.21, Cargo < 1.70), inform the developer to upgrade for reliable testing. If the command is not found at all, the tool should declare `available()` returning `false` (see ToolDef section below).

**Trust `--help` over documentation or memory.** Real CLI interfaces diverge from docs frequently. Common divergences found in this project:

| Issue type | Example |
|-----------|---------|
| Flag renamed/pluralized | Jest `--testPathPattern` → `--testPathPatterns` |
| Semantics inverted | Mypy `--show-error-codes` → `--hide-error-codes` |
| Flag format changed | Go `mod -go version` → `-go=version` (equals, not space) |
| Flag replaced by positional | CargoUpdate `-p pkg` → positional `pkg` |
| Flag doesn't exist | GlabPipelineView had `--log`/`--job` in docs but not in CLI |
| Allowed values expanded | Ruff output formats went from 4 to 13 |
| State flag split into booleans | Glab `--state opened/closed` → `--closed`/`--merged`/`--all` |

Always run `<cmd> <subcommand> --help` and build the Args struct from that output.

### 3. Sandbox compatibility analysis

Read the sandbox implementations in `crates/sandbox/src/linux.rs` (BubbleWrap) and `crates/sandbox/src/darwin.rs` (sandbox-exec) to reason about whether the command can execute correctly under each sandbox level:

| Level | What's available | Key constraints |
|-------|-----------------|-----------------|
| **Dir** | Full rootfs read-only + cwd read-write + `/dev` + `/proc` + `/tmp` | Writes outside cwd fail |
| **Readonly** | Full rootfs read-only, no network even if requested | All writes fail, network always denied |
| **Container** | Only `/usr`, `/etc`, merged-usr symlinks + cwd read-write + `/dev` + `/proc` + `/tmp` | No `$HOME`, no `/var`, no `/opt` unless bind-mounted |

For each tool, answer:

- **Does the command need to write outside cwd?** (e.g. `cargo check` writes to `~/.cargo/registry`) → needs `rw_binds` in `SandboxMeta`
- **Does it need network access?** (e.g. `pip install`, `go get`) → needs `needs_network: true`
- **Does it need read access to config outside `/usr` and `/etc`?** (e.g. `~/.cargo/config.toml`, `~/.npmrc`) → needs `ro_binds`
- **Is the tool read-only?** (e.g. `cat`, `ls`, `git log`) → should work at `Readonly` level
- **Is the tool mutating?** (e.g. `rm`, `sed`, `git commit`) → `Readonly` must be rejected; set `elicitable() -> true`

This analysis informs both the `SandboxMeta` population in `build_context()` and the sandbox level used in tests.

## Implementation Structure

Every tool file contains exactly three items:

### 1. Unit struct + Args struct

```rust
pub struct ToolName;

#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ToolNameArgs {
    /// Required arg description.
    pub arg: String,
    /// Optional flag description (-f).
    pub flag: Option<bool>,
}
```

**Argument budget: 5-10 max.** Only include args that AI coding agents use frequently or that are essential for regular use. Skip obscure flags.

### 2. `ToolDef` implementation

```rust
impl ToolDef for ToolName {
    fn name(&self) -> Cow<'static, str> {
        Cow::Borrowed("ToolName")  // PascalCase, matches MCP tool name
    }

    fn description(&self) -> Cow<'static, str> {
        Cow::Borrowed("Short description for LLM context")
    }

    fn parameters(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "arg": {
                    "type": "string",
                    "description": "Arg description"
                },
                "flag": {
                    "type": "boolean",
                    "description": "Flag description (-f)"
                }
            },
            "required": ["arg"]
        })
    }

    // Override ONLY when needed:

    fn elicitable(&self) -> bool {
        true  // Tools that mutate project state (Rm, Mv, Sed, Patch, Touch, GitCommit...)
    }

    fn available(&self) -> bool {
        which::which("tool_binary").is_ok()  // Third-party or non-guaranteed commands
    }
}
```

### Binary name fallback

Some tools have different binary names across Linux distros. Debian/Ubuntu renames tools to avoid conflicts:

| Upstream name | Debian/Ubuntu name | Reason |
|--------------|-------------------|--------|
| `bat` | `batcat` | Avoids collision with unrelated `bat` package |
| `fd` | `fdfind` | Avoids collision with `fd` network diagnostics tool |
| `pip` | `pip3` | Python 3 distinction (prefer `pip3`) |

Implement fallback with a helper function:

```rust
fn bat_binary() -> &'static str {
    if which::which("bat").is_ok() {
        "bat"
    } else {
        "batcat"
    }
}

impl ToolDef for Bat {
    fn available(&self) -> bool {
        which::which("bat").is_ok() || which::which("batcat").is_ok()
    }
    // ...
}

impl ToolExec for Bat {
    async fn build_context(&self, args: BatArgs, ctx: &mut ExecContext) {
        ctx.cmd.push(OsString::from(bat_binary()));
        // ...
    }
}
```

For pip, use the shared helper in `crates/tools/src/python/mod.rs`:

```rust
pub(crate) fn pip_bin() -> &'static str {
    if which::which("pip3").is_ok() { "pip3" } else { "pip" }
}
```

**Always check the preferred name first**, then fall back to the alternative.
```

**`parameters()` JSON schema must match `ToolNameArgs` field names and types exactly.** The schema is what the LLM sees; the struct is what gets deserialized.

### 3. `ToolExec` implementation

```rust
#[async_trait]
impl ToolExec for ToolName {
    type Args = ToolNameArgs;

    async fn build_context(&self, args: ToolNameArgs, ctx: &mut ExecContext) {
        // 1. Push binary name
        ctx.cmd.push(OsString::from("tool_binary"));

        // 2. Push flags from bool args
        if args.flag.unwrap_or(false) {
            ctx.cmd.push(OsString::from("-f"));
        }

        // 3. Push valued args
        if let Some(ref val) = args.optional_arg {
            ctx.cmd.push(OsString::from("--option"));
            ctx.cmd.push(OsString::from(val));
        }

        // 4. Push positional args last
        ctx.cmd.push(OsString::from(&args.arg));

        // 5. Populate sandbox metadata (when the tool needs special access)
        //    Set needs_network for commands that fetch from registries.
        //    Add ro_binds / rw_binds for config and cache dirs outside cwd.
        if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
            ctx.sandbox.meta.ro_binds.push(home.join(".config/tool"));
            ctx.sandbox.meta.rw_binds.push(home.join(".cache/tool"));
        }
        ctx.sandbox.meta.needs_network = true; // only if the command fetches

        // 6. Register validation rules (CRITICAL - see below)
        ctx.tool.validation_rules.insert(
            "arg",
            ValidationRule::PathConfined {
                pattern: StringPattern::NON_EMPTY | StringPattern::NO_PATH_TRAVERSAL,
                melody: Some(patterns::MELODY_UNIX_PATH),
                allow_git: false,
            },
        );
    }
}
```

## Sandbox Metadata

Tools that need resources beyond what the sandbox level provides must populate `ctx.sandbox.meta` in `build_context()`. The sandbox layer reads this metadata to add bind-mounts (Linux/bwrap) or SBPL rules (macOS/sandbox-exec).

| Field | When to set | Example tools |
|-------|------------|---------------|
| `needs_network: true` | Command fetches from registries or remote hosts | PipInstall, GoGet, NpmInstall, CargoFetch |
| `ro_binds` | Command reads config/toolchain outside `/usr` and `/etc` | Cargo (`.cargo/config.toml`, `.rustup`), Go (`.config/mise`) |
| `rw_binds` | Command writes to cache dirs outside cwd | Cargo (`.cargo/registry`), Go (`~/go`, `.cache/go-build`), pip (`.cache/pip`) |

**Rules:**
- `rw_binds` are only honoured under `Dir` and `Container` levels — `Readonly` ignores them.
- `rw_binds` outside `$HOME` are silently rejected by both sandbox backends.
- Read-only tools (e.g. `cat`, `ls`, `git log`) should leave `SandboxMeta` at its default.
- Guard bind paths with `if let Some(home) = std::env::var_os("HOME")` — don't assume `$HOME` exists.
- **Include macOS-specific cache paths** alongside Linux ones. macOS uses `~/Library/Caches/<tool>` instead of `~/.cache/<tool>`. Add both in the same helper.

Reference the ecosystem-specific meta helpers in `crates/sandbox/src/linux.rs` tests (`pip_meta()`, `npm_meta()`, `cargo_meta()`, `go_meta()`) for canonical bind-mount sets.

### Shared sandbox meta helpers

Domain `mod.rs` files provide reusable meta helpers. **Use these instead of inlining bind-mount logic in each tool.**

| Helper | Location | What it sets |
|--------|----------|-------------|
| `apply_git_meta(meta)` | `crates/tools/src/git/mod.rs` | `~/.gitconfig` ro_bind |
| `apply_git_network_meta(meta)` | `crates/tools/src/git/mod.rs` | git meta + `needs_network` |
| `apply_docker_meta(meta)` | `crates/tools/src/docker/mod.rs` | `~/.docker` ro_bind, `/var/run/docker.sock` ro_bind, `needs_network` |
| `apply_pip_meta(meta, needs_network)` | `crates/tools/src/python/mod.rs` | `~/.config/pip` ro, `~/.cache/pip` + `~/Library/Caches/pip` + `~/.local` rw |
| `apply_uv_meta(meta, needs_network)` | `crates/tools/src/python/mod.rs` | `~/.config/uv` ro, `~/.cache/uv` + `~/Library/Caches/uv` + `~/.local` rw |

When adding a new tool to an existing domain, call the domain's meta helper:

```rust
// In git tool's build_context():
super::apply_git_meta(&mut ctx.sandbox.meta);

// In docker tool's build_context():
super::apply_docker_meta(&mut ctx.sandbox.meta);

// In pip tool's build_context():
super::apply_pip_meta(&mut ctx.sandbox.meta, true /* needs_network */);
```

When creating a new domain that needs shared sandbox config, add a helper in the domain's `mod.rs` following this pattern:

```rust
pub(crate) fn apply_example_meta(meta: &mut SandboxMeta) {
    if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
        meta.ro_binds.push(home.join(".config/example"));
        meta.rw_binds.push(home.join(".cache/example"));
        // macOS cache location
        meta.rw_binds.push(home.join("Library/Caches/example"));
    }
}
```

## Validation Rules - The Critical Part

**Every non-bool argument MUST have a validation rule.** Only register rules for args present in `ctx.args` (optional args guard with `if ctx.args.contains_key("field")`).

### Rule selection guide

| Arg type | Rule | Key flags |
|----------|------|-----------|
| File/dir path | `PathConfined` | `NO_PATH_TRAVERSAL`, `NO_GIT_COMPONENT` for mutating tools, `allow_git: true` only for git tools |
| Command name | `String` with `NO_SPACES` | `melody: Some(patterns::MELODY_CMD_NAME)`, `exempt_injection_guard: false` |
| Free-text (commit msg, pattern) | `String` with `NON_EMPTY` | `exempt_injection_guard: true` - allows shell metacharacters the arg legitimately needs |
| Integer (line count, max) | `Integer(IntegerPattern::POSITIVE)` | or `NON_NEGATIVE` if zero is valid |
| Path list | `StrVector` | Same `StringRule` as single path |
| Constrained string | `AllowList(&["opt1", "opt2"])` | For args with fixed valid values |

### Injection guard exemption

`exempt_injection_guard: false` (default, strict) blocks shell metacharacters (`$`, `` ` ``, `|`, `;`, etc.).

Set `exempt_injection_guard: true` ONLY when the argument semantically requires those characters:
- Grep/sed patterns (`$`, `*`, `.`, `^`)
- Commit messages (free-form text)
- Awk programs

**When in doubt, keep `exempt_injection_guard: false`.**

### Melody patterns

- `patterns::MELODY_UNIX_PATH` - for file/directory path arguments
- `patterns::MELODY_CMD_NAME` - for command/binary name arguments

## Environment Variable Security

The validator blocks dangerous environment variables via `ENV_BLOCKLIST` in `crates/validator/src/constants.rs`. Key rules:

- **Both `LD_*` and `DYLD_*` are blocked unconditionally** — never use `#[cfg(target_os)]` guards on security-relevant blocklists. A cross-compiled binary must still block the other platform's dangerous variables.
- The `envs` global arg allows tools to inject environment variables, but the validator rejects any that match the blocklist before execution.
- When a tool needs cache/config redirection (e.g. `GOPATH`, `PIP_CACHE_DIR`), these are safe to inject via `envs` because they don't appear in the blocklist.

## Path Validation for Non-Existing Files

The validator's path confinement uses `resolve_path()` which handles files that don't exist yet:

```rust
fn resolve_path(path: &OsStr) -> io::Result<PathBuf> {
    match fs::canonicalize(path) {
        Ok(canon) => Ok(canon),
        Err(_) => {
            let parent = p.parent().unwrap_or(p);
            let name = p.file_name().unwrap_or_default();
            Ok(fs::canonicalize(parent)?.join(name))
        }
    }
}
```

This means path arguments for tools that create files (Touch, Tee, redirected output) will pass validation as long as the **parent directory** exists and is within the confined path. No special handling needed in `build_context()`.

## Router Wiring

After the tool implementation is reviewed, wire it in `crates/server/src/router.rs`:

### 1. Add import

```rust
use tools::system::tool_name::{ToolName, ToolNameArgs};
// or for a new category:
use tools::git::git_status::{GitStatus, GitStatusArgs};
```

### 2. Add endpoint method

```rust
#[tool(name = "ToolName", description = "Description matching ToolDef")]
async fn tool_name(
    &self,
    Parameters(tool_args): Parameters<ToolArgs<ToolNameArgs>>,
    req_ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
    self.exec_tool(&ToolName, tool_args, req_ctx).await
}
```

The `#[tool]` macro `name` must match `ToolDef::name()`. Description should match or closely mirror `ToolDef::description()`.

## Writing Tests

Every tool must have **at least two tests** in `crates/server/tests/tools/<category>.rs`. Tests exercise the tool through the full MCP pipeline (validation → proxy → sandbox → runner) using `TestClient`.

### Test infrastructure

```rust
use serde_json::json;

use crate::{
    harness::{TempEnv, TextExtractor},
    tools::client_with_root,
};
```

- **`TempEnv`** — creates an isolated temporary directory under `/var/tmp/mithril_tests` (avoids collision with bwrap's `--tmpfs /tmp`). Chain `.with_file(path, content).await` to scaffold files.
- **`client_with_root(&cwd)`** — creates a `TestClient` with the given path as the project root.
- **`TextExtractor::from_tool_result(&result)`** — extracts text content from the tool result for assertions.

### Availability guard

If the tool wraps a binary that may not be installed, add a guard function and early-return:

```rust
fn tool_available() -> bool {
    std::process::Command::new("tool_binary")
        .arg("--version")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

#[tokio::test]
async fn tool_does_something() {
    if !tool_available() {
        return;
    }
    // ...
}
```

### Sandbox level in tests

The `sandbox` field in `GlobalArgs` controls the isolation level per-call. Pass it in the JSON args:

```rust
// Read-only tool → test at Readonly level
client.call_tool("Cat", json!({
    "path": path.to_str().unwrap(),
    "sandbox": "readonly",
}), Some(&cwd)).await

// Mutating tool → test at Dir level (default)
client.call_tool("Rm", json!({
    "path": path.to_str().unwrap(),
}), Some(&cwd)).await

// Tool that should work in full isolation → test at Container level
client.call_tool("GoBuild", go_args(&cwd, json!({
    "sandbox": "container",
})), Some(&cwd)).await
```

**Sandbox level selection for tests:**

| Tool type | Default test sandbox | Rationale |
|-----------|---------------------|-----------|
| Read-only (cat, ls, git log) | `"readonly"` | Proves the tool works without write access |
| Mutating (rm, sed, git commit) | `"dir"` (default) | Needs cwd write access |
| Ecosystem build tools | `"container"` if feasible | Proves the tool works with minimal system access + sandbox meta binds |

### Environment variables for ecosystem tools

Some tools need environment variables pointing caches inside cwd so they work under sandbox. Follow the Go test pattern:

```rust
fn tool_args(cwd: &std::path::Path, extra: Value) -> Value {
    let cache = cwd.join(".tool_cache");

    let mut map = match extra {
        Value::Object(m) => m,
        _ => serde_json::Map::new(),
    };

    map.insert(
        String::from("envs"),
        json!({ "TOOL_CACHE": cache.to_string_lossy() }),
    );

    Value::Object(map)
}
```

### What to test

Each tool needs at least two tests. Prefer a mix of:

1. **Happy-path execution** — call the tool with commonly-used arguments that AI coding agents would use in production. Assert `!result.is_error.unwrap_or(false)` and check the output contains expected content.
2. **Validation rejection** — pass an invalid argument (empty string, zero integer, invalid enum value) and assert `result.is_err()`.

Additional tests to consider:
- Different argument combinations that agents use frequently
- Ecosystem detection (tool fails gracefully without the expected project file, e.g. `go.mod`, `Cargo.toml`)

### Auth-gated tools

Tools that require authentication (like `gh`, `glab`) cannot run happy-path tests in CI. For these, test **only validation rejections** — invalid arguments should be caught by the validation layer before any CLI execution or auth check occurs.

### Diagnosing CmdError

When a test returns `CmdError(...)`:

1. **Wrong CLI flags** — the most common cause. Re-check `<cmd> --help` output against the args struct. A flag may have been renamed, removed, or may require a different value format.
2. **Sandbox resource denial** — the command tried to access a path not available at the test sandbox level. Check whether the tool needs additional `ro_binds` / `rw_binds` in its `SandboxMeta`, or whether environment variables should redirect caches inside cwd.
3. **Missing environment variables** — some tools need `HOME`, `PATH`, or tool-specific vars. Use the `envs` global arg or `isolated_env: false` (default) to inherit the host environment.
4. **Missing project scaffolding** — the `TempEnv` may be missing required files (e.g. `go.mod`, `Cargo.toml`, `package.json`). Add them via `.with_file()`.

### Test file registration

Add the test module in `crates/server/tests/tools/mod.rs`:

```rust
pub mod <category>;  // e.g. pub mod docker;
```

If the tool belongs to an existing category (e.g. a new git tool → `git.rs`), add tests to the existing file under a new section header comment.

## Checklist

### Pre-implementation (CLI tools only)
- [ ] Ran `<cmd> <subcommand> --help` to verify up-to-date CLI flags (trust `--help`, not docs/memory)
- [ ] Ran `<cmd> --version` to confirm a recent version is installed
- [ ] Analyzed sandbox levels (Dir/Readonly/Container) for resource compatibility
- [ ] Checked if binary has alternative names across distros (bat/batcat, fd/fdfind, pip3/pip)

### Implementation
- [ ] File at `crates/tools/src/<category>/<tool_name>.rs`
- [ ] Registered in `<category>/mod.rs`
- [ ] Args struct with 5-10 max fields, doc comments on each
- [ ] `ToolDef`: name (PascalCase), description, parameters JSON schema matches args struct
- [ ] `elicitable() -> true` if tool mutates project state
- [ ] `available()` uses `which::which()` if command isn't guaranteed on PATH; checks all binary name variants with `||`
- [ ] Binary name fallback helper if distro-specific names exist
- [ ] `build_context()`: cmd vector built in correct order (binary, flags, options, positionals)
- [ ] `ctx.sandbox.meta` populated via domain meta helper (e.g. `apply_git_meta`, `apply_pip_meta`) or inline if new domain
- [ ] macOS cache paths included alongside Linux ones (`~/Library/Caches/<tool>` + `~/.cache/<tool>`)
- [ ] Every non-bool arg has a validation rule with appropriate strictness
- [ ] `exempt_injection_guard` is `true` only where semantically required
- [ ] Path args for mutating tools include `NO_GIT_COMPONENT`
- [ ] Router endpoint added in `crates/server/src/router.rs`
- [ ] `#[tool]` name matches `ToolDef::name()`

### Tests
- [ ] At least 2 tests in `crates/server/tests/tools/<category>.rs`
- [ ] Tests use `TempEnv` for isolated temp environment with required project files
- [ ] Read-only tools tested with `"sandbox": "readonly"` in args
- [ ] Container-compatible tools tested with `"sandbox": "container"` where feasible
- [ ] Ecosystem tools redirect caches inside cwd via `envs` to avoid sandbox denials
- [ ] Auth-gated tools test only validation rejections (no happy-path execution)
- [ ] Docker tools use `docker info` (not `--version`) in availability guard
- [ ] Sandbox test names include ecosystem name with underscores for CI filtering (e.g. `_pip_`, `_cargo_`)
- [ ] Test module registered in `crates/server/tests/tools/mod.rs` (if new category)

