Resources: The Read-Only Half of MCP
Expose data to the host as MCP resources — static and templated, addressed by URI — so the model can pull context in without calling a tool. In Python and TypeScript.
Series: Building MCP Servers — Part 4 of 12
Tools do things. But a lot of what a model needs from your server isn’t an action — it’s information: the contents of a file, a config value, a row from a table. You could wrap each of those in a tool, but MCP has a primitive built for exactly this, with different semantics: resources. This post covers what makes a resource different from a tool, how to expose static and parameterized data, and why the distinction is worth respecting.
Resources aren’t tools
The mechanical difference is small; the semantic one matters. A tool is model-controlled — the model decides to call it, and calling it can have side effects. A resource is application-controlled and read-only — it’s addressed by a URI, it returns data, and the host decides when to load it into context, often by letting the user pick. Think of tools as POST and resources as GET: reading a resource should never change anything.
That framing tells you which to reach for. “Send a Slack message” is a tool. “The text of this document” is a resource. When in doubt, ask whether calling it twice should be safe — if yes, it’s probably a resource.
A static resource
The simplest resource is a fixed URI returning a fixed kind of data. You give it a URI scheme of your choosing and a function that produces the body.
@mcp.resource("config://version")
def version() -> str:
"""The server's version string."""
return "1.0.0"server.registerResource(
"version",
"config://version",
{ title: "Version", mimeType: "text/plain" },
async (uri) => ({ contents: [{ uri: uri.href, text: "1.0.0" }] })
);In Python the decorator takes the URI and the return value becomes the body. TypeScript is a touch more explicit: a name, the URI, metadata (a MIME type helps the host render it), and a handler returning a contents array — each entry carries the uri it answers for and the data. The URI scheme (config://, notes://, file://) is yours to design; the host treats it as an opaque address.
A templated resource
Static resources cover the singletons. For “any note by name” or “any user by id” you want one declaration that handles a family of URIs — a resource template with placeholders.
NOTES = {"welcome": "Hello from MCP", "todo": "Write the resources post"}
@mcp.resource("notes://{name}")
def note(name: str) -> str:
"""Return the body of a single note by name."""
return NOTES.get(name, f"(no note named {name})")import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
const NOTES: Record<string, string> = {
welcome: "Hello from MCP",
todo: "Write the resources post",
};
server.registerResource(
"note",
new ResourceTemplate("notes://{name}", { list: undefined }),
{ title: "Note" },
async (uri, { name }) => {
const key = String(name);
return { contents: [{ uri: uri.href, text: NOTES[key] ?? `(no note named ${key})` }] };
}
);The {name} placeholder is bound from the requested URI and passed to your function — read notes://welcome and name is "welcome". In Python the parameter name just has to match the placeholder; in TypeScript the template variables arrive as the handler’s second argument. (A template variable can in principle be multi-valued, which is why the TypeScript code coerces it with String(...) — a small tax for type-honesty.)
How the host sees them
One wrinkle worth knowing because it surprises people at the Inspector: static resources and templates live in different lists. A fixed resource shows up under resources/list; a template shows up under resources/templates/list. That’s deliberate — a host can’t enumerate every note that could exist, so templates are advertised as patterns the host fills in, while concrete resources are enumerable. Reading is the same call for both: resources/read with the URI, which comes back as a contents array (so a single resource can return multiple parts if it needs to).
Final thoughts
Resources are the quiet primitive. They don’t do anything, which is the point — they give the host a clean, cacheable, side-effect-free way to feed your data into the model’s context, separate from the machinery of actions. Reaching for a tool when you mean a resource works, but it muddies the line the protocol drew on purpose, and it lies to the host about whether calling twice is safe. Keep reads as resources, actions as tools, and both stay honest.
Next: Prompts: Reusable, Parameterized, User-Invoked, the third primitive — templates a person deliberately reaches for.
Target keyword(s): mcp resources, mcp resource template.
Comments