How an MCP tool is shaped

A tool is three things:

  1. Name — a short, snake_case identifier (list_snippets).
  2. Description — natural language. This is the only signal Claude has for deciding when to call it.
  3. Input schema — JSON Schema. The MCP SDK accepts Zod schemas and converts them.

Plus a handler that takes the validated input and returns a JSON-serializable result.

1. Replace the placeholder with a real MCP server

Update supabase/functions/mcp/index.ts so the actual SDK handles the request:

import { Hono } from "hono";
import { logger } from "hono/logger";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from
  "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { requireAuth, type AuthEnv } from "./auth.ts";
import { supabaseFor } from "./supabase.ts";
import { registerSnippetTools } from "./tools/snippets.ts";
 
const PROJECT_URL = Deno.env.get("PROJECT_URL")!;
const ISSUER = `${PROJECT_URL}/auth/v1`;
const SELF_URL = Deno.env.get("MCP_SELF_URL")!;
 
const app = new Hono<AuthEnv>().basePath("/mcp");
app.use("*", logger());
 
// Protected Resource Metadata (unchanged from step 5)
app.get("/.well-known/oauth-protected-resource", (c) =>
  c.json({
    resource: SELF_URL,
    authorization_servers: [ISSUER],
    scopes_supported: ["openid", "email", "profile"],
    bearer_methods_supported: ["header"],
  })
);
 
app.get("/health", (c) => c.json({ ok: true }));
 
// Mount the MCP RPC behind the auth middleware
app.all("/", requireAuth, async (c) => {
  const user = c.get("user");
  const supabase = supabaseFor(user);
 
  // Build an MCP server bound to this user's request context. We rebuild
  // per request because each request carries different auth, and McpServer
  // is cheap to construct.
  const server = new McpServer({ name: "shared-skills-mcp", version: "0.1.0" });
 
  registerSnippetTools(server, { user, supabase });
 
  // Stateless transport: no session ids, every request self-contained —
  // exactly what a scale-to-zero Edge Function wants.
  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });
  await server.connect(transport);
 
  return transport.handleRequest(c.req.raw);
});
 
Deno.serve(app.fetch);

