Chapter 6: Implementing Core Capabilities
At this point you can stand up a server. The question this chapter answers is how to make that server genuinely useful to a model and to the humans who have to trust what the model does with it.
Most first-generation MCP servers fail in one of two predictable ways. They mirror an internal API one-to-one, exposing the shape of the backend instead of the shape of the work. Or they dump so much schema and description text into the client that the model loses the plot and the user’s context window fills up before any real reasoning happens.
Core capabilities — tools, resources, and prompts — are not incidental plumbing. They are the product surface. A well-designed MCP server gives the model a short menu of obvious actions, a pocket of readable context for decisions, and a handful of reusable procedures for recurring work. A poorly designed server gives the model a warehouse of slightly different verbs and expects it to figure out which one to use.
This chapter is about designing each primitive so it earns its place in the context window.
Designing Tools Around Intent
The model does not care how your backend is organized. It cares about which action seems appropriate to the user’s request. That is why intent-shaped tools consistently outperform endpoint-shaped tools, even when the endpoint-shaped version has a “more complete” surface.
Compare two ways of exposing the same ticketing system.
Endpoint-shaped:
tickets_create_or_updaterecords_mutaterun_workflowexecute_query
Intent-shaped:
create_ticketassign_ticketadd_ticket_commentclose_ticketfind_tickets_for_customer
The intent-shaped set is longer, but it is easier for the model to route across and easier for a human to approve. close_ticket says what is about to happen; run_workflow says the operator is about to get paged.
Name tools after the action a user would ask for in plain language. Test each name against a sentence a user would actually say: “please close that ticket” maps to close_ticket; no one has ever asked a colleague to “run the workflow.” If the only sentence that describes a tool is “it runs the workflow,” the tool is not shaped like an intent yet.
This also forces a useful conversation with the backend. Some internal APIs don’t expose the intent you want cleanly. That’s fine. Build a thin adapter layer in the MCP server that composes the lower-level calls and presents a single intent to the model. The model does not need to know that close_ticket actually calls three different endpoints behind the scenes. That’s your problem, not its problem.
Tool Granularity: Finding the Middle
There are three common failure modes in granularity, and each has a characteristic smell. (Whether something should be a tool, a resource or a prompt at all is a tree in Appendix L.)
Too fine. You expose fifty tiny tools because your internal API has fifty endpoints. The model has to route across a noisy surface, the user has to approve a blur of low-level actions, and the tools/list payload eats a thousand tokens per turn. A telltale sign: tools with names like get_user_by_id, get_user_by_email, get_user_by_external_id. Collapse those into find_user(identifier) and let the server figure out which lookup to do.
Too coarse. You expose one “god tool” with ten optional parameters and a mode field that switches behavior. The model cannot infer intent cleanly, error cases become vague, and approvals are impossible to reason about. A telltale sign: any tool whose description is a list of “if X then Y” clauses. Split it.
About right. You group behavior by stable user intent. Each tool is narrow enough that invocation feels obvious and approval means something. A workflow — “find a ticket, assign it, add a comment, close it” — decomposes cleanly into four tools, not one and not thirty.
A practical heuristic: if you can describe a tool in one sentence without using the words “and” or “or,” it is probably in the right granularity band. Tools described as “searches tickets and assigns them” are two tools wearing a trenchcoat.
Worked Refactor: Six Endpoints, Three Intents
Here is the notes store from Chapter 5 as a backend team would first expose it: one tool per endpoint.
notes_get(id) "GET /notes/{id}. Returns the note record."
notes_list(offset, limit, order_by) "GET /notes. Paginated list of note records."
notes_upsert(title, body, id?, tag?) "POST /notes. Creates a note, or updates it if id is given."
notes_delete(id) "DELETE /notes/{id}."
tags_list() "GET /tags. All distinct tags."
run_query(sql) "Run a SQL query against the notes database."
Every one of these is accurate, and the surface is still wrong. The descriptions narrate HTTP verbs the model cannot use. notes_upsert hides a create and an overwrite behind one approval. notes_list makes the model page through records to find anything. run_query is a fetch wrapper with write access, and no annotation can make it safe.
Apply the tests from this chapter one at a time. Intent: users “save a note”, “find the note about X”, “delete that note”. Granularity: notes_upsert fails the “and/or” test, so it becomes create_note; nobody has asked to edit a note yet, so update_note waits until someone does. notes_get is a per-item read with no side effects, so it becomes the resource template notes://detail/{note_id}. notes_list and tags_list fold into search_notes, which already filters by tag. run_query goes.
from pydantic import BaseModel
from mcp.types import ToolAnnotations
class NoteHit(BaseModel):
id: int
title: str
tag: str
snippet: str # first 160 characters of the body
class SearchResult(BaseModel):
count: int
items: list[NoteHit]
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False))
def create_note(title: str, body: str, tag: str = "general") -> dict:
"""Create a note with a title, body, and optional tag."""
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
def search_notes(query: str, tag: str | None = None, limit: int = 20) -> SearchResult:
"""Search note titles and bodies; newest first. Filter by tag to narrow."""
@mcp.tool(annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True))
def delete_note(note_id: int) -> dict:
"""Permanently delete one note by ID. Get the ID from search_notes."""
@mcp.resource("notes://detail/{note_id}")
def note_detail(note_id: int) -> str:
"""The full content of a single note."""
create_note and search_notes keep Chapter 5’s bodies (search adds a substr(body, 1, 160) column); delete_note is one DELETE statement. SearchResult gives search_notes an outputSchema, so a client can validate the response and the model reads fields instead of parsing text. The snippet field is there because of the resource rule later in this chapter: the host decides when notes://detail/{note_id} is attached, so the model needs enough of each note from a tool to act on its own.
The measurement, taken as the tools/list JSON from mcp 2.2.0 and counted with the cl100k_base tokenizer (hosts serialize differently, so treat it as a ratio, not a bill):
| Tools | tools/list tokens | |
|---|---|---|
| Endpoint mirror | 6 | 608 |
| Intent surface | 3 | 520 |
Intent surface without outputSchema | 3 | 345 |
The saving is smaller than you might expect, and the table shows why: the output schema costs 175 tokens by itself. That is a fair trade for a tool the model calls every session. What the refactor actually bought is not tokens: half as many routing decisions, an approval prompt that says “permanently delete one note”, and no tool that runs arbitrary SQL.
Laying out a server once it grows. Three principles keep a growing server honest. Keep each tool’s description next to its handler, so reading the code shows exactly what the model sees. Keep integration code (database queries, API clients) out of tool handlers, so it can be tested and reused without MCP. Keep configuration in environment variables or a config module, never in handlers: this is obvious advice that is obvious exactly until you are in a hurry and put the API key inline “just for now.”
Schema Design: Concrete, Flat, and Honest
A tool’s input schema does four jobs at once:
- It tells the model what to provide.
- It tells the host client how to render or validate the call.
- It tells the server what to trust at runtime.
- It tells future maintainers what the tool actually means.
Any schema that handles one of those jobs well and the others poorly is a source of production problems. Practical guidelines that hold up in real servers:
Prefer flat parameters. Nest only when nesting adds meaning. A schema like {ticket_id: str, assignee: str, priority: str} is easier for the model to fill than {mutation: {target: {type: "ticket", id: str}, changes: {...}}}.
Use concrete names. repository, branch, ticket_id, customer_email, start_date. Avoid input, data, payload, params, options. Generic names push the meaning into the description, which costs tokens and invites drift between schema and intent.
Use enums where the answer is closed. If priority is always one of low | normal | high | urgent, say so. Enums prevent the model from inventing "p1" or "critical" and they make the downstream validation trivial.
Use typed primitives. integer instead of string for counts. boolean instead of "true"/"false". Date formats should be specified (format: "date" or format: "date-time") so the model has a concrete target.
Mark required fields honestly. Every optional parameter is a decision the model has to make. Defaults should be safe and unsurprising. If the default behavior of delete_items with no confirm flag is “delete,” the schema is lying by omission.
Write descriptions that explain intent, not types. The schema already tells the model the type. The description should tell the model when to use the field and what a good value looks like.
A weak description:
{
"name": "query",
"description": "The query string"
}
A description that earns its tokens:
{
"name": "query",
"description": "Free-text search over ticket titles and bodies. Use short noun phrases ('printer error', 'vpn access') rather than full sentences."
}
The second version is several times longer and still cheap: it rules out a common failure mode (pasting an entire paragraph as the query) and it sets a concrete example of shape. That is how descriptions earn their place.
Context Window Economics
Tool definitions are not free. Every name, description, parameter, and schema field ships to the model as part of the system prompt on every turn. A server that registers thirty tools with verbose descriptions can easily burn several thousand tokens before the user has said anything.
That does not mean one vague sentence per tool. It means every word earns its place.
Concrete guidance that survives real deployments:
- Put enduring behavior in the description and ephemeral advice (current rollout status, migration tips) in documentation or prompts. The exception is deprecation: the notice goes in the description with a calendar sunset date, because the model is the caller that has to see it (Chapter 28).
- Don’t repeat the tool name in the description.
- Don’t explain implementation trivia (“uses the v2 REST endpoint with pagination”).
- Don’t include policy text that belongs in the host client (“Only call this tool if the user has confirmed…”).
- Do include the shape of a good argument value when it isn’t obvious from the name.
- Do include a one-line hint about when to prefer this tool over a neighbor, if ambiguity exists.
When you have more than ten tools on one server, measure the total size of tools/list. The MCP Inspector will show you the raw JSON. If your tool catalog is over 3,000 tokens for a server that a model will only use two or three tools from per session, you are paying for presence rather than use. Consider splitting the server, grouping related tools behind a smaller set of composed intents, or moving rarely used operations to resources or prompts instead.
Input Validation That Helps the Model
JSON Schema validation is necessary but not sufficient. Schemas catch missing fields and obvious type errors; they don’t catch the interesting failures: unknown usernames, stale ticket IDs, values that are syntactically valid but semantically wrong.
Every tool handler should validate the arguments it actually uses before the side effect. In Python with the official SDK’s MCPServer, that looks like:
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("tickets")
@mcp.tool()
def assign_ticket(ticket_id: str, assignee: str) -> dict:
"""Assign a ticket to a team member by username."""
errors = []
ticket = db.find_ticket(ticket_id)
if ticket is None:
errors.append(f"Ticket '{ticket_id}' not found.")
user = directory.find_user(assignee)
if user is None:
errors.append(
f"User '{assignee}' not found. "
"Call list_team_members to see valid usernames."
)
if errors:
raise ToolError(" ".join(errors))
db.assign(ticket_id, user.username)
return {
"ticket_id": ticket_id,
"assignee": user.username,
"message": f"Assigned {ticket_id} to {user.username}.",
}
Two things are happening here that matter.
First, both errors are collected before the handler raises. Models are better at recovering when they see all the problems at once; asking a model to resolve three validation errors one turn at a time is a waste of the human’s attention and the user’s tokens.
Second, each error tells the model what to do next. “User not found. Call list_team_members to see valid usernames” is a recoverable error. “Invalid input” is not. Do not make the model reverse-engineer your recovery path from logs it cannot see.
On success, return a short, factual message the model can relay. Every byte you return ends up in context.
Error Handling That Helps Recovery
MCP provides two error reporting mechanisms, and the distinction matters.
Protocol errors use the standard JSON-RPC error format and signal that something structural went wrong: unknown tool, invalid arguments that the server cannot parse at all, transport failure. These are rare and should correspond to bugs, not routine user mistakes.
Tool execution errors are reported in the normal result envelope with isError: true. These are the expected failures — rate limits, not-found conditions, business logic violations, timeouts — and the model is expected to read them and try again or give up gracefully. In the Python SDK you produce one by raising ToolError, exactly as Chapter 5’s create_note does; any other exception is treated as a crash, and the model sees only a generic message.
Request failed. leaves the model stuck. User 'jlee' was not found. Ask the user for the correct username, or call list_team_members first. states the cause and a next valid move.
A few durable rules for error payloads:
- Never include stack traces, internal hostnames, or SQL fragments in the text a model will see. Those belong in server logs.
- Say whether a failure is transient (rate limit, 503) or permanent (not found, forbidden). For transient ones, include when to retry (
retry after 30s); do not leave the model to guess. - When the error depends on state the model cannot see (quota, permissions), say so explicitly. “You do not have permission to close tickets you did not open” is more useful than “Forbidden.”
- Do not use
isErrorfor expected empty results. An empty search is a successful call that returned zero items, not an error.
Structured Output and Output Schemas
Modern MCP servers can declare outputSchema alongside inputSchema, and can return structuredContent in addition to the text block.
A structured output does three things. It lets the host client validate the response and reject malformed data before passing it to the model. It gives the model an unambiguous field map so it can extract values without string parsing. And it creates a contract future maintainers can test against.
Use structured output when:
- The tool returns information the model will need to reason about field by field (counts, statuses, prices, dates).
- Downstream tools or prompts will need to pull specific values out of the response.
- The client has UI that can render structured data better than plain text.
Do not use it when the tool returns a single piece of free text (a summary, a generated draft, an explanation). Wrapping that in {"result": "..."} just adds tokens without clarity.
In Python SDKs that support it, a return type annotation is usually enough to generate the schema:
from pydantic import BaseModel, Field
class TicketSummary(BaseModel):
id: str
title: str
status: str = Field(description="open, pending, or closed")
assignee: str | None
@mcp.tool()
def get_ticket(ticket_id: str) -> TicketSummary:
"""Fetch a ticket by ID."""
t = db.find_ticket(ticket_id)
return TicketSummary(
id=t.id, title=t.title, status=t.status, assignee=t.assignee
)
One trap to avoid: do not mix free-form narration and structured fields in the same tool. If the tool is “generate a summary,” return text. If the tool is “look up the record,” return structured content. Tools that try to do both tend to produce structured responses with a notes field that eats half the context window on every call.
Resources: Readable Context Without Side Effects
Resources are the most underused primitive in MCP, and the one with the highest payoff when used well. They offer context the host can put in front of the model without spending a tool call on it.
Resources are designed for application-driven context. The host client decides when to attach them — a user picks one from a menu, the client auto-attaches based on heuristics, or a tool call returns a resource_link the client then fetches. Reads are idempotent, side-effect-free, and return content identified by a URI.
In practice this means a resource is only as useful as the host’s attach behaviour. Clients that let the model request resources make them a cheap alternative to read tools; clients that only surface a picker make them user context. Design for the clients your users run, and keep a read tool when the model must fetch on its own. That is why the refactor above gives search_notes a snippet instead of relying on notes://detail/{note_id} alone.
Good candidates for resources:
- Policy documents the model should respect when deciding how to act.
- Project or account summaries a user might attach before asking a question.
- Schema descriptions for a database server (publish the schema once, let the model read it instead of forcing a
describe_tabletool call round-trip). - Release notes, changelogs, audit logs, recent incident reports.
- The index of a filesystem server so the model doesn’t recursively list directories.
- Pre-computed digests — the kind of “report snapshot” a dashboard would show.
Design principles that pay off:
Use stable, unsurprising URI schemes. Custom schemes are fine, but they should be predictable. A ticketing server might use ticket://open, ticket://recent, ticket://{id}. A filesystem projection might use file:// or repo://.
Separate collections from individual items. notes://all that returns ten thousand notes is worse than useless. Prefer notes://recent plus a template notes://detail/{note_id}.
Use resource templates for parameterized reads. Templates take a URI pattern (ticket://{id}) and let the client expand it. This is the right place for per-item lookups that would otherwise be a get_ticket tool call plus a context round-trip.
Add annotations when they help. The audience hint (["user"], ["assistant"], or both) tells the client who should see the content. The priority hint (0.0 to 1.0) tells the client how aggressive to be about attaching it. The lastModified timestamp lets the client cache and detect staleness. These are cheap and useful.
Use subscriptions sparingly. The spec supports change notifications for live data: since 2026-07-28, a client opens a subscriptions/listen stream and opts in to the notifications it wants (this replaced resources/subscribe), and ttlMs cache hints on reads let clients avoid polling. Not every client implements either. Treat subscriptions as an enhancement for servers with genuinely time-sensitive data, not a default.
Prompts: Workflow Scaffolding, Not Magic
Prompt templates are the third primitive and the least understood. They are not system prompts, they are not tool descriptions, and they are not a way to jailbreak the model. They are user-initiated templates — usually surfaced as slash commands in the host client — that package up a repeatable way of framing work.
Good candidates are procedures a team repeats: a PR review against a rubric, incident triage, a weekly status summary.
A prompt template typically takes one or two arguments and returns a structured sequence of messages. With MCPServer:
@mcp.prompt(title="Review PR")
def review_pr(pr_number: str, focus: str = "general") -> str:
return (
f"Review pull request #{pr_number}. "
f"Focus on {focus}. "
f"Load the diff via the pr://{pr_number} resource, "
"then walk through each changed file and list concerns. "
"For each concern, classify as blocker, fix-before-merge, or nit."
)
Two details: first, the prompt references a resource (pr://{pr_number}). Good prompts assume a server with tools and resources already in place; they don’t try to do everything themselves. Second, the prompt reduces ambiguity. It tells the model what output shape to produce, which is usually the real value of a prompt.
Prompts are user-controlled in the MCP interaction model, which means they are triggered intentionally. That makes them a poor place for invisible behavior and a good place for explicit, named procedures the user expects to run.
Tool Annotations and Behavioral Hints
Modern MCP servers can attach annotations such as readOnlyHint, destructiveHint, idempotentHint, and openWorldHint to each tool.
| Hint | What the client may do | What you must back it with |
|---|---|---|
readOnlyHint: true | Skip the confirmation prompt | A read-only connection or credential, and no hidden writes (audit rows, webhooks, cache warms) |
destructiveHint: true | Show a prominent, specific confirmation | A description that names what is lost, and a backup or undo path if one exists |
idempotentHint: true | Retry the same call after a timeout | Handlers where a repeat really is a no-op, e.g. keyed on a client-supplied ID |
openWorldHint: true | Flag that data leaves the local environment | An accurate list of the third parties the tool calls |
Two warnings matter more than the hints themselves.
First, annotations are hints, not security controls. A client cannot rely on a server’s self-declaration as a security boundary. The third column of the table is not optional; Chapter 20 covers the rest.
Second, if the hint and the actual behavior disagree, the server is at fault. A delete tool marked readOnlyHint: true is worse than an unannotated delete tool, because it trains the rest of the stack to trust bad metadata. Honest annotations are useful. Dishonest ones are a trap.
The minimum viable annotation policy for a new server: mark obvious reads as read-only, mark obvious writes as non-read-only with destructiveHint set honestly for anything a user would be sad to lose, and leave the other hints unset until you need them. Do not annotate for completeness; annotate for accuracy.
Failure Modes
Real servers break along a handful of predictable seams once they leave the tutorial.
Description rot. The tool changes, the description doesn’t, and the model keeps routing by the old description. Treat tool descriptions like API documentation: update them when the behavior changes, and consider adding a test that asserts the description mentions the current parameter names.
Schema drift. The server code validates one thing and the schema declares another. The model believes the schema. Generate the schema from the code (Pydantic models, TypeScript types) instead of hand-writing it whenever possible.
Silent side effects. A tool annotated as readOnlyHint: true triggers a webhook, writes an audit log entry, or warms a cache. Even if the user-visible state is unchanged, the hint is lying. Either remove the side effect or drop the hint.
Catalog bloat. A server accumulates tools over months and nobody audits the list. Every session pays for the unused ones. Periodically prune tools nobody has called, or move rarely used ones behind a prompt that the user can invoke deliberately.
Unrecoverable errors. A tool returns “Error: 500” and the model gives up. Classify errors by recoverability, and return messages that tell the model whether to retry, try a different approach, or ask the user.
Resource blindness. The server only exposes tools, so the model makes a get_context tool call for every question. Where the host attaches resources, moving stable context into one cuts turn count and token usage; keep the tool for hosts that don’t.
Prompt injection via resource content. A resource fetched from an upstream system contains attacker-controlled text that the model may follow as instructions. Resources are read-only from the server’s side, but the content flowing through them is not automatically trustworthy. Chapter 20 covers the defensive patterns; the minimum for this chapter is: do not concatenate resource content into system prompts without boundaries.
Implementation Checklist
Before shipping any capability, walk down this list:
- Intent. Is the tool named from the user’s point of view, and can it be described in one sentence without “and” or “or”?
- Schema. Is it flat, concrete, and honest about required fields and defaults?
- Descriptions. Are they short, operationally specific, and distinct from neighboring tools?
- Validation. Does the handler check the meaningful constraints as well as the type shape, collect every problem, and say how to fix each?
- Output. Is the return value as small as it can be while still being useful?
- Should it be a tool at all? Would a resource or prompt serve this job better, given how your users’ hosts attach resources?
- Annotations. Does each hint match what the tool actually does, per the table above?
- Budget. Have you measured
tools/list, and is each entry paying for its presence?
If every tool on your server passes this list, the remaining problems are the model’s, not yours.
What Comes Next
Up to this point, everything we’ve built has been local. The server runs on your laptop over stdio, the host client is two directories away, and the trust model is “whoever is logged in to this machine.” That posture is a lie in disguise the moment another person, another machine, or another tenant needs to reach the same server.
The next chapter is about what changes when the server stops living on your laptop and starts behaving like shared infrastructure: how stdio becomes Streamable HTTP, how “the user” becomes an identity boundary, and how all the capability design in this chapter carries over — and what breaks — when the server lives on the other side of a network hop.