1. Final deploy

You've been running supabase functions deploy mcp along the way. One more time, with the flag spelled out so you see exactly what's shipping:

supabase functions deploy mcp --no-verify-jwt

The --no-verify-jwt flag matters: Supabase's platform-level JWT verification would reject the OAuth metadata endpoints (which by spec must be unauthenticated). We do verification inside our function for the protected routes, so we want the platform check off — this flag makes explicit what our config.toml (verify_jwt = false, step 2) already declared.

Confirm the deploy:

# Public discovery — should return JSON without any auth header
curl https://<ref>.supabase.co/functions/v1/mcp/.well-known/oauth-protected-resource | jq
 
# Protected RPC — should return 401 with the WWW-Authenticate header
curl -i https://<ref>.supabase.co/functions/v1/mcp

If both look right, the function is ready for real OAuth traffic.

2. Connect Claude Code

In a terminal where Claude Code is installed:

claude mcp add shared-skills \
  --transport http \
  https://<ref>.supabase.co/functions/v1/mcp

(Note: no trailing slash — the routes we registered are exact.) Two things happen behind the scenes:

  1. Claude makes an unauthenticated request. Our server returns 401 + WWW-Authenticate: Bearer ... resource_metadata="...".
  2. Claude fetches the resource metadata, follows authorization_servers[0] to Supabase, runs the OAuth 2.1 authorization-code-with-PKCE flow (dynamically registering as a client if needed), pops a browser window for the user to sign in, exchanges the code for an access token, and stores it.

The browser handoff looks like:

Claude → opens browser at https://<ref>.supabase.co/auth/v1/oauth/authorize?...

Supabase hands off to YOUR consent page (Site URL + /oauth/consent, step 4).
User signs in with email/Google/whatever you enabled, clicks Approve.

Supabase redirects to http://127.0.0.1:<port>/callback?code=...
(the localhost URI Claude registered for itself via DCR)

Claude exchanges the code (+ PKCE verifier) for an access token.

Token saved; Claude makes its first authed request to your MCP server.

Verify the connection:

claude mcp list
# shared-skills: connected

And inside a Claude session, you should now be able to ask:

"What snippets do I have saved?"

Claude calls list_snippets() "You have 4 snippets across 2 workspaces. Want me to show one?"

That's the entire flow.

3. Connect Claude Desktop

Claude Desktop adds remote MCP servers as connectors — this is a settings-UI flow, not a JSON file:

  1. Open Settings → Connectors.
  2. Click Add custom connector.
  3. Paste the server URL: https://<ref>.supabase.co/functions/v1/mcp
  4. The first time you use it, Desktop runs the same OAuth flow — browser, your consent page, Approve — and stores the token.

(The claude_desktop_config.json file you may know from local STDIO servers doesn't take remote URLs. If you want a config-file route, the community mcp-remote package proxies a remote server behind a local STDIO process: "command": "npx", "args": ["-y", "mcp-remote", "https://<ref>.supabase.co/functions/v1/mcp"] — useful for clients that predate native remote support, unnecessary for current Claude Desktop.)

4. The production checklist

Now that it works end-to-end, harden it. Each item below has caught me at least once.

Rate limiting

Supabase Edge Functions don't ship with a built-in per-user rate limiter. The simplest option: lean on a small in-database counter.

create table public.rate_limits (
  user_id     uuid not null references auth.users(id),
  bucket      text not null,
  window_start timestamptz not null,
  count       int  not null default 0,
  primary key (user_id, bucket, window_start)
);
 
-- RLS on, no policies: PostgREST can't read or write this table directly.
-- Only the SECURITY DEFINER function below touches it — and this keeps the
-- "RLS on every table" checklist item below honest.
alter table public.rate_limits enable row level security;
 
create or replace function public.bump_rate_limit(
  p_bucket text,
  p_limit  int
) returns boolean
language plpgsql security definer
set search_path = public
as $$
declare
  -- Fixed one-minute windows; generalize the bucketing if you need more.
  v_window_start timestamptz := date_trunc('minute', now());
  v_count        int;
begin
  insert into public.rate_limits (user_id, bucket, window_start, count)
  values (auth.uid(), p_bucket, v_window_start, 1)
  on conflict (user_id, bucket, window_start)
    do update set count = public.rate_limits.count + 1
    returning count into v_count;
 
  return v_count <= p_limit;
end $$;

Then, in any expensive tool handler:

const { data: under } = await supabase.rpc("bump_rate_limit", {
  p_bucket: "save_snippet",
  p_limit: 30,           // 30 saves per minute per user
});
 
if (!under) throw new Error("rate limit exceeded — try again in a minute");

That's a coarse, single-region limiter — good enough for "stop one user from hammering the server." For finer control, drop in Upstash Ratelimit which works in Deno.

Observability

The CLI doesn't tail function logs; the dashboard does. The bare minimum: Edge Functions → mcp → Logs in the Supabase dashboard shows invocations, console output, and errors, with filtering. (Locally you've had this all along — supabase functions serve prints logs to the terminal.)

