P130 min

Resources & URIs

Expose data as resources with URI templates, MIME types, and subscription updates.

On This Page

Key Concepts

  • Resources are application-controlled data
  • URI scheme identifies the resource type
  • Static resources vs URI templates
  • MIME types for content negotiation
  • Text vs binary resource content
  • Subscriptions for real-time updates
  • resources/list and resources/read methods

Tools vs Resources

In the previous module, you learned about tools — functions the AI model can call. Resources are the other side of the coin: data the application can read. Understanding the difference is essential.

The Control Axis:

  Tools      → Model-controlled   → "I (the AI) decide to call this"
  Resources  → Application-controlled → "The app decides to fetch this"
  Prompts    → User-controlled    → "The human chooses this template"

Example:
  Tool:     The AI calls "search_files" to find something
  Resource: The app reads "file:///config.json" to provide context
  Prompt:   The user picks the "code-review" prompt template

Think of resources like files in a file system. The application (or user) browses what is available, selects what to include, and the content gets added to the conversation context. The AI model does not decide to “fetch a resource” — the host application does, often based on configuration or user action.

Why does this distinction matter? Because it affects who controls the data flow. Tools are active — the model invokes them. Resources are passive — they are offered as context. A client might automatically include certain resources in every conversation (like a project's README), while tools are invoked dynamically based on what the user asks.

Resource URIs

Every resource is identified by a URI (Uniform Resource Identifier). MCP does not mandate a specific URI scheme — you choose one that makes sense for your server. Common patterns:

URI Scheme Examples:

  file:///home/user/project/README.md      — File system path
  postgres://localhost/mydb/users/schema    — Database resource
  github://repos/owner/repo/issues/42      — GitHub issue
  config://app/settings                    — Application config
  memory://session/conversation-history    — In-memory data
  s3://bucket-name/path/to/object          — Cloud storage

The scheme (before "://") tells the client what kind of
resource this is. The rest identifies the specific item.

URIs must be unique within your server. Two resources cannot share the same URI. The URI should be descriptive enough that a human or AI can understand what it points to without additional context.

A good URI is like a good file path: self-documenting. Compare db://data/123 (meaningless) to postgres://myapp/users/schema (immediately clear).

Static Resources

The simplest type of resource is a static resource — a fixed piece of data with a known URI. When the client asks for a list of resources, your server returns all static resources with their URIs, names, and descriptions.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

const server = new McpServer({
  name: "config-server",
  version: "1.0.0",
});

// Register a static resource
// .resource() takes: URI, name (for display), and a handler
server.resource(
  // The unique URI for this resource
  "config://app/settings",

  // Human-readable name — clients display this in their UI
  "Application Settings",

  // The handler — called when a client reads this resource
  async (uri) => {
    // Read the actual data (from a file, database, etc.)
    const settings = await readSettingsFile();

    return {
      contents: [
        {
          uri: uri.href,            // Echo back the requested URI
          mimeType: "application/json",
          text: JSON.stringify(settings, null, 2),
        },
      ],
    };
  }
);

When a client connects and calls resources/list, it gets back:

{
  "resources": [
    {
      "uri": "config://app/settings",
      "name": "Application Settings",
      "mimeType": "application/json"
    }
  ]
}

The client can then call resources/read with that URI
to get the actual content.

Resource Templates

Static resources work when you know all resources upfront. But what about dynamic data? You cannot pre-register a resource for every row in a database or every file in a directory. That is where resource templates come in.

A resource template is a URI pattern with placeholders. Clients fill in the placeholders to request a specific resource.

// Register a resource template
// The URI contains {placeholders} that clients fill in
server.resource(
  // Template URI — {table} is a placeholder
  "postgres://mydb/{table}/schema",

  // Name template — can also use the placeholder
  "Schema for {table}",

  // Handler receives the resolved URI with the actual value
  async (uri) => {
    // Extract the table name from the URI
    // The SDK resolves the template and passes the full URI
    const tableName = uri.pathname.split("/")[1];

    const schema = await getTableSchema(tableName);

    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(schema, null, 2),
        },
      ],
    };
  }
);

