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 a real MCP server
Open supabase/functions/mcp/index.ts and replace it with:
import { Hono } from "hono";
import { logger } from "hono/logger";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport } from
"@modelcontextprotocol/sdk/server/streamableHttp.js";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const ISSUER = `${SUPABASE_URL}/auth/v1`;
const SELF_URL = Deno.env.get("MCP_SELF_URL")!; // e.g.
// https://<ref>.supabase.co/functions/v1/mcp
const app = new Hono();
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"],
resource_documentation: `${SELF_URL}/docs`,
})
);
// -----------------------------------------------------------------------
// MCP endpoint — the real RPC
// (Tools and the auth middleware are added in later steps; for now we
// wire the SDK in so the dev server is exercising the right code path.)
// -----------------------------------------------------------------------
app.all("/", async (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. MCP clients use this as the resource parameter in their OAuth requests (per RFC 8707), and the server uses it to validate that incoming tokens were issued for it specifically. It must match the URL clients connect to.
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 — SUPABASE_URL should NOT have a trailing slash. Issuer is <url>/auth/v1, also without trailing slash. Authentic-looking URL bugs are the #1 cause of "OAuth fails silently."
2. Add the env var
Local — supabase/functions/.env:
SUPABASE_URL=https://<ref>.supabase.co
SUPABASE_ANON_KEY=<your-anon-public-key>
MCP_SELF_URL=https://<ref>.supabase.co/functions/v1/mcpProduction secrets get pushed separately at deploy time:
supabase secrets set MCP_SELF_URL=https://<ref>.supabase.co/functions/v1/mcpSUPABASE_URL and SUPABASE_ANON_KEY are auto-populated for deployed Edge Functions, so you don't need to set them as secrets.
3. Test the metadata locally
supabase functions serve mcpIn 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"],
"resource_documentation": "https://<ref>.supabase.co/functions/v1/mcp/docs"
}4. Test the 401 + WWW-Authenticate header
curl -i http://127.0.0.1:54321/functions/v1/mcp/HTTP/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.
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, with the production URL.
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/resource 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, checking the JWKS signature, and confirming the token was minted for this exact server.