Exposing Readable Data as MCP Resources
A resource is how an MCP server hands over data for a client to read, addressed by a URI rather than invoked like a function.
Search across all documentation pages
A resource is how an MCP server hands over data for a client to read, addressed by a URI rather than invoked like a function.
Files, database rows, configuration values, and API responses are all natural fits for resources once you think of them as things a client can fetch, not actions a model performs.
Resources in the Python MCP SDK are defined with the @mcp.resource() decorator, given a URI or a URI template as its argument.
A static resource has a fixed URI, like config://settings, while a templated resource uses placeholders in curly braces, like notes://{name}, that become function parameters.
Because a resource represents a read, it should never mutate state or trigger a side effect; that distinction is what separates a resource from a tool.
Returning a sensible MIME type alongside the content helps the client decide how to render or parse what it gets back, whether that is plain text, JSON, or an image.
Resources pair naturally with tools: a tool can create or update something, and a resource can expose the result for later reading.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("resource-recipes")
@mcp.resource("config://settings")
def get_settings() -> str:
"""Return the current server configuration as JSON."""
return '{"theme": "dark", "max_results": 20}'
@mcp.resource("notes://{name}")
def get_note(name: str) -> str:
"""Return the contents of a named note."""
return f"Contents of note '{name}'"When to reach for this:
# server.py
import json
import sqlite3
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("docs-resources")
DB_PATH = Path(__file__).parent / "articles.db"
DOCS_DIR = Path(__file__).parent / "docs"
def _get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
@mcp.resource("articles://{article_id}")
def get_article(article_id: str) -> str:
"""Return a single article row as JSON, addressed by its id."""
conn = _get_db()
try:
row = conn.execute(
"SELECT id, title, body FROM articles WHERE id = ?", (article_id,)
).fetchone()
if row is None:
raise ValueError(f"No article found with id {article_id}")
return json.dumps(dict(row))
finally:
conn.close()
@mcp.resource("docs://{filename}")
def get_doc_file(filename: str) -> str:
"""Return the raw text of a markdown file from the docs directory."""
path = (DOCS_DIR / filename).resolve()
if DOCS_DIR.resolve() not in path.parents:
raise ValueError("filename must stay within the docs directory")
if not path.exists():
raise FileNotFoundError(f"No such file: {filename}")
return path.read_text(encoding="utf-8")
@mcp.resource("stats://article-count")
def get_article_count() -> str:
"""Return the total number of articles as a static summary resource."""
conn = _get_db()
try:
count = conn.execute("SELECT COUNT(*) FROM articles").fetchone()[0]
return json.dumps({"article_count": count})
finally:
conn.close()
if __name__ == "__main__":
mcp.run(transport="stdio")What this demonstrates:
articles://{article_id}) reading a single row from a real database connection.docs://{filename}) serving files from disk, with a path traversal guard before touching the filesystem.stats://article-count) with no parameters, useful for summary or aggregate data.json.dumps so structured data comes back in a predictable, parseable shape.@mcp.resource("scheme://path/{param}") registers a URI template; the SDK extracts {param} segments and passes them as function arguments when a matching URI is read.list_resources() (for concrete URIs) or list_resource_templates() (for templated ones), then reads a specific URI with read_resource(uri).config://, notes://, docs://) is arbitrary and chosen by you; it is not a real network protocol, just a namespace.| Scheme Style | Example | Best For |
|---|---|---|
| Custom namespace | config://settings | Server-specific concepts with no filesystem or network equivalent |
file://-style | file:///reports/q1.csv | Data that genuinely maps to a real file path |
| Domain-style | articles://{id}, users://{id} | Rows or records from a database, one scheme per entity type |
| Aggregate/static | stats://summary | Fixed, parameter-free summaries or counts |
# Guard against path traversal whenever a resource reads from disk using a
# user-supplied path segment.
path = (DOCS_DIR / filename).resolve()
if DOCS_DIR.resolve() not in path.parents:
raise ValueError("filename must stay within the docs directory")| Element | Type | Description |
|---|---|---|
| URI template | str | Passed to @mcp.resource(); {param} segments become function parameters |
| function parameters | str (typically) | Extracted from the matching URI segments at read time |
| return value | str (or bytes for binary content) | The resource's content, returned to the client on read_resource |
send_email://{to} implies a side effect and misleads clients that assume resources are safe to read repeatedly. Fix: move anything with a side effect into a tool.docs://../../etc/passwd can escape the intended directory if you build a path naively. Fix: resolve the path and confirm it stays inside the allowed root before reading.json.dumps(...) for anything with more than one field.try/finally (as shown above) or a connection context manager.| Alternative | Use When | Don't Use When |
|---|---|---|
| MCP Tool | The operation performs an action or computation with a result | The data is a plain read with no side effects |
| MCP Prompt | You are packaging instructions, not data | The client needs to fetch or reference actual content |
| A tool that returns the same data | You want the model to explicitly request the data as part of a reasoning step | You want the client to attach or cache the data as ambient context |
| Direct database/API access outside MCP | Only one internal application will ever need this data | You want the same data addressable from Claude Code, Claude Desktop, and other clients |
Yes, a template like orgs://{org_id}/users/{user_id} extracts both segments as separate function parameters, as long as each is delimited by its own path segment.
No. Resources are meant to be safely readable, potentially more than once, without changing anything. Anything that creates, updates, or deletes data belongs in a tool instead.
Return bytes from the handler instead of str, and the SDK will encode it appropriately for the client, typically along with an explicit MIME type describing the binary format.
The server returns an error indicating the resource does not exist. This is why exact URI scheme and template design matters, since a typo in the scheme means no handler matches.
Yes. list_resources() and list_resource_templates() let a client discover what a server offers, including templated resources with their {param} placeholders shown, before reading any specific URI.
No, it is just a namespace you choose. MCP does not require these to be resolvable network URIs; they only need to be unique and meaningful within your server.
Raise an exception with a specific message, exactly as you would in a tool handler, rather than returning an empty string or None that hides the failure.
Yes, resources can be as dynamic as any handler function; each read executes fresh, so a database-backed resource always reflects current data unless you add your own caching layer.
No. Always resolve the resulting path and verify it stays within the intended root directory before reading, to prevent path traversal into files outside the served directory.
application/json is the conventional choice, and returning content via json.dumps(...) alongside that MIME type gives clients a predictable, parseable payload.
Yes, a single FastMCP server can register any number of both static (config://settings) and templated (articles://{id}) resources side by side.
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the current Model Context Protocol specification. Model names, SDK versions, and the MCP spec move quickly - verify current specifics at platform.claude.com/docs and modelcontextprotocol.io before relying on them.
Reviewed by Chris St. John·Last updated Jul 16, 2026