Resources & Prompts — The Other Two MCP Primitives
MCP01 taught you tools — actions with side effects. This module teaches the other two primitives: Resources (read-only URI-addressed data) and Prompts (reusable message templates). Both Python and TypeScript, all code runnable.
- ✓Explain the three MCP primitives and the "side effects" decision rule that separates tools from resources
- ✓Build a file system resource server with static and dynamic URI templates
- ✓Build a prompt server with parameterized message templates for security reviews and error explanations
- ✓Combine all three primitives in a single notes-app server and understand how Claude Desktop surfaces each type
- ✓Test resources and prompts interactively with MCP Inspector
1. The Three Primitives — A Quick Map
Before MCP: imagine a librarian who can only answer questions by doing things — they can print a document, send an email, or run a calculation. Every request results in an action, even when you just wanted to read something.
The pain: you ask "what's on page 42?" and instead of handing you the book, they photocopy page 42 and hand it to you. Every read is modelled as an action with side effects, which is wasteful and confusing.
The mapping: MCP fixes this by giving the server three separate surfaces. Tools are the librarian's actions (side effects). Resources are the shelves of books you can read directly by URI address (no side effects). Prompts are pre-written question templates the librarian keeps at the desk — you fill in the blanks and they handle the rest.
- @mcp.tool() decorator
- Has side effects
- LLM decides when to call
- Returns result content
- Input schema validated
- @mcp.resource() decorator
- No side effects
- URI-addressed data
- Returns content blobs
- MIME type declared
- @mcp.prompt() decorator
- No side effects
- User-parameterized
- Returns message list
- Injected into conversation
The Side-Effects Decision Tree
The single question that separates tools from resources: does the operation change state?
2. Resources Deep Dive
A resource is a URI-addressed, read-only data object that a server exposes for a client to pull into context. Every resource has: a URI templateURI template — a URI pattern with {variable} placeholders (RFC 6570). Example: file://{path} matches file:///home/user/notes.md and binds path="/home/user/notes.md"., a MIME typeMIME type — a media type string like text/plain, application/json, or image/png that tells the client what kind of data to expect. The host can use this to decide how to render the resource in its UI., a description, and a handler that returns the content. No round-trip through the LLM — the host reads the resource directly and injects the content into context.
Resources cover a natural class of server capabilities that do not belong as tools: reading a config file, fetching a customer record by ID, listing directory contents, pulling a git commit by hash. These are all reads — they have stable URIs, no side effects, and the host should be able to cache or browse them independently of the LLM.
Static vs Dynamic Resources
Resources come in two shapes: static (fixed URI, like a config file) and dynamic (URI template with variables, like a file path or database ID). A static resource has a concrete URI. A dynamic resource uses {variable} placeholders in the URI pattern — FastMCP extracts the variables from the requested URI and passes them to your function.
You pass a URI template string as the first argument. FastMCP registers the resource, maps URI variable names to your function parameters, and wires up the resources/list and resources/read protocol methods automatically.
Using a URI template (e.g., file://{path}) means the host can present a UI to browse and parameterize resources without calling a tool. MCP Inspector shows a form for each variable. Claude Desktop can show a resource picker. The URI space is structured and explorable.
Unlike tools, resources must return either a str (text content) or bytes (binary content). If your resource returns a dict or list, FastMCP will attempt to JSON-serialize it, but the MIME type should be application/json. For text resources, always return a plain string.
# file_resource_server.py — MCP server exposing file system resources
from mcp.server.fastmcp import FastMCP
import pathlib
mcp = FastMCP("File Resources")
# ── CHUNK 1: Dynamic resource (URI template with {path} variable) ──
# WHAT: The {path} placeholder is extracted from any incoming URI matching
# "file://{path}" and passed as the `path` argument to your function.
# WHY: This lets the host request any file by URI — Claude can ask for
# file:///workspace/config.yaml and your handler receives "workspace/config.yaml".
# GOTCHA: Path traversal! Always validate or restrict the path before reading.
@mcp.resource("file://{path}")
def read_file(path: str) -> str:
"""Read a file from the workspace by path.
Returns the full text content of the file at the given path.
Use this to pull source files, configs, or notes into context.
"""
p = pathlib.Path(path)
if not p.exists():
raise FileNotFoundError(f"File not found: {path}")
if not p.is_file():
raise ValueError(f"Path is not a file: {path}")
# Only allow files under current working directory (basic safety check)
try:
p.resolve().relative_to(pathlib.Path.cwd().resolve())
except ValueError:
raise PermissionError(f"Access denied: {path} is outside the workspace")
return p.read_text(encoding="utf-8", errors="replace")
# ── CHUNK 2: Static resource (fixed URI, no template variables) ──
# WHAT: A concrete URI with no placeholders. The handler takes no extra args.
# WHY: Use static resources for well-known data that has a stable identity —
# a workspace summary, a README, a server configuration.
# GOTCHA: Static resources are still re-evaluated on every read request —
# they are not cached unless the client implements caching.
@mcp.resource("file://workspace/summary")
def workspace_summary() -> str:
"""List all files in the current workspace directory.
Returns a newline-separated list of relative file paths.
"""
files = sorted(pathlib.Path(".").rglob("*"))
file_list = [str(f) for f in files if f.is_file()]
if not file_list:
return "No files found in workspace."
return "\n".join(file_list)
# ── CHUNK 3: JSON resource with explicit MIME type ──
# WHAT: Resources can return structured data. Declare mime_type="application/json"
# so the host knows to treat the content as JSON, not plain text.
# WHY: Some hosts (Claude Desktop) render JSON resources with syntax highlighting
# or pretty-print them for the user.
@mcp.resource("workspace://stats", mime_type="application/json")
def workspace_stats() -> str:
"""Return statistics about the workspace as JSON."""
import json, os
files = list(pathlib.Path(".").rglob("*"))
stats = {
"total_files": sum(1 for f in files if f.is_file()),
"total_dirs": sum(1 for f in files if f.is_dir()),
"extensions": {},
}
for f in files:
if f.is_file():
ext = f.suffix.lower() or "(no ext)"
stats["extensions"][ext] = stats["extensions"].get(ext, 0) + 1
return json.dumps(stats, indent=2)
if __name__ == "__main__":
mcp.run()
// fileResourceServer.ts — MCP server exposing file system resources
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import * as fs from "node:fs";
import * as path from "node:path";
const server = new McpServer({ name: "File Resources", version: "1.0.0" });
// ── CHUNK 1: Dynamic resource via ResourceTemplate ──
// WHAT: In the TS SDK, dynamic resources use ResourceTemplate with URI patterns.
// Variables are extracted and passed to the readCallback.
// WHY: The SDK handles URI matching and variable extraction for you.
// GOTCHA: Always validate paths before reading — prevent directory traversal.
server.resource(
"file-read",
new ResourceTemplate("file://{path}", { list: undefined }),
async (uri, { path: filePath }) => {
const resolved = path.resolve(filePath);
const cwd = process.cwd();
if (!resolved.startsWith(cwd)) {
throw new Error(`Access denied: ${filePath} is outside workspace`);
}
if (!fs.existsSync(resolved)) {
throw new Error(`File not found: ${filePath}`);
}
const stat = fs.statSync(resolved);
if (!stat.isFile()) {
throw new Error(`Not a file: ${filePath}`);
}
const content = fs.readFileSync(resolved, "utf-8");
return {
contents: [{ uri: uri.href, mimeType: "text/plain", text: content }],
};
}
);
// ── CHUNK 2: Static resource (fixed URI, no template) ──
// WHAT: Pass the URI string directly (no ResourceTemplate) for a fixed address.
// WHY: Static resources are discoverable in resources/list even without knowing
// the URI — the host can enumerate them.
server.resource(
"workspace-summary",
"file://workspace/summary",
async (uri) => {
const files: string[] = [];
const walkDir = (dir: string) => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkDir(full);
else files.push(path.relative(process.cwd(), full));
}
};
try { walkDir(process.cwd()); } catch { /* empty workspace */ }
const text = files.length ? files.sort().join("\n") : "No files found in workspace.";
return { contents: [{ uri: uri.href, mimeType: "text/plain", text }] };
}
);
// ── CHUNK 3: JSON resource with explicit MIME type ──
server.resource(
"workspace-stats",
"workspace://stats",
async (uri) => {
const counts: Record = {};
let totalFiles = 0, totalDirs = 0;
const walkDir = (dir: string) => {
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { totalDirs++; walkDir(full); }
else { totalFiles++; const ext = path.extname(entry.name).toLowerCase() || "(no ext)"; counts[ext] = (counts[ext] ?? 0) + 1; }
}
} catch { /* skip unreadable dirs */ }
};
walkDir(process.cwd());
const stats = { total_files: totalFiles, total_dirs: totalDirs, extensions: counts };
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(stats, null, 2) }] };
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
- Three resources are registered: one dynamic (any file by path), one static (workspace summary), one JSON (workspace stats).
- The host calls
resources/listand sees all three. When it callsresources/readwith a matching URI, FastMCP resolves the template, extracts variables, and calls your handler. - No LLM round-trip needed: the host can read resources directly and inject the content into context before even asking the LLM a question.
Resource Subscriptions
The MCP protocol includes an optional resource subscriptionResource subscription — an optional MCP protocol feature where the client sends resources/subscribe with a URI, and the server pushes notifications/resources/updated events when that resource changes. Not all servers or hosts implement this — check the capability flags in the initialize handshake. mechanism. The client sends resources/subscribe for a URI; the server pushes notifications/resources/updated when that resource changes. This is useful for live files or database rows the host wants to track. FastMCP supports this via mcp.resource_updated(uri) — but it is optional and most servers skip it. For production use, verify the host declares the experimental.resourceSubscriptions capability in its initialize response before relying on it.
3. Prompts Deep Dive
A prompt is a server-defined message template that the host surfaces as a user-invocable command. The user fills in arguments; the server generates a fully formed list[Message] that is injected into the conversation. The LLM then responds to that pre-built message sequence. Unlike tools (LLM-driven) and resources (host-pulled), prompt injectionPrompt injection — in MCP context: the act of inserting a server-generated message list into the conversation before the LLM's next turn. The server controls the template; the user provides the variable values. This is distinct from adversarial prompt injection (a security concern), which is a different concept. is user-initiated: the user picks a prompt by name and fills in its parameters.
Before: a paralegal drafts every client letter from scratch. Each "demand letter" or "NDA review" is unique, slightly inconsistent, and takes 20 minutes to compose correctly.
The pain: high variance in quality, difficult to audit, easy to forget required fields. The 10th review looks nothing like the first.
The mapping: MCP prompts are the firm's standard letter templates. The paralegal (user) fills in the client name and contract date (arguments); the system generates the structured letter. Every review follows the same structure, includes all required sections, and the reviewer just needs to confirm the output.
Prompts appear as slash commandsSlash commands — in Claude Desktop, server-defined prompts appear in the prompt picker UI when you type / in the chat input. Each prompt shows its name, description, and an input form for its arguments. Selecting a prompt and filling in arguments sends the generated messages directly into the conversation. in Claude Desktop's UI. The server declares which arguments the user must supply; Claude Desktop renders a form. When submitted, the server's handler generates the full message list and Claude responds to it.
Your function takes typed parameters (the arguments the user fills in), and returns list[types.Message]. Each message has a role (user or assistant) and text content. The message list is injected directly into the conversation.
The server author knows the domain. A security engineer can encode exactly the right vulnerability checklist into a prompt template, ensuring every code review asks about SQL injection, XSS, and OWASP Top 10 — without trusting the user to remember to ask. The template is the expertise.
Tools return data; prompts return messages. Your handler must return a list containing at least one types.UserMessage or types.AssistantMessage. Returning a string directly will fail. A common pattern: a single UserMessage containing the fully formatted prompt text.
# code_review_prompts.py — MCP server exposing code review prompt templates
from mcp import types
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Code Review Prompts")
# ── CHUNK 1: Prompt with required and optional arguments ──
# WHAT: The `code` parameter is required (no default). `language` is optional
# with a default of "python". FastMCP reads the type hints to build
# the argument schema displayed in Claude Desktop's prompt picker.
# WHY: Structured code reviews require domain-specific checklists.
# Hardcoding them here ensures every security review covers all bases.
# GOTCHA: The f-string indentation inside the message text matters.
# MCP preserves whitespace in message text — keep it clean.
@mcp.prompt()
def security_review(code: str, language: str = "python") -> list[types.Message]:
"""Generate a security-focused code review prompt.
Asks Claude to review code for OWASP Top 10 vulnerabilities,
with specific line references and remediation steps.
"""
return [
types.UserMessage(
f"""Review the following {language} code for security vulnerabilities.
Focus on:
- SQL injection (parameterized queries? ORM misuse?)
- Cross-site scripting (XSS) if applicable
- Authentication and authorization flaws
- Sensitive data exposure (hardcoded secrets, logging PII)
- Insecure deserialization
- Known CVEs in dependencies if identifiable
```{language}
{code}
```
For each issue found:
1. Cite the specific line numbers
2. Explain why it is a vulnerability
3. Provide a corrected code snippet
4. Rate severity: Critical / High / Medium / Low
If no issues are found, state that explicitly and explain why the code is secure."""
)
]
# ── CHUNK 2: Multi-turn prompt (user + assistant prefill) ──
# WHAT: A prompt can return more than one message — creating a conversation
# fragment. Here we include an assistant prefill to guide Claude's
# response format immediately.
# WHY: Prefilling the assistant role forces Claude to continue in the
# established format rather than starting with preamble.
@mcp.prompt()
def explain_error(error_message: str, context: str = "") -> list[types.Message]:
"""Generate a prompt to explain an error message with a fix.
Provide the raw error output and optional surrounding context
(file name, what you were doing) for a more precise explanation.
"""
ctx_section = f"\n\nContext:\n{context}" if context.strip() else ""
return [
types.UserMessage(
f"""Explain this error message and provide a concrete fix:{ctx_section}
```
{error_message}
```
Structure your response as:
1. What went wrong (plain English, one sentence)
2. Root cause (technical explanation)
3. How to fix it (exact code or commands)
4. How to prevent it in future"""
)
]
# ── CHUNK 3: Prompt building a multi-step workflow ──
# WHAT: Prompts can encode entire multi-step workflows as a single message.
# This one builds an architecture review prompt with all required
# sections so Claude produces a structured, comparable output.
@mcp.prompt()
def architecture_review(
system_description: str,
scale: str = "startup",
concerns: str = "scalability, security, cost",
) -> list[types.Message]:
"""Generate a system architecture review prompt.
scale: startup | growth | enterprise
concerns: comma-separated focus areas (e.g., 'scalability, security, cost')
"""
concern_list = [c.strip() for c in concerns.split(",")]
concerns_formatted = "\n".join(f"- {c}" for c in concern_list)
return [
types.UserMessage(
f"""Review the following system architecture for a {scale}-stage company.
System description:
{system_description}
Evaluate specifically for:
{concerns_formatted}
Provide your assessment in this format:
## Strengths
## Risks
## Recommendations (prioritized)
## Quick wins (implementable in < 1 sprint)"""
)
]
if __name__ == "__main__":
mcp.run()
// codeReviewPrompts.ts — MCP server with code review prompt templates
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "Code Review Prompts", version: "1.0.0" });
// ── CHUNK 1: Prompt with required and optional arguments (Zod schema) ──
// WHAT: In the TS SDK, prompt arguments are declared with a Zod schema.
// The SDK uses this to validate inputs and populate the argument list
// returned in prompts/list (for Claude Desktop's UI).
// WHY: Explicit schemas provide type safety and runtime validation.
// GOTCHA: Always return the { messages: [...] } shape, not a bare array.
server.prompt(
"security-review",
"Review code for security vulnerabilities including OWASP Top 10",
{
code: z.string().min(1, "code must not be empty"),
language: z.string().default("python"),
},
async ({ code, language }) => ({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Review the following ${language} code for security vulnerabilities.\n\nFocus on:\n- SQL injection\n- Cross-site scripting (XSS)\n- Authentication and authorization flaws\n- Sensitive data exposure\n- Insecure deserialization\n\n\`\`\`${language}\n${code}\n\`\`\`\n\nFor each issue: cite line numbers, explain the vulnerability, provide a fix, and rate severity.`,
},
},
],
})
);
// ── CHUNK 2: Multi-turn prompt ──
server.prompt(
"explain-error",
"Explain an error message and provide a concrete fix",
{
error_message: z.string().min(1),
context: z.string().default(""),
},
async ({ error_message, context }) => {
const ctxSection = context.trim() ? `\n\nContext:\n${context}` : "";
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Explain this error and provide a concrete fix:${ctxSection}\n\n\`\`\`\n${error_message}\n\`\`\`\n\nStructure: 1) What went wrong 2) Root cause 3) How to fix it 4) Prevention`,
},
},
],
};
}
);
// ── CHUNK 3: Parameterized workflow prompt ──
server.prompt(
"architecture-review",
"Generate a structured system architecture review",
{
system_description: z.string().min(10),
scale: z.enum(["startup", "growth", "enterprise"]).default("startup"),
concerns: z.string().default("scalability, security, cost"),
},
async ({ system_description, scale, concerns }) => {
const concernList = concerns.split(",").map((c) => `- ${c.trim()}`).join("\n");
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Review the following system architecture for a ${scale}-stage company.\n\nSystem description:\n${system_description}\n\nEvaluate for:\n${concernList}\n\nFormat: ## Strengths\n## Risks\n## Recommendations\n## Quick wins`,
},
},
],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
In teams using Claude Desktop, prompt servers become shared best-practice libraries. A security team publishes a security-review prompt; every engineer uses it. One update to the prompt propagates to all users on next server restart — no browser extension, no copy-pasted template doc. Teams that do this report more consistent code review coverage and fewer "forgot to check for X" misses.
4. Combining All Three Primitives
A single MCP server can expose tools, resources, and prompts simultaneously. The host discovers all of them via separate protocol calls (tools/list, resources/list, prompts/list) and surfaces them in different parts of its UI. Let's build the classic example: a notes app server with all three.
The notes app needs three behaviors: save a note (side effect → tool), read a note by ID (URI-addressable read → resource), summarize all notes (reusable analysis template → prompt). Same data, three primitives, three surfaces in the UI.
FastMCP lets you mix @mcp.tool(), @mcp.resource(), and @mcp.prompt() decorators on the same FastMCP instance. All three appear in their respective list responses when the host connects.
The NOTES dict lives in the same process as all three primitives. The tool writes to it; the resource reads from it; the prompt templates reference its contents at call time. This is the power of MCP server-side state — the three primitives operate on a shared model.
In-memory state (like the NOTES dict here) is lost when the server process exits. If you need persistence, write to a file or database inside your tool handler. In development this is fine; in production always persist your state.
# notes_server.py — Complete MCP server with Tools + Resources + Prompts
from mcp import types
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Notes App")
# In-memory store: maps note_id -> {"title": str, "content": str, "tags": list[str]}
NOTES: dict[str, dict] = {}
# ══════════════ TOOLS ══════════════
# Tools = operations with side effects (write, delete, update)
@mcp.tool()
def save_note(title: str, content: str, tags: list[str] | None = None) -> dict:
"""Save a new note or overwrite an existing one.
Returns the saved note's ID and total note count.
"""
if not title.strip():
raise ValueError("title must not be empty")
if not content.strip():
raise ValueError("content must not be empty")
note_id = title.strip().lower().replace(" ", "-")
# Remove non-alphanumeric characters except hyphens
note_id = "".join(c for c in note_id if c.isalnum() or c == "-")
NOTES[note_id] = {
"title": title.strip(),
"content": content.strip(),
"tags": tags or [],
}
return {
"success": True,
"note_id": note_id,
"total_notes": len(NOTES),
}
@mcp.tool()
def delete_note(note_id: str) -> dict:
"""Delete a note by its ID.
Returns success status and the deleted note's title.
"""
if note_id not in NOTES:
raise KeyError(f"Note '{note_id}' not found. Available: {list(NOTES.keys())}")
deleted = NOTES.pop(note_id)
return {"success": True, "deleted_title": deleted["title"]}
# ══════════════ RESOURCES ══════════════
# Resources = read-only data with stable URI addresses
@mcp.resource("note://{note_id}")
def read_note(note_id: str) -> str:
"""Read a single note by its ID.
Returns the note's title, content, and tags as formatted text.
"""
if note_id not in NOTES:
raise KeyError(f"Note '{note_id}' not found. Available: {list(NOTES.keys())}")
note = NOTES[note_id]
tags_str = ", ".join(note["tags"]) if note["tags"] else "none"
return f"# {note['title']}\n\nTags: {tags_str}\n\n{note['content']}"
@mcp.resource("note://index")
def list_notes() -> str:
"""List all saved notes with their IDs and titles."""
if not NOTES:
return "No notes saved yet."
lines = [f"- {note_id}: {data['title']}" for note_id, data in sorted(NOTES.items())]
return f"Saved notes ({len(NOTES)} total):\n" + "\n".join(lines)
# ══════════════ PROMPTS ══════════════
# Prompts = message templates the user invokes by name
@mcp.prompt()
def summarize_notes(focus: str = "key themes") -> list[types.Message]:
"""Summarize all saved notes.
focus: what to emphasize — e.g., 'key themes', 'action items', 'decisions'
"""
if not NOTES:
return [types.UserMessage("There are no notes saved yet. Save some notes first using the save_note tool.")]
notes_text = "\n\n---\n\n".join(
f"**{data['title']}** (tags: {', '.join(data['tags']) or 'none'})\n{data['content']}"
for note_id, data in sorted(NOTES.items())
)
return [
types.UserMessage(
f"""Here are all the saved notes. Please summarize them with a focus on: {focus}
{notes_text}
Provide:
1. A 2-3 sentence executive summary
2. The top 3-5 {focus}
3. Any action items or open questions you notice"""
)
]
@mcp.prompt()
def find_connections(topic: str) -> list[types.Message]:
"""Find connections between notes related to a given topic."""
if not NOTES:
return [types.UserMessage(f"No notes saved yet. Add some notes first to find connections about '{topic}'.")]
notes_text = "\n\n".join(
f"[{note_id}] {data['title']}: {data['content'][:300]}..."
if len(data["content"]) > 300
else f"[{note_id}] {data['title']}: {data['content']}"
for note_id, data in NOTES.items()
)
return [
types.UserMessage(
f"""Analyze the following notes and identify connections related to: {topic}
{notes_text}
For each connection found:
- Which notes are related
- What the connection is
- Why it matters for understanding '{topic}'"""
)
]
if __name__ == "__main__":
mcp.run()
// notesServer.ts — Complete MCP server with Tools + Resources + Prompts
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "Notes App", version: "1.0.0" });
// In-memory store
const NOTES = new Map();
// ══════════════ TOOLS ══════════════
server.tool(
"save_note",
"Save a new note or overwrite an existing one by title.",
{
title: z.string().min(1, "title required"),
content: z.string().min(1, "content required"),
tags: z.array(z.string()).default([]),
},
async ({ title, content, tags }) => {
const noteId = title.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
NOTES.set(noteId, { title: title.trim(), content: content.trim(), tags });
return { content: [{ type: "text" as const, text: JSON.stringify({ success: true, note_id: noteId, total_notes: NOTES.size }) }] };
}
);
server.tool(
"delete_note",
"Delete a note by its ID.",
{ note_id: z.string().min(1) },
async ({ note_id }) => {
const note = NOTES.get(note_id);
if (!note) {
const available = [...NOTES.keys()].join(", ") || "none";
throw new Error(`Note '${note_id}' not found. Available: ${available}`);
}
NOTES.delete(note_id);
return { content: [{ type: "text" as const, text: JSON.stringify({ success: true, deleted_title: note.title }) }] };
}
);
// ══════════════ RESOURCES ══════════════
server.resource(
"note-read",
new ResourceTemplate("note://{note_id}", { list: undefined }),
async (uri, { note_id }) => {
const note = NOTES.get(note_id as string);
if (!note) {
const available = [...NOTES.keys()].join(", ") || "none";
throw new Error(`Note '${note_id}' not found. Available: ${available}`);
}
const tagsStr = note.tags.length ? note.tags.join(", ") : "none";
const text = `# ${note.title}\n\nTags: ${tagsStr}\n\n${note.content}`;
return { contents: [{ uri: uri.href, mimeType: "text/plain", text }] };
}
);
server.resource(
"note-index",
"note://index",
async (uri) => {
if (NOTES.size === 0) {
return { contents: [{ uri: uri.href, mimeType: "text/plain", text: "No notes saved yet." }] };
}
const lines = [...NOTES.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, n]) => `- ${id}: ${n.title}`);
const text = `Saved notes (${NOTES.size} total):\n` + lines.join("\n");
return { contents: [{ uri: uri.href, mimeType: "text/plain", text }] };
}
);
// ══════════════ PROMPTS ══════════════
server.prompt(
"summarize-notes",
"Summarize all saved notes with a specific focus",
{ focus: z.string().default("key themes") },
async ({ focus }) => {
if (NOTES.size === 0) {
return { messages: [{ role: "user" as const, content: { type: "text" as const, text: "No notes saved yet. Save some notes first." } }] };
}
const notesText = [...NOTES.entries()].sort(([a], [b]) => a.localeCompare(b))
.map(([, n]) => `**${n.title}** (tags: ${n.tags.join(", ") || "none"})\n${n.content}`)
.join("\n\n---\n\n");
return {
messages: [{
role: "user" as const,
content: { type: "text" as const, text: `Summarize these notes focusing on: ${focus}\n\n${notesText}\n\nProvide: 1) 2-3 sentence summary 2) Top ${focus} 3) Action items or open questions` },
}],
};
}
);
server.prompt(
"find-connections",
"Find connections between notes related to a topic",
{ topic: z.string().min(1) },
async ({ topic }) => {
if (NOTES.size === 0) {
return { messages: [{ role: "user" as const, content: { type: "text" as const, text: `No notes saved yet. Add notes first to find connections about '${topic}'.` } }] };
}
const notesText = [...NOTES.entries()].map(([id, n]) => {
const preview = n.content.length > 300 ? n.content.slice(0, 300) + "..." : n.content;
return `[${id}] ${n.title}: ${preview}`;
}).join("\n\n");
return {
messages: [{
role: "user" as const,
content: { type: "text" as const, text: `Find connections in these notes related to: ${topic}\n\n${notesText}\n\nFor each connection: which notes, what the connection is, why it matters for '${topic}'.` },
}],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
- Claude Desktop sees three separate surfaces: in the Tools palette it shows
save_noteanddelete_note; in the Resource picker it showsnote://indexand thenote://{note_id}template; in the Prompt picker (typing/) it showssummarize-notesandfind-connections. - All three share the same in-memory
NOTESdict — write with a tool, read as a resource, analyze with a prompt. One source of truth, three interaction modes. - The protocol routes each request to the right handler:
tools/call→ tool functions,resources/read→ resource handlers,prompts/get→ prompt handlers. FastMCP dispatches automatically. - No side effects in resources or prompts: reading
note://indexnever changesNOTES; generating thesummarize-notesprompt never writes anything. The primitive contracts are respected.
5. When to Use Each
The decision is almost always clear once you ask: Does this operation change state? If not, does it have a stable URI address? If not, is it a template the user fills in? The table below covers the most common scenarios you'll encounter:
| Scenario | Primitive | Why |
|---|---|---|
| Fetch a customer record by ID | Resource | Read-only, URI-addressable (customer://42) |
| Update a database row | Tool | Has side effects — writes to DB |
| Run a calculation | Tool | Produces a result but has no stable URI address |
| Standard security analysis template | Prompt | Reusable, user-parameterized, injects messages |
| List directory contents | Resource | Read-only enumeration, URI-addressable |
| Send an email | Tool | Sends data externally — irreversible side effect |
| Pull a git commit by hash | Resource | Immutable, URI-addressable (git://repo/abc1234) |
| Bug investigation workflow | Prompt | Consistent multi-step template, user supplies bug description |
| Search across documents | Tool | Query-driven — no stable URI; results depend on dynamic query |
| Display current user profile | Resource | Stable URI (user://profile), read-only representation |
| Post a message to Slack | Tool | External side effect — sends a message |
| Domain-specific persona template | Prompt | System-level message that sets Claude's behavior for a task |
Search is commonly misclassified as a resource. It feels like reading, but search results are not URI-stable: search://query=MCP returns different results over time as data changes. If the result is not reproducible by URI, it's a tool. Use a resource only when the same URI reliably returns the same (or updated) data for that address.
6. Testing Resources and Prompts with MCP Inspector
MCP Inspector handles all three primitives. The Resources tab lets you browse URI templates and fill in parameters. The Prompts tab lets you fill in prompt arguments and see the generated messages before any LLM sees them.
Testing Resources
Launch Inspector against your notes server, then test each resource by URI. Dynamic resources show an input form for each URI variable.
mcp dev notes_server.pynpx @modelcontextprotocol/inspector tsx notesServer.tsTesting Prompts
The Prompts tab in Inspector shows each prompt's arguments as a form. Fill them in and click "Get Prompt" — you see the exact message list your handler returns, before any LLM processes it.
CLI Quick-Check
You can also verify the primitives are registered from the command line without the full Inspector UI:
python -c "
from mcp.server.fastmcp import FastMCP
import notes_server
server = notes_server.mcp
print('Tools:', [t.name for t in server._tools.values()])
print('Resources:', list(server._resources.keys()))
print('Prompts:', list(server._prompts.keys()))
"npx @modelcontextprotocol/inspector tsx notesServer.ts --list-capabilitiesClaude Desktop starts a fresh subprocess on launch and only surfaces errors in its logs (macOS: ~/Library/Logs/Claude/, Windows: %APPDATA%\Claude\logs\). Debugging resources and prompts through the LLM is slow. Inspector lets you verify the raw protocol in 30 seconds, catching type errors, missing notes, and template mismatches before you involve any AI.
7. Quiz
1. A developer wants to expose a customer database so the host can pull a customer record into context by customer ID. No data should be written. Which MCP primitive is the correct choice?
customer://{id}2. What does @mcp.resource("file://{path}") do when a client requests file:///home/user/notes.md?
ValueError because the URI doesn't match the template exactlypath="/home/user/notes.md"file://{path} only — no template expansion3. A prompt handler decorated with @mcp.prompt() must return which type?
str containing the full prompt textdict with keys system and userlist[types.Message] containing one or more UserMessage or AssistantMessage objectstypes.ToolResult with an isError flag4. A team wants to build a server where the LLM can search across 10,000 documents by keyword. Which primitive should search be?
5. In Claude Desktop, how are MCP prompts surfaced to the user?
/ to see and select available prompts