SKILL.md
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, snakecase file name: GitStatus -> git/gitstatus.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.
# 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:
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 checkwrites to~/.cargo/registry) → needsrw_bindsinSandboxMeta - Does it need network access? (e.g.
pip install,go get) → needsneeds_network: true - Does it need read access to config outside
/usrand/etc? (e.g.~/.cargo/config.toml,~/.npmrc) → needsro_binds - Is the tool read-only? (e.g.
cat,ls,git log) → should work atReadonlylevel - Is the tool mutating? (e.g.
rm,sed,git commit) →Readonlymust be rejected; setelicitable() -> 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
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
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:
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:
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
#[async_trait] impl ToolExec for ToolName { type Args = ToolNameArgs;
async fn buildcontext(&self, args: ToolNameArgs, ctx: &mut ExecContext) { // 1. Push binary name ctx.cmd.push(OsString::from("toolbinary"));
// 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 needsnetwork for commands that fetch from registries. // Add robinds / rwbinds for config and cache dirs outside cwd. if let Some(home) = std::env::varos("HOME").map(PathBuf::from) { ctx.sandbox.meta.robinds.push(home.join(".config/tool")); ctx.sandbox.meta.rwbinds.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.validationrules.insert( "arg", ValidationRule::PathConfined { pattern: StringPattern::NONEMPTY | StringPattern::NOPATHTRAVERSAL, melody: Some(patterns::MELODYUNIXPATH), 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:
// In git tool's buildcontext(): super::applygit_meta(&mut ctx.sandbox.meta);
// In docker tool's buildcontext(): super::applydocker_meta(&mut ctx.sandbox.meta);
// In pip tool's buildcontext(): super::applypipmeta(&mut ctx.sandbox.meta, true / needsnetwork /);
When creating a new domain that needs shared sandbox config, add a helper in the domain's `mod.rs` following this pattern:
pub(crate) fn applyexamplemeta(meta: &mut SandboxMeta) { if let Some(home) = std::env::varos("HOME").map(PathBuf::from) { meta.robinds.push(home.join(".config/example")); meta.rwbinds.push(home.join(".cache/example")); // macOS cache location meta.rwbinds.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:
fn resolvepath(path: &OsStr) -> io::Result<PathBuf> { match fs::canonicalize(path) { Ok(canon) => Ok(canon), Err() => { let parent = p.parent().unwrapor(p); let name = p.filename().unwrapordefault(); 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
use tools::system::toolname::{ToolName, ToolNameArgs}; // or for a new category: use tools::git::gitstatus::{GitStatus, GitStatusArgs};
### 2. Add endpoint method
#[tool(name = "ToolName", description = "Description matching ToolDef")] async fn toolname( &self, Parameters(toolargs): Parameters<ToolArgs<ToolNameArgs>>, reqctx: RequestContext<RoleServer>, ) -> Result<CallToolResult, McpError> { self.exectool(&ToolName, toolargs, reqctx).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
use serde_json::json;
use crate::{ harness::{TempEnv, TextExtractor}, tools::clientwithroot, };
- **`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:
fn toolavailable() -> bool { std::process::Command::new("toolbinary") .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) }
#[tokio::test] async fn tooldoessomething() { 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:
// Read-only tool → test at Readonly level client.calltool("Cat", json!({ "path": path.tostr().unwrap(), "sandbox": "readonly", }), Some(&cwd)).await
// Mutating tool → test at Dir level (default) client.calltool("Rm", json!({ "path": path.tostr().unwrap(), }), Some(&cwd)).await
// Tool that should work in full isolation → test at Container level client.calltool("GoBuild", goargs(&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:
fn toolargs(cwd: &std::path::Path, extra: Value) -> Value { let cache = cwd.join(".toolcache");
let mut map = match extra { Value::Object(m) => m, => serdejson::Map::new(), };
map.insert( String::from("envs"), json!({ "TOOLCACHE": cache.tostring_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`:
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)