A few things to highlight:

  • McpServer, not the low-level Server. The SDK exports both; McpServer (from server/mcp.js) is the high-level API with registerTool / registerResource, and it's what all of our modules build against. Each request gets its own instance, scoped to one user.
  • The WebStandard transport is the one Deno needs. Its handleRequest takes a Fetch-standard Request (Hono's c.req.raw) and returns a Response we hand straight back. The SDK also ships a Node-flavored StreamableHTTPServerTransport wired to Node's req/res objects — that one does not work here; if you see type errors about IncomingMessage, you imported the wrong transport.
  • sessionIdGenerator: undefined puts the transport in stateless mode. No session ids are minted or demanded, so each HTTP request stands alone — which is the only mode that makes sense when every request may land on a fresh function instance. enableJsonResponse: true makes responses plain JSON instead of SSE streams, which is spec-fine for request/response tools and much nicer to curl.
  • registerSnippetTools(server, ctx) is where we keep the tool definitions. Splitting them into their own module keeps index.ts readable.

2. The tool module

Create supabase/functions/mcp/tools/snippets.ts:

import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import type { AuthedUser } from "../auth.ts";
 
type Ctx = { user: AuthedUser; supabase: SupabaseClient };
 
// Input schemas, named so we can derive the handlers' arg types with
// z.infer<>. (Deno type-checks with TS 6, which doesn't infer through the
// SDK's callback types — explicit annotations keep `deno check` clean.)
const listSnippetsInput = z.object({
  workspace_id: z.string().uuid().optional()
    .describe("Filter to a specific workspace. Omit to list across all workspaces the user belongs to."),
  tag: z.string().optional()
    .describe("Filter to snippets carrying this tag."),
  search: z.string().optional()
    .describe("Substring match against title or body."),
  limit: z.number().int().min(1).max(50).default(20),
});
 
const getSnippetInput = z.object({
  id: z.string().uuid(),
});
 
const saveSnippetInput = z.object({
  id: z.string().uuid().optional()
    .describe("Provide to update an existing snippet. Omit to create."),
  workspace_id: z.string().uuid()
    .describe("Target workspace. Required for new snippets; ignored on update."),
  title: z.string().min(1).max(120),
  body:  z.string().min(1).max(60_000),
  tags:  z.array(z.string().min(1).max(40)).max(20).default([]),
  visibility: z.enum(["private", "workspace", "public"]).default("workspace"),
});
 
export function registerSnippetTools(server: McpServer, ctx: Ctx) {
  const { supabase } = ctx;
 
  // ---------------------------------------------------------------------
  // list_snippets
  // ---------------------------------------------------------------------
  server.registerTool(
    "list_snippets",
    {
      description:
        "List prompt snippets the caller can see. " +
        "Use when the user asks for their snippets, the team's snippets, " +
        "or to find one by name or tag.",
      inputSchema: listSnippetsInput,
    },
    async (
      { workspace_id, tag, search, limit }: z.infer<typeof listSnippetsInput>,
    ): Promise<CallToolResult> => {
      let q = supabase
        .from("snippets")
        .select("id, workspace_id, title, tags, visibility, updated_at")
        .order("updated_at", { ascending: false })
        .limit(limit);
 
      if (workspace_id) q = q.eq("workspace_id", workspace_id);
      if (tag)          q = q.contains("tags", [tag]);
      if (search)       q = q.or(`title.ilike.%${search}%,body.ilike.%${search}%`);
 
      const { data, error } = await q;
      if (error) throw new Error(error.message);
 
      return {
        content: [{
          type: "text",
          text: JSON.stringify(data, null, 2),
        }],
      };
    }
  );
 
  // ---------------------------------------------------------------------
  // get_snippet
  // ---------------------------------------------------------------------
  server.registerTool(
    "get_snippet",
    {
      description:
        "Fetch the full body of one snippet by id. " +
        "Use after list_snippets when the user picks one to read or edit.",
      inputSchema: getSnippetInput,
    },
    async (
      { id }: z.infer<typeof getSnippetInput>,
    ): Promise<CallToolResult> => {
      const { data, error } = await supabase
        .from("snippets")
        .select("id, workspace_id, created_by, title, body, tags, visibility, created_at, updated_at")
        .eq("id", id)
        .maybeSingle();
 
      if (error) throw new Error(error.message);
      if (!data)  throw new Error("snippet not found or not visible to you");
 
      return {
        content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
      };
    }
  );
 
  // ---------------------------------------------------------------------
  // save_snippet
  // ---------------------------------------------------------------------
  server.registerTool(
    "save_snippet",
    {
      description:
        "Create a new snippet, or update an existing one when `id` is provided. " +
        "Use when the user says 'save this', 'add a snippet', 'update the X snippet'.",
      inputSchema: saveSnippetInput,
    },
    async (
      input: z.infer<typeof saveSnippetInput>,
    ): Promise<CallToolResult> => {
      if (input.id) {
        // UPDATE — RLS lets the creator or a workspace owner write
        const { data, error } = await supabase
          .from("snippets")
          .update({
            title: input.title,
            body: input.body,
            tags: input.tags,
            visibility: input.visibility,
          })
          .eq("id", input.id)
          .select("id, workspace_id, title, visibility, updated_at")
          .maybeSingle();
 
        if (error)  throw new Error(error.message);
        if (!data)  throw new Error("snippet not found or you can't edit it");
 
        return {
          content: [{
            type: "text",
            text: `Updated snippet "${data.title}" (${data.id})`,
          }],
        };
      }
 
      // CREATE
      const { data, error } = await supabase
        .from("snippets")
        .insert({
          workspace_id: input.workspace_id,
          title: input.title,
          body: input.body,
          tags: input.tags,
          visibility: input.visibility,
          created_by: ctx.user.sub,    // RLS will reject if this doesn't match auth.uid()
        })
        .select("id, workspace_id, title, visibility, updated_at")
        .single();
 
      if (error) throw new Error(error.message);
 
      return {
        content: [{
          type: "text",
          text: `Saved snippet "${data.title}" (${data.id}) in workspace ${data.workspace_id}.`,
        }],
      };
    }
  );
}

Worth noticing:

  • All three tools rely on RLS, not app-level checks. A user trying to save_snippet into a workspace they aren't a member of will get a clean "row-level security violation" from Postgres, which we surface as the error message.
  • Tool descriptions name the trigger phrases. The model uses the description as its routing signal. "Use when the user asks for their snippets..." beats a dry "lists snippets" — that's the difference between Claude calling the tool when needed and ignoring it.
  • save_snippet does upsert based on whether id is provided. Simpler tool surface than separate create_snippet + update_snippet.
  • The explicit z.infer<...> annotations are deliberate. Deno's current TypeScript (6.x) doesn't infer callback argument types through the SDK's conditional types, so unannotated handlers fail deno check with implicit-any errors. Naming each schema and annotating the handler costs one line per tool and keeps the whole project type-clean: deno check --import-map=supabase/functions/import_map.json supabase/functions/mcp/index.ts should pass with zero errors from here on.

3. Test from the command line

Get a token (as in step 6) and make JSON-RPC calls directly. Two things the raw protocol requires: the Accept header must offer both application/json and text/event-stream (the transport rejects anything less with a 406), and a well-behaved client sends initialize first — with our stateless transport each request stands alone, so this is protocol politeness plus a great connectivity test:

TOKEN=$(curl -s -X POST \
  "https://<ref>.supabase.co/auth/v1/token?grant_type=password" \
  -H "apikey: <your-anon-key>" \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"your-password"}' \
  | jq -r .access_token)
 
# Initialize — the first call every MCP client makes
curl -s \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"0.0.0"}}}' \
  http://127.0.0.1:54321/functions/v1/mcp | jq
{
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "tools": { "listChanged": true }
    },
    "serverInfo": { "name": "shared-skills-mcp", "version": "0.1.0" }
  },
  "jsonrpc": "2.0",
  "id": 1
}
# List tools
curl -s \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  http://127.0.0.1:54321/functions/v1/mcp | jq '.result.tools[].name'
"list_snippets"
"get_snippet"
"save_snippet"
# Call list_snippets
curl -s \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_snippets","arguments":{}}}' \
  http://127.0.0.1:54321/functions/v1/mcp | jq
{
  "result": {
    "content": [
      { "type": "text", "text": "[]" }
    ]
  },
  "jsonrpc": "2.0",
  "id": 3
}

