# Exposing Data with Resources

> When data should be a resource instead of a tool, how to declare fixed resources and URI templates, and what clients get back when they read one.

Source: https://frontmcp.dev/learn/exposing-data-with-resources

A **resource** is data your server makes available at a URI, like `docs://refund-policy` or `tickets://T-2`. Tools are for things the model decides to do. Resources are for things the application reads and puts in front of the model, usually because the user picked them. A lot of what people first write as tools, like "get the policy" or "get this record", works better as a resource.

**You will learn**
- When data should be a resource instead of a tool, and who decides to read it
- How to declare a resource with a fixed URI using `@Resource`
- How to choose a MIME type, and what FrontMCP picks when you don't
- How to cover a whole family of URIs, like `tickets://{id}`, with `@ResourceTemplate`
- What `resources/list`, `resources/templates/list` and `resources/read` return, and what happens when a resource doesn't exist

## Data that isn't an action

Support agents keep asking the model about the refund policy, so someone wrote a tool for it:

```ts policy.tool.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "get_refund_policy",
  description: "Get the refund policy",
  inputSchema: {},
})
export class GetRefundPolicy extends ToolContext {
  async execute() {
    return "# Refunds\n\nFull refund within 30 days of purchase. After that, store credit only.";
  }
}
```

It works, but a tool is the wrong shape for this:

1. **The model decides when to call it.** A support agent replying to an angry customer can't make sure the policy is in the conversation. They can only hope the model thinks to look it up.
2. **It has no address.** Nothing can point at "the refund policy": not the user, not the client's UI, not another part of your server.
3. **It takes up room among the actions.** Every tool in `tools/list` is something the model has to weigh on every turn. A document isn't an action.

MCP has a separate kind of capability for data like this. The difference is **who decides** to use it:

| | Tool | Resource |
| --- | --- | --- |
| Who decides to use it | The model, mid-conversation | The application, usually because the user picked it |
| Identified by | A name, like `get_ticket` | A URI, like `tickets://T-2` |
| Discovered with | `tools/list` | `resources/list` and `resources/templates/list` |
| Used with | `tools/call`, with arguments | `resources/read`, with a URI |
| Side effects | Allowed | None. Reading a resource shouldn't change anything. |

Clients usually show resources in an attach or @-mention menu. The user picks the refund policy, the client reads it, and its text goes into the conversation before the model says a word.

## Declaring a resource

Here is the refund policy as a resource:

```ts policy.resource.ts
import { Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({
  name: "refund-policy",
  title: "Refund policy",
  uri: "docs://refund-policy",
  mimeType: "text/markdown",
  description: "When customers can get their money back. Attach it when answering refund questions.",
})
export class RefundPolicy extends ResourceContext {
  async execute(uri: string) {
    return "# Refunds\n\nFull refund within 30 days of purchase. After that, store credit only.";
  }
}
```

A `@Resource` has:

