---
name: forge-mcp-resource
description: Exposing resources via MCP. URI scheme design, listing strategies, mimeType discipline, ranged reads, mutable resources via subscribe, when resources beat tools, multi-tenant scoping. Contains reference resources/list + resources/read handlers. Use when designing what an MCP server exposes via the `resources` capability (as opposed to tools or prompts).
license: MIT
---

# forge-mcp-resource

You are deciding what an MCP server exposes as a *resource* (read-only addressable content) versus what it exposes as a *tool* (callable action). Default agent-written MCP servers expose everything as tools, missing the point of resources entirely. This skill exists to fix that.

The mental model: **tools are verbs the model invokes; resources are nouns the model reads**. A well-designed MCP server has both, and each role belongs to the right shape.

## Quick reference (the things you must never ship)

1. A "resource" that takes parameters beyond its URI (that is a tool).
2. A "resource" read that has a side effect (mark-as-read, decrement-quota).
3. `resources/list` returning >500 entries without pagination.
4. A listed resource without `mimeType`.
5. A listed resource without `description`.
6. URI scheme that mixes case (`users://12345/Profile` AND `users://12345/profile`).
7. `resources/read` returning a redirect or placeholder ("call this tool instead").
8. Listing returning entries the connection cannot then `read`.
9. Binary content encoded as base64 inside a JSON string instead of using the protocol's `blob` field.
10. Mutable resource without `lastModified` or `etag`.

## Hard rules

### When to use a resource

**1. Resource when the LLM needs read access to addressable, relatively-stable content.** Documents, file contents, config snapshots, schema definitions, knowledge-base articles. Model dereferences a URI, reads content. No side effects, no parameters beyond the URI.

**2. Tool when the LLM needs to act, query with parameters, or trigger a side effect.** Search, create, update, delete, send.

**3. A search is a tool, not a resource.** `search_documents(query, limit)` is a tool. The *result* is a list of resource URIs the model can then read.

**4. A single immutable document is a resource.** A live, changing dashboard with parameters is a tool.

```
GOOD                              BAD
resource: doc://policies/refunds  resource: doc://search?q=refunds
resource: users://01HXY.../profile resource: users://search/anna
tool:     search_docs(query)      tool:     get_resource_uri()
tool:     create_document(...)    resource: doc://create
```

### URI design

**5. URIs are stable, opaque, and addressable.** `users://12345/profile` is a stable identity. `users://search?q=anna` is a tool call masquerading as a URI.

**6. One scheme per resource family.** Pick `users://`, `docs://`, `db://`. Each scheme is owned by one server. Scheme documented; path descriptive.

**7. Hierarchical paths, not query strings.** `db://postgres/public/users/schema` reads better than `db://?type=schema&table=users`. Model parses hierarchy more reliably.

**8. URIs are case-sensitive. Be consistent.** All-lowercase scheme, all-lowercase path. Never mix.

### Listing resources

**9. `resources/list` deterministic and paginated.** Returning 5000 entries on `list` kills client startup.

```ts
import { ListResourcesRequestSchema } from "@modelcontextprotocol/sdk/types.js";

server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
  const cursor = request.params?.cursor;
  const page = await db.documents.list({ cursor, limit: 50 });
  return {
    resources: page.items.map((doc) => ({
      uri: `doc://${doc.id}`,
      name: doc.title,
      description: doc.summary,
      mimeType: doc.mime_type ?? "text/markdown",
    })),
    nextCursor: page.next_cursor,  // null when there are no more pages
  };
});
```

**10. Group resources by prefix in the listing.** Clients render listings to humans. Logical groupings (`users://`, `users://archived/`, `users://active/`) make the listing scannable.

**11. Include `description` on every listed resource.** "User profile for the authenticated tenant, JSON encoded" beats a bare URI.

### Content types

**12. Declare `mimeType` explicitly on every resource.**

| Content | mimeType |
| --- | --- |
| Structured data | `application/json` |
| Markdown / docs | `text/markdown` |
| Plain text | `text/plain` |
| PNG / JPEG | `image/png` / `image/jpeg` |
| PDF | `application/pdf` |
| CSV | `text/csv` |
| SQL schema | `application/sql` |

**13. Prefer `text/markdown` for human-readable documents.** Clients render it; LLMs read it natively. Avoid HTML unless the source is genuinely HTML.

**14. Binary content uses `blob` encoding, not base64-in-a-string.** The MCP protocol has explicit binary support; use it.

```ts
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const buffer = await fs.readFile(resolveUri(request.params.uri));
  return {
    contents: [{
      uri: request.params.uri,
      mimeType: "image/png",
      blob: buffer.toString("base64"),  // the protocol field for binary
    }],
  };
});
```

### Read semantics

**15. Reading a resource is idempotent and side-effect free.** No "read" that consumes credit, marks-as-read, or rotates a token. If the act of reading has a side effect, it is a tool.

**16. Support ranged reads where content is large.** A `range` parameter (byte- or line-based, declared) lets the model fetch only what it needs.

```ts
// example: ranged read on a markdown file
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const uri = request.params.uri;
  const range = request.params?.range as { start: number; end: number } | undefined;
  const text = await loadText(uri);
  const slice = range ? text.slice(range.start, range.end) : text;
  return {
    contents: [{
      uri,
      mimeType: "text/markdown",
      text: slice,
      ...(range && text.length > range.end ? { truncated: true, total_length: text.length } : {}),
    }],
  };
});
```

