The other two MCP primitives: read-only data and reusable templates.
MCP Servers Module 2
| Primitive | Who initiates the read | Schema shape | Example use case |
|---|---|---|---|
| Tool | LLM (model decides to call) | Typed inputs + outputs | Search notes, create a note |
| Resource | Client / user (fetches when relevant) | URI + mimeType + read-only contents | Read note body, expose config file |
| Prompt | User (invokes by name, e.g. slash command) | Named template with optional args | "/welcome", "/summarize-today" |
Every resource has a URI (e.g., pkb://notes/hello, file:///etc/hosts), an optional name, a mimeType, and read-only contents. The LLM (or user) decides when to fetch — the server just exposes them.
pkb://) so clients can route.text/plain, application/json, text/markdown, etc.resources/list — returns metadata for all available resources (URI, name, mimeType).resources/read — fetches contents for a specific URI.The client typically lists once at startup and refreshes when the server emits notifications/resources/list_changed — so adding a new note doesn't require a client restart.
@mcp.resource("pkb://notes/{title}")
def get_note(title: str) -> str:
"""Read one note by title."""
if title not in NOTES:
raise McpError(ErrorData(code=METHOD_NOT_FOUND, message="not found"))
return NOTES[title]
FastMCP's @mcp.resource(uri_template) registers a parameterized resource. URI variables (like {title}) become function arguments — the SDK extracts them from the requested URI.
pkb://config, pkb://about). One resource, one URI.pkb://notes/{title}). One template, many URIs.Both work; pick by whether the set of URIs is bounded. If you know all the URIs in advance, static. If they're derived from data, dynamic.
Prompts are reusable prompt fragments the user can invoke. The client typically surfaces them as slash commands or insertable blocks in the chat composer. The server can parameterize them — e.g., a greet prompt that takes a name.
Unlike tools (LLM-initiated) and resources (client-initiated), prompts are user-initiated — the user picks one, the rendered text gets sent to the model.
@mcp.prompt()
def welcome(name: str = "friend") -> str:
"""Greet the user warmly and offer to summarize today's notes."""
return f"Hi {name}! I'm your PKB assistant. Want me to summarize today's notes?"
The user (or the LLM) can invoke /welcome and receive this rendered prompt — with name filled in from the user's input or the default.
It's read-only and the client should fetch when relevant. Think "file the LLM can read by URI."
It does anything else — mutate state, compute, query with structured args, invoke external services.
The user benefits from naming/invoking it; the LLM benefits from a reusable scaffold (consistent phrasing, slots for parameters).
It's a single sentence in a single tool's response. Don't promote one-off text to a primitive.
pkb://about resource that returns a one-line description of the server.pkb://notes/{title} resource so clients can read note bodies by URI.welcome prompt users can invoke as /welcome.By the end of lab, the PKB server exposes all three MCP primitives.
from mcp.server.fastmcp import FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, METHOD_NOT_FOUND
mcp = FastMCP("pkb")
# --- tools ---
@mcp.tool()
def search(args: SearchArgs) -> list[str]: ...
@mcp.tool()
def create_note(args: CreateNoteArgs) -> str: ...
# --- resources ---
@mcp.resource("pkb://about")
def about() -> str: ...
@mcp.resource("pkb://notes/{title}")
def get_note(title: str) -> str: ...
# --- prompts ---
@mcp.prompt()
def welcome(name: str = "friend") -> str: ...
def main():
mcp.run()
if __name__ == "__main__":
main()