
This lesson is part of Learn MCP, our free course on the Model Context Protocol, within The MCP Guide.
In this lesson you will add resources to the bookshop server. You will expose one fixed resource (the full title list) and one resource template (a record for any book), in Python and in TypeScript. You will also learn how to report a resource that does not exist.
A resource is read-only data that the host application loads into the model's context. The difference from a tool is who decides. The Python SDK's first-steps guide puts it simply: tools are controlled by the model, resources by the application, and prompts by the user.
The code adapts the Python Resources guide and the TypeScript Resources guide. It ran on 28 October 2026 with mcp 2.3.0 and @modelcontextprotocol/server 2.4.0.
Key Takeaways
- A resource is addressed by a URI, such as
catalog://titles, not by a function name. - A URI with a placeholder, such as
books://{title}, is a resource template. It is listed separately from fixed resources. - Your read function runs only when a client reads that URI, not when it lists resources.
- A missing resource is a protocol error (
-32602with the URI indata), not anisErrorresult. - Use a resource for context the app should load; use a tool for actions the model should take.
Step 1: Decide what should be a resource
Before writing code, sort your data. The resources spec describes resources as context such as files, database schemas or application data, each identified by a URI.
Good resources for the bookshop:
- The full list of titles (fixed, small, changes rarely).
- One record per book (many of them, addressed by title).
A poor resource: "place an order." That changes something, so it is a tool. The SDK guide's rule of thumb is that a resource is like an HTTP GET and a tool is like a POST.
Step 2: Add resources in Python
Add this to your server.py from lesson 14, or start a new file:
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
mcp = MCPServer("Bookshop")
CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}
@mcp.resource("catalog://titles")
def titles() -> str:
"""Every title in the catalog, one per line."""
return "\n".join(sorted(CATALOG))
@mcp.resource("books://{title}", mime_type="application/json")
def book(title: str) -> dict:
"""The catalog record for one book."""
if title not in CATALOG:
raise ResourceNotFoundError(f"No book titled {title!r} in the catalog.")
return {"title": title, "author": CATALOG[title]}
How the SDK reads it, per the Resources guide:
- The URI in the decorator is the address. The function name becomes the resource name, and the docstring becomes its description.
- A
strreturn is sent as text. Adictis serialized to JSON text.bytesbecome a base64 blob. mime_typedefaults totext/plain. The SDK does not guess it from your return value, so label JSON yourself.{title}in the URI must match a parameter calledtitle. The guide says a mismatch fails at import time, before any client connects.
Step 3: Read them from a test client
import anyio
from mcp import Client, MCPError
from server import mcp
async def main() -> None:
async with Client(mcp) as client:
print([r.uri for r in (await client.list_resources()).resources])
print([t.uri_template for t in (await client.list_resource_templates()).resource_templates])
print((await client.read_resource("books://Dune")).contents[0].text)
try:
await client.read_resource("books://Nothing")
except MCPError as e:
print("MCPError", e.error.code, e.error.data)
anyio.run(main)
The output on 28 October 2026:
['catalog://titles']
['books://{title}']
{
"title": "Dune",
"author": "Frank Herbert"
}
MCPError -32602 {'uri': 'books://Nothing'}
Two things to notice. The template does not appear in the resource list; it has its own list. And the missing book raised an exception on the client instead of returning a result. The Python error-handling guide explains why: "A resource read either returns contents or fails." There is no isError half-result for resources.
Step 4: Add the same resources in TypeScript
In TypeScript you call registerResource with a name, a URI or a ResourceTemplate, metadata, and a read callback:
import { McpServer, ResourceNotFoundError, ResourceTemplate } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
const CATALOG: Record<string, string> = { Dune: 'Frank Herbert', Neuromancer: 'William Gibson' };
function createServer(): McpServer {
const server = new McpServer({ name: 'bookshop', version: '1.0.0' });
server.registerResource(
'titles',
'catalog://titles',
{ title: 'Catalog titles', description: 'Every title in the catalog, one per line', mimeType: 'text/plain' },
async uri => ({ contents: [{ uri: uri.href, text: Object.keys(CATALOG).sort().join('\n') }] })
);
server.registerResource(
'book',
new ResourceTemplate('books://{title}', { list: undefined }),
{ title: 'Book record', description: 'The catalog record for one book', mimeType: 'application/json' },
async (uri, { title }) => {
const key = decodeURIComponent(String(title));
const author = CATALOG[key];
if (!author) throw new ResourceNotFoundError(uri.href);
return {
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ title: key, author }) }]
};
}
);
return server;
}
void serveStdio(createServer);
console.error('bookshop resources server running on stdio');
Points from the TypeScript guide:
- The callback returns
{ contents: [...] }. Each item echoes theuriit answers for and carriestextor a base64blob. listis a required key onResourceTemplate. Passundefinedwhen the set is unbounded, or a callback that returns concrete URIs when you can enumerate them.- The matched placeholder values arrive as the callback's second argument.
ResourceNotFoundErrortakes the missing URI and sets the error code the spec requires.
The decodeURIComponent call is our addition. A title with spaces, such as "The Left Hand of Darkness", arrives URL-encoded.
Step 5: Check it with the Inspector
npx @modelcontextprotocol/inspector --cli npx tsx src/resources.ts \
--protocol-era modern --method resources/templates/list --format json
npx @modelcontextprotocol/inspector --cli npx tsx src/resources.ts \
--protocol-era modern --method resources/read --uri books://Dune --format json
The read returned the JSON record. Reading books://Nothing failed with "Resource not found: books://Nothing" and exit code 1, not the tool-error code 5. That difference is a quick way to tell protocol errors from tool errors in a test script.
Step 6: Know when the host actually reads them
Exposing a resource does not mean the model sees it. The host decides. Some hosts let the user attach resources to a conversation; others read them automatically. Check your target host's docs.
If the model must be able to fetch data on its own, also offer a tool. A common pattern is a read-only tool that returns the same data, marked with readOnlyHint (lesson 23 covers annotations).
Try it
- Add a resource template
authors://{name}/booksthat lists every title by one author. - Make the Python version return a
list[str]and check that it arrives as JSON text. - Read an author with no books. Decide whether that is "not found" or an empty list, and implement your choice. One rule to respect: the spec says servers must not return an empty
contentsarray for a resource that does not exist. - Run
resources/templates/listin both protocol eras and confirm both templates appear.
Recap
- Resources are URI-addressed, read-only data for the application to load.
- A placeholder in the URI makes a template, listed under
resources/templates/list. - Python:
@mcp.resource(uri). TypeScript:registerResource(name, uriOrTemplate, metadata, callback). - Label JSON with a MIME type, and raise
ResourceNotFoundErrorfor a missing record.
Next step
Lesson 17 adds the third primitive: prompts. For the protocol details behind this lesson, read MCP resources explained, resource templates and resource subscriptions.
Course navigation
Previous lesson: Lesson 15: Build Your First Tool in TypeScript.
Next lesson: Lesson 17: Add Prompts.
Back to the Learn MCP course outline.
Frequently asked questions
What URI scheme should I use?
Use one that fits your data. The spec lists common schemes such as https://, file:// and git://, and allows custom ones that follow RFC 3986. It says to use https:// only when the client can fetch the resource from the web by itself. Custom schemes like books:// suit data with no natural URL.
Can a resource return an image or a PDF?
Yes. Return bytes in Python, or a base64 blob in TypeScript, and set the correct MIME type. The client receives a blob content item instead of text.
Are resources cached?
The 2026-07-28 resources spec examples include caching hints on results, ttlMs and cacheScope. The TypeScript server added them to its resources/read result in the modern era on 28 October 2026. What a client does with those hints is up to the client.
How do clients learn a resource changed?
Through change notifications. In the 2026-07-28 spec, clients open a subscriptions/listen stream and the server notifies them. The Python SDK's prompts guide shows the same pattern for prompt list changes.
Should every resource also be a tool?
No. Duplicate only data the model needs to fetch by itself during a task. Too many overlapping tools make tool selection worse.











