Tools vs. resources — why both

MCP has two surfaces:

SurfaceShapeWhen Claude uses it
Tool"Function call" — Claude invokes with argumentsWhen the user asks for an action ("save this", "find the rubric")
Resource"Readable URI" — Claude lists and readsWhen 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:

  1. 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.
  2. Composable inside the model's reasoning. Claude can mention a snippet://abc URI 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 the list callbacks can't leak either, because the underlying selects are filtered the same way.
  • Template matching is exact. workspace://{id} does not match workspace://<id>/snippets — the {id} variable stops at the / — so the three templates coexist without ambiguity, and the read callback receives id already extracted. The String(id) is because template variables are typed string | string[].
  • MIME types matter. Markdown snippets come back as text/markdown so 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:

  1. Click ResourcesList. You should get back your workspace (twice — once as metadata, once as its snippet listing) plus up-to-50 snippet URIs.
  2. Click a snippet://<id> URI → Read. The body comes back as markdown.
  3. Click a workspace://<id>/snippets URI → 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 list callback — locally, errors print in the supabase functions serve terminal; 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.