// Now clients can request:
//   postgres://mydb/users/schema     → schema for "users" table
//   postgres://mydb/orders/schema    → schema for "orders" table
//   postgres://mydb/products/schema  → schema for "products" table
// All handled by the same template.

When a client calls resources/templates/list, it gets the template pattern. It can then construct specific URIs and call resources/read with them. This is how one handler serves an entire category of resources.

Template Listing Response:

{
  "resourceTemplates": [
    {
      "uriTemplate": "postgres://mydb/{table}/schema",
      "name": "Schema for {table}",
      "description": "Returns the column definitions for a database table"
    }
  ]
}

MIME Types and Content

Resources carry a MIME type so clients know how to handle the content. The content itself can be either text or binary:

// Text content — returned in the "text" field
{
  contents: [{
    uri: "config://app/settings",
    mimeType: "application/json",      // Client knows it is JSON
    text: '{ "theme": "dark" }',       // Actual content as string
  }],
}

// Binary content — returned in the "blob" field as base64
{
  contents: [{
    uri: "file:///images/logo.png",
    mimeType: "image/png",             // Client knows it is a PNG
    blob: "iVBORw0KGgoAAAAN...",       // Base64-encoded binary data
  }],
}

Common MIME types you will use:
  text/plain             → Plain text files
  text/markdown          → Markdown documents
  application/json       → JSON data (configs, API responses)
  text/csv               → Tabular data
  text/html              → HTML content
  image/png, image/jpeg  → Images (use blob, not text)
  application/pdf        → PDFs (use blob, not text)

Rule of thumb: if you can represent it as a string, use text. If it is binary (images, PDFs, compressed files), use blob with base64 encoding.

Reading Resources

From the protocol perspective, reading resources is a two-step process:

Step 1: Discovery — client asks "what resources exist?"

  Client → Server:  resources/list
  Server → Client:  [{ uri: "config://app/settings", name: "App Settings" }, ...]

  Client → Server:  resources/templates/list
  Server → Client:  [{ uriTemplate: "db://{table}/schema", name: "..." }, ...]

Step 2: Reading — client asks "give me this specific resource"

  Client → Server:  resources/read  { uri: "config://app/settings" }
  Server → Client:  { contents: [{ uri: "...", text: "...", mimeType: "..." }] }

For templates, the client constructs the URI first:

  Client → Server:  resources/read  { uri: "db://users/schema" }
  Server → Client:  { contents: [{ uri: "...", text: "...", mimeType: "..." }] }

The key insight: resources/read returns a "contents" array.
A single read can return multiple content items. This handles
cases where a resource is a collection.

Subscriptions and Change Notifications

Resources can change over time. A config file gets updated, new data arrives in a database, a log file grows. MCP supports subscriptions so clients can be notified when a resource changes.

Subscription Flow:

1. Client subscribes to a resource URI
   Client → Server:  resources/subscribe  { uri: "config://app/settings" }

2. Server acknowledges
   Server → Client:  (success response)

3. Later, when the resource changes...
   Server → Client:  notification/resources/updated  { uri: "config://app/settings" }

4. Client re-reads the resource to get new content
   Client → Server:  resources/read  { uri: "config://app/settings" }

5. When done, client unsubscribes
   Client → Server:  resources/unsubscribe  { uri: "config://app/settings" }

The server does not push the new content in the notification — it just says “this resource changed.” The client then decides whether to re-read it. This keeps notifications lightweight and lets the client control when it actually fetches data.

Your server can also notify that its resource list has changed (new resources added, old ones removed):

// Notify clients that the available resources have changed
// (e.g., a new config file was created)
server.notification({
  method: "notifications/resources/list_changed",
});

// Clients that care will call resources/list again
// to discover the new resources.

Complete Example: Database Schema Resource

Here is a practical example: a server that exposes database table schemas as resources. This is genuinely useful — an AI coding assistant that can read your database schema makes much better SQL suggestions.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

const server = new McpServer({
  name: "db-schema-server",
  version: "1.0.0",
});

// --- Static resource: list all tables ---
server.resource(
  "postgres://myapp/tables",
  "Database Table List",
  async (uri) => {
    // In a real server, query: SELECT table_name FROM information_schema.tables
    const tables = ["users", "orders", "products", "categories"];

    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({
          database: "myapp",
          tables: tables.map(t => ({
            name: t,
            schemaUri: `postgres://myapp/${t}/schema`,
          })),
        }, null, 2),
      }],
    };
  }
);