An empty list is the right answer for a fresh database. If instead you get {"jsonrpc":"2.0","error":{"code":-32000,"message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null} — you dropped the Accept header.

4. Test from the MCP Inspector

Anthropic ships a GUI inspector:

npx @modelcontextprotocol/inspector

In the inspector:

  1. Choose Streamable HTTP transport
  2. URL: http://127.0.0.1:54321/functions/v1/mcp
  3. Authentication: Bearer Token, paste your $TOKEN
  4. Click Connect

You should see all three tools listed with their descriptions and input forms. Try a save_snippet — fill in the workspace_id (from your workspaces table), a title, and a body. Hit Send. Then list_snippets should show it.

5. The two failure modes to confirm

Insufficient permissions — try save_snippet with a workspace_id you don't belong to. RLS rejects, and it surfaces as a tool error result:

{
  "content": [{ "type": "text", "text": "new row violates row-level security policy for table \"snippets\"" }],
  "isError": true
}

Tool unknown — call tools/call with name: "drop_all_snippets". The SDK returns:

{
  "content": [{ "type": "text", "text": "MCP error -32602: Tool drop_all_snippets not found" }],
  "isError": true
}

Both are reassuring. The first proves RLS is gating writes; the second proves Claude can't invent tools you didn't expose.


Three tools down. Step 9 adds the sharing tools — share_snippet, list_workspaces, create_workspace, invite_to_workspace — and exercises the workspace-owner role checks.