Tools vs. resources — why both
MCP has two surfaces:
| Surface | Shape | When Claude uses it |
|---|---|---|
| Tool | "Function call" — Claude invokes with arguments | When the user asks for an action ("save this", "find the rubric") |
| Resource | "Readable URI" — Claude lists and reads | When Claude wants ambient context without committing to a tool call |
A resource is identified by a URI, has a MIME type, and returns content when read. Conceptually, it's the difference between a function and a file. For our server, the right resources are:
- One workspace at
workspace://<id>— metadata about a team - A workspace's snippets at
workspace://<id>/snippets— listing as JSON - A snippet at
snippet://<id>— full body of a single snippet
Why bother when we already have list_snippets + get_snippet tools? Two reasons:
- Cheaper than a tool call when Claude is exploring. A resource read is "give me this URI"; a tool call requires Claude to construct arguments and reason about whether to call it.
- Composable inside the model's reasoning. Claude can mention a
snippet://abcURI in its thinking and the user (or another tool) can resolve it. URIs are good context.
1. Register resources in index.ts
Open supabase/functions/mcp/index.ts and add a resources module call alongside the tools:
import { registerSnippetTools } from "./tools/snippets.ts";
import { registerWorkspaceTools } from "./tools/workspaces.ts";
import { registerSnippetResources } from "./resources/snippets.ts";
// ... inside the route handler, after registering tools:
registerSnippetResources(server, { user, supabase });2. The resources module
The SDK's McpServer wants resources registered up front — either at a fixed URI (registerResource(name, "config://app", ...)) or, for anything parameterized like ours, via a ResourceTemplate: a URI pattern such as snippet://{id} plus two callbacks. The list callback fills the client's resources/list catalog with concrete URIs; the read callback answers resources/read for any URI matching the pattern, with the template variables already extracted. That's the whole listing/reading protocol — no hand-rolled URI parsing.
Create supabase/functions/mcp/resources/snippets.ts:
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Variables } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { AuthedUser } from "../auth.ts";
type Ctx = { user: AuthedUser; supabase: SupabaseClient };
export function registerSnippetResources(server: McpServer, ctx: Ctx) {
const { supabase } = ctx;
// ---------------------------------------------------------------------
// workspace://<id> — metadata about one workspace.
// The template's `list` callback is what fills the client's
// resources/list catalog; the read callback serves resources/read.
// ---------------------------------------------------------------------
server.registerResource(
"workspace",
new ResourceTemplate("workspace://{id}", {
list: async () => {
const { data, error } = await supabase
.from("workspaces")
.select("id, name");
if (error) throw new Error(error.message);
return {
resources: (data ?? []).map((w) => ({
uri: `workspace://${w.id}`,
name: `Workspace: ${w.name}`,
description: `Metadata for workspace ${w.name}.`,
mimeType: "application/json",
})),
};
},
}),
{
description: "Metadata for one workspace the caller belongs to.",
mimeType: "application/json",
},
async (uri: URL, { id }: Variables) => {
const workspaceId = String(id);
const { data: ws, error: wsErr } = await supabase
.from("workspaces")
.select("id, name, created_at")
.eq("id", workspaceId)
.maybeSingle();
if (wsErr) throw new Error(wsErr.message);
if (!ws) throw new Error(`workspace ${workspaceId} not found or not visible to you`);
const { count } = await supabase
.from("snippets")
.select("id", { count: "exact", head: true })
.eq("workspace_id", workspaceId);
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({ ...ws, snippet_count: count ?? 0 }, null, 2),
}],
};
}
);
// ---------------------------------------------------------------------
// workspace://<id>/snippets — a workspace's snippet listing
// ---------------------------------------------------------------------
server.registerResource(
"workspace-snippets",
new ResourceTemplate("workspace://{id}/snippets", {
list: async () => {
const { data, error } = await supabase
.from("workspaces")
.select("id, name");
if (error) throw new Error(error.message);
return {
resources: (data ?? []).map((w) => ({
uri: `workspace://${w.id}/snippets`,
name: `Snippets in ${w.name}`,
description: `Browseable list of snippets the caller can see in ${w.name}.`,
mimeType: "application/json",
})),
};
},
}),
{
description: "Snippets the caller can see in one workspace.",
mimeType: "application/json",
},
async (uri: URL, { id }: Variables) => {
const workspaceId = String(id);
const { data, error } = await supabase
.from("snippets")
.select("id, title, tags, visibility, updated_at")
.eq("workspace_id", workspaceId)
.order("updated_at", { ascending: false });
if (error) throw new Error(error.message);
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(data ?? [], null, 2),
}],
};
}
);
// ---------------------------------------------------------------------
// snippet://<id> — one snippet's full body, as markdown
// ---------------------------------------------------------------------
server.registerResource(
"snippet",
new ResourceTemplate("snippet://{id}", {
// We cap the listing to the 50 most recently updated snippets so a
// big workspace doesn't blow up the catalog.
list: async () => {
const { data, error } = await supabase
.from("snippets")
.select("id, title, workspace_id")
.order("updated_at", { ascending: false })
.limit(50);
if (error) throw new Error(error.message);
return {
resources: (data ?? []).map((s) => ({
uri: `snippet://${s.id}`,
name: s.title,
description: `Snippet "${s.title}" (workspace ${s.workspace_id}).`,
mimeType: "text/markdown",
})),
};
},
}),
{
description: "One snippet's full body.",
mimeType: "text/markdown",
},
async (uri: URL, { id }: Variables) => {
const snippetId = String(id);
const { data, error } = await supabase
.from("snippets")
.select("title, body, tags, visibility, updated_at")
.eq("id", snippetId)
.maybeSingle();
if (error) throw new Error(error.message);
if (!data) throw new Error(`snippet ${snippetId} not found or not visible to you`);
// Return as markdown so Claude renders it cleanly.
const md = [
`# ${data.title}`,
``,
`_tags: ${data.tags.join(", ") || "(none)"} · visibility: ${data.visibility} · updated: ${data.updated_at}_`,
``,
data.body,
].join("\n");
return {
contents: [{ uri: uri.href, mimeType: "text/markdown", text: md }],
};
}
);
}A few specifics:
- RLS still does the heavy lifting. Both callbacks select rows directly; the RLS policies decide what comes back. A user reading
snippet://<id>for a snippet they can't see gets the "not found" error, identical to a tool call — and thelistcallbacks can't leak either, because the underlying selects are filtered the same way. - Template matching is exact.
workspace://{id}does not matchworkspace://<id>/snippets— the{id}variable stops at the/— so the three templates coexist without ambiguity, and the read callback receivesidalready extracted. TheString(id)is because template variables are typedstring | string[]. - MIME types matter. Markdown snippets come back as
text/markdownso Claude renders them in chat without needing to be told they're prose. Workspace metadata is JSON. - URI structure is yours to design, but stay consistent. A hierarchy (
workspace://<id>/snippets) is easier for Claude to extrapolate from than flat naming (workspace-snippets://<id>).
3. Test from the Inspector
Restart the dev server and reconnect with the MCP Inspector. You should now see a Resources tab alongside Tools:
- Click Resources → List. You should get back your workspace (twice — once as metadata, once as its snippet listing) plus up-to-50 snippet URIs.
- Click a
snippet://<id>URI → Read. The body comes back as markdown. - Click a
workspace://<id>/snippetsURI → Read. JSON listing of snippets in that workspace.
If nothing shows up under Resources, check:
registerSnippetResources(server, { user, supabase })is being called inside the route handler.- You're not throwing inside a
listcallback — locally, errors print in thesupabase functions serveterminal; for the deployed function, check the dashboard under Edge Functions → mcp → Logs.
4. What we deliberately didn't expose as resources
workspace://<id>/members— would be nice ergonomically, but membership lists are a privacy surface we don't want surfaced ambiently. Behind a tool (list_members), Claude has to explicitly decide to call it.- Public snippets across workspaces. Resources are scoped to the caller's view; we don't enumerate the entire public corpus. If you want a public browser, build it as a separate read-only endpoint, not as an MCP resource that mixes with the user's private context.
The general rule: resources are for things Claude should be able to browse without thinking. If the data is sensitive enough that exposure should require an explicit decision, keep it behind a tool.
5. About resource templates
Worth naming what we just used: resource templates are the MCP spec's parameterized URI patterns (snippet://{id}), a stable part of the spec — current revision 2025-11-25, with the next one (2026-07-28) in release-candidate stage as this is written. Clients that support templates can even construct URIs on the fly without a listing.
We lean on templates for matching and variable extraction, but still provide list callbacks with concrete URIs, for one practical reason: the catalog is how Claude discovers what exists. "Here are your 50 most recent snippets" is browseable; a bare pattern is not. The 50-cap keeps the listing sane for busy teams — users who need older snippets can search via the list_snippets tool.
The MCP surface — tools and resources — is feature-complete. Step 11 takes everything we've built, deploys it to production, connects Claude to it, and walks the OAuth flow end-to-end.