// --- Template resource: schema for any table ---
server.resource(
  "postgres://myapp/{table}/schema",
  "Schema for {table}",
  async (uri) => {
    // Parse table name from the URI path
    const segments = uri.pathname.split("/").filter(Boolean);
    const tableName = segments[0]; // "users", "orders", etc.

    // In a real server, query information_schema.columns
    const schema = await getSchemaForTable(tableName);

    if (!schema) {
      // Return an empty contents array for not found
      return { contents: [] };
    }

    // Format as a clear, readable schema description
    const formatted = [
      `Table: ${tableName}`,
      `Columns:`,
      ...schema.columns.map(col =>
        `  ${col.name} ${col.type}${col.nullable ? " (nullable)" : ""}${col.primaryKey ? " PRIMARY KEY" : ""}`
      ),
      ``,
      `Indexes:`,
      ...schema.indexes.map(idx =>
        `  ${idx.name} ON (${idx.columns.join(", ")})${idx.unique ? " UNIQUE" : ""}`
      ),
    ].join("\n");

    return {
      contents: [{
        uri: uri.href,
        mimeType: "text/plain",
        text: formatted,
      }],
    };
  }
);

// --- Helper function (simulated) ---
async function getSchemaForTable(name: string) {
  // This would be a real database query in production
  const schemas: Record<string, { columns: Array<{ name: string; type: string; nullable: boolean; primaryKey: boolean }>; indexes: Array<{ name: string; columns: string[]; unique: boolean }> }> = {
    users: {
      columns: [
        { name: "id", type: "uuid", nullable: false, primaryKey: true },
        { name: "email", type: "varchar(255)", nullable: false, primaryKey: false },
        { name: "name", type: "varchar(100)", nullable: true, primaryKey: false },
        { name: "created_at", type: "timestamptz", nullable: false, primaryKey: false },
      ],
      indexes: [
        { name: "users_pkey", columns: ["id"], unique: true },
        { name: "users_email_idx", columns: ["email"], unique: true },
      ],
    },
  };
  return schemas[name] ?? null;
}

This server exposes two resources: a static listing of all tables, and a template that serves the schema for any specific table. An AI assistant connected to this server could read the table list to understand the database structure, then read individual schemas when asked to write queries.

Try It Yourself

Design a resource server for one of these scenarios. Write out the resource URIs, names, MIME types, and what the content would look like:

  1. A config file server that exposes application config files from a directory. You need: a static resource listing all config files, and a template resource to read any specific file. Think about what URI scheme to use and how to handle nested directories.
  2. A Git repository server that exposes repository metadata. Resources might include: current branch, recent commits, modified files, and the content of any file at HEAD. Which of these are static and which are templates?
  3. A monitoring dashboard server that exposes system metrics. Think about: CPU usage, memory, disk space, and recent error logs. Which resources would benefit from subscriptions?

Key Takeaway

Resources expose data for applications to read and include as context. They are identified by URIs, can be static or templated, carry MIME types, and support subscriptions for change notifications. Unlike tools (which the AI model calls), resources are controlled by the application or user. Design resources around what context an AI assistant would benefit from having.

Common Mistakes

  • Using a tool when you should use a resource. If the data is read-only context that the application fetches, it is a resource. If the AI model needs to actively decide to retrieve it based on user intent, it is a tool. A database schema is a resource. A database query is a tool.
  • Non-descriptive URIs. data://1 tells nobody anything. postgres://myapp/users/schema is self-documenting. Treat URIs like file paths — they should be readable.
  • Returning huge resources without pagination. A resource that returns 100MB of data will choke the client. For large datasets, provide summaries or paginated templates.
  • Forgetting the MIME type. Clients use MIME types to decide how to render content. JSON without application/json might be displayed as plain text without syntax highlighting.
  • Pushing content in change notifications. Notifications say “something changed” — they do not include the new content. The client re-reads the resource if it wants the update. Sending content in notifications violates the protocol design.
  • Using text field for binary data. If the content is an image or PDF, use the blob field with base64 encoding. Putting binary data in the text field will corrupt it.