Module 3: Three Primitives — Resources, Tools, Prompts
Combining Primitives: How They Work Together in a Real Server
Combining Primitives: How They Work Together in a Real Server
Capsule description
You already know the 3 primitives separately: Resources to read data, Tools to execute actions, and Prompts to standardize interactions. But in a real MCP server, the primitives don't operate in silos — they work together as an integrated system.
A resource exposes a project's data. A tool modifies that data. A prompt guides the user to make the correct modification. The three primitives complement each other, and the key to a good MCP server is designing how they interact.
In this capsule you're going to see real combination patterns, you're going to design a coherent server where the primitives reinforce each other, and you're going to understand the design decisions that separate an amateur MCP server from a professional one.
The cohesion principle
The primitives as layers
In a well-designed MCP server, the primitives form complementary layers:
┌─────────────────────────────────────────────┐
│ Prompts (interaction layer) │
│ "How the user asks for things" │
│ ├── code-review │
│ ├── refactoring-plan │
│ └── bug-report │
├─────────────────────────────────────────────┤
│ Tools (action layer) │
│ "What actions the model can execute" │
│ ├── create_file │
│ ├── update_record │
│ └── deploy_app │
├─────────────────────────────────────────────┤
│ Resources (data layer) │
│ "What data the model can see" │
│ ├── file:///project/structure │
│ ├── db://users/list │
│ └── config://app/settings │
└─────────────────────────────────────────────┘
The natural flow: Resources give context → tools act on that context → prompts standardize how things are asked.
Concrete example: MCP Server for task management
Imagine an MCP server that connects with a task system:
Resources:
├── tasks://all → Lists all tasks
├── tasks://status/{status} → Tasks filtered by status
└── tasks://stats → Statistics (completed, pending)
Tools:
├── create_task → Creates a new task
├── update_task → Updates an existing task
├── complete_task → Marks a task as completed
└── delete_task → Deletes a task
Prompts:
├── daily-standup → "What did I do yesterday, what will I do today, what's blocking me?"
├── sprint-review → Sprint summary with completed tasks
└── task-breakdown → Breaks a large task into subtasks
Do you see how they complement each other?
- The user uses the
daily-standupprompt - The prompt internally needs data → uses the
tasks://status/in-progressresource - The model generates the standup and suggests completing finished tasks → uses the
complete_tasktool
Combination patterns
Pattern 1: Read-Act-Report
The most common pattern. The model reads data, acts on it, and reports the result.
Resource (read) → Tool (act) → Resource (verify)
Example:
1. Resource "db://orders/pending" → Reads pending orders
2. Tool "process_order" → Processes an order
3. Resource "db://orders/123" → Verifies that the order was processed
TypeScript implementation:
const server = new McpServer({ name: "order-manager", version: "1.0.0" });
// 1. Resource: read pending orders
server.resource(
"pending-orders",
"db://orders/pending",
{ description: "List of pending orders", mimeType: "application/json" },
async (uri) => {
const orders = await db.query("SELECT * FROM orders WHERE status = 'pending'");
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(orders.rows, null, 2),
}],
};
}
);
// 2. Tool: process an order
server.tool(
"process_order",
"Processes a pending order by changing its status to 'processing'",
{
orderId: z.string().describe("ID of the order to process"),
notes: z.string().optional().describe("Processing notes"),
},
async ({ orderId, notes }) => {
const result = await db.query(
"UPDATE orders SET status = 'processing', notes = $1, processed_at = NOW() WHERE id = $2 RETURNING *",
[notes || "", orderId]
);
if (result.rows.length === 0) {
return {
content: [{ type: "text", text: `Order ${orderId} not found` }],
isError: true,
};
}
return {
content: [{
type: "text",
text: JSON.stringify({
message: `Order ${orderId} processed successfully`,
order: result.rows[0],
}, null, 2),
}],
};
}
);
// 3. Resource template: verify an order
server.resource(
"order-detail",
new ResourceTemplate("db://orders/{orderId}", { list: undefined }),
{ description: "Detail of an order by ID", mimeType: "application/json" },
async (uri, params) => {
const order = await db.query("SELECT * FROM orders WHERE id = $1", [params.orderId]);
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(order.rows[0] || { error: "Not found" }, null, 2),
}],
};
}
);
Pattern 2: Prompt-Driven Workflow
The prompt guides a complete flow that uses resources and tools.
Prompt (guide) → Resource (context) → Tool (action)
Example:
1. Prompt "daily-standup" → Generates instructions for the standup
2. Resource "tasks://status/in-progress" → Provides current tasks as context
3. Tool "complete_task" → Completes tasks the user confirms as finished
TypeScript implementation:
// Prompt that orchestrates resources as context
server.prompt(
"daily-standup",
"Generates a daily standup report based on the current tasks",
{
teamMember: z.string().describe("Name of the team member"),
},
async ({ teamMember }) => {
const inProgress = await db.query(
"SELECT * FROM tasks WHERE assignee = $1 AND status IN ('in-progress', 'completed-today')",
[teamMember]
);
const blockers = await db.query(
"SELECT * FROM tasks WHERE assignee = $1 AND blocked = true",
[teamMember]
);
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Generate a standup report for ${teamMember}.
**In-progress tasks:**
${JSON.stringify(inProgress.rows, null, 2)}
**Blockers:**
${JSON.stringify(blockers.rows, null, 2)}
**Standup format:**
1. ✅ **Yesterday I completed:** [tasks with status 'completed-today']
2. 🔄 **Today I'll work on:** [tasks with status 'in-progress']
3. 🚫 **Blockers:** [tasks with blocked = true]
4. 📝 **Notes:** [relevant observations]
If there are completed tasks, suggest marking them as done using the complete_task tool.`,
},
},
],
};
}
);
Pattern 3: Resource-Enriched Tools
Tools use resources internally to enrich their operations.
Tool (execute) → Internal resource (context) → Tool (enriched result)
Example:
1. Tool "smart_create_file" → Before creating, queries the project structure
2. Internal resource → Reads the .editorconfig, .prettierrc, tsconfig
3. Tool → Creates the file with the project's correct style
The smart_create_file tool internally reads .editorconfig and .prettierrc (as if they were resources) to apply the correct conventions before writing the file. The user doesn't know that the tool queries data — they only see that the file is created with the correct style.
Design decisions
How many primitives does your server need?
Not every MCP server needs all 3 primitives. The decision depends on the use case:
Read-only server (e.g., metrics dashboard):
✅ Resources: expose metrics, logs, state
❌ Tools: there are no actions to execute
⚠️ Prompts: optional, to standardize queries
Action server (e.g., deployment tool):
⚠️ Resources: deployment state, logs
✅ Tools: deploy, rollback, scale
⚠️ Prompts: deployment checklist template
Complete server (e.g., project management):
✅ Resources: tasks, sprints, metrics
✅ Tools: CRUD of tasks, assign, complete
✅ Prompts: standup, sprint review, planning
Naming conventions
Keep consistency in the names:
// Resources: nouns, with a descriptive scheme
"db://users/list"
"db://users/{id}"
"config://app/settings"
"metrics://api/latency"
// Tools: verbs in snake_case
"create_user"
"update_task"
"deploy_application"
"search_files"
// Prompts: descriptive names in kebab-case
"code-review"
"daily-standup"
"bug-report"
"feature-spec"
Granularity: one big tool or several small ones?
// ❌ One tool that does everything
server.tool("manage_user", "Creates, updates, or deletes users", {
action: z.enum(["create", "update", "delete"]),
// ... many conditional parameters
});
// ✅ One tool per action
server.tool("create_user", "Creates a new user", { ... });
server.tool("update_user", "Updates an existing user", { ... });
server.tool("delete_user", "Deletes a user by ID", { ... });
Rule: One tool per action. The model chooses better between specific tools than between actions within a generic tool.
Complete example: Notes MCP Server
Let's look at a complete server that combines the 3 primitives cohesively:
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
interface Note {
id: string;
title: string;
content: string;
tags: string[];
createdAt: string;
updatedAt: string;
}
const notes: Map<string, Note> = new Map();
let nextId = 1;
const server = new McpServer({
name: "notes-server",
version: "1.0.0",
});
// === RESOURCES ===
server.resource(
"all-notes",
"notes://all",
{ description: "List of all notes", mimeType: "application/json" },
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(Array.from(notes.values()), null, 2),
}],
})
);
server.resource(
"note-by-id",
new ResourceTemplate("notes://note/{noteId}", {
list: async () =>
Array.from(notes.values()).map((n) => ({
uri: `notes://note/${n.id}`,
name: n.title,
description: `Note: ${n.title}`,
})),
}),
{ description: "A specific note by ID", mimeType: "application/json" },
async (uri, params) => {
const note = notes.get(params.noteId as string);
if (!note) throw new Error(`Note ${params.noteId} not found`);
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(note, null, 2),
}],
};
}
);
server.resource(
"notes-stats",
"notes://stats",
{ description: "Notes statistics", mimeType: "application/json" },
async (uri) => {
const allNotes = Array.from(notes.values());
const tagCount: Record<string, number> = {};
allNotes.forEach((n) => n.tags.forEach((t) => (tagCount[t] = (tagCount[t] || 0) + 1)));
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({
totalNotes: allNotes.length,
tags: tagCount,
lastUpdated: allNotes.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]?.updatedAt || null,
}, null, 2),
}],
};
}
);
// === TOOLS ===
server.tool(
"create_note",
"Creates a new note with title, content, and tags",
{
title: z.string().min(1).describe("The note's title"),
content: z.string().describe("The note's content"),
tags: z.array(z.string()).default([]).describe("The note's tags"),
},
async ({ title, content, tags }) => {
const id = String(nextId++);
const now = new Date().toISOString();
const note: Note = { id, title, content, tags, createdAt: now, updatedAt: now };
notes.set(id, note);
return {
content: [{
type: "text",
text: JSON.stringify({ message: `Note "${title}" created with ID ${id}`, note }, null, 2),
}],
};
}
);
server.tool(
"update_note",
"Updates the content or tags of an existing note",
{
noteId: z.string().describe("ID of the note to update"),
title: z.string().optional().describe("New title"),
content: z.string().optional().describe("New content"),
tags: z.array(z.string()).optional().describe("New tags"),
},
async ({ noteId, title, content, tags }) => {
const note = notes.get(noteId);
if (!note) {
return { content: [{ type: "text", text: `Note ${noteId} not found` }], isError: true };
}
if (title) note.title = title;
if (content) note.content = content;
if (tags) note.tags = tags;
note.updatedAt = new Date().toISOString();
return {
content: [{ type: "text", text: JSON.stringify({ message: "Note updated", note }, null, 2) }],
};
}
);
server.tool(
"delete_note",
"Deletes a note by its ID",
{ noteId: z.string().describe("ID of the note to delete") },
async ({ noteId }) => {
const note = notes.get(noteId);
if (!note) {
return { content: [{ type: "text", text: `Note ${noteId} not found` }], isError: true };
}
notes.delete(noteId);
return {
content: [{ type: "text", text: `Note "${note.title}" (ID: ${noteId}) deleted` }],
};
}
);
// === PROMPTS ===
server.prompt(
"organize-notes",
"Analyzes and suggests organization for the existing notes",
{},
async () => {
const allNotes = Array.from(notes.values());
return {
messages: [{
role: "user" as const,
content: {
type: "text" as const,
text: `Analyze these ${allNotes.length} notes and suggest how to organize them better:
${JSON.stringify(allNotes, null, 2)}
**Suggest:**
1. Tags that are missing or could be consolidated
2. Notes that could be merged
3. Notes that should be split
4. A suggested category structure`,
},
}],
};
}
);
server.prompt(
"summarize-notes",
"Generates an executive summary of all the notes",
{
format: z.enum(["bullet-points", "paragraph", "table"]).default("bullet-points").describe("Summary format"),
},
async ({ format }) => {
const allNotes = Array.from(notes.values());
return {
messages: [{
role: "user" as const,
content: {
type: "text" as const,
text: `Generate an executive summary of these notes in ${format} format:
${JSON.stringify(allNotes, null, 2)}
The summary should capture the key points of each note in 1-2 lines.`,
},
}],
};
}
);
The Python equivalent uses decorators (@server.resource, @server.tool, @server.prompt) but follows exactly the same cohesion pattern: resources to read, tools to act, prompts to guide.
Anti-patterns: what to avoid
Anti-pattern 1: Duplicate primitives
// ❌ Resource AND tool that do the same thing
server.resource("users", "db://users/all", {}, async () => { ... });
server.tool("list_users", "Lists all users", {}, async () => { ... });
// ✅ Resource for reading, tool only if it needs complex parameters
server.resource("users", "db://users/all", {}, async () => { ... });
server.tool("search_users", "Searches users with advanced filters", {
query: z.string(),
role: z.enum([...]),
sortBy: z.string(),
}, async (args) => { ... });
Anti-pattern 2: Prompts that should be tools
// ❌ Prompt that executes an action
server.prompt("deploy", "Deploys the application", {}, async () => {
await deployApp(); // ← side effect in a prompt
return { messages: [...] };
});
// ✅ Prompt that guides, tool that executes
server.prompt("deploy-checklist", "Pre-deployment checklist", {}, async () => ({
messages: [{ role: "user", content: { type: "text", text: "Check these items before deploy..." } }],
}));
server.tool("deploy_app", "Deploys the application", { ... }, async () => { ... });
Anti-pattern 3: Server without cohesion
// ❌ Disconnected primitives
server.resource("weather", ...); // Weather
server.tool("create_user", ...); // Users
server.prompt("sql-query", ...); // SQL
// ✅ Cohesive primitives
server.resource("db://users/all", ...); // User data
server.tool("create_user", ...); // User management
server.prompt("user-report", ...); // User reports
Troubleshooting
"The model doesn't combine primitives automatically"
Cause: The model doesn't know that the primitives are related.
Solution: Use descriptions that reference other primitives:
server.tool(
"update_task",
"Updates a task. Use the tasks://all resource to see the available tasks before updating.",
{ ... }
);
"The prompt doesn't have access to the current data"
Cause: The prompt generates static text without querying data.
Solution: Read the data inside the prompt's handler:
server.prompt("report", "Status report", {}, async () => {
const data = await getCurrentData(); // ← reads data dynamically
return { messages: [{ role: "user", content: { type: "text", text: `Data: ${JSON.stringify(data)}` } }] };
});
"Too many tools confuse the model"
Cause: The server exposes many tools and the model doesn't know which to choose.
Solution:
- Limit to 10-15 tools per server
- Use very specific names and descriptions
- Group functionality into separate servers if necessary
Exercises
Exercise 1: Design primitives for a use case (Easy)
Design the primitives (resources, tools, prompts) for a bookmark management MCP server. List at least 2 of each type.
See solution
Resources:
├── bookmarks://all → Lists all bookmarks
├── bookmarks://tag/{tag} → Bookmarks filtered by tag
├── bookmarks://stats → Statistics (total, per tag, most visited)
Tools:
├── add_bookmark → Adds a new bookmark (url, title, tags)
├── delete_bookmark → Deletes a bookmark by ID
├── tag_bookmark → Adds/removes tags from a bookmark
├── check_links → Checks which bookmarks have broken links
Prompts:
├── weekly-reading → "Suggest 5 bookmarks to read this week based on my tags"
├── organize-bookmarks → "Analyze my bookmarks and suggest better tag organization"
├── find-related → "Given a topic, find related bookmarks"
Exercise 2: Identify the combination pattern (Medium)
For each scenario, identify which combination pattern (Read-Act-Report, Prompt-Driven Workflow, Resource-Enriched Tools) applies and why:
- The user asks for a code review, the model reads the file, generates feedback, and optionally applies fixes
- The user runs a deployment tool that reads the config before deploying
- The model lists modified files, compares them with the previous version, and generates a changelog
See solution
-
Prompt-Driven Workflow
- Prompt: code review template with criteria
- Resource: content of the file to review
- Tool: apply suggested fixes
- The flow starts with the prompt that guides everything
-
Resource-Enriched Tools
- Tool: deploy_application
- Internal resource: config://deployment/settings
- The tool queries resources internally to enrich its execution
-
Read-Act-Report
- Resource: list of modified files + previous versions
- Tool: generate changelog (or it could be a prompt if it's just text)
- Resource: verify that the changelog was generated correctly
Exercise 3: Implement a combined flow (Medium)
Implement a mini-server in TypeScript with 1 resource, 1 tool, and 1 prompt that work together to manage a shopping list:
See solution
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({ name: "shopping-list", version: "1.0.0" });
interface Item { id: number; name: string; quantity: number; bought: boolean; }
const items: Item[] = [];
let nextId = 1;
// Resource: view the current list
server.resource("shopping-list", "shopping://list", {
description: "Current shopping list",
mimeType: "application/json",
}, async (uri) => ({
contents: [{
uri: uri.href, mimeType: "application/json",
text: JSON.stringify({ total: items.length, pending: items.filter(i => !i.bought).length, items }, null, 2),
}],
}));
// Tool: add an item
server.tool("add_item", "Adds an item to the shopping list", {
name: z.string().describe("Product name"),
quantity: z.number().default(1).describe("Quantity"),
}, async ({ name, quantity }) => {
const item: Item = { id: nextId++, name, quantity, bought: false };
items.push(item);
return { content: [{ type: "text", text: `"${name}" (x${quantity}) added to the list` }] };
});
// Prompt: suggest a weekly menu based on the list
server.prompt("weekly-menu", "Suggests a weekly menu based on the items in the list", {}, async () => {
const pending = items.filter(i => !i.bought);
return {
messages: [{
role: "user" as const,
content: {
type: "text" as const,
text: `I have these items in my shopping list:\n${JSON.stringify(pending, null, 2)}\n\nSuggest a weekly menu (Monday to Friday) using these ingredients. If something essential is missing, recommend adding it using the add_item tool.`,
},
}],
};
});
Exercise 4: Refactor disconnected primitives (Hard)
The following server has disconnected primitives. Refactor it so they're cohesive:
server.resource("weather", "api://weather/current", {}, handler);
server.tool("create_user", "Creates a user", schema, handler);
server.tool("get_forecast", "Gets a forecast", schema, handler);
server.prompt("user-welcome", "Welcome message", {}, handler);
server.resource("db://users/count", {}, handler);
See solution
Separate into 2 cohesive servers:
// Server 1: Weather Server
const weatherServer = new McpServer({ name: "weather-server", version: "1.0.0" });
weatherServer.resource("current-weather", "weather://current", {
description: "Current weather"
}, handler);
weatherServer.resource("forecast", "weather://forecast/{days}", {
description: "Forecast for N days"
}, handler);
weatherServer.prompt("weather-report", "Weather report to plan the week", {}, handler);
// Server 2: Users Server
const usersServer = new McpServer({ name: "users-server", version: "1.0.0" });
usersServer.resource("users-count", "db://users/count", {
description: "Number of registered users"
}, handler);
usersServer.resource("users-list", "db://users/all", {
description: "List of users"
}, handler);
usersServer.tool("create_user", "Creates a new user", schema, handler);
usersServer.prompt("user-welcome", "Generates a personalized welcome message", {}, handler);
Each server now has primitives that reinforce each other within a coherent domain.
Summary
In this capsule you learned:
- The 3 primitives work together as a system — not in isolation
- Read-Act-Report: Resource reads data → Tool acts → Resource verifies
- Prompt-Driven Workflow: Prompt guides → Resource gives context → Tool executes
- Resource-Enriched Tools: Tool queries data internally before acting
- A good MCP server has cohesive primitives — all related to the same domain
- Naming conventions and granularity are important design decisions
- Avoid anti-patterns: duplicate primitives, prompts with side effects, servers without cohesion
Next capsule: Mini-project — you're going to build your first complete MCP server with 1 resource, 1 tool, and 1 prompt working together.
Additional resources
- MCP Specification - How the primitives are defined in the protocol
- MCP TypeScript SDK Examples - Official examples of servers with multiple primitives
- MCP Servers Repository - Official servers as a design reference
- Awesome MCP Servers - Community servers with different combinations
- MCP Inspector - Inspect all of a server's primitives
- Domain-Driven Design Basics - Cohesion principles applicable to server design