Cover image for MCP Resources and Prompts: Giving Models Context, Not Just Actions

At a glance

Reading time

~200 words/min

Published

7 hours ago

Jun 11, 2026

Views

5

All-time total

MCP Resources and Prompts: Giving Models Context, Not Just Actions

The notes server from Part 2 speaks fluent MCP, but it only speaks in tools. That is how most servers in the wild are built, and it is a design smell: every read becomes a model decision, every tool description taxes the context window, and users cannot see or pick the data themselves. This part adds the other two primitives. Resources let the application and the user attach your data to a conversation deliberately. Prompts give users reusable, parameterized workflows they invoke by name. Same server, same protocol, noticeably better ergonomics.

What you will learn in Part 3

  • Why everything-as-a-tool wastes context and misplaces decisions
  • Direct resources and resource templates with @mcp.resource
  • URI design that stays stable as your server grows
  • Prompts as user-invoked workflows with @mcp.prompt
  • A decision framework for choosing the right primitive

1. The cost of everything-as-a-tool

Recall the control model from Part 1: tools are chosen by the model, resources by the application and user, prompts by the user. When you expose get_note and list_notes as tools, you are asking the model to decide, on every turn, whether to fetch data, and you are paying for those tool descriptions in every single request whether they are used or not. Worse, the user has no affordance: they cannot browse what the server offers and attach the relevant piece themselves, the way they would drag a file into a chat.

Resources flip that. A resource is addressable data with a URI, a name, and a MIME type. The host can list them, show them in a picker, let the user attach one, and read it into context without the model spending a single token deciding anything. The data crosses the same wire, but the decision moves to where it belongs. Keep tools for actions that change things or genuinely require judgment about parameters; serve plain reads as resources.

2. Direct resources: data with an address

A direct resource is a fixed URI bound to a function. The decorator looks like the tool decorator, but the mental model differs: you are not defining an operation, you are publishing a document that happens to be computed on read. Add these to server.py from Part 2.

@mcp.resource("notes://index")
def notes_index() -> str:
    """A directory of every saved note, one title per line."""
    notes = _load()
    if not notes:
        return "No notes saved yet."
    return "\n".join(f"- {title}" for title in sorted(notes))


@mcp.resource("notes://recent", mime_type="application/json")
def recent_notes() -> str:
    """The five most recently saved notes as JSON."""
    notes = _load()
    recent = dict(list(notes.items())[-5:])
    return json.dumps(recent, indent=2)

Two details to notice. The URI scheme, notes://, is yours to invent; pick something that names the domain, not the implementation, so it survives a storage rewrite. And the MIME type tells clients how to treat the bytes: text/plain is the default, application/json invites structured handling, and binary types work too for images or files. When a client calls resources/list, both entries appear with their names and descriptions; when it calls resources/read with a URI, your function runs and the content comes back tagged with that MIME type.

3. Resource templates: one pattern, many documents

You cannot register a fixed URI for every note a user might create. Resource templates solve this with RFC 6570 style placeholders: declare a pattern once, and the parameters in the URI become arguments to your function. This is the resource counterpart of a parameterized route in FastAPI, and the SDK matches and dispatches for you.

@mcp.resource("notes://{title}")
def note_by_title(title: str) -> str:
    """The full body of a single saved note."""
    notes = _load()
    if title not in notes:
        raise ValueError(f"No note titled {title!r}")
    return notes[title]

A client can now read notes://standup or notes://deploy-checklist, and your function receives the title. To make the matching tangible, the playground below implements a tiny URI template matcher of its own. It is the same idea the SDK applies: turn placeholders into named capture groups, match, and hand the named parts to the function. Try adding your own template with two placeholders.

Python playground

Checkpoint

Your server stores one document per customer. Which design serves them best?

4. How clients actually consume resources

It helps to see the traffic once. Discovery is resources/list for fixed URIs and resources/templates/list for patterns; reading is resources/read with a concrete URI. The response carries content items, each tagged with the URI and MIME type, because one read may return several pieces. Subscriptions exist too: a client can subscribe to a URI and your server can push notifications/resources/updated when it changes, which hosts use to keep attached context fresh without polling.

// resources/read request and response, abbreviated
{ "jsonrpc": "2.0", "id": 7, "method": "resources/read",
  "params": { "uri": "notes://standup" } }

{ "jsonrpc": "2.0", "id": 7, "result": { "contents": [ {
    "uri": "notes://standup",
    "mimeType": "text/plain",
    "text": "Demo the notes server on Thursday. Bring the Inspector."
} ] } }

In Claude Desktop, resources surface through the attachment picker: a user opens the server's entry, sees Notes index and the individual notes, and attaches what they need. In Claude Code, the same data is addressable with at-mentions. Either way, your server just answered resources/read; the experience around it is the host's job, which is exactly the separation of concerns the protocol promised in Part 1.

Because pickers show names and descriptions, those fields are user interface copy, not metadata. Name resources the way a colleague would scan for them, Notes index rather than notes_idx_v2, and write the description for the moment of choice: what is in here, and when would I attach it. The same care you will spend on tool descriptions in Part 4 applies one part early, just aimed at a human reader instead of a model. A resource nobody can identify in a list might as well not exist, however well it is implemented behind the URI.

5. Prompts: workflows users invoke by name

The third primitive is the most underused, which is a shame because users feel it most directly. A prompt is a named, parameterized template your server publishes; hosts render prompts as slash commands or menu entries, collect the arguments, and the result lands in the conversation as messages. The killer feature is that a prompt can pull in your server's own data on the way, so the workflow arrives pre-loaded with context.

