Guides building a quality MCP server for an external API: task-shaped tool design, auth, error handling, and testing.
What it does
You want an AI assistant to work inside a service you already use, such as your ticketing system, your database, or an internal API. The connector that makes this possible is called an MCP server, and building one is easy to get half right: the assistant can technically reach the service but keeps calling the wrong thing, asking for data it cannot use, or stopping at an error message that tells it nothing.
With this skill active, the work starts from the jobs the assistant should be able to finish rather than from a list of API routes. Each tool gets a name and a description written for the assistant that will choose it, typed inputs so a wrong value comes back as a correction instead of a failure, results trimmed to what is actually useful, and error messages that say what to do next. Credentials stay in the environment, and anything that changes data is kept separate from anything that only reads.
You end up with a working server, a short review pass over its tool list, and a set of real task questions used to check that an assistant can actually complete work with it.
You end up with a working MCP server, a reviewed tool list, and task questions that prove an agent can use it.
When to reach for it
Use it when you are connecting a service to an AI assistant for the first time, or when a connector already exists and the assistant keeps using it wrong, which is nearly always a design problem rather than a model problem.
Skip it if someone already publishes a maintained connector for that service. Installing theirs is faster than building your own, and this skill is for the case where nothing suitable exists.
---
name: mcp-server-builder
description: Builds an MCP server that exposes an external API or system as tools an agent can call correctly on the first try - tool design and naming, typed schemas, model-facing descriptions, pagination, errors that guide recovery, auth, and a test pass. Use when wrapping a service as MCP tools, or when reviewing a server whose tools an agent keeps misusing.
license: Apache-2.0
metadata:
source: anthropics/skills
---
# MCP Server Builder
You build the adapter between an agent and an outside system. MCP (Model Context Protocol) is the open standard that lets a client discover your tools and call them over one interface, so the same server works in any MCP-capable client.
The measure of quality is not coverage or elegance. It is whether an agent that has never seen your server accomplishes a real task with it, first try, without reading your source.
## Workflow
1. **Collect the jobs, not just the endpoints.** Ask what an agent should actually accomplish with this service. Write those down as sentences ("find the open tickets for a customer and add a note"). They drive every later decision.
2. **Read the API.** Endpoints, auth model, rate limits, pagination style, error shapes, and which operations are destructive. Note anything that needs an id the agent has no way to obtain.
3. **Pick language and transport.** stdio for a local server that runs as a subprocess of the client; streamable HTTP for a remote one serving multiple clients. TypeScript and Python both have first-party SDKs; follow whichever the project already uses. See `references/reference.md` for the transport comparison.
4. **Design the tool set.** Cover the service's common operations, and add a task-shaped tool wherever a job takes three predictable calls in a row. Details and the naming convention are below.
5. **Type every input.** Zod in TypeScript, Pydantic in Python. Constraints, enums, and a description on each field, with an example value where the format is not obvious.
6. **Write each description for the model.** Name and description are the only things an agent sees when choosing. State what the tool does, when to use it, and what comes back.
7. **Shape the output.** Return the fields an agent reasons over, plus the identifiers it needs for follow-up calls. Paginate lists. Declare an output schema where the SDK supports one.
8. **Turn errors into guidance.** Every failure returns text that names the problem and the next move.
9. **Build one tool end to end before the rest.** Auth, call, shape, error path, all working, then repeat the pattern.
10. **Test from a real client.** Discovery, a happy path, a bad input, an auth failure. Then write about ten realistic task questions and check an agent can answer them using only the tools.
```text
Server review checklist
[ ] Every tool name is prefixed with the service and starts with a verb
[ ] Every description says what it does, when to use it, and what it returns
[ ] Every input field is typed, constrained, and described
[ ] List tools take a limit and return has_more plus a cursor or next offset
[ ] Outputs carry the ids needed for the follow-up call
[ ] Every error path returns a sentence an agent can act on
[ ] Destructive tools are annotated and separated from read tools
[ ] Credentials come from the environment and never appear in output or logs
[ ] A real client completed one full task without reading the source
```
## Tool design
**Name them predictably.** `{service}_{action}_{resource}`, lowercase with underscores: `linear_create_issue`, `linear_list_projects`. The service prefix matters because your server will sit alongside others in the same agent, and a bare `search` collides. Start with a verb, and be specific enough that the name alone narrows the choice.
**Match tool shape to the work.** Two failure modes sit either side of the right answer:
- One tool per endpoint, so the agent has to orchestrate five calls the server could have done itself.
- Only a handful of workflow tools, so anything the author did not anticipate is impossible.
Cover the common operations, then add workflow tools for the sequences that recur. When you are unsure, lean toward coverage: an agent can compose small tools, but it cannot invent one that does not exist.
**Annotate behavior.** `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. These are hints for clients, not a security boundary, so still enforce anything that matters in the code.
**Paginate every list.** Respect a `limit`, default it to something modest, and return `has_more` with a cursor or next offset. A tool that dumps ten thousand rows into a context window has failed even though it returned the right data.
## Descriptions are prompts
An agent picks a tool from its name and description alone. Write them the way you would write instructions to a competent stranger who cannot see your code.
```python
@mcp.tool()
def zendesk_search_tickets(
query: str,
status: Literal["open", "pending", "solved"] | None = None,
limit: int = 25,
) -> dict:
"""Search support tickets by text, optionally filtered by status.
Use to find tickets when you do not already have a ticket id. Returns the id,
subject, status, requester email, and last update time for each match, plus
has_more and next_cursor. Use zendesk_get_ticket for the full conversation.
"""
```
The last sentence is doing real work: it tells the agent where to go next, which is how you keep a search tool from returning entire conversations nobody asked for.
## Errors that guide recovery
A failure is a chance to tell the agent what to do differently. Return the problem and the fix, inside the result rather than as a protocol crash.
```typescript
try {
const result = await api.listOrders(args);
return { content: [{ type: "text", text: format(result) }] };
} catch (error) {
return {
isError: true,
content: [{
type: "text",
text: `Could not list orders: ${describe(error)}. ` +
`If this was a rate limit, wait 30s and retry with a smaller limit.`,
}],
};
}
```
Compare what the agent can do with each of these:
| Returned | What happens next |
| --- | --- |
| `500` | The agent guesses, or gives up |
| `Error: invalid request` | The agent retries the same call |
| `status must be one of open, pending, solved` | The agent fixes the argument and succeeds |
Never let an error carry a stack trace, a query string with credentials in it, or internal hostnames.
## Auth and safety
- Read credentials from environment variables or secure config. Validate them at startup and fail with a clear message rather than on the first tool call.
- Scope tokens to the minimum the tools need. For per-user auth, keep each session's token isolated.
- Validate inputs before they reach the API: path segments, urls, sizes, ranges. A tool that takes a path and reads a file is a directory-traversal bug waiting to happen.
- Keep read and write in separate tools. A lookup that can mutate data will eventually mutate data.
- A local HTTP server binds to `127.0.0.1` and checks the `Origin` header.
- Log tool calls for debugging, with argument values redacted.
## Example
**Request.** "Wrap our ticketing API so the support agent can work in it."
**Weak.** Three tools: `get(path)`, `post(path, body)`, `put(path, body)`. This is the HTTP client with extra steps. The agent has to know the routes, so it guesses, and every mistake is a 404 with no recovery path.
**Strong.** Five tools, named and shaped around the work:
```text
tickets_search_tickets(query, status?, assignee?, limit=25) read
tickets_get_ticket(ticket_id) read
tickets_add_comment(ticket_id, body, public=false) write
tickets_set_status(ticket_id, status) write
tickets_list_queues() read
```
`search` returns ids plus enough context to choose between results, and says in its description to call `get_ticket` for the full thread. `set_status` takes an enum, so a wrong value comes back as `status must be one of open, pending, solved` and the agent self-corrects instead of failing. Writes are separate tools with `readOnlyHint: false`, so a client can require confirmation on exactly those two.
## Edge cases
- **The API is enormous.** Cover the operations that appear in the jobs list, then stop. Every extra tool costs context in every session and adds a way to choose wrong. Growth is cheap later; a bloated first release is not.
- **An operation needs an id the agent cannot get.** That is a missing tool. Add the lookup, or accept a human-meaningful identifier and resolve it inside the server.
- **The API paginates in its own way.** Hide it. The agent sees your `limit` and cursor, not their `page[offset]` scheme.
- **A response is huge.** Summarize or paginate at the server. Returning forty fields per row so the agent can ignore thirty-seven of them wastes the window you are trying to protect.
- **Long-running operations.** Return a job id immediately with a tool to check status, rather than holding the call open until it times out.
- **The server exists and an agent keeps misusing one tool.** Read the description as if you had never seen the code. Nearly always the name is ambiguous, the return shape is undocumented, or two tools sound like the same thing.
- **Changing a tool other agents already use.** Add the new shape alongside the old one and deprecate in the description. A silent shape change breaks every caller at once.
## Files
- `references/reference.md` - naming conventions, the transport comparison, the pagination contract, annotation table, response format guidance, and the security checklist. Read during design, and again before the final review.
- `references/examples.md` - two full builds: an endpoint-mirroring server rewritten into a task-shaped one, and a read-only search server with its schemas, error messages, and evaluation questions. Read before designing your own tool set.
- `scripts/lint_tools.py` - run it against a JSON dump of your server's tool list: `python scripts/lint_tools.py tools.json`. Flags naming inconsistencies, thin descriptions, undescribed parameters, missing pagination, and unannotated destructive tools. It reads the declared shape only, so it catches design slips, not runtime bugs.
## Attribution
Adapted from the `mcp-builder` skill in [anthropics/skills](https://github.com/anthropics/skills/tree/main/skills/mcp-builder), Apache-2.0. The four-phase workflow, naming conventions, pagination and annotation guidance, transport selection and evaluation approach come from there. The lint script, the error-recovery table and the worked builds are ours.
references/examples.md
# Worked builds
Two servers in full: one rewritten after agents kept misusing it, one built from scratch as read-only.
## Contents
- [Build 1: an endpoint mirror, rewritten](#build-1-an-endpoint-mirror-rewritten)
- [Build 2: a read-only search server](#build-2-a-read-only-search-server)
- [Patterns worth copying](#patterns-worth-copying)
---
## Build 1: an endpoint mirror, rewritten
**The complaint.** "The agent takes eleven calls to answer 'what is blocking the release' and half the time it gives up."
**What was there.** A generated server with one tool per route, twenty-eight of them:
```text
issues_get_issues(project_id, state, labels, assignee, page, per_page, sort, direction)
issues_get_issue(issue_id)
issues_get_issue_links(issue_id)
issues_get_issue_comments(issue_id, page)
issues_get_labels(project_id)
issues_get_milestones(project_id)
...
```
Tracing one real session showed what went wrong. To answer the question the agent had to: list issues in the milestone, page through them, fetch each issue's links to find blockers, fetch each blocker to read its state, then fetch comments to find out why. Eleven calls, most of them returning forty-field payloads, and by call eight the earlier results had been pushed out of the window.
**The rewrite.** Nine tools. The common reads stayed, with trimmed output. Three workflow tools were added for the sequences that kept repeating:
```text
issues_search(query, state?, milestone?, assignee?, limit=25) read
issues_get(issue_id, include_comments=false) read
issues_list_blockers(issue_id, depth=1) read
issues_milestone_status(milestone) read
issues_create(title, body, project_id, labels?) write
issues_comment(issue_id, body) write
issues_set_state(issue_id, state) write
issues_assign(issue_id, assignee) write
issues_list_projects() read
```
`issues_milestone_status` is the whole original eleven-call sequence, done server-side:
```python
@mcp.tool(annotations={"readOnlyHint": True})
def issues_milestone_status(milestone: str) -> dict:
"""Summarize a milestone: counts by state, and every open issue that is blocked.
Use to answer "what is blocking the release" or "how is milestone X doing".
Returns totals by state plus a blocked list, each entry carrying the issue id,
title, and the id and title of what blocks it. Use issues_get for full detail
on any one of them.
"""
issues = api.list_issues(milestone=milestone, per_page=100)
blocked = []
for issue in issues:
if issue["state"] != "open":
continue
for link in api.issue_links(issue["id"]):
if link["type"] == "blocked_by" and link["target"]["state"] == "open":
blocked.append({
"id": issue["id"],
"title": issue["title"],
"blocked_by_id": link["target"]["id"],
"blocked_by_title": link["target"]["title"],
})
return {
"milestone": milestone,
"counts": count_by_state(issues),
"blocked": blocked,
"blocked_count": len(blocked),
}
```
**Result.** One call instead of eleven, and the answer fits in a short response. The per-endpoint reads were kept, so an agent asking something the workflow tool does not cover can still compose its own path.
**The judgment call.** The temptation was to delete the granular tools and ship only workflow tools. That would have made every unanticipated question impossible. Coverage stayed; the workflow tools were added on top, for the sequences that observation showed were real.
---
## Build 2: a read-only search server
**Brief.** "Let an agent search our internal documentation. No writes."
**The jobs list, written before any code:**
1. Find the documents that answer a question.
2. Read one of them in full.
3. Find out which team owns a document and when it was last reviewed.
Three jobs, three tools. The temptation to add "list all spaces", "list all authors" and "get attachment" was resisted, because no job needed them.
```python
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True})
def docs_search(
query: str,
space: str | None = None,
limit: int = 10,
cursor: str | None = None,
response_format: Literal["markdown", "json"] = "markdown",
) -> dict:
"""Search internal documentation by natural-language query.
Use whenever a question might be answered by an existing internal document.
Returns the doc id, title, space, owning team, last-reviewed date, and a
matching excerpt for each hit, plus has_more and next_cursor. Call
docs_get_document with an id to read the whole page.
"""
```
Three details in that description earn their place. It says *when* to reach for the tool, which is what makes it get chosen. It lists the fields that come back, so the agent knows it does not need a second call to learn the owner. It names the follow-up tool, so search results do not become a dead end.
**Error paths, written before the happy path was finished:**
| Situation | What the tool returns |
| --- | --- |
| No matches | `No documents matched "kafka retention" in space "eng". Try a broader query or drop the space filter.` |
| Unknown space | `No space named "engg". Known spaces: eng, product, ops, people.` |
| Limit over the maximum | `limit must be between 1 and 50; got 500. Use the cursor to page through more.` |
| Expired credential | `Documentation API rejected the credential. Check DOCS_API_TOKEN is set and has not expired.` |
Every one of those names the next move. The last one deliberately says which environment variable to check without printing its value.
**Evaluation questions used to sign it off** (three of the ten):
1. "Which team owns the incident response runbook, and when was it last reviewed?" - needs search then a field read. Answer: `platform-ops, 2 March`.
2. "Is there a documented process for rotating database credentials, and does it mention the staging environment?" - needs search then a full read. Answer: `yes, and no`.
3. "How many documents in the ops space have not been reviewed in over a year?" - needs paging through results. Answer: `7`.
Each was solved by hand through the tools first. Question 3 is what surfaced the missing `cursor` parameter: solving it manually was impossible with a fixed limit of 10.
---
## Patterns worth copying
1. Write the jobs list before the tool list. It decides what to build and, more usefully, what not to.
2. Trace a real session before redesigning. The eleven-call path in build 1 was invisible from reading the tool list.
3. Add workflow tools on top of coverage, never instead of it.
4. Put the follow-up call in the description. It is the cheapest way to stop an agent from over-fetching.
5. Write the error messages early. They expose missing parameters faster than the happy path does.
6. Solve your own evaluation questions by hand. Anything you cannot do with the tools, an agent certainly cannot.
references/reference.md
# MCP server reference
The conventions that make a server predictable to an agent and to the next person who edits it.
## Contents
- [Naming](#naming)
- [Transports](#transports)
- [Response formats](#response-formats)
- [Pagination contract](#pagination-contract)
- [Tool annotations](#tool-annotations)
- [Error handling](#error-handling)
- [Security checklist](#security-checklist)
- [Test pass](#test-pass)
- [Evaluation questions](#evaluation-questions)
---
## Naming
**Server name**
| Language | Pattern | Example |
| --- | --- | --- |
| Python | `{service}_mcp` | `zendesk_mcp` |
| Node / TypeScript | `{service}-mcp-server` | `zendesk-mcp-server` |
Descriptive of the service, no version number, inferable from the task ("connect to Zendesk").
**Tool names**
`{service}_{action}_{resource}`, lowercase with underscores. `github_create_issue`, `stripe_list_invoices`, `slack_send_message`.
- The service prefix prevents collisions when several servers are loaded at once.
- The verb comes first after the prefix, so a scan of the tool list reads as a list of actions.
- Avoid generic verbs that mean nothing on their own: `get`, `run`, `query`, `execute`.
- Keep the vocabulary consistent. If one tool says `list`, no other tool says `fetch_all`.
## Transports
| Criterion | stdio | Streamable HTTP |
| --- | --- | --- |
| Deployment | Local, as a subprocess of the client | Remote, as a service |
| Clients | One | Many at once |
| Setup cost | Low | Medium |
| Server-to-client messages | No | Yes |
Choose stdio for developer tools and desktop integrations, streamable HTTP for anything hosted or shared. Prefer stateless request handling on HTTP; it scales and it debugs far more easily than session state.
One rule that catches everyone: a stdio server must never write logs to stdout. Stdout is the protocol channel. Log to stderr.
## Response formats
Offer both where a tool returns data, with a `response_format` parameter:
- **Markdown** (usually the default): human and model readable. Real names with ids in parentheses, timestamps in readable form, verbose metadata omitted.
- **JSON**: every field, consistent names and types, for when the agent is going to compute over it.
Whichever the format, include the identifiers needed for the next call. A search result an agent cannot act on is a dead end.
## Pagination contract
Any tool that returns a collection:
- Accepts `limit` and respects it. Default 20 to 50.
- Accepts a `cursor` or `offset`.
- Returns the page plus `has_more`, and `next_cursor` or `next_offset`.
- Never loads the full result set into memory to return a slice of it.
```json
{
"total": 150,
"count": 20,
"offset": 0,
"items": [],
"has_more": true,
"next_offset": 20
}
```
## Tool annotations
| Annotation | Default | Meaning |
| --- | --- | --- |
| `readOnlyHint` | false | The tool does not modify anything |
| `destructiveHint` | true | The tool may destroy or overwrite data |
| `idempotentHint` | false | Repeating the call with the same arguments changes nothing further |
| `openWorldHint` | true | The tool reaches an external system |
Clients use these for confirmation prompts and safety rails. They are hints, not enforcement: anything that must not happen has to be prevented in the code as well.
## Error handling
- Report tool failures inside the result (`isError: true` with text), not as protocol errors. A protocol error tells the agent the call broke; a result error tells it what to do instead.
- Name the problem, then the next move: which argument was wrong and what values are allowed, or how long to wait before retrying.
- Use the service's own vocabulary for ids and fields, so the correction the agent makes matches the API.
- Do not surface internal detail: stack traces, sql, file paths, hostnames, or anything from the auth header.
- Release connections and temporary files on the error path, not only the success path.
## Security checklist
- Credentials from environment or secret storage. Never in source, never in a default argument.
- Validate credentials at startup and fail loudly, so a missing key does not surface as a confusing tool error later.
- Least privilege on every token. A read-only integration uses a read-only key.
- Validate and constrain every input before it reaches the API or the filesystem: enum values, ranges, sizes, url schemes, path segments.
- Separate read tools from write tools, and annotate the writes.
- Local HTTP servers bind to `127.0.0.1` and validate the `Origin` header.
- Redact argument values in logs. The point of logging tool calls is the sequence, not the payload.
## Test pass
Run all of these from a real MCP client before calling the server done:
1. **Discovery**: the client lists every tool with a readable description.
2. **Happy path**: a real task completes end to end.
3. **Bad input**: a wrong enum value or a missing required field returns a message that names the fix.
4. **Auth failure**: an invalid credential returns a clear, non-leaking message.
5. **Empty result**: a search with no matches returns an explicit empty result, not an error.
6. **Large result**: a list larger than the limit paginates instead of flooding the response.
## Evaluation questions
Ten questions are enough to know whether an agent can actually use the server. Each one should be:
- **Independent** of the others.
- **Read-only**, so the suite can be run repeatedly.
- **Complex** enough to need several tool calls.
- **Realistic**, drawn from the jobs list you started with.
- **Verifiable**, with one answer that can be checked by comparing strings.
- **Stable**, so the answer does not change next week.
Solve each one yourself through the tools first. If you cannot, the agent has no chance, and you have found a missing tool rather than a bad question.
scripts/lint_tools.py
#!/usr/bin/env python3
"""Lint the declared shape of an MCP server's tools.
Feed it a JSON dump of your server's tool list - the result of an MCP `tools/list`
call, or the same array written by hand. It checks the things an agent depends on
and a human reviewer skims past: naming consistency, descriptions that say when to
use the tool and what comes back, described parameters, pagination on list tools,
and annotations on anything that writes.
It reads declarations only. It cannot call your API, so it finds design slips, not
runtime bugs.
Usage:
python scripts/lint_tools.py tools.json
python scripts/lint_tools.py tools.json --strict # warnings also fail
Accepted input shapes:
{"tools": [ {...}, {...} ]}
[ {...}, {...} ]
Exit codes:
0 clean (warnings allowed unless --strict)
1 at least one error, or a warning under --strict
2 the input could not be read or parsed
"""
import json
import re
import sys
SNAKE_RE = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$")
GENERIC_VERBS = {"get", "run", "query", "execute", "call", "do", "post", "put", "fetch", "handle"}
LIST_VERBS = ("list", "search", "find", "query")
WRITE_VERBS = ("create", "update", "delete", "remove", "set", "add", "send", "write", "archive", "cancel")
DESTRUCTIVE_VERBS = ("delete", "remove", "drop", "purge", "archive", "revoke", "cancel")
PAGE_LIMIT_KEYS = {"limit", "max_results", "page_size", "per_page", "count"}
PAGE_CURSOR_KEYS = {"cursor", "offset", "page", "next_cursor", "start"}
MIN_DESCRIPTION = 60
EXIT_OK = 0
EXIT_FINDINGS = 1
EXIT_UNUSABLE = 2
def load_tools(path):
"""Read the file and return a list of tool dicts. Raises ValueError on bad shape."""
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except OSError as exc:
raise ValueError(f"cannot read {path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"{path} is not valid JSON: {exc}") from exc
if isinstance(data, dict):
data = data.get("tools")
if not isinstance(data, list):
raise ValueError('expected a JSON array of tools, or an object with a "tools" array')
for index, tool in enumerate(data):
if not isinstance(tool, dict):
raise ValueError(f"tools[{index}] is not an object")
return data
def common_prefix(names):
"""The service prefix used by most tools, or None if there is no clear majority.
Majority rather than unanimity, so that one stray name does not hide every check
that depends on knowing where the prefix ends.
"""
counts = {}
for name in names:
if "_" in name:
head = name.split("_", 1)[0]
counts[head] = counts.get(head, 0) + 1
if not counts:
return None
head, hits = max(counts.items(), key=lambda item: item[1])
return head if hits * 2 >= len(names) else None
def schema_properties(tool):
schema = tool.get("inputSchema") or tool.get("input_schema") or {}
if not isinstance(schema, dict):
return {}, []
properties = schema.get("properties")
required = schema.get("required")
return (
properties if isinstance(properties, dict) else {},
required if isinstance(required, list) else [],
)
def lint_tool(tool, index, prefix, seen):
"""Return (errors, warnings) for one tool declaration."""
errors, warnings = [], []
name = tool.get("name")
if not isinstance(name, str) or not name:
errors.append(f"tools[{index}] has no name")
return errors, warnings
if name in seen:
errors.append(f"{name}: duplicate tool name")
seen.add(name)
if not SNAKE_RE.match(name):
errors.append(f"{name}: name should be lowercase words joined by single underscores")
if prefix and not name.startswith(prefix + "_"):
warnings.append(f"{name}: does not share the service prefix \"{prefix}_\" used by the other tools")
starts = name[len(prefix) + 1:] if prefix and name.startswith(prefix + "_") else name
if starts in GENERIC_VERBS:
warnings.append(f"{name}: \"{starts}\" is too generic to choose from a tool list")
description = tool.get("description")
if not isinstance(description, str) or not description.strip():
errors.append(f"{name}: no description - an agent picks tools by name and description alone")
description = ""
else:
if len(description) < MIN_DESCRIPTION:
warnings.append(
f"{name}: description is {len(description)} characters; it likely says what the tool "
"does but not when to use it or what it returns"
)
if "return" not in description.lower():
warnings.append(f"{name}: description never says what comes back")
properties, required = schema_properties(tool)
has_schema = bool(tool.get("inputSchema") or tool.get("input_schema"))
if not has_schema:
errors.append(f"{name}: no input schema")
elif properties:
undescribed = sorted(
key for key, spec in properties.items()
if not isinstance(spec, dict) or not str(spec.get("description", "")).strip()
)
if undescribed:
warnings.append(f"{name}: parameters with no description: {', '.join(undescribed)}")
if not required and any(k for k in properties):
warnings.append(f"{name}: no required list, so every parameter reads as optional")
if starts.startswith(LIST_VERBS):
if not (set(properties) & PAGE_LIMIT_KEYS):
warnings.append(f"{name}: returns a collection but takes no limit")
if not (set(properties) & PAGE_CURSOR_KEYS):
warnings.append(f"{name}: returns a collection but has no cursor or offset")
annotations = tool.get("annotations")
annotations = annotations if isinstance(annotations, dict) else {}
writes = starts.startswith(WRITE_VERBS)
if not annotations:
warnings.append(f"{name}: no annotations - clients cannot tell reads from writes")
else:
if writes and annotations.get("readOnlyHint") is True:
errors.append(f"{name}: named as a write but annotated readOnlyHint: true")
if starts.startswith(DESTRUCTIVE_VERBS) and annotations.get("destructiveHint") is False:
errors.append(f"{name}: named as destructive but annotated destructiveHint: false")
if not writes and "readOnlyHint" not in annotations:
warnings.append(f"{name}: looks read-only but does not declare readOnlyHint")
if not tool.get("outputSchema") and not tool.get("output_schema"):
warnings.append(f"{name}: no output schema, so clients cannot predict the result shape")
return errors, warnings
def main(argv):
strict = "--strict" in argv
paths = [a for a in argv if not a.startswith("--")]
if len(paths) != 1:
print("usage: python scripts/lint_tools.py tools.json [--strict]", file=sys.stderr)
return EXIT_UNUSABLE
try:
tools = load_tools(paths[0])
except ValueError as exc:
print(str(exc), file=sys.stderr)
return EXIT_UNUSABLE
if not tools:
print("no tools in the file", file=sys.stderr)
return EXIT_UNUSABLE
names = [t.get("name") for t in tools if isinstance(t.get("name"), str)]
prefix = common_prefix(names)
if not prefix:
print("WARN no single service prefix shared by all tool names")
seen = set()
total_errors = total_warnings = 0
for index, tool in enumerate(tools):
errors, warnings = lint_tool(tool, index, prefix, seen)
for message in errors:
print(f"ERROR {message}")
for message in warnings:
print(f"WARN {message}")
total_errors += len(errors)
total_warnings += len(warnings)
print(f"\n{len(tools)} tools, {total_errors} error(s), {total_warnings} warning(s)")
if total_errors or (strict and total_warnings):
return EXIT_FINDINGS
return EXIT_OK
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Install it
Claude Code (this project)
Unzip the download into .claude/skills/. The folder is already named right.
Claude Code (every project)
Unzip into ~/.claude/skills/ to use it everywhere.
claude.ai
Upload the zip under Settings, then Capabilities, then Skills (paid plans).
How to call it
Just describe the task. The description line inside the skill does the matching. Or call it directly:
/mcp-server-builderbuild an MCP server for this APIconnect our internal tool to Claudethe agent keeps calling my tool wrongreview my MCP tool designwrap this service as tools an agent can use
Works beyond Claude
Skills use the open Agent Skills format. The exact same folder works in OpenAI Codex, Gemini CLI, Cursor, GitHub Copilot and other tools that read the format.
Using a plain chat app? Paste SKILL.md into ChatGPT or Gemini as instructions. You lose auto-triggering, but the method still works.