What "MCP server" means in HTTP land
The MCP spec defines two transports the spec-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 Streamable HTTP transport is "just" HTTPS + JSON-RPC + bearer tokens. The MCP SDK handles the JSON-RPC plumbing; we provide the HTTP glue and the tools.
1. 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";
// Not used yet — imported so any import-map/SDK resolution problem
// surfaces now, not in step 8 where we wire these up for real.
import "@modelcontextprotocol/sdk/server/mcp.js";
import "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
const PROJECT_URL = Deno.env.get("PROJECT_URL")!;
const ISSUER = `${PROJECT_URL}/auth/v1`;
const SELF_URL = Deno.env.get("MCP_SELF_URL")!; // e.g.
// https://<ref>.supabase.co/functions/v1/mcp
// Paths reach the function prefixed with its name (step 2), hence basePath.
const app = new Hono().basePath("/mcp");
app.use("*", logger());
// -----------------------------------------------------------------------
// Protected Resource Metadata (RFC 9728)
// MCP clients fetch this to discover the authorization server.
// -----------------------------------------------------------------------
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"],
})
);
// -----------------------------------------------------------------------
// MCP endpoint — the real RPC
// (Auth middleware lands in step 6, the SDK handlers in step 8.)
// -----------------------------------------------------------------------
app.all("/", (c) => {
// Step 6 will gate this on a valid bearer token. For now, return a clean
// 401 with a WWW-Authenticate header so we can confirm the discovery
// chain works end-to-end from a real MCP client.
return c.json(
{ error: "unauthorized" },
401,
{
"WWW-Authenticate":
`Bearer realm="${SELF_URL}", ` +
`resource_metadata="${SELF_URL}/.well-known/oauth-protected-resource"`,
}
);
});
app.get("/health", (c) => c.json({ ok: true }));
Deno.serve(app.fetch);A few details worth pausing on:
MCP_SELF_URL — the canonical URL of this MCP server. It's published as the resource field of the metadata document and named in the WWW-Authenticate realm, so clients know exactly which server they're being asked to authorize against. It must match the URL clients connect to — same host, same path, no trailing slash.
WWW-Authenticate header — the format is dictated by RFC 9728 §5.1. The crucial part is resource_metadata="..." pointing at the discovery URL. Without this, a compliant MCP client doesn't know where your auth server lives.
Trailing slash sensitivity — PROJECT_URL should NOT have a trailing slash. Issuer is <url>/auth/v1, also without trailing slash. And the MCP endpoint itself is .../functions/v1/mcp — Hono's routing treats /mcp and /mcp/ as different paths, so we standardize on the no-slash form everywhere: env vars, curl tests, and the URL you eventually hand to claude mcp add. Authentic-looking URL bugs are the #1 cause of "OAuth fails silently."
2. Add the env var
Local — supabase/functions/.env (extending step 2's file):
PROJECT_URL=https://<ref>.supabase.co
PROJECT_ANON_KEY=<your-anon-public-key>
MCP_SELF_URL=https://<ref>.supabase.co/functions/v1/mcpProduction values get pushed as secrets at deploy time. All three are ours to manage — remember from step 2 that we deliberately avoided the reserved SUPABASE_* names, so nothing here collides with what the platform injects:
supabase secrets set \
PROJECT_URL=https://<ref>.supabase.co \
PROJECT_ANON_KEY=<your-anon-public-key> \
MCP_SELF_URL=https://<ref>.supabase.co/functions/v1/mcp3. Test the metadata locally
supabase functions serveIn another shell:
curl http://127.0.0.1:54321/functions/v1/mcp/.well-known/oauth-protected-resource | jqYou should see:
{
"resource": "https://<ref>.supabase.co/functions/v1/mcp",
"authorization_servers": ["https://<ref>.supabase.co/auth/v1"],
"scopes_supported": ["openid","email","profile"],
"bearer_methods_supported": ["header"]
}Note the values are the production URLs from your .env — we're serving the function locally, but it describes (and will validate tokens from) your cloud project. That's intentional: there's one auth server and one database, the cloud ones, whether the function runs on your laptop or on Supabase.
4. Test the 401 + WWW-Authenticate header
curl -i http://127.0.0.1:54321/functions/v1/mcpHTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="https://<ref>.supabase.co/functions/v1/mcp",
resource_metadata="https://<ref>.supabase.co/functions/v1/mcp/.well-known/oauth-protected-resource"
content-type: application/json
{"error":"unauthorized"}That's the exact signal MCP clients react to: 401 + the metadata URL.
5. Verify the discovery chain from a client's perspective
Walk through what Claude would do:
# 1) Claude makes its initial MCP request, gets 401 + WWW-Authenticate
curl -sI http://127.0.0.1:54321/functions/v1/mcp | grep WWW-Authenticate
# 2) Claude fetches the resource metadata
curl -s http://127.0.0.1:54321/functions/v1/mcp/.well-known/oauth-protected-resource | jq
# 3) Claude reads the first authorization_servers entry and fetches AS metadata
AS=$(curl -s http://127.0.0.1:54321/functions/v1/mcp/.well-known/oauth-protected-resource \
| jq -r '.authorization_servers[0]')
# AS is https://<ref>.supabase.co/auth/v1 — fetch its metadata at the
# well-known path (note the path-insertion form per RFC 8414).
curl -s "https://<ref>.supabase.co/.well-known/oauth-authorization-server/auth/v1" | jqIf all three calls return clean JSON, the discovery chain is complete. Claude would now know exactly where to send the user to sign in — which, thanks to step 4, is your consent page.
6. Deploy what we have
supabase functions deploy mcpPublic discovery check:
curl https://<ref>.supabase.co/functions/v1/mcp/.well-known/oauth-protected-resource | jqYou should see the same JSON.
7. What we're deferring
The app.all("/", ...) route returns 401 unconditionally — fine for a connection test, useless for actual MCP traffic. The next step replaces that with real token validation, and the step after that wires the MCP SDK's tool handlers in.
The Protected Resource Metadata is the only piece of the auth dance the server has to publish before any client can talk to it. With that in place, step 6 implements the verification side: parsing the bearer token and checking its signature, issuer, and expiry against Supabase's JWKS.