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

  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

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

  • capabilities: { tools: {}, resources: {} } is set when you construct the Server. (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:

  1. 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 the list_snippets tool.
  2. 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.