For more than that, ship to a real log service. Edge Functions support outbound HTTP, so:

async function logEvent(level: string, msg: string, ctx: Record<string, unknown>) {
  if (Deno.env.get("LOG_ENDPOINT")) {
    await fetch(Deno.env.get("LOG_ENDPOINT")!, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ level, msg, ts: new Date().toISOString(), ...ctx }),
    }).catch(() => { /* don't fail the request if logging fails */ });
  }
  console.log(`[${level}] ${msg}`, ctx);
}

Wire it into the auth middleware to log every authenticated call:

await logEvent("info", "mcp.request", {
  sub: user.sub,
  clientId: user.clientId,
  path: c.req.path,
  method: c.req.method,
});

What to alert on:

  • Sustained 5xx rate > 1% — a regression
  • Authentication-failure spike — possible credential-stuffing
  • Unusual tool-call patterns from a single sub — abuse

Key rotation

Signing keys do not rotate themselves — rotation is a deliberate, manual act on the dashboard's Project Settings → JWT Keys page (the same page as step 4's migration): create/standby a new key, click Rotate keys, and revoke the old one after existing tokens expire. The good news is that our server needs zero changes when you do: because we use createRemoteJWKSet (step 6), jose fetches the new public key the first time it sees the unfamiliar kid. Worth rehearsing once: rotate, wait a moment, hit the MCP server with a freshly minted token — it should keep working without a redeploy.

The anon key is not a secret per se (it's in client-side code, including your consent page), but if it ever needs rotating (compromise, public-repo leak), do it in the dashboard and update it everywhere it's pinned: supabase secrets set PROJECT_ANON_KEY=... for the function, plus the consent page.

Backups

supabase/storage is great until the day you drop table snippets;. Set up nightly backups:

  • Free tier: daily automated backups, 7-day retention
  • Pro+: point-in-time recovery, 7-30 day retention

Confirm in the dashboard under Project Settings → Database → Backups. For belt-and-suspenders, schedule a daily pg_dump to S3:

# Run via GitHub Actions or a cron worker
pg_dump "$SUPABASE_DB_URL" \
  --schema=public \
  --no-owner \
  --no-privileges \
  | gzip > "snippets-$(date +%F).sql.gz"
 
aws s3 cp "snippets-$(date +%F).sql.gz" "s3://my-backups/snippets/"

Security review

A short list of things to double-check before publishing the server URL widely:

  • Service role key is NOT in any Edge Function. Only the anon key is referenced; user tokens drive everything else.
  • RLS is enabled on every table in the public schema we created — including rate_limits above. Re-run the verification queries from step 3.
  • The SECURITY DEFINER inventory is known and minimal. We have exactly five: bootstrap_personal_workspace (signup trigger), is_workspace_member / is_workspace_owner (recursion-safe policy helpers), create_workspace (step 3), and user_id_for_email (step 9) — plus bump_rate_limit if you added it. Each does one narrow thing. Don't expand their bodies without thought — definer functions bypass RLS.
  • You've decided your client posture. Supabase's OAuth server doesn't support RFC 8707 resource binding today, so any valid token from your project is accepted regardless of which app obtained it (RLS still pins it to that user's data). If that's not acceptable, set ALLOWED_CLIENT_IDS (step 6) to the client ids you observe in the logs.
  • CORS is restrictive. Streamable HTTP MCP doesn't require browser CORS, but if you ever expose the same endpoint to a web client, lock Access-Control-Allow-Origin to specific origins.
  • Snippet bodies don't get returned to non-members. Pick a private snippet, mint a token for a user not in its workspace, attempt get_snippet. Should return "not found or not visible."
  • Logs scrubbed of bodies. It's tempting to log tool_input for debugging; for save_snippet that means snippet bodies in your log retention. Either redact bodies in the logger, or accept that logs are at the same sensitivity as the database and apply the same access controls.

