Lesson 5 of 12Track 3

Building MCP servers

Resources and prompts

The other two MCP primitives.

Interactive exercise ~10 min

The other two primitives

The previous lesson exposed a tool. MCP has two more primitives that often get overlooked: resources and prompts. Most servers don't need them. The ones that do really need them; using a tool where a resource was the right shape produces awkward APIs.

This lesson covers what each primitive is for, when to reach for it, and how to expose them with FastMCP.

Resources: read-only data

A resource is a piece of data identified by a URI that the agent (or a human) can fetch. Think cat or wget, not query. Resources are passive: they don't take parameters in the way tools do; they have a stable identity and you can list them, read them, sometimes subscribe to changes.

@mcp.resource("doc://onboarding")
def onboarding_doc() -> str:
    """The onboarding handbook for new engineers."""
    return open("docs/onboarding.md").read()
 
 
@mcp.resource("config://feature-flags")
def feature_flags() -> dict:
    """Current feature flag state."""
    return {"new_search": True, "dark_mode": True, "beta_export": False}

Each resource has a URI (doc://onboarding, config://feature-flags) and a function that returns its content.

When to use a resource instead of a tool

The dividing line:

  • Tools are functions: they take arguments, perform actions, return results. They might have side effects.
  • Resources are content: they have an identity, no required arguments, and reading them is read-only.

Examples:

  • "Read this file" -> resource (file:///path/to/file).
  • "Search files for 'foo'" -> tool (it takes a query argument).
  • "Get current weather for city X" -> tool (parameter required).
  • "Get the user's profile" -> resource (user://current/profile).
  • "Update the user's profile" -> tool (action with side effects).

The cleanest way to think about it: if the URI alone is enough to fetch the content, it's a resource. If you need parameters that don't fit naturally in a URL, it's a tool.

Resource templates

For resources with parameterized URIs (a row in a database, a file in a path), MCP supports URI templates:

@mcp.resource("user://{user_id}/profile")
def user_profile(user_id: str) -> dict:
    """Profile for a specific user by ID."""
    return DB.get_user(user_id)

The host can list available templates and the agent can read user://42/profile or user://alice/profile as needed. This is still a resource (read-only, identified by URI) but with a parameter slot.

Prompts: server-provided templates

A prompt is a named, parameterized prompt template that the server provides for the host to use. The host typically presents these to the user (or the agent) for selection: "this server offers a summarize_pr prompt; want to use it?"

@mcp.prompt()
def summarize_pr(pr_url: str, audience: str = "engineering") -> list:
    """Summarize a pull request for a given audience."""
    return [
        {"role": "user", "content": (
            f"Summarize the pull request at {pr_url} for the {audience} audience. "
            f"Focus on what changed and why. Keep it under 200 words."
        )},
    ]

The server returns a list of message objects; the host injects them into the agent's conversation when the user picks the prompt.

When to use a prompt

Prompts are the rarest of the three primitives. They make sense when:

  • The server has expert knowledge of how to phrase a request well, and wants to ship that knowledge to clients.
  • The host has a UI that surfaces prompts as menu items or slash commands.
  • The user benefits from a curated catalog of "things this server knows how to ask the agent for."

For a typical agent-only host with no UI, prompts are mostly dead weight. The agent's own system prompt covers the same ground. For human-facing hosts (IDE plugins, chat apps, Claude Code), prompts can be a really nice UX: the user picks "summarize this PR" and gets the right phrasing for free.

Slash commands as prompts

Many MCP-compatible hosts surface server-provided prompts as slash commands. If a server offers a summarize_pr prompt, the host might expose it as /summarize-pr in the chat input. The user types the command, fills parameters, and the prompt fires. This is the canonical UX for prompts.

Putting it together

A server that exposes all three primitives:

from mcp.server.fastmcp import FastMCP
 
 
mcp = FastMCP("github-helper")
 
 
# Resource: read-only content with a URI.
@mcp.resource("repo://current/readme")
def current_readme() -> str:
    """The README of the currently-active repository."""
    return open("./README.md").read()
 
 
# Tool: action with parameters and possible side effects.
@mcp.tool()
def open_pr(branch: str, title: str, body: str) -> dict:
    """Open a pull request from `branch` with the given title and body."""
    pr = github.create_pr(branch=branch, title=title, body=body)
    return {"number": pr.number, "url": pr.url}
 
 
# Prompt: a named template the user can invoke.
@mcp.prompt()
def review_pr(pr_url: str) -> list:
    """Review the pull request at the given URL."""
    return [{"role": "user", "content": f"Review the PR at {pr_url}. Look for bugs, style issues, and missing tests."}]
 
 
if __name__ == "__main__":
    mcp.run()

One server. Three primitives. Each one expresses a different shape of capability.

Capability declarations matter

A server has to declare which primitives it supports during the initialize handshake. FastMCP does this for you based on what you've registered. A server with no @mcp.resource() decorators won't claim resource support; clients won't list resources from it.

This is how the protocol stays light: clients don't waste round-trips asking about primitives the server doesn't have, and servers don't have to handle every primitive even if they only need one.

Resource notifications (advanced)

Some hosts subscribe to resources to be notified when their content changes. A server can push notifications:

@mcp.resource("dashboard://metrics")
def metrics() -> dict:
    return get_current_metrics()
 
 
@mcp.subscribe("dashboard://metrics")
def on_subscribe(uri):
    # set up a watcher; when metrics change, call:
    # mcp.send_notification("notifications/resources/updated", {"uri": uri})
    ...

This is overkill for most servers. It matters when resources change frequently and clients want push instead of poll. Skip it unless you have a specific need.

A failure mode: tools-with-no-args masquerading as resources

A common confusion: writing a tool that takes no arguments and just returns data ("get_user_profile()"). That's a resource. Move it to @mcp.resource("user://current/profile"). The host gets a clearer picture of what the server offers, and the agent has cleaner mental models for "fetch data" vs "do something."

The reverse mistake (forcing a parameterized fetch to be a resource) is less common but happens. Resources with templates handle simple parameterization; anything more complex than that is a tool.

When in doubt, start with tools

The three primitives sound symmetric in the spec but they aren't in practice. Tools are 90% of MCP usage. Resources are 9%. Prompts are 1%. Start with tools and only reach for resources or prompts when their specific shape buys you something. Adding a @mcp.resource decorator to fit one piece of read-only data is fine; restructuring your whole server around the three primitives because the spec lists them is a waste of time.

Key takeaway

Resources are read-only data identified by URIs (think cat). Prompts are server-provided templates the host can surface to users (think slash commands). Most servers only need tools; reach for resources for content fetches without complex parameters and prompts for human-facing UX. The next lesson covers the full server lifecycle: capability negotiation, errors, and graceful failure.

>_resources-and-prompts.py
Loading editor...
Output will appear here.

Done with this lesson?