1. **`uri`**, the address clients read. The scheme (`docs://`) is yours to choose. Pick one that says what kind of thing lives there.
2. **`name`**, an identifier, and optionally a **`title`** for clients to show people.
3. **`description`**, what the data is and when it's worth attaching. Clients show it in their pickers, so write it for the person choosing what to attach.
4. **`mimeType`**, what kind of text it is. More on that [below](#picking-a-mime-type).
5. **The class**, which extends `ResourceContext` and implements `execute(uri)`. It runs every time a client reads the resource.

The [`@Resource` reference](https://frontmcp.dev/reference/sdk/resource) lists every option.

`execute()` can return plain text or an object, and FrontMCP builds the MCP result around it. Open the **Call** tab: the page already sent `resources/read`, and the text came back inside `contents`, labelled with the URI and the MIME type.

**Deep dive: What does the client actually receive?**
A client discovers resources with `resources/list`. Open the **Wire** tab and expand it. For this server it returns:

```json
{
  "resources": [
    {
      "name": "refund-policy",
      "title": "Refund policy",
      "uri": "docs://refund-policy",
      "description": "When customers can get their money back. Attach it when answering refund questions.",
      "mimeType": "text/markdown"
    }
  ]
}
```

That's metadata only. The content arrives when the client sends `resources/read` with `{ "uri": "docs://refund-policy" }`:

```json
{
  "contents": [
    {
      "uri": "docs://refund-policy",
      "mimeType": "text/markdown",
      "text": "# Refunds\n\nFull refund within 30 days of purchase. After that, store credit only."
    }
  ]
}
```

`contents` is a list, because one read can return several pieces. (The real responses carry a few more fields, like `_meta`; the Wire tab shows all of them.)

## Picking a MIME type

`mimeType` tells the client what the text is, so it can decide how to show it and how to hand it to the model. Two cover most help desk data:

- **`text/markdown`** for prose a person or model reads: policies, articles, canned replies.
- **`application/json`** for records a model or program picks fields out of: a ticket, a customer, some counts.

When you leave `mimeType` out, FrontMCP picks one from what `execute()` returns: an object is sent as JSON and labelled `application/json`, and a string is labelled `text/plain`. Open the **Tests** tab to see all three:

```ts resources.ts active
import { Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({
  name: "refund-policy",
  uri: "docs://refund-policy",
  mimeType: "text/markdown",
  description: "When customers can get their money back",
})
export class RefundPolicy extends ResourceContext {
  async execute(uri: string) {
    return "# Refunds\n\nFull refund within 30 days of purchase.";
  }
}

@Resource({
  name: "ticket-stats",
  uri: "stats://tickets",
  description: "How many tickets are open and closed right now",
})
export class TicketStats extends ResourceContext {
  async execute(uri: string) {
    return { open: 2, closed: 1 };
  }
}

@Resource({
  name: "support-hours",
  uri: "docs://support-hours",
  description: "When the support team is online",
})
export class SupportHours extends ResourceContext {
  async execute(uri: string) {
    return "Monday to Friday, 8:00 to 18:00 UTC.";
  }
}
```

```ts resources.test.ts
import { test, expect } from "@frontmcp/testing";

test("the policy is markdown, because we said so", async ({ mcp }) => {
  expect(await mcp.resources.read("docs://refund-policy")).toHaveMimeType("text/markdown");
});

test("an object is sent as JSON", async ({ mcp }) => {
  const stats = await mcp.resources.read("stats://tickets");
  expect(stats).toHaveMimeType("application/json");
  expect(stats.json()).toEqual({ open: 2, closed: 1 });
});

test("a string with no mimeType is plain text", async ({ mcp }) => {
  expect(await mcp.resources.read("docs://support-hours")).toHaveMimeType("text/plain");
});
```

The fallback is fine for JSON, but set `mimeType` anyway: it also goes out in `resources/list`, so clients know what they'll get before they read. Notice that `ticket-stats` doesn't declare one, so the Capabilities card has no type to show.

## A family of resources with a template

The refund policy has one URI. Tickets don't: there's `T-1`, `T-2`, and a new one every few minutes. You can't declare a `@Resource` for each. A **resource template** covers all of them with one URI pattern:

```ts ticket.resource.ts
import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Login link expired", status: "open" },
];

@ResourceTemplate({
  name: "ticket",
  uriTemplate: "tickets://{id}",
  mimeType: "application/json",
  description: "One support ticket, with its title and status",
})
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    return tickets.find((t) => t.id === id);
  }
}
```

`{id}` in `uriTemplate` is a parameter. When a client reads `tickets://T-2`, FrontMCP matches the URI against the template and passes `{ id: "T-2" }` as the second argument of `execute()`. The type parameter on `ResourceContext<{ id: string }>` describes those parameters. Templates aren't in `resources/list`, because there's no single URI to list. Clients find them with `resources/templates/list`, which returns the `uriTemplate` instead of a `uri`. Look for it in the Wire tab. (Every option is in the [`@ResourceTemplate` reference](https://frontmcp.dev/reference/sdk/resource-template).)

Now look at the result in the Call tab. `execute()` returned a plain object, and FrontMCP built the content around it the same way as for a fixed resource: the ticket as JSON, the template's MIME type, and the `uri` that was read, `tickets://T-2`. A client that attaches `T-1` and `T-2` gets two pieces of content, each labelled with its own ticket. Change the id in the Call tab to read other tickets.

To set every field of the result yourself, return `{ contents: [{ uri, mimeType, text }] }` instead, and FrontMCP sends it as it is. The [`@Resource` reference](https://frontmcp.dev/reference/sdk/resource#executeuri-params) lists every shape `execute()` can return.

> **Note**
A template can have several parameters, like `customers://{customer}/tickets/{id}`. Each one matches a single path segment, so `tickets://{id}` matches `tickets://T-2` but not `tickets://T-2/comments`.

The [Research Assistant](https://frontmcp.dev/examples/research-assistant) example uses two templates, `kb://{id}` and `tickets://{id}`, so a client can open every source an answer cites.

## When the ticket doesn't exist

Read `T-9` in the example above. `tickets.find()` returns `undefined`, so `execute()` returns nothing, and the read fails with `Resource "tickets://T-9" read failed: Resource output not found`. That's how a server that broke looks, and nothing in it says the ticket doesn't exist.

When a template's parameters don't point at anything, throw `ResourceNotFoundError`:

```ts ticket.resource.ts active
import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  { id: "T-3", title: "Login link expired", status: "open" },
];

@ResourceTemplate({
  name: "ticket",
  uriTemplate: "tickets://{id}",
  mimeType: "application/json",
  description: "One support ticket, with its title and status",
})
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) throw new ResourceNotFoundError(uri);
    return ticket;
  }
}
```

```ts ticket.test.ts
import { test, expect } from "@frontmcp/testing";

test("T-2 reads as JSON", async ({ mcp }) => {
  const result = await mcp.resources.read("tickets://T-2");
  expect(result).toHaveMimeType("application/json");
  expect(result.json()).toMatchObject({ id: "T-2", status: "closed" });
});

test("T-9 is a not-found error that names the URI", async ({ mcp }) => {
  const result = await mcp.resources.read("tickets://T-9");
  expect(result).toBeError(-32602);
  expect(result.error.message).toBe("Resource not found: tickets://T-9");
});

test("a URI that no resource matches gets the same error", async ({ mcp }) => {
  const result = await mcp.resources.read("tickets://T-2/comments");
  expect(result).toBeError(-32602);
  expect(result.error.message).toBe("Resource not found: tickets://T-2/comments");
});
```

Unlike a tool error, a resource error isn't a result with `isError`. It's a JSON-RPC error, so there are no `contents` and nothing gets attached. The Call tab shows it: code `-32602`, which MCP 2026-07-28 uses for a resource that doesn't exist, and the message `Resource not found: tickets://T-9`. The **Tests** tab shows that a URI no resource or template matches fails the same way, before any of your code runs. For a client, a ticket that doesn't exist and a URI that points nowhere are the same thing: there's nothing to read.

> **Pitfall: Throw ResourceNotFoundError, not a plain Error**
A `ResourceNotFoundError` tells the client the ticket doesn't exist. A plain `Error` tells it the server failed:

```ts ticket.resource.ts
import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk";

const tickets = [{ id: "T-1", title: "Cannot log in", status: "open" }];

@ResourceTemplate({ name: "ticket", uriTemplate: "tickets://{id}", mimeType: "application/json" })
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    // 🚩 Says the read failed, not that T-9 doesn't exist
    if (!ticket) throw new Error(`There's no ticket ${id}.`);
    return ticket;
  }
}
```

The client gets code `-32603`, an internal error, and `Resource "tickets://T-9" read failed: There's no ticket T-9.` That's in development, which is how the Playground runs. With `NODE_ENV=production`, FrontMCP hides the message of an error like this, and the client only gets `Internal FrontMCP error. Please contact support with error ID: …`. For an error the client should read, throw `ResourceNotFoundError`, or a `PublicMcpError` with your own message.

## Recap

- Tools are for actions the model decides to take. Resources are data the application reads, usually because the user picked it, and they're addressed by URI.
- `@Resource` declares one fixed URI. Its class extends `ResourceContext` and returns the content from `execute(uri)`.
- Set `mimeType`: `text/markdown` for prose, `application/json` for records. Without it, objects are sent as JSON and strings as `text/plain`.
- `@ResourceTemplate` covers a family of URIs like `tickets://{id}` and passes the parameters to `execute(uri, params)`. FrontMCP labels what it returns with the URI that was read.
- Clients discover resources with `resources/list` and templates with `resources/templates/list`, and read both with `resources/read`.
- Throw `ResourceNotFoundError` when a URI doesn't point at anything. The client gets a `-32602` JSON-RPC error instead of content. A plain `Error` reads as a server failure, and production hides its message.

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**.

### Challenge: Turn a tool into a resource
The escalation policy is a document, but it was written as a tool. Make it a resource at `docs://escalation-policy`, in markdown, and remove the tool.

```ts escalation.ts
import { Tool, ToolContext } from "@frontmcp/sdk";

@Tool({
  name: "get_escalation_policy",
  description: "Get the escalation policy",
  inputSchema: {},
})
export class GetEscalationPolicy extends ToolContext {
  async execute() {
    return "# Escalation\n\nEscalate to the on-call lead when a customer is blocked for more than 4 hours.";
  }
}
```

```ts escalation.ts solution
import { Resource, ResourceContext } from "@frontmcp/sdk";

@Resource({
  name: "escalation-policy",
  uri: "docs://escalation-policy",
  mimeType: "text/markdown",
  description: "When to hand a ticket to the on-call lead",
})
export class EscalationPolicy extends ResourceContext {
  async execute(uri: string) {
    return "# Escalation\n\nEscalate to the on-call lead when a customer is blocked for more than 4 hours.";
  }
}
```

```ts escalation.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("`get_escalation_policy` is no longer a tool", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("get_escalation_policy");
});

test("`docs://escalation-policy` is listed as a resource", async ({ mcp }) => {
  expect(await mcp.resources.list()).toContainResource("docs://escalation-policy");
});

test("it's markdown, and it still says when to escalate", async ({ mcp }) => {
  const result = await mcp.resources.read("docs://escalation-policy");
  expect(result).toHaveMimeType("text/markdown");
  expect(result.text()).toContain("Escalate to the on-call lead");
});
```

**Hint:**
Swap `@Tool` for `@Resource` and `ToolContext` for `ResourceContext`. A resource needs a `uri` instead of an `inputSchema`.

**Solution:**
The policy now has an address, so a user can attach it and the client can read it before the model starts. Because it's markdown, the client knows it's prose, and because it's no longer in `tools/list`, the model has one fewer action to weigh.

### Challenge: Add a template for customers
Support agents want to attach a customer's details the same way they attach a ticket. Add a template at `customers://{id}` that returns the customer as JSON.

```ts resources.ts
import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open", customer: "C-1" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "C-2" },
];

const customers = [
  { id: "C-1", name: "Acme Corp", plan: "enterprise" },
  { id: "C-2", name: "Globex", plan: "starter" },
];

@ResourceTemplate({
  name: "ticket",
  uriTemplate: "tickets://{id}",
  mimeType: "application/json",
  description: "One support ticket, with its title, status and customer",
})
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) throw new ResourceNotFoundError(uri);
    return ticket;
  }
}

// Add a `customers://{id}` template here.
```

```ts resources.ts solution
import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open", customer: "C-1" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed", customer: "C-2" },
];

