Resources

Difficulty

Resources answer a different question than tools do. A tool answers "what can the model do?" A resource answers "what data can be attached to context?"

A resource entry from resources/list

{
  "uri": "file:///home/user/project/README.md",
  "name": "README.md",
  "description": "Project readme",
  "mimeType": "text/markdown"
}

Reading it

// Request
{"jsonrpc": "2.0", "id": 3, "method": "resources/read", "params": {
  "uri": "file:///home/user/project/README.md"
}}

// Response
{"jsonrpc": "2.0", "id": 3, "result": {
  "contents": [{
    "uri": "file:///home/user/project/README.md",
    "mimeType": "text/markdown",
    "text": "# My Project\n\nThis project does..."
  }]
}}

Tools vs. resources at a glance

Nothing stops a server from offering both over the same underlying data — a filesystem server, for instance, can expose read_file/write_file as tools while also exposing recently opened files as resources:

ToolsResources
Who decides to use itThe model, mid-conversationThe host application, often ahead of time
Has "arguments"?Yes, an inputSchemaNo — just a URI to read
Side effectsCan perform actionsRead-only by design
Typical triggerModel reasons it needs to call somethingUser attaches a file, or host auto-includes recent context

Related Resources

A fixed resource list works fine for a handful of files. It breaks down for anything with an unbounded or huge set of addressable items — every row in a database table, for instance.

Fixed resources vs. templates

// resources/list — fixed, enumerable resources
{"resources": [
  {"uri": "config://app/settings", "name": "App Settings"}
]}

// resources/templates/list — a parameterized family
{"resourceTemplates": [
  {
    "uriTemplate": "postgres://db/{table}/{id}",
    "name": "Database Row",
    "description": "Fetch a single row by table and primary key",
    "mimeType": "application/json"
  }
]}

RFC 6570 syntax basics

{var} is a simple placeholder substitution. A URI template file:///{path} with path=notes/todo.md expands to file:///notes/todo.md. More advanced RFC 6570 operators exist too, like {?query} for query-string expansion, but most MCP servers stick to simple path substitution for readability.

Why templates matter

Without them, a server backing a database with millions of rows has two bad options: enumerate every row through resources/list (impossible at scale), or give up on modeling rows as resources at all. A template lets the server instead say "here's the shape of a valid resource URI for this data source." The client constructs a specific URI on demand. Often the model has seen a row ID mentioned elsewhere — a tool result, a resource, the conversation — and asks the host to read postgres://db/orders/42 directly.

Practical use

Templates are typically combined with tools. A search_orders tool returns a list of order IDs, and the host (or model) then reads each one as a resource via the template. The search tool never has to inline every order's full content in its own result.

Related Resources

Pagination in MCP follows a simple cursor pattern, used identically across resources/list, tools/list, and prompts/list.

The listing flow with pagination

// First page
{"method": "resources/list", "params": {}}
// -> {"resources": [...], "nextCursor": "eyJvZmZzZXQiOjEwMH0="}

// Next page
{"method": "resources/list", "params": {"cursor": "eyJvZmZzZXQiOjEwMH0="}}
// -> {"resources": [...], "nextCursor": null}   // no nextCursor => done

The cursor is an opaque string from the client's point of view. It might encode an offset, a database key, anything the server wants. Clients must not try to parse or construct cursors themselves — only pass back exactly what the server last gave them.

Reading a resource

{"method": "resources/read", "params": {"uri": "file:///project/data.csv"}}

// Text content
{"result": {"contents": [
  {"uri": "file:///project/data.csv", "mimeType": "text/csv", "text": "id,name\n1,Alice\n2,Bob"}
]}}

// Binary content
{"result": {"contents": [
  {"uri": "file:///project/logo.png", "mimeType": "image/png", "blob": "iVBORw0KGgoAAAANSUhEUg..."}
]}}

Why cursor-based, not offset/limit

A raw numeric offset breaks if the underlying data set changes between page fetches — items shift, and a delete shifts every subsequent offset. An opaque cursor lets the server encode whatever positioning scheme is actually stable for its data source: a database's own keyset pagination, a stable sort key, or a snapshot ID. None of that implementation detail, or its instability, leaks into the protocol itself.

A common implementation mistake

Treating resources/list as if it always returns everything in one call, and never checking for nextCursor. This works fine in local testing with a handful of files. It silently truncates results the moment a server is pointed at a real, large dataset.

Related Resources

Without subscriptions, the only way to detect a resource has changed is to keep re-reading it on a timer. That wastes requests and still misses changes between polls.

The subscription flow

// Client subscribes to one resource
{"method": "resources/subscribe", "params": {"uri": "file:///var/log/app.log"}}

// ... time passes, the file changes on disk ...

// Server pushes a notification (no content included, just the fact of a change)
{"method": "notifications/resources/updated", "params": {"uri": "file:///var/log/app.log"}}

// Client reacts by re-reading
{"method": "resources/read", "params": {"uri": "file:///var/log/app.log"}}

Why the notification carries no content

Sending the full new content inline would force the server to guess whether the client actually still wants it. It would also bloat notifications for resources that update frequently but are only occasionally re-read. Instead, the notification is a cheap "this changed" signal. The client decides whether, and when, it's worth the cost of an actual resources/read.

Preconditions and cleanup

  • Requires the server to have declared "resources": {"subscribe": true} in its capabilities. A server without that capability doesn't support resources/subscribe at all.
  • A client should call resources/unsubscribe once it no longer needs updates for a URI — say, the corresponding UI panel closed. That lets the server stop tracking and pushing updates for it. Leaving subscriptions open indefinitely wastes server-side bookkeeping, and can eventually leak memory in a long-running server process.

A realistic use case

A code-editing host might subscribe to the currently open file's resource URI. If an external process — a formatter, a build step, another editor instance — modifies it on disk, the host's in-memory view is invalidated and refreshed automatically. The user never ends up working against stale content without realizing it.

Related Resources

JSON has no native binary type, so MCP needs an explicit convention for anything that isn't plain text.

The two shapes

// Text resource
{"uri": "file:///notes.md", "mimeType": "text/markdown", "text": "# Notes\n..."}

// Binary resource
{"uri": "file:///diagram.png", "mimeType": "image/png", "blob": "iVBORw0KGgoAAAANSUhEUg..."}

text is a plain JSON string — readable, diffable, directly usable. blob is a base64-encoded string, since JSON strings can't hold arbitrary binary bytes; the consumer must base64-decode it back into raw bytes before use.

Why this distinction matters for cost and correctness

Base64 inflates payload size by roughly 33%, and for genuinely text-based content it adds an unnecessary encode/decode round trip on both ends for no benefit. A server should only reach for blob when the underlying data truly isn't text — always base64-wrapping everything "to be safe" needlessly bloats every resource read. Getting mimeType right matters too, since it's the signal a host or model uses to decide how to interpret the content:

import base64, mimetypes

def read_resource(path: str):
    mime, _ = mimetypes.guess_type(path)
    if mime and mime.startswith("text/"):
        with open(path, "r", encoding="utf-8") as f:
            return {"uri": f"file://{path}", "mimeType": mime, "text": f.read()}
    else:
        with open(path, "rb") as f:
            data = f.read()
        return {"uri": f"file://{path}", "mimeType": mime or "application/octet-stream", "blob": base64.b64encode(data).decode()}

Related Resources