Exposing Data with Resources

Beginner

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:

Open
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.";
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

ToolResource
Who decides to use itThe model, mid-conversationThe application, usually because the user picked it
Identified byA name, like get_ticketA URI, like tickets://T-2
Discovered withtools/listresources/list and resources/templates/list
Used withtools/call, with argumentsresources/read, with a URI
Side effectsAllowedNone. 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:

Open
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.";
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.
  5. The class, which extends ResourceContext and implements execute(uri). It runs every time a client reads the resource.

The @Resource reference 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 diveWhat does the client actually receive?Show detailsHide details

A client discovers resources with resources/list. Open the Wire tab and expand it. For this server it returns:

{
  "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" }:

{
  "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:

Open
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.";
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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);
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

{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.)

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 lists every shape execute() can return.

The 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:

Open
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;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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 1 of 3

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.

Open
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.";
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.