const customers = [
  { id: "C-1", name: "Acme Corp", plan: "enterprise" },
  { id: "C-2", name: "Globex", plan: "starter" },
];

@ResourceTemplate({
  name: "ticket",
  uriTemplate: "tickets://{id}",
  mimeType: "application/json",
  description: "One support ticket, with its title, status and customer",
})
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) throw new ResourceNotFoundError(uri);
    return ticket;
  }
}

@ResourceTemplate({
  name: "customer",
  uriTemplate: "customers://{id}",
  mimeType: "application/json",
  description: "One customer, with their name and plan",
})
export class CustomerResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const customer = customers.find((c) => c.id === id);
    if (!customer) throw new ResourceNotFoundError(uri);
    return customer;
  }
}
```

```ts customers.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("`customers://{id}` is listed as a template", async ({ mcp }) => {
  expect(await mcp.resources.listTemplates()).toContainResourceTemplate("customers://{id}");
});

test("reading `customers://C-2` returns Globex as JSON", async ({ mcp }) => {
  const result = await mcp.resources.read("customers://C-2");
  expect(result).toHaveMimeType("application/json");
  expect(result.json()).toMatchObject({ id: "C-2", name: "Globex" });
});

test("the content is labelled `customers://C-2`", async ({ mcp }) => {
  const result = await mcp.resources.read("customers://C-2");
  expect(result.raw.contents[0].uri).toBe("customers://C-2");
});
```

**Hint:**
Copy the shape of the ticket template: `@ResourceTemplate` with a `uriTemplate` and a `mimeType`, and a class that extends `ResourceContext<{ id: string }>` whose `execute()` returns the customer.

**Solution:**
The template covers every customer, including ones added later, and it's listed in `resources/templates/list`. FrontMCP labels each read with the URI it was for, so the content says which customer it's about. The solution also throws `ResourceNotFoundError` for unknown ids, which the checks don't require but a real server should.

### Challenge: Don't attach a ticket that doesn't exist
Reading `tickets://T-9` "succeeds" with `{"error":"not found"}`, which a client would attach as if it were a ticket. Make the read fail instead, with an error that names the URI.

