Lesson 06 Resources & Prompts
Theme:

Resources & Prompts

Lesson 06 · MCP Servers · ~10 min read

The three primitives, in scope

Lesson 01 sketched the three things an MCP server can expose; this lesson is where two of them finally get hands-on. The reason the protocol bothers separating them is that the LLM and the user consume each one differently, and confusing the categories produces servers that technically work but feel wrong in a real client. Tools are actions — the LLM decides when to invoke them and the call mutates state, calls an external API, or computes something fresh. Resources are data — read-only blobs the client lists and reads on the user's (or the LLM's) behalf, much like files in a filesystem. Prompts are templates — pre-authored message scaffolds the user picks from a menu, often via a slash command, to seed a conversation.

PrimitiveDirectionWhen consumed
ToolLLM → server (call)Mid-conversation, when the model decides
ResourceClient → server (read)On demand by client/user; attached to context
PromptUser → client → server (fetch)At the start of a turn, as a slash-command template

Resources in depth

A resource has three things that matter: a URI, a MIME type, and contents. The URI uses standard scheme://path form with an optional query string. The scheme is yours to choose — built-in servers reach for file:/// for filesystem paths and sqlite:/// for database rows, and your own server is welcome to invent a scheme that names its domain (the PKB you are building uses pkb://). MIME types tell the client how to render and how the LLM should treat the bytes: text/plain, text/markdown, application/json, or image/png are the common ones.

Resources come in two flavours. Static resources are enumerable up front — the server knows their URIs at start-up and returns them all from resources/list. Dynamic resources are templated: the URI carries a parameter (pkb://notes/{title}) and the client substitutes a value before calling resources/read. The MCP spec also defines a notifications/resources/list_changed message the server can push when its catalogue changes — so a long-running server that ingests new notes can tell the client to re-fetch the list without restarting the connection.

In FastMCP, the surface is one decorator: @mcp.resource(uri_template). The function body returns the bytes (or a string, which becomes text/plain automatically); the decorator handles registration with resources/list and dispatch from resources/read. Parameters in the URI template become arguments of the decorated function, exactly the way Flask or FastAPI path parameters do. Look at the reference mcp-server-everything server in the official repo for canonical examples covering text, image, and binary resources end to end.

Prompts in depth

Prompts answer a question tools can't: "what's the canonical way to start a conversation about this server's domain?" They are user-facing templates, not LLM-callable actions. In Claude Desktop they appear as slash commands; in Cursor they show up in the command palette. When the user picks one, the client fetches it with prompts/get, substitutes any parameters the prompt declares, and inserts the resulting messages into the conversation as if the user had typed them.

A FastMCP prompt is, again, one decorator: @mcp.prompt(). The simplest shape returns a single string — that becomes a single-message prompt with the string as user text. The fuller shape returns a list of Message objects, each with a role ("user" or "assistant") and content. Returning a list lets you stage a whole opening, including an assistant-side primer ("I'll help you triage your notes; what should we look at first?"). Parameters declared on the function appear as required fields in the prompt's schema; the client collects them in a form before invoking the prompt. The everything reference server ships both flavours — a one-liner simple_prompt and a parameterised complex_prompt — and is the fastest way to see the shapes side by side.

Choosing the right primitive

Once you know all three exist the temptation is to reach for whichever is most familiar — usually tools, because they were introduced first and feel like "doing things". Resist that. The decision-tree is short:

Two worked examples for the ambiguous cases that come up every time someone designs their first server. Should get_weather(city) be a tool or a resource? A tool, because the city is a dynamic input picked by the LLM and the result varies over time — listing every weather://{city} in resources/list would be either impossibly large or immediately stale. Should the contents of ~/.bashrc be exposed? A resource: the bytes are read-only, the URI is naturally file:///Users/.../.bashrc, and the client should be able to enumerate the user's dotfiles without the LLM having to "call" anything.

URI design

URIs are part of the public contract of your server; you live with the ones you ship. A few conventions repay themselves over and over. Use plural collection nouns: pkb://notes/{title} reads as "the notes collection, the one named {title}", whereas pkb://note/{title} reads as a single thing that needs naming twice. Nest paths for nested concepts: pkb://notes/{title}/attachments/{name} beats pkb://attachment?note={title}&name={name} on every measure — it sorts, it documents itself, and it composes. Avoid query strings unless you actually need filtering: a query string says "this URI represents a search", and a search is almost always a tool, not a resource. The bare pkb://{title} form is the worst of all worlds — it gives the LLM no clue what kind of thing it is fetching.

Common pitfalls

  1. Using a tool when a resource fits better. If the data is read-only and the client could meaningfully list it, exposing it as a tool forces the LLM to call the tool to discover anything, rather than seeing the entries in resources/list for free.
  2. Returning HTML or binary from a prompt. Prompts are user-readable text that end up in the message stream verbatim. If you need to embed structured content, expose it as a resource and reference the URI from the prompt.
  3. Hard-coding URIs the client can't discover. A tool that mints a random pkb://notes/abc123 and returns the string is fine as a confirmation, but if there's no matching entry in resources/list the LLM has no way to fetch that note later. Always make sure dynamic resources are reachable via the templated URI.

The full PKB shape after this lesson

Pulling the two lessons together, your server.py now exposes five primitives. From Lesson 04, the echo tool — your sanity-check round-tripper. From Lesson 05, the search tool with a Pydantic input model. New in this lesson: one static resource (pkb://about, a one-paragraph description of the server itself), one dynamic resource (pkb://notes/{title}, the body of a named note), and one prompt (welcome, a single-message opener that introduces the PKB and suggests two starting actions). That is the seed shape every later lesson grows from: tools for what the LLM does, resources for what the user/LLM reads, and prompts for how a conversation begins.

References