myco:two-tier-tool-discovery — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited myco:two-tier-tool-discovery (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
The unifi-mcp project implements a meta-tool (tool_index) that lets agents and users enumerate available tools at two fidelity levels: a compact default (~38K chars, names and descriptions only) and a full-schema tier (~127K chars, with complete input schemas). Maintaining it requires keeping four artifacts in sync: the local discovery layer, the Worker cloud handler, the plugins/*/skills/*/SKILL.md manifests, and the package semver.
tool_index is a meta-tool that describes other tools. It must remain a single canonical entry point — do not add a second discovery handler in a plugin or subsystem, as this would fragment the index and break agents.apps/*/src/*/tool_index.py (backend handler with FastMCP-registered wrapper) and packages/unifi-mcp-shared/src/unifi_mcp_shared/tool_index.py (shared handler)apps/worker/worker/src/ (the Worker's tool index and MCP handling code)tool_index_handler via an assembled args dict. Both layers need updating when adding a new parameter.FastMCP maps tools/call arguments to Python function parameters by name. The discovery system uses a two-layer design to exploit this:
Layer 1 — FastMCP-registered wrapper (in tool_index.py, typically named _tool_index_wrapper): Uses explicit named params. FastMCP calls this with category="clients" etc.
Layer 2 — Backend handler (tool_index_handler in each tool_index.py): Still receives an assembled args: dict | None. The wrapper builds this dict and passes it down.
Wrong — registering `tool_index_handler` directly with `@tool_decorator` would silently drop all named arguments:
# If this were registered directly with @tool_decorator, args would always
# be None — no caller sends {\\\"args\\\": {...}}, so FastMCP never populates it.
async def tool_index_handler(args: dict | None = None) -> dict:
category = args.get(\\\"category\\\") if args else None # args is always None
search = args.get(\\\"search\\\") if args else None # never reachedCorrect — the actual pattern: a thin named-param wrapper delegates to the backend:
# In tool_index.py — this is what @tool_decorator registers with FastMCP:
async def _tool_index_wrapper(
category: str | None = None,
search: str | None = None,
include_schemas: bool = False,
) -> dict:
args = {}
if category is not None:
args[\\\"category\\\"] = category
if search is not None:
args[\\\"search\\\"] = search
if include_schemas:
args[\\\"include_schemas\\\"] = include_schemas
return await tool_index_handler(args or None)
# In tool_index.py — the backend:
async def tool_index_handler(args: dict | None = None) -> dict:
args = args or {}
return get_tool_index(
category=args.get(\\\"category\\\"),
search=args.get(\\\"search\\\"),
include_schemas=bool(args.get(\\\"include_schemas\\\", False)),
)This rule applies to every handler in tool_index.py: the FastMCP-facing function must use named params.
apps/*/src/*/tool_index.py where the wrapper and handler reside.args dict it assembles before calling tool_index_handler.tool_index_handler — in each app's tool_index.py and the shared packages/unifi-mcp-shared/src/unifi_mcp_shared/tool_index.py. In each, add args.get(\\\"new_param\\\") and thread it into the get_tool_index() call.get_tool_index() (shared tool_index.py), implement the actual filtering/shaping logic.register_tool() call's input_schema in the app's tool_index.py to document the new parameter for schema-aware callers.tool_index with and without the new parameter to confirm correct behavior in both modes.The two tiers exist to manage token budgets across different agent tasks.
| Tier | Invocation | Approximate size | Contents |
|---|---|---|---|
| Compact (default) | Omit include_schemas or pass false | ~38K chars | Tool names + descriptions only |
| Full | include_schemas: true | ~127K chars | Names + descriptions + full input schemas |
Agents performing tool discovery in a fresh context cannot afford 127K chars on every call. The compact default lets them enumerate categories and identify the right tool. The full tier is for when they need to invoke a tool and require the exact parameter schema. This design is intentionally backwards-compatible: callers that predate include_schemas receive compact output automatically.
tool_index.py, accept include_schemas: bool = False as a named param and include it in the assembled args dict.get_tool_index() (packages/unifi-mcp-shared/src/unifi_mcp_shared/tool_index.py), when building each tool entry in the response:name and descriptioninputSchema (or equivalent) only when include_schemas is Trueget_tool_index(). Future maintainers need to understand why the flag exists.include_schemas=True includes schema fieldsEvery parameter added to the local tool discovery layer must be mirrored in the Worker cloud handler. This is a standing maintenance obligation — not optional. A stale Worker silently ignores new parameters and returns wrong results with no error.
apps/worker/worker/src/ for the Worker-side tool discovery and MCP handling path.tool_index handler or equivalent catalog-shaping code.apps/worker/worker/test/, especially contract fixtures shared with the relay when protocol shape changes.make worker-check or root make check before opening the PR.Coordination order: shared Python code changes and Worker parity changes land together in the monorepo PR; release order is still upstream Python packages first, then worker/v* if the Worker depends on newly released relay behavior.
plugins/*/skills/*/SKILL.md files are agent-readable invocation manifests. Agents read these at runtime to learn what parameters a tool accepts. A stale manifest causes agents to call tool_index with wrong or missing parameters — treat this with the same urgency as a code bug, not as documentation.
The manifest location varies by runtime environment:
.agents/plugins/*/skills/*/SKILL.md~/.codex-plugin/myco/skills/*/SKILL.md (OpenClaw globally-unique skill directories).agents/plugins/*/skills/*/SKILL.md (mirrored from monorepo)~/.agents/plugins/*/skills/*/SKILL.md (XDG-compliant home paths)When tool_index parameters change, all four paths must be synchronized to ensure all runtimes reflect the new behavior.
tool_index, find all SKILL.md files that reference it across all runtime paths: # Local monorepo
grep -rl \\\"tool_index\\\" .agents/plugins/*/skills/*/SKILL.md
# OpenClaw
grep -rl \\\"tool_index\\\" ~/.codex-plugin/myco/skills/*/SKILL.md 2>/dev/null || true
# XDG home paths
grep -rl \\\"tool_index\\\" ~/.agents/plugins/*/skills/*/SKILL.md 2>/dev/null || truebump-plugin-versions.yml).In PR #145, 7 SKILL.md files across plugins/unifi-network/skills/*/, plugins/unifi-protect/skills/*/, and plugins/unifi-access/skills/*/ were swept as part of the handler change. Missing even one leaves a stranded manifest that will mislead future agents.
Adding optional parameters to the tool discovery layer is a minor semver bump — new capability, fully backwards-compatible. Removing a parameter or changing its semantics is a major bump.
packages/unifi-mcp-shared/pyproject.toml (minor: X.Y.Z → X.(Y+1).0).worker/v* tag in this monorepo. Coordinate release order:bump-plugin-versions.ymlThe monorepo CI uses bump-plugin-versions.yml to synchronize SKILL.md manifests across all four runtime environments (monorepo, OpenClaw, Claude desktop, XDG paths). When tool_index parameter or behavior changes:
main.bump-plugin-versions.yml automatically:bump-plugin-versions.yml with the failed type as input.This ensures agents across all runtimes see the same tool discovery behavior.
args: dict Wrapper Trap Is a Silent FailureIf you accidentally register tool_index_handler directly with @tool_decorator (bypassing the named-param wrapper), there is no warning, no exception, and no test failure. The handler receives None for its args param and returns unfiltered results as if no parameters were passed. This is the exact problem the two-layer wrapper was introduced to solve. Always verify new or changed discovery params with a live call before merging.
After regenerating tools_manifest.json from live introspection, the tool descriptions it contains may lag behind actual parameter defaults in the Python source. For example, a parameter's docstring may claim "Default empty list" but the actual code default is None. When agents read tools_manifest.json, they trust the descriptions to match runtime behavior. Stale docstrings in the source lead to stale manifest descriptions and agents make wrong assumptions about optional parameters.
Prevention:
tool_index.py, immediately update the Python docstring to match.tools_manifest.json via a local build or CI trigger.Every handler in tool_index.py — and any other FastMCP handler in the project — must use explicit named parameters if callers will pass named arguments. The args: dict pattern is only safe for backend functions that are not directly FastMCP-registered. If a handler appears to ignore its parameters, the registration path is the first thing to check.
max_response_bytes)The MCP spec is actively discussing a max_response_bytes response-size cap. The two-tier design is already aligned: compact is the default, full is opt-in. When the standard lands, include_schemas may become the enforcement mechanism. No breaking change should be needed because the compact tier is already the default behavior.
tool_index is the canonical tool discovery entry point. If someone proposes adding a second discovery handler in a plugin or subsystem, decline. Fragmentation breaks agents that rely on a single index and makes parity maintenance across local and Worker paths exponentially harder.
Tool descriptions in @server.tool(description=...) should describe what the tool does, broadly what shape it returns, and how it relates to neighbouring tools. They MUST NOT contain per-field semantic disambiguation. Field-by-field meaning belongs on the Pydantic Field(description=...) of the underlying model — which is exactly what the full-schema tier of this discovery system surfaces.
Wrong — per-field semantics bloating the tool description:
@server.tool(
name="unifi_list_clients",
description=(
"Returns connected clients with mac, `name` (user-assigned alias "
"from the UniFi console), `hostname` (DHCP-reported), IP, "
"`status` (online/offline)… Prefer `name` over `hostname` for "
"human-readable labels when both are set. …"
),
)Right — terse tool description + meaning on the Pydantic Field:
# Tool layer (apps/<server>/src/.../tools/clients.py)
@server.tool(
name="unifi_list_clients",
description=(
"Returns connected clients with mac, name, hostname, ip, status "
"(online/offline), connection type (wired/wireless), and for "
"wireless clients: ssid, signal_dbm, channel, radio. …"
),
)
# Model layer (packages/unifi-core/src/unifi_core/<server>/models/clients.py)
name: Optional[str] = Field(
default=None,
description="User-assigned alias set in the UniFi console (preferred for display)",
json_schema_extra={"mutable": False},
)
hostname: Optional[str] = Field(
default=None,
description="DHCP-reported hostname from the device itself",
json_schema_extra={"mutable": False},
)The compact discovery tier (no schemas) ships the lean tool description. Agents that need field meaning call unifi_tool_index with include_schemas=true and read the Field descriptions through the surfaced JSON schema. This is the entire point of the two-tier design: protect token budgets by default, opt into depth on demand.
Why this matters operationally: every agent that calls tool_index without include_schemas pays for whatever text is in tool descriptions on every invocation. Per-field documentation in those strings multiplies cost without benefit — the agent that cares about field semantics will request the schema tier anyway.
Operational nuance that ISN'T a field meaning (e.g. "merges live /stat/sta with /rest/user snapshot for active clients") does belong in the tool description — that information doesn't live on any individual field.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.