When to Use
- User says "I want to build a ___ MCP server"
- User has an API, database, or system they want to expose to LLMs
- User wants to plan tools before scaffolding
- Existing server needs a new capability area (design the addition, not just a single tool)
Do NOT use for single-tool additions — use add-tool directly.
Inputs
Gather before designing. Ask the user if not obvious from context:
- Domain — what system, API, or capability is this server wrapping? Or is the server providing internal capability with no external dependency (computation, text/code utilities, in-memory state)?
- Data sources / source of truth — APIs, databases, file systems, external services? Or is the server itself the source (in-memory state, pure computation, local-only utility, embedded model)?
- Target users — what will the LLM (and its human) be trying to accomplish?
- Scope constraints — read-only? write access? admin operations? what's off-limits?
If the domain has a public API, read its docs before designing. For internal-only servers, skip API research and go straight to user goals. Don't design from vibes either way.
Server scope and audience
Before committing to a server boundary, answer: what workflow does this server serve, and who is the audience?
The unit of a server is a user workflow, not an API. A single rich API can earn its own server when the audience is large and the API surface supports a full workflow (PubMed for literature research, SEC EDGAR for financial analysis, Shodan for internet-wide device intelligence). Multiple APIs should collapse into one server when they serve the same workflow from different angles — a "threat intelligence" server that aggregates VirusTotal, AbuseIPDB, and GreyNoise is more useful than three separate servers because the user's goal is "assess this indicator," not "query VirusTotal."
Don't default to one-API-one-server. That's the right call when the API is deep enough and the audience is large enough, but it's not the starting point. The starting point is the workflow:
| Signal |
Server boundary |
| Single API with rich surface, large audience |
Standalone server named for the platform (pubmed-mcp-server, secedgar-mcp-server) |
| Multiple APIs serving the same workflow |
One server named for the workflow (threat-intel-mcp-server), APIs are internal sources |
| Domain with distinct sub-audiences |
Consider splitting — a pentester and a SOC analyst have different workflows even in the same domain |
| Pure computation, no external deps |
Standalone server named for the capability (calculator-mcp-server, redteam-mcp-server) |
When multiple APIs collapse into one server, the tool surface is organized around what the user is doing, not which API gets called. The agent says "investigate this domain" and the server routes to the best available source internally. Individual APIs become service-layer implementation details, not tool-surface identities.
Server Naming
The server name (repo name, npm package, public identity) must communicate what it does at a glance. The test: can a human or agent scanning a server list tell what this server does from the name alone?
- Use the canonical platform/brand name, not abbreviations.
libofcongress-mcp-server not loc-mcp-server ("loc" reads as lines-of-code or location). federal-reserve-mcp-server not fred-mcp-server ("fred" reads as a person's name).
- Add a descriptive suffix when the base name is a non-obvious acronym. Pattern:
{acronym}-{domain}-mcp-server — e.g., eia-energy-mcp-server, bls-labor-mcp-server, nhtsa-vehicle-safety-mcp-server. Skip when the name is already self-descriptive (earthquake-mcp-server, wikidata-mcp-server).
- Don't overclaim scope. A name asserts breadth on two independent axes, and must be honest on both. Source breadth: is this a first-party wrapper around one provider's API, or genuine aggregation across several independent sources behind one normalized surface? Domain or jurisdiction breadth: one, or many? A generic name claims breadth on whichever axis it leaves unqualified.
threat-intel-mcp-server earns its generic name by aggregating independent sources for one workflow; that same name over a single vendor's API would be a defect — name that for the vendor. When the real scope is one jurisdiction, put it in the name (uk-legislation-mcp-server, statistics-canada-mcp-server) rather than letting a generic noun imply worldwide coverage a user will only discover is absent after installing. Note that aggregating several bodies within one jurisdiction earns a cross-source name, not a cross-jurisdiction one.
- Aggregation is a claim about entities, not endpoints. One provider publishing five APIs is still first-party. Wrapping several endpoints of the same upstream does not make a server an aggregator and does not earn a generic name.
- The tool prefix derives from the name but is a separate identifier. Every tool is
{prefix}_{verb}_{noun}, so the prefix shows up in every tool call an agent sees, and a descriptive one gives agents domain context without reading the server's instructions. The prefix names the source, dropping the descriptive qualifier the repo name carries for human browsing: eia-energy-mcp-server → eia_, nhtsa-vehicle-safety-mcp-server → nhtsa_. Because it names the source rather than the repo, the two can diverge — a later repo rename does not have to move the prefix, and usually shouldn't: renaming an advertised tool surface is a breaking change for every existing client, while renaming a package is not.
Steps
1. Research External Dependencies
Applies when: the server wraps an external API or service. Skip for internal-only servers (computation, local file ops, in-memory state, code analysis utilities) and jump to Step 2.
Before designing, verify the APIs and services the server will wrap. Read the docs, then hit the API — real requests reveal what docs omit.
Research inline by default — fetch docs, read SDK readmes, confirm assumptions before committing them to the design. For each external dependency:
- Fetch API docs, confirm endpoint availability, auth methods, rate limits
- Check for official SDKs or client libraries (npm packages)
- Note any API quirks, pagination patterns, or data format considerations
When research is genuinely parallelizable (multiple independent APIs, several SDKs to evaluate), spawn background agents for the independent legs while you proceed with domain mapping. Skip the overhead for a single API — just read it yourself.
Live API probing. After reading docs, make real requests against the API to verify assumptions:
- Response shapes — confirm actual field names, nesting, and types. Docs frequently lag or omit fields.
- Batch/filter endpoints — look for
filter.ids, bulk GET, or query-by-multiple-IDs patterns. A single batch request replaces N individual fetches and eliminates serial-request bottlenecks and rate-limit accumulation.
- Field selection — check if the API supports
fields or select parameters to request only the data you need. This reduces payload size dramatically for large objects.
- Pagination behavior — verify token format, page size limits, and what happens when results exceed one page.
- Error shapes — trigger real 400/404/429 responses to see the actual error format, not just what docs claim.
- Unknown-param behavior — send one deliberately misspelled parameter. If the API silently ignores it (plausible but unfiltered results instead of an error), every typo'd or unverified param name becomes a silent-wrongness bug — the service layer then needs a strict allowlist of confirmed spellings, and new filters require a probe before they ship.
- Omission semantics — for each major optional parameter, check what omitting it actually returns. Some APIs default to the intuitive scope; others silently widen (all historical versions, all statuses, global instead of regional). A default that changes result meaning becomes a server-side default plus an echoed output field, not something left to the agent.
Stopping condition: at minimum, probe one list/search endpoint, one single-item GET, one error case (force a 404 or 400), and one unknown-param request. For large APIs with many resource types, add one probe per major noun. Stop when the response shapes and error envelope are confirmed.
This step prevents building a service layer against assumed response shapes that don't match reality.
2. Map User Goals, Then Domain Operations
Start with user goals, not endpoints. Enumerate the outcomes an agent (and its human) will actually try to accomplish with this server — usually 3–10, scaled to domain size. These drive the workflow tools that form the spine of the surface. Endpoint-inventory-first design produces 1:1 API mirrors; goal-first design produces tools agents reach for. For internal-only servers, goals map to capabilities rather than endpoints — e.g., "format markdown to GFM," "tokenize text by model," "compute file hash."
Example user goals for a project management server:
- Find tasks I'm assigned to that are due soon
- Create a task in a project, assign it, and notify the owner
- Mark a task complete and log the outcome
- Audit a project's overdue work
Then enumerate the underlying domain operations the system supports, grouped by noun. These are the raw material workflow tools compose and single-action tools back-fill where workflows don't cover an edge case.
| Noun |
Operations |
| Project |
list, get, create, archive |
| Task |
list (by project), get, create, update status, assign, comment |
| User |
list, get current |
The user-goal list shapes the tool surface; the operation list fills in the gaps. Not every operation becomes a tool — an operation stays as raw material (not its own tool) when it's already fully covered by an existing tool's output, or when the only agents who'd use it are in scenarios outside this server's stated purpose.
3. Classify into MCP Primitives
Tools are the primary interface. Not all MCP clients expose resources — many are tool-only (Claude Code, Cursor, most chat UIs). Design the tool surface to be self-sufficient: an agent with only tool access should be able to do everything the server is built for. Resources add convenience for clients that support them (injectable context, stable URIs), but are not a reliable access path.
| Primitive |
Use when |
Examples |
| Tool |
The default. Any operation or data access an agent needs to accomplish the server's purpose. |
Search, create, update, analyze, fetch-by-ID, list reference data |
| App Tool |
Rare — default to a standard tool. Only when a human will actively interact with the result in real time and the target client supports MCP Apps. Most clients are tool-only and most agent workflows are read-by-LLM, not viewed-by-human. App tools add an iframe + CSP, app.ontoolresult/callServerTool plumbing, host-context wiring, and a format() text twin that still has to be content-complete (since most clients only see that). Two surfaces to keep in sync, two failure modes per change. |
Dense tabular state a human scrubs through; form-based human approval in an MCP Apps-capable client |
| Resource |
Additionally expose as a resource when the data is addressable by stable URI, read-only, and useful as injectable context. |
Config, schemas, status, entity-by-ID lookups |
| Prompt |
Reusable message template that structures how the LLM approaches a task |
Analysis framework, report template, review checklist |
| Neither |
Internal detail, admin-only, not useful to an LLM |
Token refresh, webhook setup, migrations |
What the tool surface needs to cover depends on the server: a read-only research server has different economics than a CRUD project management server. Consider the domain, the expected agent workflows, whether it wraps one API or many, and what data relationships exist. The test is: can a tool-only agent accomplish everything this server is for?
Common traps:
- Data locked behind resources: If something an agent needs is only accessible via a resource, it's invisible to tool-only clients. That data might warrant its own tool, or it might already be covered by an existing tool's output — but it needs a tool path somewhere.
- CRUD explosion: Don't map every REST endpoint to a tool. Related operations on the same noun often belong in one tool with an
operation/mode parameter (see Step 4).
- 1:1 endpoint mirroring: API endpoints are designed for programmatic consumers. LLM tools should be designed for workflows — what an agent is trying to accomplish, not what HTTP calls happen under the hood.
Irreversible operations stay in the UI. The "Neither" bucket above covers operations that aren't useful to an LLM. There's a second, sharper reason to exclude something from the tool surface: operations whose failure mode is catastrophic and unrecoverable. Examples span domains — dropping a production database table (data loss across every row), force-emptying a versioned cloud-storage bucket (no recovery once the lifecycle policy fires), revoking the workspace's last admin role (locks everyone out, recovery requires vendor support), GDPR permanent-delete on a customer profile (un-restorable by design), purging an analytics warehouse partition older than the retention window (auditable history gone), or deleting the single audience on a free-plan email platform (nukes every subscriber and historical report in one call). These are useful to an LLM in principle, but the blast radius of a mis-call is disproportionate to any agent workflow. Humans do these in the vendor UI, where confirmation dialogs and undo paths exist. Agents shouldn't have the tool at all.
This is distinct from destructiveHint — that annotation is for operations that are destructive but recoverable (deleting a task, reverting a commit) and agents should still have them. The "stays in the UI" line applies only to operations whose failure is both catastrophic and irreversible.
4. Design Tools
This is the highest-leverage step. Tool definitions — names, descriptions, parameters, output schemas — are the entire interface contract the LLM reads to decide whether and how to call a tool. Every field is context. Design accordingly.
Tool shapes you'll encounter
Most tools follow the {server}_{verb}_{noun} default — one focused responsibility, one clear verb, often (but not always) one upstream call. API-wrapping examples: pubmed_search_articles, pubmed_fetch_articles. Internal-only examples: markdown_format_text, regex_test_pattern, tokens_count_text — same naming convention, no external dep. Two variants warrant explicit design pressures of their own:
| Shape |
Purpose |
Typical form |
Examples |
| Workflow |
Multi-step orchestration that replaces a common agent chain |
N upstream calls (often parallelized); may request confirmation; may need mid-flow cleanup |
clinicaltrials_find_studies (search → filter → rank) |
| Instruction |
State-aware procedural guidance — advice, not action |
Static markdown + a few live-state fetches, readOnlyHint: true, outputs nextToolSuggestions pre-filling the recommended follow-up. No writes. |
git_wrapup_instructions |
| Reference |
Decode opaque domain vocabulary — codes, enums, identifier formats, coverage windows — so agents can build valid inputs for the rest of the surface |
Static tables or one cached fetch (often zero upstream calls); consolidate N lists under one topic enum; readOnlyHint: true, openWorldHint: false when offline |
medcode_list_systems, osv_list_ecosystems |
These aren't boxes every tool must fit into — some blend shapes — but the design pressures differ enough that naming them helps avoid re-discovering the patterns per server. The subsections below cover considerations specific to each — workflow framing applies broadly; instruction tools, reference tools, and workflow safety are their own subsections.
Think in workflows, not endpoints
The unit of a tool is a useful action, not an API call. Ask: "What is the agent trying to accomplish?" — not "What endpoints does the API have?"
A single tool can call multiple APIs internally, apply local filtering, reshape data, and return enriched results. The LLM doesn't know or care about the underlying calls.
// Workflow tool — search + local filter pipeline, not a raw API proxy
const findStudies = tool('clinicaltrials_find_studies', {
description: 'Matches patient demographics and medical profile to eligible clinical trials. Filters by age, sex, conditions, location, and healthy volunteer status. Returns ranked list of matching studies with eligibility explanations.',
// handler: listStudies() → filter by eligibility → rank by location proximity → slice
});
Tip — mode consolidation. When a tool has several related operations on the same noun, you can consolidate them under one tool with a mode/operation enum. This affects both naming (noun-led, e.g., github_pull_request) and handler design (dispatch by mode). Use when it tightens the surface; skip when ops diverge enough to warrant separate tools.
Multi-source tools and fallback chains
Applies when: a server aggregates multiple data sources for the same workflow, and the "best" source varies by input type, availability, or coverage. Skip for single-API servers.
When a tool's goal can be served by multiple sources, design it as a multi-source tool — the agent calls one tool, the handler routes to the best source (or fans out to several) internally. This is the difference between a "PubMed wrapper" and a "literature research server": pubmed_search_articles tries PubMed first, falls back to EuropePMC for broader coverage, then Unpaywall for open access. The agent doesn't choose which API to hit — the server makes that decision based on what works.
Two patterns:
Source fallback chains — try sources in priority order, fall through on failure or empty results. Best when sources cover the same data with different depth or availability. The output should indicate which source provided the data so the agent (and human) can assess provenance.
// Handler pseudocode — not a real implementation
async handler(input, ctx) {
// Primary: PubMed E-utilities (authoritative, best metadata)
const result = await pubmedService.search(input.query);
if (result.items.length > 0) return { ...result, source: 'pubmed' };
// Fallback: EuropePMC (broader coverage, includes preprints)
const epmcResult = await epmcService.search(input.query);
if (epmcResult.items.length > 0) return { ...epmcResult, source: 'europepmc' };
return { items: [], source: 'none', message: 'No results from any source.' };
}
Multi-source fan-out — query multiple sources in parallel, merge results. Best when sources provide complementary data about the same entity. Use Promise.allSettled so one failing source doesn't tank the whole call.
// Handler pseudocode — indicator enrichment across threat intel sources
async handler(input, ctx) {
const [vt, abuse, greynoise] = await Promise.allSettled([
vtService.lookup(input.indicator),
abuseIpService.check(input.indicator),
greynoiseService.query(input.indicator),
]);
return {
indicator: input.indicator,
sources: {
virustotal: vt.status === 'fulfilled' ? vt.value : { error: vt.reason.message },
abuseipdb: abuse.status === 'fulfilled' ? abuse.value : { error: abuse.reason.message },
greynoise: greynoise.status === 'fulfilled' ? greynoise.value : { error: greynoise.reason.message },
},
// Server synthesizes a verdict from available data — the agent gets a conclusion, not raw API dumps
assessment: synthesizeVerdict(vt, abuse, greynoise),
};
}
In both patterns, the tool surface is organized around what the user is doing. Sources are service-layer details — the agent sees threat_enrich_indicator, not virustotal_lookup + abuseipdb_check + greynoise_query. Mode-based dispatch by input type (e.g., indicator_type: 'ip' | 'domain' | 'hash') naturally routes to different source chains per mode, since different sources cover different indicator types.
There is no fixed ceiling on tool count — tools need to earn their keep, but don't artificially limit the surface. If the domain genuinely has 20 distinct workflows, expose 20 tools.
Cut the surface
After mapping tools, review the full list critically. A tool that covers a niche use case, serves a tiny fraction of agents, or duplicates what another tool already handles is a candidate for deferral. Drop it from the design and note it as a future addition if demand warrants. Every tool in the surface is cognitive load for tool selection — a tight surface outperforms a comprehensive one.
Instruction tools
Applies when: the domain has recurring "how do I do X well given my current state" questions worth merging with static procedural content. Skip otherwise.
Some domains benefit from a tool whose output is guidance, not data — a markdown playbook tailored by live account state, with pre-filled next-step tool calls. These sit between Prompts (static templates, client-invokable) and action tools (do work, return data): they return advice, but the advice is worth more than static text because it merges procedural content with the agent's actual situation.
Characteristics:
- Output is markdown guidance, not structured data (though the output schema still has fields — typically
guidance, diagnostics, and nextToolSuggestions)
- Merges static procedural content with live state — the value is the tailoring. "You have 12 staged files spanning 4 unrelated changes — split them into separate commits before pushing" beats a generic best-practices article. The same shape works in other domains: "Your slowest query is 2.3s on
orders.customer_id — add the index before tuning the planner" (database advisor), "Error rate spiked 4× at 14:32 UTC, 4 minutes after the web@a3f9c2 deploy — roll back before chasing the upstream provider" (incident triage).
readOnlyHint: true, openWorldHint: false — no writes, deterministic given the same inputs and account state
- Outputs
nextToolSuggestions — an array of recommended follow-up tool calls with arguments pre-filled from the diagnostics, not just tool names. The agent consumes the playbook, then executes steps with other tools.
- Consolidate by
topic enum — what could be N separate per-topic tools collapses into one
const wrapupInstructions = tool('git_wrapup_instructions', {
description: 'Procedural guidance tailored to current repo state. Returns best-practice markdown merged with live diagnostics (staged/unstaged files, branch info, recent commits) and pre-filled follow-up tool calls. Read-only; the agent then executes steps with other tools.',
annotations: { readOnlyHint: true, openWorldHint: false },
input: z.object({
topic: z.enum(['review-changes', 'stage-and-commit', 'push-to-remote'])
.describe('Playbook topic. Determines which static guidance is returned and which live state is fetched for tailoring.'),
}),
output: z.object({
guidance: z.string()
.describe('Markdown playbook content, tailored to current account state.'),
diagnostics: z.record(z.unknown())
.describe('Live state used to tailor the guidance (e.g., staged file count, branch divergence, recent commit cadence).'),
nextToolSuggestions: z.array(z.object({
toolName: z.string().describe('Tool to call next.'),
reason: z.string().describe('Why this step is recommended given current state.'),
args: z.record(z.unknown()).describe('Arguments pre-filled from diagnostics.'),
})).describe('Recommended follow-up calls with arguments already populated.'),
}),
});
Prior art: git_wrapup_instructions walks through staging, commit, and push with repo state inspected. If a server has recurring "how do I do X well given my state" questions, an instruction tool typically beats N topic-specific tools and duplicating guidance in tool descriptions.
Reference tools
Applies when: the domain speaks in opaque vocabulary — enum codes, classification systems, identifier formats, per-source coverage windows — that agents must supply as inputs elsewhere. Skip when inputs are self-evident (free text, ISO dates, well-known formats).
A reference tool is the surface's decoder ring: which codes exist, what they mean, what format each identifier takes, what each source covers. Consolidate what could be N per-list tools under one topic enum, and serve it from static tables or a cached fetch so it stays cheap to call. Its second job is structural: it is the standing target of recovery routing — error recovery strings, zero-hit notices, and resolver guidance across the surface end with "consult the reference tool, topic X" (see Error design), which only works when the tool exists. Implement it first — it usually has no service dependency, and it grounds field-testing for every other tool.
Workflow tool safety
Applies when: a tool performs multi-step mutations with destructive modes (send/apply/promote) that benefit from human confirmation before the irreversible step fires. Skip for read-only or idempotent workflows.
Tools that perform multi-step mutations (the Workflow shape) have two safety considerations beyond single-call tools. Both are about giving the agent — and the human behind it — a chance to catch a bad invocation before it commits.
Confirmation-gated destructive modes, with an annotation fallback. When a workflow's mode parameter switches between safe and destructive arms (draft vs send, plan vs apply), gate the destructive arm on a confirmation the handler asks for via ctx.requestInput(...), so a human approves before the irreversible step fires. The handler is re-entered with the answer on ctx.inputs; it does not await mid-call.
The gate is always reachable — ctx.requestInput is present on every transport and both protocol eras — but it is not always answerable: a client that never fulfils the input_required result simply doesn't retry, and the destructive step never runs. The same holds for a 2025-era HTTP client when the server runs MCP_SESSION_MODE=stateless, which disables the legacy round-trip shim — the gate refuses and the destructive step never fires. That is the safe outcome, but it makes the tool unusable for those clients, so weigh it before defaulting such a server to stateless (api-context § ctx.requestInput). Keep destructiveHint: true in annotations so those clients' own approval flows still surface the risk.
annotations: { destructiveHint: true }, // client-side approval flows still see the risk
// ...
const Confirm = z.object({ confirmed: z.literal(true).describe('Type true to apply.') });
handler(input, ctx) {
if (input.mode === 'apply') {
// A decline is terminal — re-asking would loop until the round budget runs out.
const view = ctx.inputs.view('confirm');
if (view.kind === 'elicit' && view.action !== 'accept') {
throw validationError('Migration cancelled by user.');
}
if (!ctx.inputs.accepted('confirm', Confirm)) {
return ctx.requestInput({
inputRequests: {
confirm: inputRequired.elicit({
message: `Apply migration affecting ${affectedRowCount} rows in production? Cannot be rolled back automatically.`,
requestedSchema: Confirm,
}),
},
});
}
}
// destructive step proceeds
}
Safe defaults on parameters that determine blast radius. When a workflow accepts a parameter that controls how far-reaching a mutation is, default to the safer value. A bulk file-update tool defaulting mode: 'preview' (no writes) means a sloppy agent call shows a diff rather than blasting changes; an apply-plan tool defaulting dryRun: true means a misread plan previews rather than executes; an object-delete tool requiring an explicit confirmCount matching the result-set size means an unscoped query can't silently nuke a million rows. Agents that genuinely want the destructive behavior have to name it explicitly, which surfaces intent in the tool call and in logs.
Tool descriptions
The description is the LLM's primary signal for tool selection. It must answer: what does this do, and when should I use it?
- Be concrete about capability. "Search for clinical trial studies using queries and filters" beats "Interact with studies."
- Include operational guidance when it matters. If the tool has prerequisites, constraints, or gotchas the LLM needs to know, say so in the description. Don't add boilerplate workflow hints when the tool is self-explanatory.
- Prefer a single cohesive paragraph. Pack operational guidance into prose sentences (separated by periods or em-dashes) rather than bullet lists or blank-line-separated sections. Descriptions render inline in most clients, and bullet structure reads as visual noise rather than signal. Operation-by-operation bullets also duplicate info that already lives in the
operation enum's .describe().
- Don't leak. Descriptions are for the consumer, not the author. Three categories to audit against:
- Implementation details — endpoint paths, API call counts, internal parameter mappings, routing logic. Describe what the tool does and when to use it, not how it's wired up.
- Meta-coaching — directives about how to use the output. "Treat X as the canonical Y", "callers should…", "the LLM should…". The description sells the tool; it doesn't coach the reader.
- Consumer-aware phrasing — references to "LLM", "agent", "Claude", or any specific reader. The description shouldn't name who's reading it.
// Good — describes a prerequisite the LLM must know
description: 'Set the session working directory for all git operations. This allows subsequent git commands to omit the path parameter.'
// Good — self-explanatory, no workflow hints needed
description: 'Show the working tree status including staged, unstaged, and untracked files.'
// Good — warns about constraints
description: 'Fetches trial results data for completed studies. Only available for studies where hasResults is true.'
Descriptions should be as long as needed — concise but complete. Don't artificially truncate, and don't pad with filler.
Parameter descriptions
Every .describe() is prompt text the LLM reads. Parameters should convey: what the value is, what it affects, and (where non-obvious) how to use it well.
- Constrain the type. Enums and literals over free strings. Regex validation for formatted IDs. Ranges for numeric bounds.
- Use JSON-Schema-serializable types only. The MCP SDK serializes schemas to JSON Schema for
tools/list. Types like z.custom(), z.date(), z.transform(), z.bigint(), z.symbol(), z.void(), z.map(), z.set() throw at runtime. Use structural equivalents (e.g., z.string().describe('ISO 8601 date') instead of z.date()).
- Explain costs and tradeoffs when a parameter choice has meaningful consequences.
- Name alternative approaches when a simpler path exists.
- Include format patterns for structured values, but don't pad descriptions with redundant examples.
// Good — explains cost, recommends action, names the alternative
fields: z.array(z.string()).optional()
.describe('Specific fields to return (reduces payload size). Without this, the full study record (~70KB each) is returned. Use full data only when you need detailed eligibility criteria, locations, or results.'),
// Good — explains what the flag does AND how to override
autoExclude: z.boolean().default(true)
.describe('Automatically exclude lock files and generated files from diff output to reduce context bloat. Set to false if you need to inspect these files.'),
// Good — names the format and gives one example
nctIds: z.union([z.string(), z.array(z.string()).max(5)])
.describe('A single NCT ID (e.g., "NCT12345678") or an array of up to 5 NCT IDs to fetch.'),
Output design
The output schema and format function control what the LLM reads back. Design for the agent's next decision, not for a UI or an API consumer. See the add-tool skill's Tool Response Design section for implementation-level patterns (partial success, empty results, metadata, context budget).
Principles:
- Server reports what only the server can know; agent decides what only the agent can know. Schema, scopes, rate limits, and raw observable state belong to the server. Semantic correctness, intent-vs-effect matching, and recovery choice belong to the agent. For mutators, this means surfacing pre/post observable state rather than throwing on synthetic deltas the server can't authoritatively classify —
file shrunk could be deliberate truncation or a bug; only the agent knows. See add-tool skill's Mutator response design.
- Include IDs and references for chaining. If the agent might act on a result, return the identifiers it needs for follow-up tool calls.
- Curate vs. pass-through depends on domain. Medical/scientific data — don't trim fields that could alter correctness. CRUD responses — return what the agent needs, not the full API payload. Match fidelity to consequence.
- Surface what was done, not just results. After a write operation, include the post-state so the LLM can chain without an extra round trip.
- Seed orientation context alongside the primary result. When a tool's call position makes the agent's next moves predictable, attaching a compact snapshot of relevant state — recent activity, tracked state, a couple of reference items — both saves round-trips and primes the LLM on the project's patterns. Surfacing recent commits teaches the commit-message style the agent should match when it later writes one; recent tags teach the versioning convention; reference records teach the naming format. Common fits: tools that open or close a session (set working dir, wrap-up), state-changing verbs where the caller wants post-action confirmation (commit, push, merge), entry points that drop the agent into a new scope (clone, checkout). Gather sub-operations in parallel with
Promise.allSettled so a single failure degrades to a warning rather than tanking the outer call.
- Communicate filtering. If the tool silently excluded content, tell the LLM what was excluded and how to get it back. The agent can't act on what it doesn't know about.
// git_diff — when lock files are filtered, the output tells the LLM
output: z.object({
diff: z.string().describe('Unified diff output.'),
excludedFiles: z.array(z.string()).optional()
.describe('Files automatically excluded from the diff (e.g., lock files). Call again with autoExclude=false to include them.'),
}),
- Empty results are a designed surface, not a fallthrough. For every search/list tool, spec the zero-hit behavior at design time: zero hits are success + an
enrichment notice, never an error, and the notice is composed from condition → fragment pairs (too-narrow filter, a defaulted date window, a syntax trap), each routing to a concrete next call — relax a named filter, switch to the named sibling tool, or consult the reference tool. Relatedly, when the server applies a default that changes result semantics (an as-of date, an implicit status filter), echo the applied value in the output — an agent can't reason about a filter it can't see.
- Capped lists disclose truncation. When a tool accepts a cap-like input (
limit, per_page, page_size, max_results, max_items) and returns an array, the handler must disclose when the cap was hit. Standard fields: truncated: true, shown, cap in the enrichment block via ctx.enrich.truncated({ shown, cap }). ctx.enrich.total(n) (writes totalCount) is also recognized. Silent caps leave the agent treating a partial set as complete. The capped-list-no-truncation lint rule enforces this; see api-linter and api-context's ctx.enrich.truncated() section.
- Truncate large output with counts. When a list exceeds a reasonable display size, show the top N and append "...and X more". Don't silently drop results.
- Spill big analytical results to a queryable surface. When a tool's row set is something an agent would run SQL over (aggregate, group, join) and can exceed any reasonable context budget — paginated APIs, streamed exports, big query results — pair an inline preview with a
DataCanvas table holding the full set. Two rules gate this: (1) it must earn its keep on shape, not size — a discovery/search surface of categorical metadata (titles, IDs) is not analytical and doesn't get a canvas regardless of row count; for name→ID resolution over a bounded list use MCP-side list filtering; (2) the canvas_id is reachable only if the same server also exposes a dataframe_query tool — emit one without the other and the handle is dead output. Compute distributions or refinement hints across the full result, not the preview, so aggregate signal stays honest. See api-canvas for the spillover() helper and both rules in full.
- Outline one large document into sections. When a single tool call returns one document-shaped record (not many rows) that can exceed context — a ~130KB FDA drug label, a big API entity dominated by a few fat fields — return a section outline (top-level keys + per-section byte size) instead of truncating, and let the agent re-call with
sections: [...] to pull only what it needs. The outlineOnOverflow() helper (@cyanheads/mcp-ts-core/utils) measures the payload and returns a full | outline result; declare the tool's output as a flat z.object with a kind discriminator and presence-based optional arms (folding in OUTLINE_VARIANT.shape.sections / .notice) — tool() rejects a z.discriminatedUnion output — so format()-parity holds when each arm renders on field presence. Pure measure + key-slice — Workers-portable, unlike canvas-bound spillover(). Distinct from spillover on shape: spillover splits a row collection, this outlines one fat record. See the techniques skill's outline-on-overflow reference.
- Mirror a bulk upstream instead of paginating it live. When the server wraps a large or slow API whose corpus is queried far more than it changes, sync it once into a persistent local index and query that as the primary data path — not the live API per request. Match the backend to corpus size: ≲ tens of thousands of rows → an in-memory index (server-level, no primitive); ~10⁴–10⁷ → the
MirrorService (embedded SQLite + FTS5; declare a schema + a sync ingester via defineMirror/sqliteMirrorStore, then runSync/query, see api-mirror); ≳ 10⁸ → an external store. Distinct lifecycle from DataCanvas: a mirror is long-lived and cross-session, refreshed on a schedule; canvas is ephemeral and per-session.
format() is the markdown twin of structuredContent — make both content-complete. Different MCP clients forward different surfaces to the model: some (e.g., Claude Code) read structuredContent from output, others (e.g., Claude Desktop) read content[] from format(). Both must carry the same data so every client sees the same picture — format() just dresses it up with markdown. A thin format() that re
…(truncated)
1---
2name: design-mcp-server
3description: >
4 Design the tool surface, resources, and service layer for a new MCP server. Use when starting a new server, planning a major feature expansion, or when the user describes a domain/API they want to expose via MCP. Produces a design doc at docs/design.md that drives implementation.
5metadata:
6 author: cyanheads
7 version: "2.23"
8 audience: external
9 type: workflow
10---
11
12## When to Use
13
14- User says "I want to build a ___ MCP server"
15- User has an API, database, or system they want to expose to LLMs
16- User wants to plan tools before scaffolding
17- Existing server needs a new capability area (design the addition, not just a single tool)
18
19Do NOT use for single-tool additions — use `add-tool` directly.
20
21## Inputs
22
23Gather before designing. Ask the user if not obvious from context:
24
251. **Domain** — what system, API, or capability is this server wrapping? Or is the server providing internal capability with no external dependency (computation, text/code utilities, in-memory state)?
262. **Data sources / source of truth** — APIs, databases, file systems, external services? Or is the server itself the source (in-memory state, pure computation, local-only utility, embedded model)?
273. **Target users** — what will the LLM (and its human) be trying to accomplish?
284. **Scope constraints** — read-only? write access? admin operations? what's off-limits?
29
30If the domain has a public API, read its docs before designing. For internal-only servers, skip API research and go straight to user goals. Don't design from vibes either way.
31
32### Server scope and audience
33
34Before committing to a server boundary, answer: **what workflow does this server serve, and who is the audience?**
35
36The unit of a server is a *user workflow*, not an API. A single rich API can earn its own server when the audience is large and the API surface supports a full workflow (PubMed for literature research, SEC EDGAR for financial analysis, Shodan for internet-wide device intelligence). Multiple APIs should collapse into one server when they serve the same workflow from different angles — a "threat intelligence" server that aggregates VirusTotal, AbuseIPDB, and GreyNoise is more useful than three separate servers because the user's goal is "assess this indicator," not "query VirusTotal."
37
38**Don't default to one-API-one-server.** That's the right call when the API is deep enough and the audience is large enough, but it's not the starting point. The starting point is the workflow:
39
40| Signal | Server boundary |
41|:-------|:----------------|
42| Single API with rich surface, large audience | Standalone server named for the platform (`pubmed-mcp-server`, `secedgar-mcp-server`) |
43| Multiple APIs serving the same workflow | One server named for the workflow (`threat-intel-mcp-server`), APIs are internal sources |
44| Domain with distinct sub-audiences | Consider splitting — a pentester and a SOC analyst have different workflows even in the same domain |
45| Pure computation, no external deps | Standalone server named for the capability (`calculator-mcp-server`, `redteam-mcp-server`) |
46
47When multiple APIs collapse into one server, the tool surface is organized around what the user is doing, not which API gets called. The agent says "investigate this domain" and the server routes to the best available source internally. Individual APIs become service-layer implementation details, not tool-surface identities.
48
49## Server Naming
50
51The server name (repo name, npm package, public identity) must communicate what it does at a glance. The test: can a human or agent scanning a server list tell what this server does from the name alone?
52
53- **Use the canonical platform/brand name, not abbreviations.** `libofcongress-mcp-server` not `loc-mcp-server` ("loc" reads as lines-of-code or location). `federal-reserve-mcp-server` not `fred-mcp-server` ("fred" reads as a person's name).
54- **Add a descriptive suffix when the base name is a non-obvious acronym.** Pattern: `{acronym}-{domain}-mcp-server` — e.g., `eia-energy-mcp-server`, `bls-labor-mcp-server`, `nhtsa-vehicle-safety-mcp-server`. Skip when the name is already self-descriptive (`earthquake-mcp-server`, `wikidata-mcp-server`).
55- **Don't overclaim scope.** A name asserts breadth on two independent axes, and must be honest on both. *Source breadth:* is this a first-party wrapper around one provider's API, or genuine aggregation across several independent sources behind one normalized surface? *Domain or jurisdiction breadth:* one, or many? A generic name claims breadth on whichever axis it leaves unqualified. `threat-intel-mcp-server` earns its generic name by aggregating independent sources for one workflow; that same name over a single vendor's API would be a defect — name that for the vendor. When the real scope is one jurisdiction, put it in the name (`uk-legislation-mcp-server`, `statistics-canada-mcp-server`) rather than letting a generic noun imply worldwide coverage a user will only discover is absent after installing. Note that aggregating several bodies *within* one jurisdiction earns a cross-source name, not a cross-jurisdiction one.
56- **Aggregation is a claim about entities, not endpoints.** One provider publishing five APIs is still first-party. Wrapping several endpoints of the same upstream does not make a server an aggregator and does not earn a generic name.
57- **The tool prefix derives from the name but is a separate identifier.** Every tool is `{prefix}_{verb}_{noun}`, so the prefix shows up in every tool call an agent sees, and a descriptive one gives agents domain context without reading the server's instructions. The prefix names the *source*, dropping the descriptive qualifier the repo name carries for human browsing: `eia-energy-mcp-server` → `eia_`, `nhtsa-vehicle-safety-mcp-server` → `nhtsa_`. Because it names the source rather than the repo, the two can diverge — a later repo rename does not have to move the prefix, and usually shouldn't: renaming an advertised tool surface is a breaking change for every existing client, while renaming a package is not.
58
59## Steps
60
61### 1. Research External Dependencies
62
63**Applies when:** the server wraps an external API or service. Skip for internal-only servers (computation, local file ops, in-memory state, code analysis utilities) and jump to Step 2.
64
65Before designing, verify the APIs and services the server will wrap. Read the docs, then **hit the API** — real requests reveal what docs omit.
66
67Research inline by default — fetch docs, read SDK readmes, confirm assumptions before committing them to the design. For each external dependency:
68
69- Fetch API docs, confirm endpoint availability, auth methods, rate limits
70- Check for official SDKs or client libraries (npm packages)
71- Note any API quirks, pagination patterns, or data format considerations
72
73When research is genuinely parallelizable (multiple independent APIs, several SDKs to evaluate), spawn background agents for the independent legs while you proceed with domain mapping. Skip the overhead for a single API — just read it yourself.
74
75**Live API probing.** After reading docs, make real requests against the API to verify assumptions:
76
77- **Response shapes** — confirm actual field names, nesting, and types. Docs frequently lag or omit fields.
78- **Batch/filter endpoints** — look for `filter.ids`, bulk GET, or query-by-multiple-IDs patterns. A single batch request replaces N individual fetches and eliminates serial-request bottlenecks and rate-limit accumulation.
79- **Field selection** — check if the API supports `fields` or `select` parameters to request only the data you need. This reduces payload size dramatically for large objects.
80- **Pagination behavior** — verify token format, page size limits, and what happens when results exceed one page.
81- **Error shapes** — trigger real 400/404/429 responses to see the actual error format, not just what docs claim.
82- **Unknown-param behavior** — send one deliberately misspelled parameter. If the API silently ignores it (plausible but unfiltered results instead of an error), every typo'd or unverified param name becomes a silent-wrongness bug — the service layer then needs a strict allowlist of confirmed spellings, and new filters require a probe before they ship.
83- **Omission semantics** — for each major optional parameter, check what omitting it actually returns. Some APIs default to the intuitive scope; others silently widen (all historical versions, all statuses, global instead of regional). A default that changes result *meaning* becomes a server-side default plus an echoed output field, not something left to the agent.
84
85**Stopping condition:** at minimum, probe one list/search endpoint, one single-item GET, one error case (force a 404 or 400), and one unknown-param request. For large APIs with many resource types, add one probe per major noun. Stop when the response shapes and error envelope are confirmed.
86
87This step prevents building a service layer against assumed response shapes that don't match reality.
88
89### 2. Map User Goals, Then Domain Operations
90
91Start with **user goals**, not endpoints. Enumerate the outcomes an agent (and its human) will actually try to accomplish with this server — usually 3–10, scaled to domain size. These drive the workflow tools that form the spine of the surface. Endpoint-inventory-first design produces 1:1 API mirrors; goal-first design produces tools agents reach for. For internal-only servers, goals map to capabilities rather than endpoints — e.g., "format markdown to GFM," "tokenize text by model," "compute file hash."
92
93Example user goals for a project management server:
94
95- Find tasks I'm assigned to that are due soon
96- Create a task in a project, assign it, and notify the owner
97- Mark a task complete and log the outcome
98- Audit a project's overdue work
99
100Then enumerate the underlying **domain operations** the system supports, grouped by noun. These are the raw material workflow tools compose and single-action tools back-fill where workflows don't cover an edge case.
101
102| Noun | Operations |
103|:-----|:-----------|
104| Project | list, get, create, archive |
105| Task | list (by project), get, create, update status, assign, comment |
106| User | list, get current |
107
108The user-goal list shapes the tool surface; the operation list fills in the gaps. Not every operation becomes a tool — an operation stays as raw material (not its own tool) when it's already fully covered by an existing tool's output, or when the only agents who'd use it are in scenarios outside this server's stated purpose.
109
110### 3. Classify into MCP Primitives
111
112**Tools are the primary interface.** Not all MCP clients expose resources — many are tool-only (Claude Code, Cursor, most chat UIs). Design the tool surface to be self-sufficient: an agent with only tool access should be able to do everything the server is built for. Resources add convenience for clients that support them (injectable context, stable URIs), but are not a reliable access path.
113
114| Primitive | Use when | Examples |
115|:----------|:---------|:--------|
116| **Tool** | The default. Any operation or data access an agent needs to accomplish the server's purpose. | Search, create, update, analyze, fetch-by-ID, list reference data |
117| **App Tool** | **Rare — default to a standard tool.** Only when a human will actively interact with the result in real time *and* the target client supports MCP Apps. Most clients are tool-only and most agent workflows are read-by-LLM, not viewed-by-human. App tools add an iframe + CSP, `app.ontoolresult`/`callServerTool` plumbing, host-context wiring, and a `format()` text twin that still has to be content-complete (since most clients only see that). Two surfaces to keep in sync, two failure modes per change. | Dense tabular state a human scrubs through; form-based human approval in an MCP Apps-capable client |
118| **Resource** | *Additionally* expose as a resource when the data is addressable by stable URI, read-only, and useful as injectable context. | Config, schemas, status, entity-by-ID lookups |
119| **Prompt** | Reusable message template that structures how the LLM approaches a task | Analysis framework, report template, review checklist |
120| **Neither** | Internal detail, admin-only, not useful to an LLM | Token refresh, webhook setup, migrations |
121
122What the tool surface needs to cover depends on the server: a read-only research server has different economics than a CRUD project management server. Consider the domain, the expected agent workflows, whether it wraps one API or many, and what data relationships exist. The test is: can a tool-only agent accomplish everything this server is for?
123
124**Common traps:**
125
126- **Data locked behind resources**: If something an agent needs is only accessible via a resource, it's invisible to tool-only clients. That data might warrant its own tool, or it might already be covered by an existing tool's output — but it needs a tool path somewhere.
127- **CRUD explosion**: Don't map every REST endpoint to a tool. Related operations on the same noun often belong in one tool with an `operation`/`mode` parameter (see Step 4).
128- **1:1 endpoint mirroring**: API endpoints are designed for programmatic consumers. LLM tools should be designed for workflows — what an agent is *trying to accomplish*, not what HTTP calls happen under the hood.
129
130**Irreversible operations stay in the UI.** The "Neither" bucket above covers operations that aren't useful to an LLM. There's a second, sharper reason to exclude something from the tool surface: operations whose failure mode is catastrophic and unrecoverable. Examples span domains — dropping a production database table (data loss across every row), force-emptying a versioned cloud-storage bucket (no recovery once the lifecycle policy fires), revoking the workspace's last admin role (locks everyone out, recovery requires vendor support), GDPR permanent-delete on a customer profile (un-restorable by design), purging an analytics warehouse partition older than the retention window (auditable history gone), or deleting the single audience on a free-plan email platform (nukes every subscriber and historical report in one call). These are useful to an LLM *in principle*, but the blast radius of a mis-call is disproportionate to any agent workflow. Humans do these in the vendor UI, where confirmation dialogs and undo paths exist. Agents shouldn't have the tool at all.
131
132This is distinct from `destructiveHint` — that annotation is for operations that are destructive but recoverable (deleting a task, reverting a commit) and agents should still have them. The "stays in the UI" line applies only to operations whose failure is both catastrophic *and* irreversible.
133
134### 4. Design Tools
135
136This is the highest-leverage step. Tool definitions — names, descriptions, parameters, output schemas — are the **entire interface contract** the LLM reads to decide whether and how to call a tool. Every field is context. Design accordingly.
137
138#### Tool shapes you'll encounter
139
140Most tools follow the `{server}_{verb}_{noun}` default — one focused responsibility, one clear verb, often (but not always) one upstream call. API-wrapping examples: `pubmed_search_articles`, `pubmed_fetch_articles`. Internal-only examples: `markdown_format_text`, `regex_test_pattern`, `tokens_count_text` — same naming convention, no external dep. Two variants warrant explicit design pressures of their own:
141
142| Shape | Purpose | Typical form | Examples |
143|:------|:--------|:-------------|:---------|
144| **Workflow** | Multi-step orchestration that replaces a common agent chain | N upstream calls (often parallelized); may request confirmation; may need mid-flow cleanup | `clinicaltrials_find_studies` (search → filter → rank) |
145| **Instruction** | State-aware procedural guidance — advice, not action | Static markdown + a few live-state fetches, `readOnlyHint: true`, outputs `nextToolSuggestions` pre-filling the recommended follow-up. No writes. | `git_wrapup_instructions` |
146| **Reference** | Decode opaque domain vocabulary — codes, enums, identifier formats, coverage windows — so agents can build valid inputs for the rest of the surface | Static tables or one cached fetch (often zero upstream calls); consolidate N lists under one `topic` enum; `readOnlyHint: true`, `openWorldHint: false` when offline | `medcode_list_systems`, `osv_list_ecosystems` |
147
148These aren't boxes every tool must fit into — some blend shapes — but the design pressures differ enough that naming them helps avoid re-discovering the patterns per server. The subsections below cover considerations specific to each — workflow framing applies broadly; instruction tools, reference tools, and workflow safety are their own subsections.
149
150#### Think in workflows, not endpoints
151
152The unit of a tool is a *useful action*, not an API call. Ask: "What is the agent trying to accomplish?" — not "What endpoints does the API have?"
153
154A single tool can call multiple APIs internally, apply local filtering, reshape data, and return enriched results. The LLM doesn't know or care about the underlying calls.
155
156```ts
157// Workflow tool — search + local filter pipeline, not a raw API proxy
158const findStudies = tool('clinicaltrials_find_studies', {
159 description: 'Matches patient demographics and medical profile to eligible clinical trials. Filters by age, sex, conditions, location, and healthy volunteer status. Returns ranked list of matching studies with eligibility explanations.',
160 // handler: listStudies() → filter by eligibility → rank by location proximity → slice
161});
162```
163
164> **Tip — mode consolidation.** When a tool has several related operations on the same noun, you can consolidate them under one tool with a `mode`/`operation` enum. This affects both naming (noun-led, e.g., `github_pull_request`) and handler design (dispatch by mode). Use when it tightens the surface; skip when ops diverge enough to warrant separate tools.
165
166#### Multi-source tools and fallback chains
167
168**Applies when:** a server aggregates multiple data sources for the same workflow, and the "best" source varies by input type, availability, or coverage. Skip for single-API servers.
169
170When a tool's goal can be served by multiple sources, design it as a **multi-source tool** — the agent calls one tool, the handler routes to the best source (or fans out to several) internally. This is the difference between a "PubMed wrapper" and a "literature research server": `pubmed_search_articles` tries PubMed first, falls back to EuropePMC for broader coverage, then Unpaywall for open access. The agent doesn't choose which API to hit — the server makes that decision based on what works.
171
172Two patterns:
173
174**Source fallback chains** — try sources in priority order, fall through on failure or empty results. Best when sources cover the same data with different depth or availability. The output should indicate which source provided the data so the agent (and human) can assess provenance.
175
176```ts
177// Handler pseudocode — not a real implementation
178async handler(input, ctx) {
179 // Primary: PubMed E-utilities (authoritative, best metadata)
180 const result = await pubmedService.search(input.query);
181 if (result.items.length > 0) return { ...result, source: 'pubmed' };
182
183 // Fallback: EuropePMC (broader coverage, includes preprints)
184 const epmcResult = await epmcService.search(input.query);
185 if (epmcResult.items.length > 0) return { ...epmcResult, source: 'europepmc' };
186
187 return { items: [], source: 'none', message: 'No results from any source.' };
188}
189```
190
191**Multi-source fan-out** — query multiple sources in parallel, merge results. Best when sources provide complementary data about the same entity. Use `Promise.allSettled` so one failing source doesn't tank the whole call.
192
193```ts
194// Handler pseudocode — indicator enrichment across threat intel sources
195async handler(input, ctx) {
196 const [vt, abuse, greynoise] = await Promise.allSettled([
197 vtService.lookup(input.indicator),
198 abuseIpService.check(input.indicator),
199 greynoiseService.query(input.indicator),
200 ]);
201 return {
202 indicator: input.indicator,
203 sources: {
204 virustotal: vt.status === 'fulfilled' ? vt.value : { error: vt.reason.message },
205 abuseipdb: abuse.status === 'fulfilled' ? abuse.value : { error: abuse.reason.message },
206 greynoise: greynoise.status === 'fulfilled' ? greynoise.value : { error: greynoise.reason.message },
207 },
208 // Server synthesizes a verdict from available data — the agent gets a conclusion, not raw API dumps
209 assessment: synthesizeVerdict(vt, abuse, greynoise),
210 };
211}
212```
213
214In both patterns, the tool surface is organized around what the user is doing. Sources are service-layer details — the agent sees `threat_enrich_indicator`, not `virustotal_lookup` + `abuseipdb_check` + `greynoise_query`. Mode-based dispatch by input type (e.g., `indicator_type: 'ip' | 'domain' | 'hash'`) naturally routes to different source chains per mode, since different sources cover different indicator types.
215
216There is no fixed ceiling on tool count — tools need to earn their keep, but don't artificially limit the surface. If the domain genuinely has 20 distinct workflows, expose 20 tools.
217
218#### Cut the surface
219
220After mapping tools, review the full list critically. A tool that covers a niche use case, serves a tiny fraction of agents, or duplicates what another tool already handles is a candidate for deferral. Drop it from the design and note it as a future addition if demand warrants. Every tool in the surface is cognitive load for tool selection — a tight surface outperforms a comprehensive one.
221
222#### Instruction tools
223
224**Applies when:** the domain has recurring "how do I do X well given my current state" questions worth merging with static procedural content. Skip otherwise.
225
226Some domains benefit from a tool whose output is **guidance, not data** — a markdown playbook tailored by live account state, with pre-filled next-step tool calls. These sit between Prompts (static templates, client-invokable) and action tools (do work, return data): they return advice, but the advice is worth more than static text because it merges procedural content with the agent's actual situation.
227
228Characteristics:
229
230- **Output is markdown guidance**, not structured data (though the output schema still has fields — typically `guidance`, `diagnostics`, and `nextToolSuggestions`)
231- **Merges static procedural content with live state** — the value is the tailoring. "You have 12 staged files spanning 4 unrelated changes — split them into separate commits before pushing" beats a generic best-practices article. The same shape works in other domains: "Your slowest query is 2.3s on `orders.customer_id` — add the index before tuning the planner" (database advisor), "Error rate spiked 4× at 14:32 UTC, 4 minutes after the `web@a3f9c2` deploy — roll back before chasing the upstream provider" (incident triage).
232- **`readOnlyHint: true`, `openWorldHint: false`** — no writes, deterministic given the same inputs and account state
233- **Outputs `nextToolSuggestions`** — an array of recommended follow-up tool calls with arguments **pre-filled** from the diagnostics, not just tool names. The agent consumes the playbook, then executes steps with other tools.
234- **Consolidate by `topic` enum** — what could be N separate per-topic tools collapses into one
235
236```ts
237const wrapupInstructions = tool('git_wrapup_instructions', {
238 description: 'Procedural guidance tailored to current repo state. Returns best-practice markdown merged with live diagnostics (staged/unstaged files, branch info, recent commits) and pre-filled follow-up tool calls. Read-only; the agent then executes steps with other tools.',
239 annotations: { readOnlyHint: true, openWorldHint: false },
240 input: z.object({
241 topic: z.enum(['review-changes', 'stage-and-commit', 'push-to-remote'])
242 .describe('Playbook topic. Determines which static guidance is returned and which live state is fetched for tailoring.'),
243 }),
244 output: z.object({
245 guidance: z.string()
246 .describe('Markdown playbook content, tailored to current account state.'),
247 diagnostics: z.record(z.unknown())
248 .describe('Live state used to tailor the guidance (e.g., staged file count, branch divergence, recent commit cadence).'),
249 nextToolSuggestions: z.array(z.object({
250 toolName: z.string().describe('Tool to call next.'),
251 reason: z.string().describe('Why this step is recommended given current state.'),
252 args: z.record(z.unknown()).describe('Arguments pre-filled from diagnostics.'),
253 })).describe('Recommended follow-up calls with arguments already populated.'),
254 }),
255});
256```
257
258Prior art: [`git_wrapup_instructions`](https://github.com/cyanheads/git-mcp-server) walks through staging, commit, and push with repo state inspected. If a server has recurring "how do I do X well given my state" questions, an instruction tool typically beats N topic-specific tools and duplicating guidance in tool descriptions.
259
260#### Reference tools
261
262**Applies when:** the domain speaks in opaque vocabulary — enum codes, classification systems, identifier formats, per-source coverage windows — that agents must supply as inputs elsewhere. Skip when inputs are self-evident (free text, ISO dates, well-known formats).
263
264A reference tool is the surface's decoder ring: which codes exist, what they mean, what format each identifier takes, what each source covers. Consolidate what could be N per-list tools under one `topic` enum, and serve it from static tables or a cached fetch so it stays cheap to call. Its second job is structural: it is the standing **target of recovery routing** — error `recovery` strings, zero-hit notices, and resolver guidance across the surface end with "consult the reference tool, topic X" (see Error design), which only works when the tool exists. Implement it first — it usually has no service dependency, and it grounds field-testing for every other tool.
265
266#### Workflow tool safety
267
268**Applies when:** a tool performs multi-step mutations with destructive modes (`send`/`apply`/`promote`) that benefit from human confirmation before the irreversible step fires. Skip for read-only or idempotent workflows.
269
270Tools that perform multi-step mutations (the Workflow shape) have two safety considerations beyond single-call tools. Both are about giving the agent — and the human behind it — a chance to catch a bad invocation before it commits.
271
272**Confirmation-gated destructive modes, with an annotation fallback.** When a workflow's `mode` parameter switches between safe and destructive arms (`draft` vs `send`, `plan` vs `apply`), gate the destructive arm on a confirmation the handler asks for via `ctx.requestInput(...)`, so a human approves before the irreversible step fires. The handler is re-entered with the answer on `ctx.inputs`; it does not `await` mid-call.
273
274The gate is always *reachable* — `ctx.requestInput` is present on every transport and both protocol eras — but it is not always *answerable*: a client that never fulfils the `input_required` result simply doesn't retry, and the destructive step never runs. The same holds for a 2025-era HTTP client when the server runs `MCP_SESSION_MODE=stateless`, which disables the legacy round-trip shim — the gate refuses and the destructive step never fires. That is the safe outcome, but it makes the tool unusable for those clients, so weigh it before defaulting such a server to `stateless` (`api-context` § `ctx.requestInput`). Keep `destructiveHint: true` in annotations so those clients' own approval flows still surface the risk.
275
276```ts
277annotations: { destructiveHint: true }, // client-side approval flows still see the risk
278// ...
279const Confirm = z.object({ confirmed: z.literal(true).describe('Type true to apply.') });
280
281handler(input, ctx) {
282 if (input.mode === 'apply') {
283 // A decline is terminal — re-asking would loop until the round budget runs out.
284 const view = ctx.inputs.view('confirm');
285 if (view.kind === 'elicit' && view.action !== 'accept') {
286 throw validationError('Migration cancelled by user.');
287 }
288 if (!ctx.inputs.accepted('confirm', Confirm)) {
289 return ctx.requestInput({
290 inputRequests: {
291 confirm: inputRequired.elicit({
292 message: `Apply migration affecting ${affectedRowCount} rows in production? Cannot be rolled back automatically.`,
293 requestedSchema: Confirm,
294 }),
295 },
296 });
297 }
298 }
299 // destructive step proceeds
300}
301```
302
303**Safe defaults on parameters that determine blast radius.** When a workflow accepts a parameter that controls how far-reaching a mutation is, default to the safer value. A bulk file-update tool defaulting `mode: 'preview'` (no writes) means a sloppy agent call shows a diff rather than blasting changes; an apply-plan tool defaulting `dryRun: true` means a misread plan previews rather than executes; an object-delete tool requiring an explicit `confirmCount` matching the result-set size means an unscoped query can't silently nuke a million rows. Agents that genuinely want the destructive behavior have to name it explicitly, which surfaces intent in the tool call and in logs.
304
305#### Tool descriptions
306
307The description is the LLM's primary signal for tool selection. It must answer: *what does this do, and when should I use it?*
308
309- **Be concrete about capability.** "Search for clinical trial studies using queries and filters" beats "Interact with studies."
310- **Include operational guidance when it matters.** If the tool has prerequisites, constraints, or gotchas the LLM needs to know, say so in the description. Don't add boilerplate workflow hints when the tool is self-explanatory.
311- **Prefer a single cohesive paragraph.** Pack operational guidance into prose sentences (separated by periods or em-dashes) rather than bullet lists or blank-line-separated sections. Descriptions render inline in most clients, and bullet structure reads as visual noise rather than signal. Operation-by-operation bullets also duplicate info that already lives in the `operation` enum's `.describe()`.
312- **Don't leak.** Descriptions are for the consumer, not the author. Three categories to audit against:
313 - *Implementation details* — endpoint paths, API call counts, internal parameter mappings, routing logic. Describe what the tool does and when to use it, not how it's wired up.
314 - *Meta-coaching* — directives about how to use the output. "Treat X as the canonical Y", "callers should…", "the LLM should…". The description sells the tool; it doesn't coach the reader.
315 - *Consumer-aware phrasing* — references to "LLM", "agent", "Claude", or any specific reader. The description shouldn't name who's reading it.
316
317```ts
318// Good — describes a prerequisite the LLM must know
319description: 'Set the session working directory for all git operations. This allows subsequent git commands to omit the path parameter.'
320
321// Good — self-explanatory, no workflow hints needed
322description: 'Show the working tree status including staged, unstaged, and untracked files.'
323
324// Good — warns about constraints
325description: 'Fetches trial results data for completed studies. Only available for studies where hasResults is true.'
326```
327
328Descriptions should be as long as needed — concise but complete. Don't artificially truncate, and don't pad with filler.
329
330#### Parameter descriptions
331
332Every `.describe()` is prompt text the LLM reads. Parameters should convey: what the value is, what it affects, and (where non-obvious) how to use it well.
333
334- **Constrain the type.** Enums and literals over free strings. Regex validation for formatted IDs. Ranges for numeric bounds.
335- **Use JSON-Schema-serializable types only.** The MCP SDK serializes schemas to JSON Schema for `tools/list`. Types like `z.custom()`, `z.date()`, `z.transform()`, `z.bigint()`, `z.symbol()`, `z.void()`, `z.map()`, `z.set()` throw at runtime. Use structural equivalents (e.g., `z.string().describe('ISO 8601 date')` instead of `z.date()`).
336- **Explain costs and tradeoffs** when a parameter choice has meaningful consequences.
337- **Name alternative approaches** when a simpler path exists.
338- **Include format patterns** for structured values, but don't pad descriptions with redundant examples.
339
340```ts
341// Good — explains cost, recommends action, names the alternative
342fields: z.array(z.string()).optional()
343 .describe('Specific fields to return (reduces payload size). Without this, the full study record (~70KB each) is returned. Use full data only when you need detailed eligibility criteria, locations, or results.'),
344
345// Good — explains what the flag does AND how to override
346autoExclude: z.boolean().default(true)
347 .describe('Automatically exclude lock files and generated files from diff output to reduce context bloat. Set to false if you need to inspect these files.'),
348
349// Good — names the format and gives one example
350nctIds: z.union([z.string(), z.array(z.string()).max(5)])
351 .describe('A single NCT ID (e.g., "NCT12345678") or an array of up to 5 NCT IDs to fetch.'),
352```
353
354#### Output design
355
356The output schema and `format` function control what the LLM reads back. Design for the agent's *next decision*, not for a UI or an API consumer. See the `add-tool` skill's **Tool Response Design** section for implementation-level patterns (partial success, empty results, metadata, context budget).
357
358**Principles:**
359
360- **Server reports what only the server can know; agent decides what only the agent can know.** Schema, scopes, rate limits, and raw observable state belong to the server. Semantic correctness, intent-vs-effect matching, and recovery choice belong to the agent. For mutators, this means surfacing pre/post observable state rather than throwing on synthetic deltas the server can't authoritatively classify — `file shrunk` could be deliberate truncation or a bug; only the agent knows. See `add-tool` skill's **Mutator response design**.
361- **Include IDs and references for chaining.** If the agent might act on a result, return the identifiers it needs for follow-up tool calls.
362- **Curate vs. pass-through depends on domain.** Medical/scientific data — don't trim fields that could alter correctness. CRUD responses — return what the agent needs, not the full API payload. Match fidelity to consequence.
363- **Surface what was done, not just results.** After a write operation, include the post-state so the LLM can chain without an extra round trip.
364- **Seed orientation context alongside the primary result.** When a tool's call position makes the agent's next moves predictable, attaching a compact snapshot of relevant state — recent activity, tracked state, a couple of reference items — both saves round-trips *and* **primes the LLM on the project's patterns**. Surfacing recent commits teaches the commit-message style the agent should match when it later writes one; recent tags teach the versioning convention; reference records teach the naming format. Common fits: tools that open or close a session (set working dir, wrap-up), state-changing verbs where the caller wants post-action confirmation (commit, push, merge), entry points that drop the agent into a new scope (clone, checkout). Gather sub-operations in parallel with `Promise.allSettled` so a single failure degrades to a warning rather than tanking the outer call.
365- **Communicate filtering.** If the tool silently excluded content, tell the LLM what was excluded and how to get it back. The agent can't act on what it doesn't know about.
366
367```ts
368// git_diff — when lock files are filtered, the output tells the LLM
369output: z.object({
370 diff: z.string().describe('Unified diff output.'),
371 excludedFiles: z.array(z.string()).optional()
372 .describe('Files automatically excluded from the diff (e.g., lock files). Call again with autoExclude=false to include them.'),
373}),
374```
375
376- **Empty results are a designed surface, not a fallthrough.** For every search/list tool, spec the zero-hit behavior at design time: zero hits are success + an `enrichment` notice, never an error, and the notice is composed from condition → fragment pairs (too-narrow filter, a defaulted date window, a syntax trap), each routing to a concrete next call — relax a named filter, switch to the named sibling tool, or consult the reference tool. Relatedly, when the server applies a default that changes result *semantics* (an as-of date, an implicit status filter), echo the applied value in the output — an agent can't reason about a filter it can't see.
377- **Capped lists disclose truncation.** When a tool accepts a cap-like input (`limit`, `per_page`, `page_size`, `max_results`, `max_items`) and returns an array, the handler must disclose when the cap was hit. Standard fields: `truncated: true`, `shown`, `cap` in the `enrichment` block via `ctx.enrich.truncated({ shown, cap })`. `ctx.enrich.total(n)` (writes `totalCount`) is also recognized. Silent caps leave the agent treating a partial set as complete. The `capped-list-no-truncation` lint rule enforces this; see `api-linter` and `api-context`'s `ctx.enrich.truncated()` section.
378- **Truncate large output with counts.** When a list exceeds a reasonable display size, show the top N and append "...and X more". Don't silently drop results.
379- **Spill big *analytical* results to a queryable surface.** When a tool's row set is something an agent would run SQL over (aggregate, group, join) *and* can exceed any reasonable context budget — paginated APIs, streamed exports, big query results — pair an inline preview with a `DataCanvas` table holding the full set. **Two rules gate this:** (1) it must earn its keep on *shape, not size* — a discovery/search surface of categorical metadata (titles, IDs) is not analytical and doesn't get a canvas regardless of row count; for name→ID resolution over a bounded list use [MCP-side list filtering](#mcp-side-list-filtering); (2) the `canvas_id` is reachable only if the same server **also exposes a `dataframe_query` tool** — emit one without the other and the handle is dead output. Compute distributions or refinement hints across the full result, not the preview, so aggregate signal stays honest. See `api-canvas` for the `spillover()` helper and both rules in full.
380- **Outline one large *document* into sections.** When a single tool call returns one document-shaped record (not many rows) that can exceed context — a ~130KB FDA drug label, a big API entity dominated by a few fat fields — return a section *outline* (top-level keys + per-section byte size) instead of truncating, and let the agent re-call with `sections: [...]` to pull only what it needs. The `outlineOnOverflow()` helper (`@cyanheads/mcp-ts-core/utils`) measures the payload and returns a `full | outline` result; declare the tool's `output` as a flat `z.object` with a `kind` discriminator and presence-based optional arms (folding in `OUTLINE_VARIANT.shape.sections` / `.notice`) — `tool()` rejects a `z.discriminatedUnion` output — so `format()`-parity holds when each arm renders on field presence. Pure measure + key-slice — Workers-portable, unlike canvas-bound `spillover()`. Distinct from spillover on *shape*: spillover splits a row collection, this outlines one fat record. See the `techniques` skill's `outline-on-overflow` reference.
381- **Mirror a bulk upstream instead of paginating it live.** When the server wraps a large or slow API whose corpus is queried far more than it changes, sync it once into a persistent local index and query that as the primary data path — not the live API per request. Match the backend to corpus size: ≲ tens of thousands of rows → an in-memory index (server-level, no primitive); ~10⁴–10⁷ → the `MirrorService` (embedded SQLite + FTS5; declare a schema + a `sync` ingester via `defineMirror`/`sqliteMirrorStore`, then `runSync`/`query`, see `api-mirror`); ≳ 10⁸ → an external store. Distinct lifecycle from DataCanvas: a mirror is long-lived and cross-session, refreshed on a schedule; canvas is ephemeral and per-session.
382- **`format()` is the markdown twin of `structuredContent` — make both content-complete.** Different MCP clients forward different surfaces to the model: some (e.g., Claude Code) read `structuredContent` from `output`, others (e.g., Claude Desktop) read `content[]` from `format()`. Both must carry the same data so every client sees the same picture — `format()` just dresses it up with markdown. A thin `format()` that returns only a count or title leaves `content[]`-only clients blind to data that `structuredContent` clients can see. Render all fields the LLM needs, with structured markdown (headers, bold labels, lists) for readability.
383- **Agent-facing context must reach both client surfaces — put it in `enrichment`.** `structuredContent` (from `output`) and `content[]` (from `format()`) are read by different clients. Empty-result notices, the query/filter as the server parsed it, and pagination totals — the context the agent *reasons with*, distinct from the domain payload — reach only `content[]` if hand-authored into `format()` text alone, leaving `structuredContent`-only clients (Claude Code) blind. (The reverse can't happen: `format-parity` drags every `output` field into `format()`, so `output`-authored context already reaches both.) An `enrichment` block — the success-path counterpart to `errors[]`, populated via `ctx.enrich(...)` — reaches both automatically: merged into `structuredContent`, advertised as `output.extend(enrichment)`, mirrored into a `content[]` trailer, no `format()` entry needed. How each field renders in that trailer is a per-tool call — a kind-tag (`notice`/`total`/`echo`/`delta`) when a canonical form fits, a domain key like `totalFound` otherwise, and an `enrichmentTrailer.render` for any structured (object/array) field so it doesn't ship as a JSON blob. See `add-tool`'s **Tool Response Design**.
384
385#### Batch input design
386
387**Applies when:** the upstream API supports batch requests (filter-by-IDs, bulk GET) OR agents commonly need multiple items per call. Skip for inherently single-target operations.
388
389Some tools naturally operate on multiple items — fetching several entities, updating a set of records, running checks across a list. Decide during design whether a tool accepts single items, arrays, or both.
390
391**When to accept array input:**
392
393| Accept array | Keep single-item | Separate batch tool |
394|:-------------|:-----------------|:--------------------|
395| The upstream API supports batch requests (fetch-by-IDs, bulk update) | The operation is inherently single-target (read a file, run a query) | Batch has fundamentally different output shape or error semantics |
396| Reduces N+1 round trips for a common workflow | Array input adds complexity with no backend efficiency gain | Single-item tool is simple; batch version needs progress, partial failure handling |
397| Agent commonly needs multiple items in one step | The tool already returns a collection (search results) | |
398
399**If a tool accepts arrays, design for partial success.** When 3 of 5 items succeed, the agent needs to know which succeeded, which failed, and why — not just a success/failure boolean. Plan the output schema to report per-item results:
400
401```ts
402output: z.object({
403 succeeded: z.array(ItemResultSchema).describe('Items that completed successfully.'),
404 failed: z.array(z.object({
405 id: z.string().describe('Item ID that failed.'),
406 error: z.string().describe('What went wrong and how to resolve it.'),
407 })).describe('Items that failed with per-item error details.'),
408}),
409```
410
411Single-item tools don't need this — they either succeed or throw. The partial success question only arises when the tool can partially complete.
412
413**Telemetry:** The framework automatically detects partial success — when a handler returns a result with a non-empty `failed` array, the span gets `mcp.tool.partial_success`, `mcp.tool.batch.succeeded_count`, and `mcp.tool.batch.failed_count` attributes. No manual instrumentation needed.
414
415#### Convenience shortcuts for complex inputs
416
417**Applies when:** a tool wraps a structured query language or filter system where the 80% case is a simple string. Skip when the primary input is already simple.
418
419When a tool wraps a complex query language or filter system, provide a simple shortcut parameter for the 80% case alongside the full-power escape hatch. This keeps simple queries simple while preserving full expressiveness.
420
421```ts
422// text_search handles the common case; query handles everything else
423text_search: z.string().optional()
424 .describe('Convenience shortcut: full-text search across title and abstract. For structured filters or field-specific matching, use the query parameter instead.'),
425query: z.record(z.unknown()).optional()
426 .describe('Full query object for structured filters. Supports operators: _eq, _gt, _and, _or, ...'),
427```
428
429The pattern: name the shortcut for what it does (`text_search`, `name_search`), document what it expands to, and point to the full parameter for advanced use. Validate that at least one of the two is provided.
430
431#### MCP-side list filtering
432
433**Applies when:** an upstream API has no native search, the relevant set is bounded (fits one or a few fetches), and an agent needs to resolve a name → opaque ID. Skip when the API already searches, or when the set is unbounded (bills, votes, filings) — that belongs in the DataCanvas dataframe layer (`*_dataframe_query`), not an in-memory filter.
434
435Two params, two behaviors — keep them named distinctly:
436
437- **`query`** → **upstream** full-text search. The API does the work; it may honor operators and ranking.
438- **a local filter param** → **fetched-then-filtered on our side**. Name it for the mechanic: `filter` or `nameContains` (the latter self-documents the local, name-keyed half of the split). Don't overload `query` for it — the two have different semantics and different cost.
439
440**Earns-its-keep gate — all must hold:** bounded set; no native upstream search; real scan pain (opaque IDs, a large/unordered list, or a default page that hides relevant rows); and it filters the natural lookup key (name/title). When any fails, skip it — paginate, or send the agent to upstream `query`.
441
442**Correctness: filter the *complete* bounded set, not the current page.** Fetch up to the cap (or page through) before filtering — filtering one page returns a misleading partial slice.
443
444**Matching: strict token match is the default.** Normalize (lowercase, strip punctuation/diacritics) and require every query token to appear, so word order and missing interior words still match. That strict core is the ~90% case, needs no fuzzy library, and is too small to centralize (~6 lines — guidance, not a shared helper). Add a fuzzy fallback **only when a caller genuinely needs typo tolerance** (an LLM caller rarely does): fire it only when the strict match is empty, score against the best-matching *token* in each name (not the whole string) and **cap** the results — or one short query clears the threshold against dozens of long multi-word names — and label its hits `approximate`. Often a bare "no match — call the unfiltered list to browse" beats an `approximate` guess: it lets the model self-correct instead of committing to the wrong record. See `add-tool` for the param + handler implementation.
445
446#### Error design
447
448Errors are part of the tool's interface — design them during the design phase, not as an afterthought. Three aspects: **the contract** (which failures are public), **classification** (what error code), and **messaging** (what the LLM reads).
449
450**Declare a typed contract for domain failures.** When a tool has known failure modes the agent should plan around (`no_match`, `queue_full`, `vendor_down`), enumerate them as `errors: [{ reason, code, when, recovery, retryable? }]` on the definition. `recovery` is required metadata — the agent's next move when this failure fires (≥ 5 words, lint-validated; spread `ctx.recoveryFor('reason')` into the throw-site `data` to send it on the wire as `data.recovery.hint`). The framework types `ctx.fail(reason, …)` against the declared reason union (typos become TS errors) and auto-populates `data.reason` on the thrown error for stable observability. The error reaches clients with parity across both surfaces — `structuredContent.error` (Claude Code) and `content[]` text (Claude Desktop). Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`) bubble from anywhere and don't need to be enumerated. See `api-errors` skill for the full pattern.
451
452**Classify errors by origin.** Different error sources need different codes and different recovery guidance. Map the failure modes for each tool during design:
453
454| Origin | Examples | Error code | Agent can recover? |
455|:-------|:---------|:-----------|:-------------------|
456| **Client input** | Bad ID format, invalid params, missing required field, out-of-range value | `ValidationError` | Yes — fix the input and retry |
457| **Upstream API** | 5xx, rate limit (429), timeout, network error | `ServiceUnavailable` | Maybe — retry later, or the upstream is down |
458| **Not found** | Valid ID format but entity doesn't exist | `NotFound` (or `ValidationError` if ambiguous) | Yes — check the ID, try a search |
459| **Auth/permissions** | Insufficient scopes, expired token | `Forbidden` / `Unauthorized` | Maybe — escalate or re-auth |
460| **Server internal** | Parse failure, missing config, unexpected state | `InternalError` | No — server-side issue |
461
462(`InvalidParams` also exists — the SDK emits it when input fails Zod schema validation before the handler runs. Anything the handler itself throws about inputs uses `ValidationError`.)
463
464The framework auto-classifies many of these at runtime (HTTP status codes, JS error types, common patterns), but explicit classification in the handler gives better error messages. For declared contract failures, throw via `ctx.fail('reason', …)`. For ad-hoc throws outside the contract, use error factories (`notFound()`, `validationError()`, etc.) when the code matters; plain `throw new Error()` when the framework's auto-classification is good enough.
465
466**Expected misses are results, not errors.** When a tool's whole job is resolving one identifier — a citation, a code, a name → ID — a no-match is an expected outcome the agent must reason about, not a failure. Return `{ found: false, guidance }` instead of throwing, and treat `guidance` as a first-class recovery surface: per miss outcome, say what didn't parse or resolve and route to the named tool that can recover (the broader search tool, the reference tool). Agents self-correct better from a structured miss than from a throw — a throw reads as "something broke," a miss result reads as "adjust and retry."
467
468**Write error messages as recovery instructions.** The message is the agent's only signal for what to do next — and the strongest recovery instruction ends in a named tool call, never a bare "check your input."
469
470```ts
471// Bad — dead end, no recovery path
472throw new Error('Not found');
473
474// Good — names both resolution options
475"No session working directory set. Please specify a 'path' or use 'git_set_working_dir' first."
476
477// Good — structured hint in error data using the canonical `data.recovery.hint` shape.
478// The framework auto-mirrors `data.recovery.hint` into the content[] text as
479// `Recovery: <hint>` so format()-only clients (Claude Desktop) see the same
480// guidance structuredContent clients (Claude Code) read from `error.data.recovery.hint`.
481throw forbidden(
482 "Cannot perform 'reset --hard' on protected branch 'main' without explicit confirmation.",
483 {
484 branch: 'main',
485 operation: 'reset --hard',
486 recovery: { hint: 'Set the confirmed parameter to true to proceed.' },
487 },
488);
489
490// Good — upstream error with actionable context
491throw notFound(`Paper '${id}' not found on arXiv. Verify the ID format (e.g., '2401.12345' or '2401.12345v2').`);
492```
493
494**During design, settle the full contract for each tool** — reason, code, when-clause, *and the verbatim `recovery` string* — in the tool's section of the design doc; they become the literal `errors: [...]` entries during scaffolding. Hold every recovery string (and zero-hit notice, and resolver `guidance`) to the **no-dead-ends rule: it names the concrete next tool call**, with the reference tool as the most common routing target. Settled at design time these stay sharp; left to implementation they degrade into "check your input." Not every failure needs a contract entry; baseline infrastructure errors (5xx, timeouts, validation) are fine to let bubble.
495
496#### Design table
497
498Summarize each tool:
499
500| Aspect | Decision |
501|:-------|:---------|
502| **Name** | Lowercase snake_case with a canonical server prefix. **3 segments is the strong default** (`{server}_{verb}_{noun}` — e.g., `pubmed_search_articles`, `clinicaltrials_find_studies`). **2 is fine when the operation name is canonical** and no noun adds signal (`git_pull`, `git_status` — "pull" already implies the remote). Don't invent a word to pad to 3. **4 is fine when the noun is inherently two words** (`patentsview_search_patent_families`) or the prefix is multi-part. Use the canonical platform/brand name as prefix, not abbreviations (`patentsview_` not `patents_`, `clinicaltrials_` not `ct_`). The verb+noun pair should be unambiguous within the server — if two tools could plausibly share a name, the noun isn't specific enough (`read_fulltext` not `read_text` when structured metadata is a separate concept). **Treat name length as a scope smell only when** the extra segment is the *verb* overreaching (e.g., `foo_create_and_send_notification` → split or use modes). |
503| **Granularity** | Scope each tool to one coherent agent action. The implementation can be a single API call (`pubmed_search_articles`), a multi-step workflow, or internal-only — match the unit to the work, don't constrain by call count. |
504| **Description** | Concrete capability statement. Add operational guidance (prerequisites, constraints, gotchas) when non-obvious. |
505| **Input schema** | `.describe()` on every field. Constrained types (enums, literals, regex). Explain costs/tradeoffs of parameter choices. |
506| **Output schema** | Designed for the LLM's next action. Include chaining IDs. Communicate filtering. Post-write state where useful. |
507| **Errors** | Declare domain failure modes as a typed contract (`errors: [{ reason, code, when, recovery, retryable? }]`) so `ctx.fail` is type-checked and capable clients can preview failures via `tools/list`. Every `recovery` string follows the no-dead-ends rule — it names the next tool call. |
508| **Annotations** | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Helps clients auto-approve safely. |
509| **Auth scopes** | `tool:<snake_tool_name>:<verb>` or `resource:<kebab-resource-name>:<verb>` (e.g., `tool:inventory_search:read`, `resource:echo-app-ui:read`). Domain-led `<domain>:<verb>` (e.g., `inventory:read`) is an acceptable alternative — pick one convention per server and stay consistent. Skip for read-only or stdio-only servers. |
510
511### 5. Design Resources
512
513Resources are supplementary — a convenience for clients that support injectable context via stable URIs. Since many clients are tool-only, verify that any data exposed via resources is also reachable through the tool surface. This doesn't require a 1:1 resource-to-tool mapping — the data might be covered by an existing tool's output, bundled into a broader tool, or warrant its own dedicated tool, depending on the server's purpose and how agents will use it.
514
515For each resource:
516
517| Aspect | Decision |
518|:-------|:---------|
519| **URI template** | `scheme://{param}/path`. Server domain as scheme. Keep shallow. |
520| **Params** | Minimal — typically just an identifier. Complex queries belong in tools. |
521| **Pagination** | Needed if lists exceed ~50 items. Opaque cursors via `extractCursor`/`paginateArray`. |
522| **list()** | Provide if discoverable. Top-level categories or recent items, not exhaustive dumps. |
523| **Tool coverage** | Verify the data is reachable via tools — either a dedicated tool, included in another tool's output, or not needed for tool-only agents. |
524
525### 6. Design Prompts (if needed)
526
527Optional. Use when the server has recurring interaction patterns worth structuring:
528
529- Analysis frameworks, report templates, multi-step workflows
530
531Skip for purely data/action-oriented servers.
532
533### 7. Plan Services and Config
534
535**Services** — one per external dependency (or per source, for multi-source servers). Init/accessor pattern. Skip if all tools are thin wrappers with no shared state. For multi-source servers, each upstream API gets its own service with its own auth, rate limits, and retry config — tools compose across services internally, agents never see the service boundary.
536
537**Server-as-service.** When the server IS the source of truth (knowledge graph, in-memory task tracker, local scratchpad, embedded inference wrapper), the resilience table below doesn't apply — there's no upstream to retry. The design questions shift to state management: what's tenant-scoped vs. global, what TTLs apply, what survives a restart, what the storage backend is. Plan persistence via `ctx.state` for tenant-scoped KV (auto-namespaced by `tenantId`), or use a `StorageService` provider directly when data must cross tenants. Service init still happens in `setup()`, accessed via `getMyService()` at request time. Calls within the server are local and synchronous-ish — the API-efficiency table below also doesn't apply.
538
539**Analytical API servers: DataCanvas is one option.** For servers that fetch **analytical** data — result sets an agent runs SQL over (aggregate, group, join, time-series) — and want to expose a SQL workspace, the framework's optional `DataCanvas` primitive (Tier 3, opt-in via `CANVAS_PROVIDER_TYPE=duckdb`) handles lifecycle, ID generation, eviction, and export wiring so you don't design your own. **It earns its keep on shape, not size:** a discovery/search surface returning categorical metadata (titles, IDs, types) — where the workflow is find-the-record-then-drill-in — does *not* qualify even when the result is large; resolve names over a bounded set with [MCP-side list filtering](#mcp-side-list-filtering) instead. **If you opt in, the consumer tools are mandatory:** a tool that emits a `canvas_id` MUST be paired with a `dataframe_query` (and `dataframe_describe`) tool in the same surface — a `canvas_id` with no query tool is dead output the agent can't reach. Surface `canvas_id` as an optional input on register/query/export tools; the framework mints on omit and resolves on match. Tools access it via `ctx.core.canvas?` (undefined when disabled or running on Workers — DuckDB has no V8-isolate build). See `api-canvas` for the full reference.
540
541For services wrapping external APIs, plan the resilience layer.
542
543| Concern | Decision |
544|:--------|:---------|
545| **Retry boundary** | Service method wraps full pipeline (fetch + parse), not just the network call. Use `withRetry` from `/utils`. |
546| **Backoff calibration** | Match base delay to upstream recovery time: 200–500ms (ephemeral), 1–2s (rate-limited), 2–5s (degraded). |
547| **HTTP status check** | `fetchWithTimeout` already handles this — non-OK → `ServiceUnavailable`. |
548| **Parse failure classification** | Response handler detects HTML error pages and throws transient errors, not `SerializationError`. |
549| **Exhausted retry messaging** | `withRetry` enriches the final error with attempt count automatically. |
550
551For API efficiency, design the service methods to minimize upstream calls:
552
553| Concern | Decision |
554|:--------|:---------|
555| **Batch over N+1** | If the API supports filter-by-IDs or bulk-GET endpoints, use a single batch request instead of N individual fetches. Cross-reference the response against requested IDs to detect missing items. |
556| **Field selection** | If the API supports `fields`/`select` parameters, request only the fields the tool needs. A full study record might be 70KB; selecting 4 fields might be 5KB. |
557| **Request consolidation** | When a tool needs data from multiple related endpoints, check if a single endpoint with broader field selection can serve the same data in one round trip. |
558| **Pagination awareness** | If a batch request might exceed the API's page size, either paginate internally or assert/throw when results are truncated so callers aren't silently missing data. |
559
560**Config** — list env vars (API keys, base URLs). Goes in `src/config/server-config.ts` as a separate Zod schema.
561
562### 8. Write the Design Doc
563
564Create `docs/design.md` with the structure below. The MCP surface (tools, resources, prompts) goes first — it's what matters most and what the developer will reference during implementation.
565
566```markdown
567# {{Server Name}} — Design
568
569## MCP Surface
570
571### Tools
572| Name | Description | Key Inputs | Annotations |
573|:-----|:------------|:-----------|:------------|
574
575### Resources
576| URI Template | Description | Pagination |
577|:-------------|:------------|:-----------|
578
579### Prompts
580| Name | Description | Args |
581|:-----|:------------|:-----|
582
583## Overview
584
585What this server does, what system it wraps, who it's for.
586
587## Requirements
588
589- Bullet list of capabilities and constraints
590- Auth requirements, rate limits, data access scope
591
592## User Goals
593
5941. Numbered outcomes agents will accomplish (from Step 2) — every tool in the surface traces back to one
595
596## Tools — detail
597
598One subsection per tool: a param table (param | type | maps-to | notes), the output field
599list, the error contract table (reason | code | when | recovery — verbatim strings), and
600zero-hit notice fragments for search tools. This is the section implementation reads
601tool-by-tool.
602
603## Services
604| Service | Wraps | Used By |
605|:--------|:------|:--------|
606
607## Config
608| Env Var | Required | Description |
609|:--------|:---------|:------------|
610
611## Server Instructions
612
613Draft `instructions` string for `createApp()` — the orientation every client sees at
614initialize: canonical workflow chain, identifier semantics, rate-limit posture. A short
615paragraph; tool descriptions carry the rest.
616
617## Implementation Order
618
6191. Config and server setup
6202. Services (external API clients)
6213. Reference tool (static, no service dependency — grounds field-testing for everything else)
6224. Read-only tools
6235. Write tools
6246. Resources
6257. Prompts
626
627Each step is independently testable.
628
629<!-- Optional sections — include when the trigger fires: -->
630## Domain Mapping <!-- nouns × operations → API endpoints; include when ≥3 nouns each with ≥3 operations -->
631## Workflow Analysis <!-- how tools chain for real tasks; include when any tool makes ≥3 upstream calls -->
632## Design Decisions <!-- rationale for consolidation, naming, tradeoffs; include when a choice would otherwise be opaque -->
633## Known Limitations <!-- inherent API/data constraints the server can't solve; include when a constraint visibly caps utility -->
634## API Reference <!-- query language, pagination, rate limits; include when worth documenting -->
635```
636
637Keep it concise. The design doc is a working reference, not a spec document — enough to orient a developer (or agent) implementing the server, not more.
638
639**Workflow Analysis example.** For multi-step workflow tools, document the upstream call sequence in a table — it drives several downstream decisions during implementation: the service-layer method shape, retry boundaries, where cleanup or the confirmation round belongs, and what post-action state to fetch for the response.
640
641`deploy_release` (5–8 upstream calls, plus a confirmation round):
642
643| # | Call | Purpose | Mode gate |
644|:--|:-----|:--------|:----------|
645| 0 | `ctx.requestInput` confirmation | Human approval before promote | `promote` |
646| 1 | `POST /releases` | Create release record | always |
647| 2 | `PUT /releases/{id}/artifacts` | Attach build artifacts | always |
648| 3 | `GET /releases/{id}/preflight` | Health checks, smoke tests | always |
649| 4 | `POST /releases/{id}/canary` | Deploy to 5% of traffic | `canary` |
650| 5 | `POST /releases/{id}/promote` | Roll out to 100% | `promote` |
651| 6 | `POST /releases/{id}/rollback` | Restore previous version | `rollback` |
652| 7 | `GET /releases/{id}` | Post-action state for response | always |
653| — | `DELETE /releases/{id}/canary-traffic` | Cleanup canary if mid-flow error | on error + `cleanupOnError` |
654
655The table surfaces design questions early: should the confirmation round happen before or after the artifacts are attached? Does cleanup drop the canary on any failure, or only failures past the promote step? What does the response body need from the final GET — version, traffic percentage, health summary? Answering these during design is far cheaper than mid-implementation.
656
657### 9. Confirm and Proceed
658
659If the user has already authorized implementation — any message that contains both a design request and a build/implement verb in the same clause (e.g., "build me a ___ server", "design and implement a ___") — proceed directly to scaffolding using the design doc as the plan. Otherwise, present the design doc to the user for review before implementing.
660
661## After Design
662
663Execute the plan using the scaffolding skills:
664
6651. `add-service` for each service
6662. `add-tool` for each standard tool
6673. `add-resource` for each standalone resource
6684. `add-prompt` for each prompt
6695. `add-app-tool` *only if any app tools survived the design step* (rare — see the App Tool row in Step 3)
6706. `devcheck` after each addition
671
672## Checklist
673
674Items without an `If …:` prefix apply to every design. Conditional items only apply when the trigger fires — otherwise skip them.
675
676- [ ] Server scope decided — workflow identified, audience sized, boundary drawn (standalone single-API vs. multi-source aggregation vs. internal-only)
677- [ ] **If multi-source:** tool surface organized around user workflows, not API identity. Sources are service-layer details.
678- [ ] External APIs/dependencies researched and verified (docs fetched, SDKs identified)
679- [ ] **If wrapping an external API:** live API probed (at minimum: one list/search, one single-item GET, one error case)
680- [ ] User goals enumerated first (3–10 outcomes agents will accomplish, scaled to domain size), then domain operations mapped as raw material
681- [ ] Each operation classified as tool, resource, prompt, or excluded
682- [ ] Catastrophically irreversible operations excluded from the tool surface (stay in vendor UI) — not just `destructiveHint`
683- [ ] Tool surface audited — niche, overlapping, or low-value tools cut or deferred
684- [ ] Tool surface is self-sufficient — a tool-only agent can accomplish everything the server is for
685- [ ] Workflow and Instruction variants considered where they add value (single-action tools are the default)
686- [ ] Tool descriptions are concrete and include operational guidance where non-obvious
687- [ ] Parameter `.describe()` text explains what the value is, what it affects, and tradeoffs
688- [ ] Input schemas use constrained types (enums, literals, regex) over free strings
689- [ ] Output schemas designed for LLM's next action — chaining IDs, post-write state, filtering communicated
690- [ ] `format()` renders all data the LLM needs — different clients forward different surfaces (Claude Code → `structuredContent`, Claude Desktop → `content[]`); both must carry the same data, not just a count or title
691- [ ] Error messages guide recovery — name what went wrong and the next tool call (no dead ends)
692- [ ] **If a tool has known domain failure modes:** typed error contract declared (`errors: [{ reason, code, when, recovery, retryable? }]`) with verbatim `recovery` strings settled in the design doc, each naming the next tool call
693- [ ] **If the server has search/list tools:** zero-hit notices specced (condition → fragment, each routing to a named next call); server-applied defaults that change result semantics echoed in output
694- [ ] **If a tool resolves a single identifier:** no-match returns `{ found: false, guidance }` — a result, not a throw — with guidance routing per miss outcome
695- [ ] **If the domain has opaque vocabulary (codes, identifier formats, coverage windows):** reference tool designed (`topic` enum), implemented first, and used as the routing target in recovery strings and notices
696- [ ] Annotations set correctly (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`)
697- [ ] Server-level `instructions` string drafted — workflow chain, identifier semantics, rate-limit posture (ships via `createApp()` on every initialize)
698- [ ] Design doc written to `docs/design.md`
699- [ ] Design confirmed with user (or user pre-authorized implementation)
700- [ ] **If ops share a noun:** related operations consolidated under one tool with `mode`/`operation` enum
701- [ ] **If an upstream API has no native search but the relevant set is bounded:** MCP-side list filtering considered — a distinct local filter param (`filter`/`nameContains`, not `query`), filtering the full set, strict token match (fuzzy only when a caller needs typo tolerance)
702- [ ] **If the server has workflow tools:** call-flow documented (upstream sequence + mode arms) in design doc's Workflow Analysis
703- [ ] **If state-aware procedural guidance adds value:** instruction tool considered with `nextToolSuggestions` pre-filled from diagnostics
704- [ ] **If workflow tools have destructive modes:** destructive arm gated on a `ctx.requestInput` confirmation read back from `ctx.inputs`, with `destructiveHint` annotation so clients that never fulfil the round still surface the risk
705- [ ] **If a parameter determines blast radius:** safe default set (e.g., `mode: 'preview'`, `dryRun: true`, `confirmCount` required)
706- [ ] **App tools default to no.** If one was proposed, verified there's a real human-in-the-loop in an MCP Apps-capable client justifying the iframe/CSP/`format()`-twin maintenance cost — otherwise dropped in favor of a standard tool
707- [ ] **If the server exposes resources:** URIs use `{param}` templates, pagination planned for large lists
708- [ ] **If the server is itself the source of truth (no external API):** state lifecycle planned — tenant-scoped vs. global, TTLs, what survives restart, storage backend chosen
709- [ ] **If the server has external deps or shared state:** service layer planned (or explicitly skipped with reasoning)
710- [ ] **If services wrap external APIs:** resilience planned (retry boundary, backoff, parse classification)
711- [ ] **If multi-source server:** each source has its own service with independent auth/retry/rate-limit config. Fallback chains or fan-out strategy documented per tool. Output includes source provenance.
712- [ ] **If exposing a SQL/analytical workspace is in scope:** DataCanvas considered (`api-canvas` skill), and it earns its keep on *analytical* fit (an agent would SQL it), not row count — a discovery/search surface of categorical metadata doesn't qualify. Any tool emitting a `canvas_id` is paired with a `dataframe_query` (+ `dataframe_describe`) tool in the same surface — a token with no query tool is dead output
713- [ ] **If the server needs runtime config:** env vars identified in `server-config.ts`