Building MCP Servers
Exposing Resources
Tools let the model act. Resources let it read. Files, database rows, config — anything you're willing to hand over without letting it be changed.
Resource vs Tool
The line between them is not "is this data or an action." A tool can return pure data and still be a tool. The real question is who initiates, and whether anything can change as a result.
Which one should this be?
Make it a Tool
- The model can decide it needs this, unprompted
- Arguments let one tool cover many cases
- Every tool is a new way for the model to be wrong at your expense
- Needs a careful description to be called at the right time
Make it a Resource
- Safe to expose broadly — nothing can be damaged
- Cacheable, because a URI means the same thing each time
- The model cannot reach for it on its own
- A large resource can swallow the context window
The practical test: if you'd want a human to approve it before it runs, it's a tool. If you'd be comfortable with it happening silently a hundred times, it's a resource.
Defining Resources
Resources use URIs to identify content:
@mcp.resource("config://app/settings", mime_type="application/json")
def app_settings() -> str:
"""Current application configuration."""
return json.dumps(load_app_settings(), indent=2)
@mcp.resource("file:///var/log/app.log", mime_type="text/plain")
def app_logs() -> str:
"""Recent application log entries."""
return read_file("/var/log/app.log")
One decorator declares the resource and reads it. The URI is the identifier the client asks for, the docstring becomes the description, and the function body only runs when something actually requests it — so listing a hundred resources costs nothing until one is read.
URI Schemes
You can use any URI scheme that makes sense for your data:
| Scheme | Use Case | Example |
|---|---|---|
file:// | Local files | file:///home/user/doc.txt |
db:// | Database records | db://users/123 |
config:// | Configuration | config://app/settings |
api:// | External APIs | api://weather/london |
Reading Resources
Implement the read handler to return content:
@mcp.resource("user://{user_id}/profile", mime_type="application/json")
async def user_profile(user_id: str) -> str:
"""Profile for a single user."""
return json.dumps(await db.get_user(user_id))
A {placeholder} in the URI makes it a resource template: the client fills in the
value, and it arrives as a function argument. Templates are listed separately from
concrete resources — a client discovers them through resources/templates/list — which
is what lets you expose a million user profiles without enumerating any of them.
Binary Resources
For binary data like images, return bytes and declare the MIME type:
@mcp.resource("image://logo", mime_type="image/png")
def logo() -> bytes:
"""The application logo."""
return load_image("logo.png") # plain bytes — no base64 by hand
Return bytes and the SDK base64-encodes it into a blob for you. Encoding it yourself
double-encodes it, which produces a resource that transfers perfectly and renders as
garbage — one of those bugs that looks like a client problem for an afternoon.
Dynamic Resources
Resources can be generated dynamically based on parameters:
# Register what is known at startup
for project in load_known_projects():
mcp.add_resource(
Resource(
uri=f"project://{project.id}",
name=f"Project: {project.name}",
description=f"Overview of {project.name}",
mimeType="application/json",
)
)
add_resource takes an mcp.types.Resource and registers it alongside the decorated
ones, which covers the case where the set is known at startup but not at edit time.
For anything that changes while the server is running, prefer the template above.
Enumerating every row means a database query on every resources/list, and clients call
that far more often than you would guess — a template moves the work to the read, where
someone is actually waiting for the answer.
Next: error handling, and why the model is an unusual audience for it. :::
Sign in to rate