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.tsThat 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.