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 snippet at
snippet://<id>— full body of a single snippet - A workspace's snippets at
workspace://<id>/snippets— listing as JSON
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
Create supabase/functions/mcp/resources/snippets.ts:
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { AuthedUser } from "../auth.ts";
type Ctx = { user: AuthedUser; supabase: SupabaseClient };
export function registerSnippetResources(server: Server, ctx: Ctx) {
const { supabase } = ctx;
// ---------------------------------------------------------------------
// resources/list — what URIs does this server expose?
// The SDK calls our handler whenever a client asks for the catalog.
// ---------------------------------------------------------------------
server.setRequestHandler("resources/list", async () => {
// Workspaces the caller belongs to
const { data: ws, error: wsErr } = await supabase
.from("workspaces")
.select("id, name");
if (wsErr) throw new Error(wsErr.message);
const workspaceResources = (ws ?? []).flatMap((w) => [
{
uri: `workspace://${w.id}`,
name: `Workspace: ${w.name}`,
description: `Metadata for workspace ${w.name}.`,
mimeType: "application/json",
},
{
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",
},
]);
// Snippets — surface individual snippet URIs so Claude can link to them.
// We cap this to avoid blowing up the listing for users in big workspaces.
const { data: snips, error: snErr } = await supabase
.from("snippets")
.select("id, title, workspace_id")
.order("updated_at", { ascending: false })
.limit(50);
if (snErr) throw new Error(snErr.message);
const snippetResources = (snips ?? []).map((s) => ({
uri: `snippet://${s.id}`,
name: s.title,
description: `Snippet "${s.title}" (workspace ${s.workspace_id}).`,
mimeType: "text/markdown",
}));
return { resources: [...workspaceResources, ...snippetResources] };
});
// ---------------------------------------------------------------------
// resources/read — actually fetch the content for a URI.
// ---------------------------------------------------------------------
server.setRequestHandler("resources/read", async (req) => {
const uri = req.params.uri as string;
// snippet://<id>
if (uri.startsWith("snippet://")) {
const id = uri.slice("snippet://".length);
const { data, error } = await supabase
.from("snippets")
.select("title, body, tags, visibility, updated_at")
.eq("id", id)
.maybeSingle();
if (error) throw new Error(error.message);
if (!data) throw new Error(`snippet ${id} 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, mimeType: "text/markdown", text: md }],
};
}
// workspace://<id>/snippets — listing
const listMatch = uri.match(/^workspace:\/\/([0-9a-f-]{36})\/snippets$/);
if (listMatch) {
const workspaceId = listMatch[1];
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,
mimeType: "application/json",
text: JSON.stringify(data ?? [], null, 2),
}],
};
}
// workspace://<id> — metadata
const wsMatch = uri.match(/^workspace:\/\/([0-9a-f-]{36})$/);
if (wsMatch) {
const workspaceId = wsMatch[1];
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,
mimeType: "application/json",
text: JSON.stringify({ ...ws, snippet_count: count ?? 0 }, null, 2),
}],
};
}
throw new Error(`Unknown resource URI: ${uri}`);
});
}A few specifics:
- RLS still does the heavy lifting. The resource handlers 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. - 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 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:
capabilities: { tools: {}, resources: {} }is set when you construct theServer. (We did this in step 8.)registerSnippetResources(server, { user, supabase })is being called inside the route handler.- You're not throwing inside
resources/list— check the function logs (supabase functions logs mcp --tail).
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
The MCP spec has a feature called resource templates — parameterized URI patterns like snippet://{id} that clients can use to construct URIs on the fly. We're not using them here for two reasons:
- Templates are best when there are too many resources to enumerate in
resources/list. For a per-user MCP, listing 50 most-recent snippets is a fine cap; users who need older ones can search via thelist_snippetstool. - The SDK's template support is still maturing as of the May 2026 spec revision. Stick with concrete URIs for now; templates are a worthwhile upgrade once the SDK shape stabilizes.
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.