add-tool — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited add-tool (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.
An MCP tool is an LLM-consumed artifact. The tool's name and description are read by every Claude/GPT/etc. model that connects to this server, on every session, before the user has typed anything. Be ruthless about clarity and length. Write for the model, not for human readers (humans read docs/tools.md and the README).
Before starting:
openapi-spec.json — check the request/response schema, enum values, and required fields.core (daily-use, exposed by default) or full (specialty/coach, opt-in via INTERVALS_ICU_TOOLSET=full). When tier work lands per issue #27, tag accordingly.get_activity_*, *_event, create_*), the opening sentence of the description MUST lead with the distinguishing access pattern, not the shared concept.client.py.File: src/intervals_icu_mcp/client.py
Add an async method to ICUClient following the existing pattern. Type the local variable so pyright doesn't widen Any:
async def get_something(self, athlete_id: str | None = None) -> list[dict[str, Any]]:
"""Fetch something from the API."""
athlete_id = athlete_id or self.config.intervals_icu_athlete_id
response = await self._request("GET", f"/athlete/{athlete_id}/something")
result: list[dict[str, Any]] = response.json()
return resultTrust the API contract — don't add if isinstance(data, list) else [] defensive ternaries unless there's a real reason. Pydantic models are preferred when the response shape is stable; raw dicts are fine for variable-shape endpoints (e.g. custom_items.content).
File: src/intervals_icu_mcp/tools/<category>.py (existing file or new category)
from typing import Annotated, Any
from fastmcp import Context
from ..auth import ICUConfig
from ..client import ICUAPIError, ICUClient
from ..response_builder import ResponseBuilder
async def tool_name(
activity_id: Annotated[str, "The Intervals.icu activity ID (use icu_search_activities to find one if you only have a description)"],
athlete_id: Annotated[
str | None, "Athlete ID (only for coaches; uses configured default otherwise)"
] = None,
ctx: Context | None = None,
) -> str:
"""Single-line summary that distinguishes this tool from siblings.
Use when the user says things like: "<concrete user prompt>", "<another
one>". Match the user's intent to the tool's specific access pattern.
Args:
activity_id: The Intervals.icu activity ID
athlete_id: Athlete ID (uses configured default if not provided)
Returns:
JSON string with the data
"""
assert ctx is not None
config: ICUConfig = await ctx.get_state("config")
try:
async with ICUClient(config) as client:
result = await client.method(activity_id, athlete_id=athlete_id)
return ResponseBuilder.build_response(
data={"key": "value"},
metadata={"count": len(result)},
query_type="tool_type",
)
except ICUAPIError as e:
return ResponseBuilder.build_error_response(e.message, error_type="api_error")
except Exception as e:
return ResponseBuilder.build_error_response(
f"Unexpected error: {str(e)}", error_type="internal_error"
)Notes:
await is required.try for both ICUAPIError (HTTP-level) and Exception (unexpected).validation_error responses before the API call. Don't let the API tell the user their input was wrong if we can catch it cheaper upstream._to_dict(item) helper if the response payload needs field selection. Pattern: only include keys when the value is not None — see tools/custom_items.py:_custom_item_to_dict.File: src/intervals_icu_mcp/server.py
Import the function with the others in its category, then register with appropriate annotations:
mcp.tool(
name="icu_<verb>_<noun>",
annotations={
"readOnlyHint": True, # False for writes
"destructiveHint": False, # True for destructive ops
"idempotentHint": True,
"openWorldHint": True,
},
)(tool_name)Naming convention: icu_<verb>_<noun> for reads; verb-prefixed for writes (create_, update_, delete_, add_, bulk_*). Plural noun for list-returning tools (get_activity_messages); singular for single-record tools (get_activity_message_by_id if it existed).
File: tests/test_<category>_tools.py
We use pytest-asyncio configured globally (no @pytest.mark.asyncio decorator needed) and respx for HTTP mocking:
import json
from unittest.mock import AsyncMock, MagicMock
from httpx import Response
from intervals_icu_mcp.tools.<category> import tool_name
class TestToolName:
async def test_tool_name_success(self, mock_config, respx_mock):
mock_ctx = MagicMock()
mock_ctx.get_state = AsyncMock(return_value=mock_config)
respx_mock.get("/athlete/i123456/endpoint").mock(
return_value=Response(200, json={"id": 1, "name": "Test"})
)
result = await tool_name(arg="x", ctx=mock_ctx)
response = json.loads(result)
assert response["data"]["id"] == 1
async def test_tool_name_validation_error(self, mock_config):
# Test that invalid input is rejected without an HTTP call
mock_ctx = MagicMock()
mock_ctx.get_state = AsyncMock(return_value=mock_config)
result = await tool_name(arg="invalid", ctx=mock_ctx)
response = json.loads(result)
assert response["error"]["type"] == "validation_error"
async def test_tool_name_api_error(self, mock_config, respx_mock):
# Test that API errors surface cleanly
mock_ctx = MagicMock()
mock_ctx.get_state = AsyncMock(return_value=mock_config)
respx_mock.get("/athlete/i123456/endpoint").mock(return_value=Response(404))
result = await tool_name(arg="x", ctx=mock_ctx)
response = json.loads(result)
assert response["error"]["type"] == "api_error"Cover at minimum: happy path, validation rejection (if applicable), API error surfacing, athlete_id override (if applicable).
Fixtures mock_config and respx_mock come from tests/conftest.py.
make can-releaseConfirms ruff (formatting + lint), pyright (strict on src/), all tests pass, and pyroma scores 10/10.
CLAUDE.md — tool count in Project Overview, plus tool category list if a new category was added.README.md — tool count line and the Available Tools table.docs/tools.md — per-category table.tests/test_transport_integration.py — the assert len(tools) == N assertion needs bumping.Before marking the work done, run through these. They matter more than people expect.
These are targets, not hard caps. Going over with good reason is fine — but justify it in the PR.
Annotated params): aim for ≤200 tokens. Top-end tools with many parameters can go higher; review them especially carefully.intervals-icu://workout-syntax as the canonical example.Annotated descriptions where it helps disambiguation: "Use when the user says: 'show me my last ride', 'what's my recent activity?'"If your tool name shares a prefix, suffix, or noun with another tool, the opening sentence MUST disambiguate. Anthropic identifies tool-name similarity as the top cause of LLM tool-selection failures — descriptions are the only thing standing between the LLM and a wrong-tool round-trip.
Technique (per PR #30):
Existing confusable clusters (study these before adding to them):
icu_get_activity_* family (details = SUMMARY / intervals = per-LAP / streams = RAW per-second)icu_search_activities (LIGHT) vs icu_search_activities_full (FULL — heavy)icu_create_event (ONE new) / icu_bulk_create_events (MANY new) / icu_duplicate_events (COPY existing forward)icu_get_wellness_data (RANGE of days) vs icu_get_wellness_for_date (ONE specific date)icu_get_*_curves (best-effort across durations) vs icu_get_*_histogram (time-in-zone distribution per activity)icu_*_message (singular = write) vs icu_*_messages (plural = read)If adding to one of these clusters, study how the existing siblings differentiate and follow that pattern.
If the tool has any of:
…ship 1-2 input_examples with the tool definition. Anthropic reports 72% → 90% accuracy on complex parameter handling when examples are included. Token cost is small (~50-200 tokens per example) vs. the round-trip cost of a model getting it wrong. See issue #29 for the project-wide rollout plan.
Tool descriptions are at the top of Anthropic's prompt-caching hierarchy: tools → system → messages. Any change to a tool description invalidates the cache for everything below it, costing 1.25× (5-min cache) or 2× (1-hour cache) the base input tokens for the next session that hits this server.
Practical rule: batch description changes into one PR. Don't drip them across multiple commits — each commit cascades. If you're touching descriptions, touch them all at once.
fetched_at, query_type, or other debug fields to the response metadata — ResponseBuilder handles metadata; if you have a meaningful per-tool addition (count, message, etc.), pass it via the metadata= param. (Issue #25 is removing the auto-debug fields entirely.)model.model_dump() or model.dict() directly. Use a _to_dict helper that selects fields. Lets you skip null-valued fields, control output shape, and keep the response token-lean.if isinstance(data, list) else []). Trust the API contract; if a response really is unstable, model that explicitly with Pydantic + extra="allow".All tools return JSON via ResponseBuilder.build_response():
{
"data": {...},
"analysis": {...}, // optional - computed insights
"metadata": {...} // optional - count, message, etc.
}Errors via ResponseBuilder.build_error_response():
{
"error": {
"message": "...",
"type": "api_error" | "validation_error" | "internal_error",
"timestamp": "...",
"suggestions": [...] // optional
}
}Never return raw API responses or unstructured strings.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.