Tools, Resources, Prompts
Most MCP server implementations I review make the same mistake on day one: they put everything into tools. Database reads, file lookups, pre-built prompt templates — all crammed into callTool. It works, but you are throwing away the protocol's most useful design constraint. MCP ships three distinct primitives for a reason, and conflating them costs you discoverability, caching, and model-side UX.
The Three Primitives at a Glance
Before diving into each one, here is how they relate:
The separation is intentional. Resources are meant to be cached and paginated. Tools carry side effects. Prompts are composable message templates the model can invoke by name.
Tools: Actions with Side Effects
A tool is anything that changes state or triggers a process. Think send_email, create_ticket, run_query, deploy_service. The model calls it, something happens in the world.
Tool definitions live in tools/list and are invoked via tools/call. The schema is a JSON Schema object that the model uses to decide how to call it.
// tools/list response shape
{
tools: [
{
name: "create_ticket",
description: "Create a support ticket in the project tracker.",
inputSchema: {
type: "object",
properties: {
title: { type: "string" },
priority: { type: "string", enum: ["low", "medium", "high"] },
body: { type: "string" }
},
required: ["title", "body"]
}
}
]
}The handler:
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name === "create_ticket") {
const { title, priority = "medium", body } = req.params.arguments as any;
const ticket = await tracker.create({ title, priority, body });
return {
content: [{ type: "text", text: `Ticket #${ticket.id} created.` }]
};
}
throw new Error("Unknown tool");
});Rule of thumb: if the operation would belong behind a POST, PUT, or DELETE in a REST API, it is a tool.
Resources: Stable, Readable Data
Resources represent data the model should be able to read without triggering a side effect. Documentation pages, configuration files, database records, knowledge-base articles. The key property is that they are addressable by URI.
// resources/list response
{
resources: [
{
uri: "db://customers/acme-corp",
name: "ACME Corp customer record",
mimeType: "application/json"
},
{
uri: "docs://runbook/deployment",
name: "Deployment runbook",
mimeType: "text/markdown"
}
]
}Reading a resource:
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
const { uri } = req.params;
if (uri.startsWith("db://customers/")) {
const id = uri.replace("db://customers/", "");
const record = await db.customers.findOne(id);
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify(record, null, 2)
}
]
};
}
throw new Error("Resource not found");
});Because resources are read-only by contract, clients can safely cache them. Many MCP clients will also surface them in a separate "context" panel rather than in the tool-call flow — giving users visibility into what data the model is working with.
Rule of thumb: if it maps to a GET with a stable URL, it is a resource.
Prompts: Reusable Message Templates
Prompts are the least-understood primitive. They are not prompts in the sense of raw strings you stuff into a system message. They are named, parameterised message sequences that the server owns and the client can invoke by name.
// prompts/list response
{
prompts: [
{
name: "triage_ticket",
description: "Produce a triage assessment for a support ticket.",
arguments: [
{ name: "ticket_id", required: true },
{ name: "tone", required: false }
]
}
]
}Getting the rendered messages:
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
if (req.params.name === "triage_ticket") {
const { ticket_id, tone = "professional" } = req.params.arguments ?? {};
const ticket = await tracker.get(ticket_id);
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `You are a ${tone} support engineer. Triage this ticket:\n\n${JSON.stringify(ticket, null, 2)}`
}
}
]
};
}
throw new Error("Unknown prompt");
});Why does this matter? Because the business logic for how to talk to the model about a ticket now lives in the server, not scattered across every client. When your triage criteria change, you update one place.
Decision Matrix
If you are still unsure, ask: who owns the lifecycle? Tools are stateless handlers — the client drives them. Resources are server-owned, addressable content. Prompts are server-owned conversational scaffolding.
Mixing Primitives in Practice
A real SaaS integration will use all three. Consider a customer-success agent:
- Resource
crm://accounts/{id}— read the account record. - Tool
create_follow_up_task— schedule a callback. - Prompt
draft_renewal_email— generate a renewal message in the company's voice.
None of those belong in the same category, and none of them should be collapsed into a single mega-tool called do_crm_thing.
Key Takeaways
- Tools are for side-effectful operations; treat them like POST/PUT/DELETE endpoints.
- Resources are stable, read-only, URI-addressed data; clients may cache them.
- Prompts are server-owned message templates that encode reusable reasoning patterns.
- Conflating all three into tools creates a brittle, un-cacheable, undiscoverable server.
- The decision matrix is simple: mutation → tool, stable read → resource, reusable conversation shape → prompt.
- Prompt templates shift prompt-engineering ownership to the server, enabling centralised updates without client changes.