```ts ticket.resource.ts
import { ResourceContext, ResourceTemplate } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@ResourceTemplate({
  name: "ticket",
  uriTemplate: "tickets://{id}",
  mimeType: "application/json",
  description: "One support ticket, with its title and status",
})
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    return tickets.find((t) => t.id === id) ?? { error: "not found" };
  }
}
```

```ts ticket.resource.ts solution
import { ResourceContext, ResourceNotFoundError, ResourceTemplate } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed" },
];

@ResourceTemplate({
  name: "ticket",
  uriTemplate: "tickets://{id}",
  mimeType: "application/json",
  description: "One support ticket, with its title and status",
})
export class TicketResource extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) throw new ResourceNotFoundError(uri);
    return ticket;
  }
}
```

```ts ticket.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("reading `tickets://T-9` is an error", async ({ mcp }) => {
  expect(await mcp.resources.read("tickets://T-9")).toBeError();
});

test("the error names `tickets://T-9`", async ({ mcp }) => {
  const result = await mcp.resources.read("tickets://T-9");
  expect(result.error?.message ?? "").toContain("tickets://T-9");
});

test("`tickets://T-1` still reads", async ({ mcp }) => {
  const result = await mcp.resources.read("tickets://T-1");
  expect(result).not.toBeError();
  expect(result.json()).toMatchObject({ id: "T-1", title: "Cannot log in" });
});
```

**Hint:**
`ResourceNotFoundError` is exported from `@frontmcp/sdk` and takes the URI. Throw it when there's no ticket.

**Solution:**
Throwing `ResourceNotFoundError(uri)` turns the read into a `-32602` JSON-RPC error with the message `Resource not found: tickets://T-9`. The client gets no `contents`, so there's nothing to attach by mistake.
