⌂ Home MCP Track — Building with the Model Context Protocol ⚡ MODULE 02 of 8 · MCP Track
~55 min Beginner → Intermediate Concept + Lab
MODULE 02 · Resources & Prompts

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.

After this module you will:
  • 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

In MCP01 you built tools — functions the LLM calls to take actions. That's one of three communication patterns the protocol defines. Before we build resources and prompts, let's see where each fits.
Analogy — The Library Desk

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.

Tools
Actions
  • @mcp.tool() decorator
  • Has side effects
  • LLM decides when to call
  • Returns result content
  • Input schema validated
Resources
Read Data
  • @mcp.resource() decorator
  • No side effects
  • URI-addressed data
  • Returns content blobs
  • MIME type declared
Prompts
Templates
  • @mcp.prompt() decorator
  • No side effects
  • User-parameterized
  • Returns message list
  • Injected into conversation
Animation 1 — Three Primitives Side-by-Side: Properties Revealing
Tools
Resources
Prompts

The Side-Effects Decision Tree

The single question that separates tools from resources: does the operation change state?

Does the operation change anything (write file, call API, update DB, send email)?
YES — use a Tool
NO — it reads data with a stable URI address?
YES — use a Resource
NO — it's a reusable message template for the user to fill in?
⤷ use a Prompt
Tools are familiar from MCP01. Let's go deep on resources — the primitive that turns your server into a readable data store.

2. Resources Deep Dive

Technical Definition — MCP Resources

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.

WHAT — The @mcp.resource() decorator

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.

WHY — URI templates, not arbitrary strings

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.

GOTCHA — Resources must return strings or bytes

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.

Python — file_resource_server.py
# 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()
TypeScript — fileResourceServer.ts
// 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);
What Just Happened?
  • Three resources are registered: one dynamic (any file by path), one static (workspace summary), one JSON (workspace stats).
  • The host calls resources/list and sees all three. When it calls resources/read with 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.
Animation 2 — Resource Read Lifecycle: No LLM Round-Trip
HOST
Request URI
resources/read
CLIENT
Match Template
file://{path}
SERVER
Read Data
handler runs
CONTEXT
Content Injected
into conversation

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.

Resources handle read-only data. Prompts handle something different — they give the server control over the message templates the user applies. Let's build them.

3. Prompts Deep Dive

Technical Definition — MCP Prompts

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.

Analogy — The Law Firm's Standard Letters

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.

WHAT — The @mcp.prompt() decorator

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.

WHY — Server controls the template

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.

GOTCHA — Return type is list[types.Message], not str

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.

Python — code_review_prompts.py
# 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()
TypeScript — codeReviewPrompts.ts
// 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);
Animation 3 — Prompt Injection Flow: User → Server → Conversation
USER
Select Prompt
/security-review
UI
Fill Args
code, language
SERVER
Generate Msgs
list[Message]
LLM
Receives & Responds
structured output
Why It Matters

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.

You now understand resources and prompts in isolation. The full power appears when you combine all three in one server — let's build it.

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.

Design Before You Build

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.

WHAT — One server, three primitives

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.

WHY — In-process state shared across all three

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.

GOTCHA — Server-side state resets on restart

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.

Python — notes_server.py
# 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()
TypeScript — notesServer.ts
// 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);
What Just Happened?
  • Claude Desktop sees three separate surfaces: in the Tools palette it shows save_note and delete_note; in the Resource picker it shows note://index and the note://{note_id} template; in the Prompt picker (typing /) it shows summarize-notes and find-connections.
  • All three share the same in-memory NOTES dict — 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://index never changes NOTES; generating the summarize-notes prompt never writes anything. The primitive contracts are respected.
You can build anything with these three primitives. But when facing a new use case, the question is: which one? Here's a reference table.

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
The Gray Area — Search

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.

You know what to build. Let's verify it works before connecting to Claude — use MCP Inspector to test resources and prompts interactively.

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

WHAT

Launch Inspector against your notes server, then test each resource by URI. Dynamic resources show an input form for each URI variable.