Scaling notes (for when this isn't just your team)

The architecture scales reasonably well in its default shape:

  • Edge Functions scale horizontally and cold-start in ~100ms. A team of 50 users making low-hundreds-of-calls-per-day is well within the free or pro tier.
  • Postgres + RLS is the bottleneck if you grow. The is_workspace_member helper runs once per query as a function call; if you see slow tool latencies in the logs, run explain analyze on a representative query and consider indexing workspace_members(workspace_id, user_id) (we did this in step 3 — confirm it's still there) and snippets(workspace_id, updated_at desc).
  • MCP connections themselves are stateless. Each request rebuilds the McpServer and Supabase client, and the transport runs without session ids. There's no in-memory state to lose if a function instance spins down.

The first thing to outgrow is probably the resource listing — 50 snippets per user is arbitrary and small for an active team. Promote that to a paginated cursor-based API when it bites.

5. Add the slash command, for ergonomics

This is optional but nice. In Claude Code, create a project slash command at .claude/commands/snippet.md:

---
description: Save the current discussion as a team snippet
---
 
Use the `save_snippet` MCP tool on the `shared-skills` server.
 
Workspace: ask me which workspace to save to if I haven't said.
Title: come up with something short and descriptive.
Body: the relevant block(s) of our conversation, cleaned up.
Tags: 2-3 relevant ones.
Visibility: "workspace" by default.
 
Confirm the snippet id after saving.

Now anyone on the team can type /snippet and Claude will use the MCP tool to capture the moment. Pair with /find <topic> (use list_snippets) and /load <topic> (chain list_snippetsget_snippet) for the full "shared skills" experience.

6. What you built

Counting from step 1:

  • A multi-tenant MCP server, deployed to Supabase Edge Functions.
  • OAuth 2.1 with dynamic client registration, PKCE, a consent page you own, and JWKS-verified (ES256) bearer tokens.
  • A Postgres schema with workspaces, member roles, and snippet visibility — entirely policed by Row-Level Security.
  • Seven MCP tools (list_snippets, get_snippet, save_snippet, share_snippet, list_workspaces, create_workspace, invite_to_workspace) and three MCP resource types.
  • A production checklist covering rate limits, observability, backups, and security review.

The end-to-end thing your teammates see: they run claude mcp add shared-skills <url>, sign in once, and from then on every Claude session knows their entire team's prompt library.

7. Where to take it next

A few directions, depending on what you want:

  • Versioning snippets. Add a snippet_versions table that captures each edit. save_snippet writes both. Adds an undo, plus an audit trail for "who wrote this prompt and when."
  • Snippet variables. Add a templating layer — {{topic}}, {{audience}} — so a saved prompt can be partially filled by Claude before use. The MCP tool surface stays the same; the change is in how Claude reads and uses the body.
  • A web admin UI. Same Supabase project, a Next.js app on Vercel reading/writing the same snippets table. RLS already does the multi-tenancy; you're just adding a different surface. (It can share a domain with your consent page from step 4.)
  • Convert this into a Smithery-style public listing. Smithery and the MCP Registry let users discover hosted MCP servers; the auth + multi-tenancy you built is what makes it safe to do that.

You shipped a small, real thing that does something genuinely useful. That's the whole win.