Skip to content
Back to skills

Mesh Tool Authoring

ASecurity

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.

  • 10 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 24, 2026
ai-agentsrustgojavabashsqlnodegitsecuritydocumentation

Works with

  • cli
  • mcp

Security analysis

A100/100

Scanned September 28, 2026

npx -y skills add VictorAgahi/causalmesh --skill mesh-tool-authoring --agent claude-code

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.

Security grade badge for Mesh Tool Authoring
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/victoragahi-mesh-tool-authoring/badge)](https://www.skillsdirectory.com/skills/victoragahi-mesh-tool-authoring)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.