Terminal
mcp dev notes_server.py
Starting MCP Inspector... Connecting to server: python notes_server.py Server connected: Notes App v1.0.0 Tools: save_note, delete_note Resources: note://{note_id}, note://index Prompts: summarize_notes, find_connections Inspector running at: http://localhost:5173 # ── Test Resources in the Inspector UI ── # 1. Click "Resources" in the left panel # 2. You see two resources listed: # - note://index (static, click Read directly) # - note://{note_id} (template, shows a "note_id" text input) # # 3. Test static resource: click "note://index" → click "Read" # Expected (empty server): "No notes saved yet." # # 4. First save a note via Tools tab: # Tool: save_note # Args: {"title": "MCP Overview", "content": "MCP is an open protocol..."} # Result: {"success": true, "note_id": "mcp-overview", "total_notes": 1} # # 5. Back to Resources → "note://{note_id}" # Enter note_id: mcp-overview → click Read # Expected: # # MCP Overview # Tags: none # MCP is an open protocol... # # 6. Read note://index again: # Expected: "Saved notes (1 total):\n- mcp-overview: MCP Overview"
Terminal
npx @modelcontextprotocol/inspector tsx notesServer.ts
Starting MCP Inspector... Connecting to server: tsx notesServer.ts Server connected: Notes App v1.0.0 Tools: save_note, delete_note Resources: note://{note_id}, note://index Prompts: summarize-notes, find-connections Inspector running at: http://localhost:5173 # Same UI steps as Python — the Inspector is language-agnostic. # Resources tab shows URI templates; fill in variables and click Read. # Test sequence: save a note via Tools, then read it back via Resources.

Testing Prompts

WHAT

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.

MCP Inspector — Prompts Tab
# ── Testing Prompts in Inspector UI ── # 1. Click "Prompts" in the left panel # 2. Select "summarize_notes" (Python) or "summarize-notes" (TypeScript) # 3. You see the argument form: # focus: [text input] (default: "key themes") # # 4. Enter focus: "action items" → click "Get Prompt" # Expected (with 2 notes saved): # Message 1 (user): # "Here are all the saved notes. Please summarize them with a focus on: action items # # **MCP Overview** (tags: none) # MCP is an open protocol... # ..." # # 5. Inspector shows the raw messages — this is what will be injected into # the conversation when the user picks this prompt in Claude Desktop. # # Key insight: you're validating the template BEFORE connecting to any LLM. # If the generated message looks wrong, fix the server and re-test here # rather than debugging through Claude Desktop's UI.

CLI Quick-Check

You can also verify the primitives are registered from the command line without the full Inspector UI:

Terminal
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())) "
Tools: ['save_note', 'delete_note'] Resources: ['note://{note_id}', 'note://index'] Prompts: ['summarize_notes', 'find_connections']
Terminal
npx @modelcontextprotocol/inspector tsx notesServer.ts --list-capabilities
Capabilities: tools: save_note, delete_note resources: note://{note_id} (template), note://index (static) prompts: summarize-notes, find-connections
Always Test with Inspector Before Claude Desktop

Claude 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?

A
Tool — because it requires a function call with an argument (the customer ID)
B
Resource — because it is read-only and can be URI-addressed as customer://{id}
C
Prompt — because it returns structured data the LLM should analyze
D
Either Tool or Resource — they are functionally equivalent for reads

2. What does @mcp.resource("file://{path}") do when a client requests file:///home/user/notes.md?

A
Raises a ValueError because the URI doesn't match the template exactly
B
Matches the template and calls the handler with path="/home/user/notes.md"
C
Matches but passes the full URI string as a single argument, not extracted variables
D
Registers the resource for the literal URI file://{path} only — no template expansion

3. A prompt handler decorated with @mcp.prompt() must return which type?

A
A plain str containing the full prompt text
B
A dict with keys system and user
C
A list[types.Message] containing one or more UserMessage or AssistantMessage objects
D
A types.ToolResult with an isError flag

4. A team wants to build a server where the LLM can search across 10,000 documents by keyword. Which primitive should search be?

A
Resource — searching is a read operation with no side effects
B
Tool — search results are not URI-stable; the same URI would return different results over time
C
Prompt — a search query is a user-parameterized template
D
None — search should always go through the LLM directly, not MCP

5. In Claude Desktop, how are MCP prompts surfaced to the user?

A
As tool buttons in the tool palette that the LLM can invoke automatically
B
As slash commands in the chat input — the user types / to see and select available prompts
C
Automatically injected into every new conversation as system messages
D
Listed in the resource browser alongside URI-addressable resources