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/listandresources/readreturn, 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:
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:
- 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.
- 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.
- It takes up room among the actions. Every tool in
tools/listis 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:
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:
uri, the address clients read. The scheme (docs://) is yours to choose. Pick one that says what kind of thing lives there.name, an identifier, and optionally atitlefor clients to show people.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.mimeType, what kind of text it is. More on that below.- The class, which extends
ResourceContextand implementsexecute(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/markdownfor prose a person or model reads: policies, articles, canned replies.application/jsonfor 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:
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:
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:
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.
@Resourcedeclares one fixed URI. Its class extendsResourceContextand returns the content fromexecute(uri).- Set
mimeType:text/markdownfor prose,application/jsonfor records. Without it, objects are sent as JSON and strings astext/plain. @ResourceTemplatecovers a family of URIs liketickets://{id}and passes the parameters toexecute(uri, params). FrontMCP labels what it returns with the URI that was read.- Clients discover resources with
resources/listand templates withresources/templates/list, and read both withresources/read. - Throw
ResourceNotFoundErrorwhen a URI doesn't point at anything. The client gets a-32602JSON-RPC error instead of content. A plainErrorreads 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.
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.