Building MCP Servers

Exposing Resources

5 min read

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?

Model-initiated

Make it a Tool

Who starts itThe model, mid-conversation
Takes argumentsYes — a JSON Schema you define
Can change stateYes
Discoverytools/list
Pros
  • The model can decide it needs this, unprompted
  • Arguments let one tool cover many cases
Cons
  • 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
User-attached

Make it a Resource

Who starts itThe user or host attaches it
Takes argumentsNo — the URI is the address
Can change stateNo, by contract
Discoveryresources/list
Pros
  • Safe to expose broadly — nothing can be damaged
  • Cacheable, because a URI means the same thing each time
Cons
  • 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:

SchemeUse CaseExample
file://Local filesfile:///home/user/doc.txt
db://Database recordsdb://users/123
config://Configurationconfig://app/settings
api://External APIsapi://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. :::

Quiz

Module 2 Quiz: Building MCP Servers

Take Quiz
Was this lesson helpful?

Sign in to rate