Building a Simple MCP Server on Supabase (and Why Claude Is the Best UI You'll Ever Not Build)

How I built a simple, secure MCP server on Supabase and why Claude can serve as the UI for agent-facing apps.

Building a Simple MCP Server on Supabase (and Why Claude Is the Best UI You’ll Ever Not Build)

The Problem

I’ve been using AI agents to help me plan cooking schedules and track recipes, particularly dietary variants like low-FODMAP versions of things I like to cook. The problem is I’ve been flipping between different agents (Claude, ChatGPT, others) to try out their features, and I kept hitting the same wall: my data was nowhere. No recipe I added to one agent survived into another. No central store, no sharing, no consistency.

What I really wanted was simple:

  • A secure place to store my recipes that any agent could reach
  • Auth so that only I (and people I explicitly share with) can access my data
  • An API that speaks the language agents actually understand: MCP

The secondary problem is one I suspect a lot of people hit when they start thinking about MCP servers: how much infrastructure do I actually need here? The answer, it turns out, is much less than you think.

The full source code for this project is at VerdantForge/MiseEnPlace if you want to follow along.


The Insight That Changes Everything: Claude Is Your UI

Before getting into the architecture, I want to surface the most important lesson from this project, because it reframes how you should think about building data-backed MCP servers.

You don’t need a UI.

With a traditional web app, you model your domain, build a backend, then spend equal or greater time building a frontend: forms, tables, navigation, modals, responsive layouts, loading states. That frontend is essentially a translation layer, taking your data and turning it into something a human can interact with.

Claude does that translation natively, and it does it better than a static UI ever could.

When I connect Claude to my MCP server, I don’t get a recipe management screen. I get a collaborator who can:

  • Fetch a recipe from a URL I paste, parse it, normalize it to my preferred format, and store it, all in one message
  • Display recipes as beautifully formatted cooking guides with step-by-step instructions rendered inline in the chat
  • Cross-reference my recipes against my dietary restrictions without me building a single filter
  • Help me scale a recipe for a dinner party, substituting ingredients I have on hand

And crucially: different users of the same MCP server can get totally different experiences just by asking for different things. One person says “show me a shopping list.” Another says “give me a timeline for cooking this for a dinner at 7pm.” The MCP server has no opinion about either. It just provides the data. Claude handles the presentation.

This means your job as the MCP author reduces to two things:

  1. Model your domain accurately in the database
  2. Secure the objects properly so users only see what they should

Everything else is negotiable.


Schema Design: Embrace Simplicity

Because Claude handles the parsing and interpretation layer, your schema can be radically simple. In a traditional app, you might be tempted to add structured fields for every piece of information you want to display or filter. With an AI on the front end, you can delegate that entirely.

The schema for this project:

create table public.recipes (
  id         uuid        primary key default gen_random_uuid(),
  user_id    uuid        not null default auth.uid() references auth.users(id) on delete cascade,
  title      text        not null,
  content    text        not null,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

That’s it. content is free-form text. No structured ingredients, no steps array, no nutritional fields. Claude reads whatever format I store and understands it, whether that’s a formal recipe, a rough notes dump, or a URL to parse.

The RLS (Row Level Security) policies are the real work: four simple policies that ensure every query is scoped to the authenticated user. Supabase enforces these at the database level, so even if your MCP code has a bug, a user cannot read another user’s rows.

create policy "Users can read own recipes"
  on public.recipes for select
  using (auth.uid() = user_id);

-- insert / update / delete policies follow the same pattern

Architecture

The stack satisfies all requirements with minimal moving parts:

RequirementSolution
DatabaseSupabase Postgres with RLS
AuthSupabase Auth (GoTrue) with OAuth 2.1 server
MCP APISupabase Edge Function (Deno) with mcp-lite
HostingSupabase Cloud (free tier) + Netlify (auth UI)

System diagram

MCP client (Claude, etc.)

  ├─ GET /.well-known/oauth-protected-resource   ← RFC 9728 metadata
  ├─ Redirected to Netlify auth UI for sign-in
  │        │
  │        └─ Supabase Auth handles credentials → issues access token

  └─ POST /mcp  (Bearer: <access-token>)

       ├─ JWT middleware validates token
       ├─ User-scoped Supabase client created → RLS enforced
       └─ mcp-lite dispatches tool calls

The Edge Function is a Hono app with three concerns:

  1. OAuth protected resource metadata: tells MCP clients which endpoint is protected and where the authorization server lives (RFC 9728)
  2. Auth redirect stub: bounces the browser from the edge function domain to the Netlify auth UI
  3. MCP endpoint: validates the JWT, builds a user-scoped database client, and hands off to mcp-lite

Why a separate auth UI?

Supabase Auth is a full OAuth 2.1 authorization server, but it doesn’t ship a sign-in UI. MCP clients expect to redirect the browser to an authorization URL and receive a token back. You need something at that URL to collect credentials.

The solution here is a minimal Vite + TypeScript static site deployed to Netlify. It calls Supabase Auth directly from the browser, with no proxy, no server-side session, nothing to maintain. Netlify’s CDN serves it for free. The auth UI is genuinely about 200 lines of TypeScript.


The OAuth Flow in Practice

When an MCP client connects for the first time, it goes through a standard OAuth 2.1 dance:

  1. Client attempts to call a tool → hits a 401
  2. The WWW-Authenticate header in the response points to the protected resource metadata
  3. Client fetches metadata → discovers the authorization server URL
  4. Client registers itself (dynamic client registration)
  5. Client starts the authorization flow → browser opens the Netlify sign-in page
  6. User signs in → Supabase Auth issues an access token
  7. Client stores the token → subsequent requests include Authorization: Bearer <token>

From that point on, every tool call carries the token. The JWT middleware in the edge function validates it against Supabase Auth on each request, then passes a user-scoped database client into the tool handlers. Because that client carries the user’s token, Postgres RLS runs automatically, so no tool code needs to manually filter by user ID.

// index.ts — /mcp handler
mcpApp.all("/mcp", async (c) => {
  const authHeader = c.req.header("Authorization") ?? "";
  const supabase = createClient(supabaseUrl, supabaseAnonKey, {
    global: { headers: { Authorization: authHeader } },
    auth: { persistSession: false },
  });

  return await requestContext.run({ supabase }, async () => {
    return await httpHandler(c.req.raw);
  });
});
// mcp.ts — tool handler, no user_id filtering needed
mcp.tool("listRecipes", {
  description: "List all recipes belonging to the authenticated user",
  inputSchema: z.object({}),
  handler: async () => {
    const db = getDb(); // user-scoped client, RLS enforced in Postgres
    const { data, error } = await db
      .from("recipes")
      .select("id, title, created_at, updated_at")
      .order("created_at", { ascending: false });
    // ...
  },
});

The AsyncLocalStorage trick is worth highlighting: requestContext stores a per-request Supabase client so tool handlers can call getDb() without threading a client through every function signature. Each concurrent request gets its own isolated client automatically.


Claude as UI: What It Looks Like in Practice

Once the MCP server is running and connected to Claude, the experience is genuinely different from interacting with a traditional app.

Adding a recipe:

“Here’s a link to a chicken stir-fry recipe. Store it in my recipe book, but adapt the format so it’s more like my mise en place style — prep bowls with gram measurements.”

Claude fetches the URL, parses the recipe, reformats the content according to my preferred structure, and calls createRecipe. I never touch a form.

Displaying a recipe:

“Show me the chicken stir-fry step by step in a way I can follow while cooking.”

Claude calls getRecipe and renders the content as a formatted, step-by-step guide inside the chat window. Whatever layout makes sense for the request, Claude generates on the spot: bold headings, ingredient tables, numbered steps.

Customization by demand:

“Give me a shopping list for making the chicken stir-fry and the pasta carbonara for 6 people.”

Claude calls listRecipes, fetches both recipes, scales them, deduplicates overlapping ingredients, and returns a consolidated shopping list. None of this logic exists in my MCP server. The MCP server just provides reliable data access; Claude does the reasoning.

The key point here is that the MCP server is a contract. It defines the vocabulary: what operations exist, what data looks like. Claude interprets that vocabulary differently for every user, every request. Two people using the same MCP server will have completely different experiences based on how they talk to Claude, and that’s entirely by design.


Building It: The Key Files

The entire MCP logic lives in four files:

supabase/migrations/20260413000000_create_recipes.sql: table schema and RLS policies. This is your domain model and security layer. Get this right and everything else follows.

supabase/functions/mcp-server/mcp/mcp.ts: mcp-lite server with tool definitions. CRUD operations plus a list. Each tool is ~10-15 lines.

supabase/functions/mcp-server/auth/jwt-middleware.ts: validates Bearer tokens against Supabase Auth and returns RFC-compliant WWW-Authenticate headers on failure. This is the piece that makes OAuth-capable MCP clients work correctly.

supabase/functions/mcp-server/auth/oauth-protected-resource.ts: serves the RFC 9728 metadata that MCP clients use to discover the auth server.

The Hono router in index.ts wires these together. The Netlify site in site/ handles sign-in. Total code across everything: maybe 600 lines.


Where to Go From Here

On the Supabase stack

Supabase is a great starting point because:

  • It’s fully open source and self-hostable; supabase start runs the whole stack locally in Docker
  • The free cloud tier is genuinely generous for personal projects
  • Postgres + RLS is a solid security model that scales well
  • Edge Functions run Deno, which works natively with the Fetch API that MCP and mcp-lite expect

The one gap is the auth UI: Supabase doesn’t ship one, so you need to build and host it yourself (the Netlify piece). The work is modest, though it’s an extra step that a more opinionated platform irons out.

For production or more complex projects: consider Cloudflare

If you’re building something beyond a personal project, the Cloudflare platform is worth looking at seriously. Workers + D1 (SQLite) + KV gives you a similarly integrated stack with some advantages:

  • The Workers runtime is more mature for edge deployment
  • Cloudflare Access can handle OAuth in front of your Worker without writing any auth code
  • Durable Objects give you stateful sessions if you need streaming or progress notifications in your MCP tools
  • mcp-lite has a first-class Cloudflare Worker example

The tradeoff is that Cloudflare’s stack is proprietary, so you can’t self-host it. If open source and self-hostability matter to your use case, Supabase remains the better choice.

The broader pattern

The recipe domain is just the running example. The same pattern applies to anything you want to make accessible to AI agents:

  • A personal knowledge base (notes, bookmarks, reading lists)
  • A task tracker scoped to a team
  • A product catalog for an e-commerce business
  • Any legacy database you want to expose to AI tooling

The work is always the same: model the domain, write the RLS policies, define the tools. Claude (or any MCP-capable agent) handles the rest.


Summary

Building a production-ready MCP server on Supabase is genuinely achievable in a weekend. The stack handles auth, database security, and hosting. You write the schema and the tools, maybe 600 lines total, and the hard parts are solved for you.

But the deeper lesson is about what MCP enables architecturally. When you expose a well-secured data API to an AI client, the AI becomes your UI. Users don’t get a fixed interface; they get a collaborator that presents the same underlying data however makes sense for their current need. The MCP server enforces the vocabulary and the security contract; the AI handles everything from there.

That’s a meaningful shift in how much you need to build. Model your data carefully, secure your objects properly, and then get out of the way.


References