@mcp.prompt(title="Weekly review")
def weekly_review(team: str) -> str:
    """Draft a weekly review from everything in the notes."""
    notes = _load()
    digest = "\n\n".join(f"## {t}\n{b}" for t, b in notes.items())
    return (
        f"You are writing the weekly review for the {team} team.\n"
        f"Base it only on the notes below. Group by theme, lead with\n"
        f"decisions made, and end with open questions.\n\n{digest}"
    )

Invoke it from a host and the model receives a fully formed brief: instructions plus every note, assembled server-side in one step. No tool descriptions consumed, no model decision about what to fetch, no user copy-pasting. Prompts can also return multiple messages with roles when you need to seed a back-and-forth shape, but a single well-built string covers most workflows. If you find yourself documenting a ritual like paste the notes, then ask for a summary in this exact format, that ritual wants to be a prompt.

Treat prompt text as code, because it is. It lives in your repository, it changes behavior when it changes, and it deserves review like any function. Small wording edits move outcomes in ways diffs make visible and chat history does not, which is exactly why centralizing the template in the server beats letting every user maintain their own drifting copy. When a better phrasing emerges, you ship it once and every host gets the improvement on the next invocation, which is a quiet but real operational win over pinned snippets in personal notes.

Build the instruction half of your own review prompt below; this is the part of the string worth iterating on before you commit it to code.

Prompt builder

Draft the instruction block for a review prompt

Assembled prompt


6. Choosing the right primitive

With all three primitives live in one server, the decision framework becomes mechanical. Ask who should decide that this happens. If the answer is the model, mid-conversation, based on what the user just said, it is a tool. If the answer is the application or the user selecting data to work over, it is a resource. If the answer is the user kicking off a known workflow, it is a prompt. Then ask a second question, does it change anything, because anything with side effects must be a tool so the host can gate it behind permission prompts.

The notes server, mapped correctly
Capability Primitive Why
add_note Tool Side effects; the model decides parameters from conversation
search_notes Tool Judgment call about the query, results feed reasoning
notes://index, notes://{title} Resource Plain reads the user or host attaches deliberately
weekly_review Prompt A named workflow a person invokes on demand

Checkpoint

A capability deletes every note older than a date the user mentions in chat. Tool, resource, or prompt?

! Common mistakes to avoid

  • Exposing every read operation as a tool

    Serve plain reads as resources or templates. You save tool-description tokens on every request and give users a picker instead of a prayer.

  • Baking storage details into URIs, like notes://json/{file}

    URIs are a public contract. Name the domain concept, notes://{title}, so swapping JSON for Postgres in Part 8 changes nothing for clients.

  • Returning raw JSON blobs from prompts

    Prompts land in front of the model as instructions. Assemble readable text with clear structure; if data must come along, label and delimit it.

  • Forgetting MIME types on structured resources

    Declare mime_type="application/json" when you serve JSON so clients can parse instead of treating it as prose.

The bottom line

Your server now offers all three primitives, each doing the job it was designed for: tools for actions the model chooses, resources for data the application and user attach, prompts for workflows people invoke by name. The protocol cost was two decorators and a URI scheme. The payoff is a server that spends the model's attention only where judgment is needed. Next we take the hardest primitive, tools, and make them genuinely reliable: schemas that guide, errors that teach, and structured output that downstream code can trust.

? Frequently asked questions

Can a resource and a tool share the same underlying function? +

Yes, and it is common during migration: keep a get_note tool for hosts that lean on tools while publishing notes://{title} for those with good resource UX. Factor the logic into one helper and have both call it.

How many resources is too many to list? +

resources/list is paginated, so the protocol copes, but a thousand entries makes a useless picker. Past a few dozen, prefer templates plus an index resource that helps users find the URI they want.

Do prompts support arguments with completion? +

Yes. Hosts can ask the server to complete argument values as the user types, using the completion capability. The SDK wires completion for resource template parameters and prompt arguments declared with enums or custom completers.

When do resource subscriptions matter? +

When attached context goes stale in ways that mislead, like a config or a live document. For append-mostly data like notes, listChanged on the index is usually enough and far simpler.

Up next: Part 4, tool design agents get right.

Newsletter

Want more posts like this?

Get practical software notes and tutorials delivered when something new is published.

No spam. Unsubscribe anytime.

How did this land?

Comments

0
Log in or sign up to join the discussion and react to this post.

No comments yet. Be the first to share your thoughts.

Related posts

Important functionalities of Pandas in Python : Tricks and Features

Pandas is one of my favorite libraries in python. It’s very useful to visualize the data in a clean structural manner. Nowadays Pandas is widely used in Data Science, Machine Learning and other areas.

5 years ago

How to get data from twitter using Tweepy in Python?

To start working on Python you need to have Python installed on your PC. If you haven’t installed python. Go to the Python website and get it installed.

6 years ago

Predicting per capita income of the US using linear regression

Python enables us to predict and analyze any given data using Linear regression. Linear Regression is one of the basic machine learning or statistical techniques created to solve complex problems.

6 years ago

Essential Sorting Algorithms for Computer Science Students

Algorithms are commonly taught in Computer Science, Software Engineering subjects at your Bachelors or Masters. Some find it difficult to understand due to memorizing.

6 years ago

AI Agents in 2026: What Developers Actually Need to Know

A hype-free developer guide to AI agents in 2026 to the agent loop, tools, memory, MCP, evaluation, guardrails, and the cost and failure modes that separate a demo from production.

1 week ago