What "MCP server" means in HTTP land
The MCP spec defines two transports the current Claude understands:
- STDIO — Claude launches your server as a subprocess and talks JSON-RPC over stdin/stdout. No HTTP, no auth.
- Streamable HTTP — Claude makes regular HTTPS requests to your server, with optional server-sent events streamed back. This is what we're building.
The SDK handles the JSON-RPC plumbing; we provide the HTTP glue, the token gate, and the tools.
1. The shared database client
Create supabase/functions/mcp/db.ts:
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
const PROJECT_URL = Deno.env.get("PROJECT_URL")!;
const SERVICE_ROLE_KEY = Deno.env.get("SERVICE_ROLE_KEY")!;
// One shared, service-role client. Unlike the per-user OAuth version — which
// builds a fresh client per request so each carries a different user's token —
// there's a single identity here (the team), so we build the client once at
// module load and reuse it for every request.
export const db: SupabaseClient = createClient(PROJECT_URL, SERVICE_ROLE_KEY, {
auth: {
persistSession: false,
autoRefreshToken: false,
},
});This is the payoff of the RLS-locked table from step 3: the service-role key bypasses RLS, so this client can read and write snippets freely — and because that key never leaves the function, only callers who cleared the token gate ever benefit from it.
One shared client is safe here precisely because there's no per-user token to keep isolated. In the OAuth build, sharing a client across requests would leak one user's auth into another's request — a real bug. Here it's just efficient.
2. Replace mcp/index.ts with the skeleton
Open supabase/functions/mcp/index.ts and replace it with:
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 { requireTeamToken } from "./auth.ts";
import { db } from "./db.ts";
import { registerSnippetTools } from "./tools/snippets.ts";
import { registerSnippetResources } from "./resources/snippets.ts";
// Paths reach the function prefixed with its name (step 2), hence basePath.
const app = new Hono().basePath("/mcp");
app.use("*", logger());
app.get("/health", (c) => c.json({ ok: true }));
// The MCP RPC, behind the token gate. Note what's absent: no
// `/.well-known/oauth-protected-resource` endpoint — a static-token server has
// no auth server to advertise.
app.all("/", requireTeamToken, async (c) => {
const server = new McpServer({ name: "team-token-mcp", version: "0.1.0" });
registerSnippetTools(server, { db });
registerSnippetResources(server, { db });
// 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);The tool and resource files don't exist yet — step 6 writes them — so deno check will complain about the two missing imports until then. That's expected; comment those two import lines and the two register... calls out if you want a clean local run before step 6, or just push straight through.
A few details worth pausing on:
WebStandardStreamableHTTPServerTransport, not the NodeStreamableHTTPServerTransport. The web-standard one takes a FetchRequest(c.req.raw), which is what Deno/Hono hand you. The Node transport wantsIncomingMessage/ServerResponseand won't work here.- Stateless (
sessionIdGenerator: undefined,enableJsonResponse: true). Each request rebuilds theMcpServerand returns a plain JSON response. There's no in-memory session to lose when a function instance spins down — ideal for scale-to-zero. - Rebuilding
McpServerper request is cheap and keeps the handler self-contained. The shareddbclient is the only thing that persists between requests.
3. Quick sanity check (before the tools exist)
If you commented out the tool imports, you can confirm the gate works right now:
supabase functions serve# No token → plain 401, no WWW-Authenticate header
curl -i http://127.0.0.1:54321/functions/v1/mcp
# Wrong token → plain 401
curl -i -H "Authorization: Bearer wrong" \
http://127.0.0.1:54321/functions/v1/mcp
# Right token → reaches the MCP handler (a raw MCP request needs a JSON-RPC
# body; an empty POST will get an MCP-level error, which is fine — it proves
# the request got *past* the gate)
curl -i -X POST -H "Authorization: Bearer $TEAM_TOKEN" \
-H "Content-Type: application/json" \
http://127.0.0.1:54321/functions/v1/mcpConfirm the first two return 401 with no WWW-Authenticate header (curl -i | grep -i www-authenticate should print nothing). Step 6 fills in the actual snippet tools.