Use when adding, renaming or changing an MCP tool in MeshMCP: the McpTool trait, schemars argument schemas, negative prompting in DESCRIPTION, the 48 KB output budget, audit, skill hints and integration tests.
Installs into .claude/skills of the current project.
Are you the author of Mesh Tool Authoring?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/victoragahi-mesh-tool-authoring)
---
name: mesh-tool-authoring
description: >-
Use when adding, renaming or changing an MCP tool in MeshMCP: the McpTool trait,
schemars argument schemas, negative prompting in DESCRIPTION, the 48 KB output budget,
audit, skill hints and integration tests.
---
# MeshMCP Tool Authoring Skill
Six tools are exposed over MCP: `smart_search`, `find_dependents`, `analyze_grpc`,
`analyze_impact`, `search_docs`, `visualize_mesh`. They all implement one trait in
[`crates/mesh-server/src/tools/mod.rs`](../../../crates/mesh-server/src/tools/mod.rs).
---
## 1. Quick Navigation & Codebase References
- **Trait and registry**: [`crates/mesh-server/src/tools/mod.rs`](../../../crates/mesh-server/src/tools/mod.rs)
- `McpTool { NAME, DESCRIPTION, Args, meta(), run(), truncation_hint(), subject(), mutates(), returns_document() }`
- `ToolOutput { text, files_accessed, secrets_redacted }`, `ToolOutput::text()`
- `ToolError = (i32, String)`, `GOVERNANCE_BLOCKED_CODE = -32001`
- `ToolRegistry::list_tools()` (built once in a `LazyLock`), `::describe::<T>()`
- `ToolRegistry::call_tool()` (dispatch by name; turns a tool's `Err` into an `isError`
result), `::is_known_tool()`, `::invoke::<T>()` (parse, governance gate, `spawn_blocking`,
skill and Git notes, 48 KB cap, audit + latency)
- **Reference implementations**: [`smart_search.rs`](../../../crates/mesh-server/src/tools/smart_search.rs)
(scope validation, index-first lookup, snippet budget), [`find_dependents.rs`](../../../crates/mesh-server/src/tools/find_dependents.rs)
(the minimal shape), [`search_docs.rs`](../../../crates/mesh-server/src/tools/search_docs.rs),
[`analyze_grpc.rs`](../../../crates/mesh-server/src/tools/analyze_grpc.rs),
[`analyze_impact.rs`](../../../crates/mesh-server/src/tools/analyze_impact.rs),
[`visualize_mesh.rs`](../../../crates/mesh-server/src/tools/visualize_mesh.rs)
- **Output rendering**: [`crates/mesh-parsers/src/markdown.rs`](../../../crates/mesh-parsers/src/markdown.rs)
- `MAX_OUTPUT_BYTES = 48 * 1024`, `NON_RESULT_RESERVE_BYTES`, `MarkdownFormatter::format_*`,
`SearchResult`, `SearchPage`
- **JSON-RPC surface**: [`crates/mesh-server/src/lib.rs`](../../../crates/mesh-server/src/lib.rs)
(`tools/list`, `tools/call`) and [`crates/mesh-server/src/protocol.rs`](../../../crates/mesh-server/src/protocol.rs) (`RequestMeta`)
- **Audit**: [`crates/mesh-core/src/audit.rs`](../../../crates/mesh-core/src/audit.rs) (`AuditLogger::record_entry`)
- **Skill hints**: [`crates/mesh-core/src/governance.rs`](../../../crates/mesh-core/src/governance.rs) (`GovernanceEngine::recommend_skill`)
- **Tests**: [`crates/mesh-server/tests/integration_tests.rs`](../../../crates/mesh-server/tests/integration_tests.rs)
---
## 2. The trait
```rust
pub trait McpTool {
const NAME: &'static str;
const DESCRIPTION: &'static str;
type Args: DeserializeOwned + Serialize + JsonSchema + Send + 'static;
fn meta(args: &Self::Args) -> Option<&RequestMeta>;
fn run(args: &Self::Args, state: &AppState) -> Result<ToolOutput, ToolError>;
fn truncation_hint(_args: &Self::Args, _state: &AppState) -> Option<String> { None }
fn subject(_args: &Self::Args) -> Option<&str> { None }
fn mutates(_args: &Self::Args) -> bool { false }
fn returns_document(_args: &Self::Args) -> bool { false }
}
```
`run` is **synchronous on purpose**: every tool touches the disk, tree-sitter or SQLite.
`ToolRegistry::invoke` runs it inside `tokio::task::spawn_blocking`, so the JSON-RPC event
loop (and, in daemon mode, the other clients) never stalls. Do not make `run` `async`, and
do not call `block_on` inside it.
`subject` has a default returning `None` and reports what the call is about, for skill
matching:
```rust
fn subject(args: &Self::Args) -> Option<&str> {
Some(args.scope.as_deref().unwrap_or(GLOBAL_SCOPE_LABEL)) // smart_search
}
```
`truncation_hint` supplies the narrowing advice appended when the output still exceeds 48 KB.
`returns_document` is `true` for outputs that must stay parseable (`visualize_mesh` JSON/HTML):
no note is inserted into them.
`mutates` defaults to `false`. Every shipped tool is read-only and leaves it there. If your
tool actually changes something on disk or in a downstream system, override it — `true`
routes the call through the RSAH governance check (`state.governance.evaluate_guard(subject)`)
before `run` executes, whatever `read_governance_mode` says, and a hit short-circuits with
`GOVERNANCE_BLOCKED_CODE` (`-32001`), surfaced as an `isError` result carrying the RSAH JSON.
Read-only tools are checked only when `[engines.policy] read_governance_mode` is `audit_warn`
(log) or `enforce_refusal` (refuse). Read-only tools must never override `mutates` to `true`:
it would make inspecting a guarded scope fail under the default `allow_all`.
---
## 3. Adding a tool, end to end
### Step 1 — new file `crates/mesh-server/src/tools/<name>.rs`
Copy the shape of `find_dependents.rs`:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FindDependentsArgs {
#[schemars(
with = "String",
description = "Target contract name (ex: 'UserAuthRequest') or package identifier (ex: '@volontariapp/domain-user') to trace reverse dependencies for."
)]
pub target: CompactStr,
#[serde(default)]
#[schemars(with = "String", description = "Result granularity: 'symbol' (default) ... or 'package' ...")]
pub granularity: Option<CompactStr>,
#[serde(default)]
// Accepted for W3C trace propagation, hidden from `tools/list`.
#[schemars(skip)]
pub _meta: Option<RequestMeta>,
}
```
Rules for the args type, all load-bearing:
- `#[serde(deny_unknown_fields)]` on every args struct (Commandment 5). A typo'd argument
must be an error, not a silently ignored field.
- `CompactStr` fields need `#[schemars(with = "String")]`, otherwise the generated schema
describes `compact_str`'s internals.
- Optional arguments get `#[serde(default)]`.
- Always carry `pub _meta: Option<RequestMeta>` with `#[schemars(skip)]` and return it from
`meta()`, or W3C `traceparent` propagation into the audit row breaks for your tool. It is
hidden from `tools/list` because the model cannot use it and it cost schema tokens.
- Reject an invalid enum-like value explicitly (see `validate_granularity`) instead of falling
back silently to a default.
- Put a concrete example inside every `description` — these strings are the agent's only
documentation.
### Step 2 — implement the trait
```rust
impl McpTool for FindDependentsTool {
const NAME: &'static str = "find_dependents";
const DESCRIPTION: &'static str = "Resolves the reverse dependency graph across packages, shared modules, gRPC services, and event streams — at symbol granularity by default, or one result per (repo, package) with granularity: 'package'. DO NOT USE to search freeform text or string literals (use smart_search or ripgrep).";
type Args = FindDependentsArgs;
fn meta(args: &Self::Args) -> Option<&RequestMeta> { args._meta.as_ref() }
fn run(args: &Self::Args, state: &AppState) -> Result<ToolOutput, ToolError> {
// Simplified: the real `run` also validates `granularity`, dedups by package,
// and appends the not-indexed-files note for rejected files in scope.
let snapshot = state.snapshot();
let dependents = snapshot.contract_graph.find_dependents(args.target.as_str());
// Label each result with the root it was crawled from — a raw
// substring match (ContractGraph::find_dependents' last-resort
// fallback) can span multiple unrelated services that share a
// locally-aliased package name, so the formatter groups by this
// label instead of flattening everything into one list.
let labeled: Vec<(&_, String)> = dependents
.into_iter()
.map(|node| {
let label = state
.allowed_roots
.get(node.repo_id as usize)
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(unknown root)".to_string());
(node, label)
})
.collect();
Ok(ToolOutput::text(MarkdownFormatter::format_dependents(
args.target.as_str(),
&labeled,
)))
}
}
```
**Negative prompting (Commandment 5).** Every `DESCRIPTION` states what the tool does and
then names the tool to use instead. All six shipped descriptions follow the pattern
`… DO NOT USE <wrong situation> (use <other_tool>).` — an agent that cannot tell two
tools apart will call both. Keep it to one sentence plus one `DO NOT USE` clause.
### Step 3 — register it
Three edits in `tools/mod.rs`, all required:
1. `pub mod <name>;` and the `use` for the tool type.
2. A `ToolRegistry::describe::<YourTool>()` line in the `list_tools()` `json!` array.
3. A `YourTool::NAME => Self::invoke::<YourTool>(arguments, state).await?` arm in
`call_tool`, and the name in `is_known_tool` (checked before the "still indexing" gate).
The fallthrough is `unknown => return Err((-32602, "Unknown tool: …"))`, as the MCP spec
prescribes.
Forgetting (2) makes the tool invisible to clients; forgetting (3) makes it advertised
and un-callable. `test_list_tools_contains_all_tools` asserts the array length, so it
will catch a missing entry in (2) — bump the expected count there. Then document the schema in
`docs/mcp-tools.md` under a `### Tool N: \`<name>\`` heading: `test_mcp_tools_md_schema_drift_check`
fails if the documented properties differ from the advertised ones.
### Step 4 — errors
`ToolError` is `(i32, String)`. The code classifies the failure internally; `call_tool` sends
every tool failure to the client as a successful JSON-RPC response whose `CallToolResult` has
`isError: true` and the message as text (MCP 2024-11-05), so the agent can correct its call.
Only an unknown tool name (`-32602`) and a failed tool task (`-32603`) are JSON-RPC errors.
Use `-32602` for invalid params, which is what `SecurityError::jsonrpc_code()` returns for
every sandbox failure (Commandment 4). Propagate scope failures verbatim, through
`resolve_with_aliases` so `[workspace.mount_aliases]` and the `workspace_root` anchor apply:
```rust
let validated_scope = ValidatedScope::resolve_with_aliases(
raw_scope,
&state.allowed_roots,
&state.config.workspace.mount_aliases,
state.config.workspace.resolved_workspace_root.as_deref(),
)
.map_err(|e| (e.jsonrpc_code(), e.to_string()))?;
```
Any tool taking a path-like argument must resolve it through `ValidatedScope` before
touching the filesystem. Never pass a raw `&str` or `PathBuf` to a reader or crawler.
---
## 4. The 48 KB output budget
`MAX_OUTPUT_BYTES = 48 * 1024` in `crates/mesh-parsers/src/markdown.rs`. Render through a
`MarkdownFormatter::format_*` function rather than building Markdown in the tool: the
formatter is where the budget and the truncation affordance live.
`format_search_page` checks before appending each entry:
```rust
if header.len() + body.len() + entry_str.len() > MAX_OUTPUT_BYTES - 1024 {
return Self::build_truncated_search_output(
&header, &body, idx, results.len(), query, &scope_counts,
);
}
```
The 1 KB headroom is the truncation footer itself, which reports how many matches were
displayed of how many total and lists the top sub-scopes with a concrete follow-up call.
Truncating without that guidance leaves the agent blind to the remainder — if you add a
new formatter, add the same affordance.
Also bound the per-item cost inside the tool. `smart_search` caps a snippet at
`SNIPPET_LINES` lines with `SNIPPET_LEAD` lines of lead-in, and each line at
`MAX_SNIPPET_LINE_BYTES`, backing off to a char boundary via `floor_char_boundary` —
slicing a multi-byte line at a fixed byte index panics.
---
## 5. What the registry does around `run`
You get these for free; do not reimplement them in the tool:
- **Argument parsing** into `T::Args`, with `-32602` and the tool name on failure (audited as
`ERROR`).
- **Governance gate**: if `T::subject(&args)` matches a configured stop rule and either
`T::mutates(&args)` is `true` or `read_governance_mode` is `enforce_refusal`, the call is
refused with `GOVERNANCE_BLOCKED_CODE` (`-32001`) before `run` is called, and audited as
`ERROR`; under `audit_warn` a read is only logged.
- **`spawn_blocking`**, so `run` may block.
- **Notes at the top**: on `Ok` (and unless `returns_document`), a matching
`recommend_skill(T::NAME, T::subject(&args))` inserts a `Project skill for this area` note at
the start of `out.text`, and `FileWatcherService::git_operation_note` inserts a "Git operation
in progress" note while reloads are held. They are prepended so the 48 KB cut never removes a
tool's own trailing note (such as the not-indexed files note). Implement `subject()` so your
tool participates.
- **48 KB cap**: output over `MAX_TOOL_OUTPUT_BYTES` is cut on a line boundary, an open code
fence is closed, and a note with `T::truncation_hint` (or a generic hint) is appended.
- **Audit**: unless `cryptographic_audit_trail = false`, `state.audit.record_entry(...)` is
called on the same blocking thread with the tool name, the serialized args,
`SUCCESS`/`ERROR`, `files_accessed` and `secrets_redacted`, followed by
`record_tool_latency` (wall-clock of the whole call, read by `mesh-mcp stats`). It is
best-effort — a failed write is logged to `mesh::audit`, never returned. Populate `files_accessed` and `secrets_redacted` on `ToolOutput` yourself;
the registry only forwards them. `ToolOutput::text()` leaves both empty.
Because the whole args struct is serialized into the audit row, never put a secret or a
file body in an argument.
---
## 6. Tests
Integration tests live in `crates/mesh-server/tests/integration_tests.rs` and drive the
real dispatcher:
```rust
let res = ToolRegistry::call_tool("smart_search", args, state).await;
```
`setup_test_environment()` builds a temp workspace (a Java service, a `proto-registry`)
and returns an `Arc<AppState>`. For a new tool add at least:
- a success case asserting on `val["content"][0]["text"]`;
- a sandbox-escape case, if the tool takes a path — see
`test_smart_search_sandbox_escape_rejected`;
- a case for whatever the `DO NOT USE` clause warns against, so the boundary between your
tool and its neighbour is pinned down.
Then run the mandatory gates:
```bash
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
```
`unwrap()` is forbidden outside `#[cfg(test)]`; the test crates carry
`#![allow(clippy::unwrap_used, clippy::expect_used)]` at the top, production code does not.