How an MCP tool is shaped
A tool is three things:
- Name — a short, snake_case identifier (
list_snippets). - Description — natural language. This is the only signal Claude has for deciding when to call it.
- 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-levelServer. The SDK exports both;McpServer(fromserver/mcp.js) is the high-level API withregisterTool/registerResource, and it's what all of our modules build against. Each request gets its own instance, scoped to one user.- The
WebStandardtransport is the one Deno needs. ItshandleRequesttakes a Fetch-standardRequest(Hono'sc.req.raw) and returns aResponsewe hand straight back. The SDK also ships a Node-flavoredStreamableHTTPServerTransportwired to Node'sreq/resobjects — that one does not work here; if you see type errors aboutIncomingMessage, you imported the wrong transport. sessionIdGenerator: undefinedputs 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: truemakes 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 keepsindex.tsreadable.
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_snippetinto 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_snippetdoes upsert based on whetheridis provided. Simpler tool surface than separatecreate_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 faildeno checkwith implicit-anyerrors. 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.tsshould 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/inspectorIn the inspector:
- Choose Streamable HTTP transport
- URL:
http://127.0.0.1:54321/functions/v1/mcp - Authentication: Bearer Token, paste your
$TOKEN - 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.