MCP Server Design Patterns: Tools, Resources, and Prompts Done Right
Most MCP servers I've reviewed share the same antipattern: everything is a tool. The developer discovered tools/call, found it worked, and stopped thinking. The result is an AI agent that has to fire a round-trip RPC to read a config file, enumerate a list, or fetch a static schema — operations that should cost zero network hops and zero LLM tokens to reason about.
The Model Context Protocol gives you three distinct primitives for a reason. Tools, resources, and prompts are not interchangeable. Picking the wrong one produces fragile agents, bloated context windows, and unnecessary latency. This post is the decision framework I wish existed when I built my first MCP server.
The Three Primitives at a Glance
Before the heuristics, get the semantics right.
Tools are callable functions with side effects (or expensive reads). The LLM invokes them by name with structured arguments. The server executes and returns a result. Tools are imperative.
Resources are addressable content identified by a URI. The client fetches them when it decides it needs them. Resources are declarative and read-only from the protocol's perspective.
Prompts are parameterized prompt templates the server exposes so clients can construct well-formed LLM inputs without baking prompt strings into application code.
The distinction matters most at the margin. When you are unsure which to use, ask yourself two questions:
- Does calling this change state or trigger computation with variable cost?
- Does the client need to decide to fetch it, or should it be available for free inspection?
If the answer to (1) is yes, use a tool. If the content is stable and the client should be able to peek at it cheaply, use a resource. If you are packaging a prompt strategy for reuse, use a prompt.
Tool Design: Granularity Is Everything
The single biggest tool design mistake is mapping your internal service API 1:1 to MCP tools. Internal APIs are designed for human developers reading docs. MCP tools are designed for a language model that has limited context and must choose between them at inference time.
The Granularity Spectrum
Too coarse: execute_anything(sql: string)
About right: query_orders(status, date_range, limit)
Too fine: get_order_status(id) + get_order_date(id) + get_order_items(id)Too coarse means the LLM must construct raw SQL or raw API calls — you have given it rope to hang itself. Too fine means the agent fires three tools to assemble one logical entity, burning context and latency. The sweet spot is a tool that corresponds to one semantic intention.
Schema First
Every tool must have a complete JSON Schema for its input. Do not use additionalProperties: true. Do not use type: object without listing properties. An underspecified schema is an invitation for the model to hallucinate arguments.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({ name: "orders-mcp", version: "1.0.0" });
server.tool(
"query_orders",
"Fetch orders filtered by status and date range. Returns at most `limit` records.",
{
status: z.enum(["pending", "shipped", "delivered", "cancelled"]).optional(),
from_date: z.string().date().optional().describe("ISO 8601 date, inclusive"),
to_date: z.string().date().optional().describe("ISO 8601 date, inclusive"),
limit: z.number().int().min(1).max(100).default(20),
},
async ({ status, from_date, to_date, limit }) => {
const rows = await db.orders.findMany({
where: {
...(status && { status }),
...(from_date && { created_at: { gte: new Date(from_date) } }),
...(to_date && { created_at: { lte: new Date(to_date) } }),
},
take: limit,
orderBy: { created_at: "desc" },
});
return {
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
};
}
);Notice: the schema uses Zod so TypeScript types are inferred automatically. The description is a single sentence that tells the model when to use the tool, not what it does internally.
Idempotency and Side-Effect Labeling
Tools that mutate state should say so in their description. Some MCP clients and orchestration layers use this signal to require confirmation before invocation. Add a readonly annotation in the description for read tools so automated pipelines can skip confirmation prompts:
server.tool(
"cancel_order",
"[MUTATES] Cancel an order by ID. Irreversible once shipped.",
{ order_id: z.string().uuid() },
async ({ order_id }) => { /* ... */ }
);Resource Design: URIs Are Your API
Resources expose content at a stable URI. Think of them as the MCP equivalent of a GET endpoint — but one that the model can enumerate and decide to read without being told to.
URI Schemes That Scale
Design your URI namespace before you write a single handler. A good URI scheme is hierarchical, guessable, and does not bleed implementation details.
orders:// # list all orders (resource template)
orders://{order_id} # single order
orders://{order_id}/items # line items for an order
schema://tables # database schema listing
schema://tables/{table_name} # single table DDL
config://feature-flags # read-only config snapshotDo not do this:
# Bad: exposes internal service names and numeric IDs
http://internal-svc-01/api/v3/orders/by-id?id=1234Static vs. Dynamic Resources
Static resources return content that does not change per-call. Use them for schemas, configuration snapshots, and reference data. Dynamic resources accept URI template parameters.
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
// Static resource
server.resource(
"feature-flags",
"config://feature-flags",
{ mimeType: "application/json" },
async () => ({
contents: [{
uri: "config://feature-flags",
mimeType: "application/json",
text: JSON.stringify(await getFeatureFlags()),
}],
})
);
// Dynamic resource with URI template
server.resource(
"order-detail",
new ResourceTemplate("orders://{order_id}", { list: undefined }),
{ mimeType: "application/json" },
async (uri, { order_id }) => {
const order = await db.orders.findUnique({ where: { id: order_id } });
if (!order) throw new Error(`Order ${order_id} not found`);
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(order),
}],
};
}
);When Resources Beat Tools
If the LLM needs to read something to understand context before deciding what to do, it belongs in a resource. Classic examples:
- Database schema (the model reads this to know which tables exist before writing a query tool call)
- Feature flag state (read at the start of a session, not queried per-request)
- API documentation snippets embedded as reference material
- User preferences loaded once per conversation
Prompt Design: Bake Your Expertise In
Prompts are underused. They are how you encode domain knowledge into the server itself rather than scattering prompt strings across every client that connects.
A prompt template is a function: it takes parameters, returns a list of PromptMessage objects. The client sends those messages directly to the LLM.
server.prompt(
"analyze_order_anomalies",
"Generate a prompt asking the LLM to analyze order anomalies for a given date range.",
{
from_date: z.string().date(),
to_date: z.string().date(),
threshold_pct: z.number().default(15),
},
async ({ from_date, to_date, threshold_pct }) => {
const summary = await getOrderSummary(from_date, to_date);
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `You are an e-commerce analyst. Below is the order summary for ${from_date} to ${to_date}.
Identify any anomalies where a metric deviates more than ${threshold_pct}% from the prior period.
Format findings as a bulleted list with recommended actions.
Order Summary:
${JSON.stringify(summary, null, 2)}`,
},
},
],
};
}
);Notice the prompt handler does data fetching. The template is not a dumb string interpolation — it is an async function that can call databases, format data, and produce a ready-to-send message list. This keeps prompt engineering centralized and versioned alongside your MCP server.
Decision Heuristics: A Quick Reference
A few concrete mappings to cement the heuristics:
| Capability | Wrong choice | Right choice | Reason |
|---|---|---|---|
| Read DB schema | Tool | Resource | Stable, cheap, should be passively available |
| Execute a query | Resource | Tool | Expensive, variable, may mutate |
| Summarize a report | Tool | Prompt | The output is a prompt message, not data |
| List available reports | Tool | Resource | Enumerable, read-only catalogue |
| Send a notification | Resource | Tool | Has side effects |
| Load user preferences | Tool | Resource | Static per-session, zero compute |
Common Antipatterns
Antipattern 1: Fat tools. A tool that does five things based on an action parameter is a dispatch table masquerading as a tool. Split it.
Antipattern 2: Stateful resources. Resources should not depend on session state. If the content changes based on who is asking, encode identity in the URI or the tool, not in hidden server-side session.
Antipattern 3: Prompt templates without data fetching. A prompt that just wraps a static string is not using the primitive correctly. If the prompt does not pull in live data, it probably belongs in the client's system prompt, not an MCP server prompt.
Antipattern 4: Missing list handlers. If you expose dynamic resources, implement resources/list. Without it, the agent has no way to discover what URIs exist. Every resource template should have a corresponding list handler.
server.resource(
"orders-list",
"orders://",
{ mimeType: "application/json" },
async () => {
const ids = await db.orders.findMany({ select: { id: true }, take: 200 });
return {
contents: [{
uri: "orders://",
mimeType: "application/json",
text: JSON.stringify(ids.map(r => `orders://${r.id}`)),
}],
};
}
);Versioning and Evolution
MCP servers evolve. Tools get new parameters. Resources move. Prompts get refined. Handle this without breaking clients:
- Add optional parameters with defaults rather than new tool names.
- Deprecate old URIs by returning content with a
deprecatedfield in the JSON body pointing to the new URI. - Bump the server's
versionfield inMcpServerconstructor when making breaking changes. - Use semantic versioning and expose it in a
meta://server-inforesource so clients can assert compatibility.
Key Takeaways
- Tools are for imperative, stateful, or expensive operations — not for reading static content you could expose as a resource.
- Resources use hierarchical URIs; design the URI namespace before writing any handler code.
- Prompts are async data-fetching functions that produce
PromptMessagearrays — bake your domain expertise and data formatting into them. - Always provide complete JSON Schema (via Zod or raw schema objects) for tool inputs; underspecified schemas lead to hallucinated arguments.
- Implement
resources/listfor every resource template; without it, the agent cannot discover what exists. - Label mutating tools explicitly in their description; some orchestration layers use this to require human confirmation before invocation.