**17. `resources/read` returns the full content or an error.** Do not return a redirect, a placeholder, or a "you should call this tool instead." If the content is not directly readable, do not list it.

### Mutability

**18. Static resources do not change for the lifetime of the connection.** Stable schemas, config snapshots, archived documents.

**19. Mutable resources support `resources/subscribe`.** When content changes, notify the client. A long-running session benefits enormously from change notifications instead of re-reading.

**20. Set `lastModified` and `etag` on mutable resources.** Clients can cache.

### Privacy and scoping

**21. Resources scoped by the connection's auth identity.** A tenant cannot enumerate or read another tenant's resources, even by guessing URIs.

**22. `resources/list` only returns what the connection can read.** Filter at the server, not at the client.

**23. Sensitive content gets a sensitivity hint.** Custom metadata `"sensitivity": "internal" | "confidential" | "public"`.

### Performance

**24. Resource reads under 2 seconds.** Above 5 seconds the client UI feels broken. Cache aggressively. Pre-compute where you can.

**25. `resources/list` under 500ms for the first page.** Called on connect and on every "refresh."

## Common AI-output patterns to reject

| Pattern | Why wrong | Fix |
| --- | --- | --- |
| `resource: doc://search?q=...` | Resource with parameters | Make it a `search_docs` tool |
| `resource read` marks-as-read | Side effect | Make it a `mark_read` tool |
| `resources/list` no pagination | Slow startup | Cursor-based pagination |
| Listed resource no `mimeType` | Client cannot route content | Set explicitly per resource |
| Listed resource no `description` | Hard to skim | Description on every entry |
| Returning binary as base64 in a `text` field | Wrong protocol shape | Use `blob` field |
| Cross-tenant URIs accessible by guessing | Auth bypass | Server-side filter on identity |
| Mutable resource no `lastModified` | Client cannot cache | Add metadata |
| Listing entries that fail on read | Broken UX | Filter listing to readable only |
| Synchronous read of a 50MB file | Slow UI, big payload | Ranged read with truncation |

## Worked example: minimal resource server

```ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
  { name: "kb", version: "0.1.0" },
  { capabilities: { resources: {} } },
);

// resources/list - paginated, with descriptions and mimeTypes
server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
  const cursor = request.params?.cursor ?? undefined;
  const page = await db.kb.list({ cursor, limit: 50, tenant_id: ctx.tenantId });
  return {
    resources: page.items.map((article) => ({
      uri: `kb://${article.id}`,
      name: article.title,
      description: article.summary.slice(0, 200),
      mimeType: "text/markdown",
    })),
    nextCursor: page.next_cursor,
  };
});

// resources/read - full content (or ranged if requested)
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const uri = request.params.uri;
  const match = /^kb:\/\/([a-zA-Z0-9_-]+)$/.exec(uri);
  if (!match) {
    throw new McpError(ErrorCode.InvalidParams, `Invalid kb:// URI: ${uri}`);
  }
  const article = await db.kb.findById(match[1], { tenant_id: ctx.tenantId });
  if (!article) {
    throw new McpError(ErrorCode.InvalidParams, `Article not found: ${uri}`);
  }
  return {
    contents: [{
      uri,
      mimeType: "text/markdown",
      text: article.body,
    }],
  };
});

// Optional: subscribe to changes on the KB article
server.setRequestHandler(SubscribeResourceRequestSchema, async (request) => {
  // wire your DB triggers / pubsub to call server.notification({...})
  // when the article changes.
});
```

What this shows: paginated list (rule 9); description + mimeType on every entry (rules 11-12); URI validated against scheme (rule 5); tenant scoping enforced at the query (rule 21); read is pure (rule 15).

## Workflow

When designing the resource surface:

1. **List read-only content the model needs.** If everything takes parameters, you have no resources, only tools.
2. **Pick URI scheme(s).** One per family, documented.
3. **Decide what is listed vs only reachable by URI.** Not everything appears in `resources/list`; some is reachable only when the model is told a specific URI.
4. **Pick mime types per resource family.** Default `text/markdown` or `application/json`.
5. **Decide which need `subscribe`.** Static ones do not.
6. **Implement `list`, then `read`, then `subscribe`. Test in that order.**

## Verification

Resource design is structural; verification is by review. Manual checklist:

- [ ] Every URI scheme documented.
- [ ] `resources/list` paginates and stays under 500ms first page.
- [ ] Every listed resource has `description` and `mimeType`.
- [ ] No resource takes parameters beyond URI.
- [ ] No resource read has side effects.
- [ ] Listing filtered by connection identity.

## When to skip this skill

- MCP server that exposes only tools (most servers).
- You are calling resources from a client, not designing them.
- Data is fundamentally interactive (search, dashboards, live queries) - those are tools.

## Related skills

- [`forge-mcp-server`](../forge-mcp-server/SKILL.md) - the server that hosts the resources.
- [`forge-mcp-tool-design`](../forge-mcp-tool-design/SKILL.md) - when "should be a tool" - this is the partner skill.
