Tool inputs, typed once

Create supabase/functions/mcp/tools/snippets.ts. We name each Zod schema so we can derive the handler's argument type with z.infer<> — Deno's type-checker doesn't infer through the SDK's callback types, and the explicit annotation keeps deno check clean:

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";
 
type Ctx = { db: SupabaseClient };
 
const listSnippetsInput = z.object({
  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."),
  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([]),
  author: z.string().min(1).max(80).optional()
    .describe("Optional label for who wrote this — there's no real identity " +
      "in a shared-token server, so this is just a note."),
});
 
const deleteSnippetInput = z.object({ id: z.string().uuid() });

The handlers

Because there's no per-user identity, these are pure CRUD against one table — no created_by, no visibility, no membership checks. Append to the same file:

export function registerSnippetTools(server: McpServer, ctx: Ctx) {
  const { db } = ctx;
 
  // list_snippets ------------------------------------------------------
  server.registerTool(
    "list_snippets",
    {
      description:
        "List the team's prompt snippets. Use when the user asks for their " +
        "snippets, the team's snippets, or to find one by name or tag.",
      inputSchema: listSnippetsInput,
    },
    async (
      { tag, search, limit }: z.infer<typeof listSnippetsInput>,
    ): Promise<CallToolResult> => {
      let q = db
        .from("snippets")
        .select("id, title, tags, author, updated_at")
        .order("updated_at", { ascending: false })
        .limit(limit);
 
      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 db
        .from("snippets")
        .select("id, title, body, tags, author, created_at, updated_at")
        .eq("id", id)
        .maybeSingle();
 
      if (error) throw new Error(error.message);
      if (!data) throw new Error("snippet not found");
      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 given. " +
        "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) {
        const { data, error } = await db
          .from("snippets")
          .update({
            title: input.title,
            body: input.body,
            tags: input.tags,
            author: input.author ?? null,
          })
          .eq("id", input.id)
          .select("id, title, updated_at")
          .maybeSingle();
 
        if (error) throw new Error(error.message);
        if (!data) throw new Error("snippet not found");
        return { content: [{ type: "text", text: `Updated snippet "${data.title}" (${data.id}).` }] };
      }
 
      const { data, error } = await db
        .from("snippets")
        .insert({
          title: input.title,
          body: input.body,
          tags: input.tags,
          author: input.author ?? null,
        })
        .select("id, title, updated_at")
        .single();
 
      if (error) throw new Error(error.message);
      return { content: [{ type: "text", text: `Saved snippet "${data.title}" (${data.id}).` }] };
    }
  );
 
  // delete_snippet -----------------------------------------------------
  server.registerTool(
    "delete_snippet",
    {
      description:
        "Delete a snippet by id. There's no per-user ownership here — anyone " +
        "with the team token can delete any snippet, so confirm with the user first.",
      inputSchema: deleteSnippetInput,
    },
    async ({ id }: z.infer<typeof deleteSnippetInput>): Promise<CallToolResult> => {
      const { data, error } = await db
        .from("snippets")
        .delete()
        .eq("id", id)
        .select("id, title")
        .maybeSingle();
 
      if (error) throw new Error(error.message);
      if (!data) throw new Error("snippet not found");
      return { content: [{ type: "text", text: `Deleted snippet "${data.title}" (${data.id}).` }] };
    }
  );
}

The delete_snippet description earns its keep: it tells Claude to confirm first, since there's no ownership to stop one teammate deleting another's snippet. Tool descriptions are the cheapest safety rail you have — use them.

The resource

snippet://{id} lets Claude browse the library, not just call tools against it. The template's list callback populates the client's resource catalog; the read callback serves one snippet as markdown. 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";
 
type Ctx = { db: SupabaseClient };
 
export function registerSnippetResources(server: McpServer, ctx: Ctx) {
  const { db } = ctx;
 
  server.registerResource(
    "snippet",
    new ResourceTemplate("snippet://{id}", {
      // Cap the catalog at the 50 most-recent so a big library doesn't blow up.
      list: async () => {
        const { data, error } = await db
          .from("snippets")
          .select("id, title")
          .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}".`,
            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 db
        .from("snippets")
        .select("title, body, tags, author, updated_at")
        .eq("id", snippetId)
        .maybeSingle();
 
      if (error) throw new Error(error.message);
      if (!data) throw new Error(`snippet ${snippetId} not found`);
 
      const md = [
        `# ${data.title}`,
        ``,
        `_tags: ${data.tags.join(", ") || "(none)"}` +
          `${data.author ? ` · by ${data.author}` : ""}` +
          ` · updated: ${data.updated_at}_`,
        ``,
        data.body,
      ].join("\n");
 
      return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: md }] };
    }
  );
}

Type-check it

cd supabase/functions && deno check mcp/index.ts

That resolves every import against the pinned deps and type-checks the whole tree. Fix anything it flags before deploying — a clean deno check locally is the difference between a five-second deploy and a ten-minute round-trip debugging a cloud build. Step 7 deploys and connects Claude.