The MCP TypeScript SDK, paired with Supabase Edge Functions, gives you a hosted, globally-distributed server that sits between an AI client and your database. The AI client never gets raw database access. Per the Supabase deploy guide, the recommended transport for this stack is WebStandardStreamableHTTPServerTransport. That is the only implementation covered below. What you get below: a synthetic read-only product catalogue, paginated query tools, RLS-enforced two-user isolation, an Inspector transcript walkthrough, and notes on deploying and versioning.
The server and data flow
MCP sits between an AI client (Claude Code, Claude Desktop, Cursor) and your backend. The client speaks JSON-RPC over HTTP to your server. Your server exposes tools. Tools are typed functions the client can call. The server decides what those tools can touch.
For Supabase, that boundary is critical. As UI Bakery's breakdown puts it: the AI client should not get unlimited direct access to your database. You design the tools, you define the queries, and Supabase Row Level Security (RLS) handles per-row access based on the caller's identity.
The data flow looks like this:
- Claude Code sends a JSON-RPC
tools/callrequest to your Edge Function URL. - The Edge Function receives it, extracts the JWT from the
Authorizationheader, and creates a Supabase client scoped to that identity. - The tool runs a query. RLS policies on the table filter the rows the caller can see.
- The result is serialised back as a JSON-RPC response.
Notice there is no service-role key in that chain. A service-role client bypasses RLS entirely, so if you use one in a caller-facing tool, your RLS policies are decoration. Keep the service-role key for background admin tasks only.
Synthetic sample data
The examples here use a synthetic products table with three columns: id (uuid), owner_id (uuid, foreign key to auth.users ), and name (text). Two synthetic users, alice and bob, each own a disjoint set of rows. This setup is explicitly illustrative and not derived from a real client project.
create table public.products (
id uuid primary key default gen_random_uuid(),
owner_id uuid references auth.users(id),
name text not null
);
Seed it with, say, ten rows for each user using their respective UUIDs.
Define a small tool surface with pagination
Narrow tools are better than wide ones. A tool that returns an entire table is a liability. Pagination keeps payloads predictable and avoids hitting Edge Function response limits.
The tool surface for this server is deliberately small:
list_products: returns a page of products owned by the caller, acceptspageandpage_sizeparameters, defaults to page 1 with 20 rows per page.get_product: returns a single product byid, fails gracefully if the caller does not own it.
That is it. Two tools. You are not building a query engine. If you want to understand how to structure a larger directory-style read layer on Supabase, the 25,000-page directory post is worth reading first.
Defining the schema with Zod
The MCP TypeScript SDK uses Zod for input validation. Your tool definitions look roughly like this (illustrative):
import { z } from 'npm:zod'
``
const ListProductsInput = z.object({
page: z.number().int().min(1).default(1),
page_size: z.number().int().min(1).max(100).default(20),
})
``
const GetProductInput = z.object({
id: z.string().uuid(),
})
Zod validates before your handler runs. The client gets a typed error back rather than a runtime exception if it sends garbage. Good behaviour to build in from the start.
Implement and run the TypeScript server
The Supabase deploy guide uses @modelcontextprotocol/sdk@1.25.3 with WebStandardStreamableHTTPServerTransport. That is the pinned version at time of writing. Check the current docs before you ship.
Scaffolding
mkdir my-mcp-server && cd my-mcp-server
supabase init
supabase functions new mcp
Your function lives at supabase/functions/mcp/index.ts. Replace the default contents with something along these lines (illustrative structure):
import 'jsr:@supabase/functions-js/edge-runtime.d.ts'
import { McpServer } from 'npm:@modelcontextprotocol/sdk@1.25.3/server/mcp.js'
import { WebStandardStreamableHTTPServerTransport } from 'npm:@modelcontextprotocol/sdk@1.25.3/server/streamableHttp.js'
import { createClient } from 'npm:@supabase/supabase-js@2'
import { z } from 'npm:zod'
``
Deno.serve(async (req) => {
const authHeader = req.headers.get('Authorization') ?? ''
const jwt = authHeader.replace('Bearer ', '')
``
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: Bearer ${jwt} } } }
)
``
const server = new McpServer({ name: 'products-mcp', version: '1.0.0' })
``
server.tool('list_products', 'List products owned by the caller',
{ page: z.number().int().min(1).default(1), page_size: z.number().int().min(1).max(100).default(20) },
async ({ page, page_size }) => {
const from = (page - 1) * page_size
const { data, error } = await supabase
.from('products')
.select('id, name')
.range(from, from + page_size - 1)
if (error) return { content: [{ type: 'text', text: Error: ${error.message} }] }
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
}
)
``
server.tool('get_product', 'Get a single product by id',
{ id: z.string().uuid() },
async ({ id }) => {
const { data, error } = await supabase
.from('products')
.select('id, name')
.eq('id', id)
.maybeSingle()
if (error) return { content: [{ type: 'text', text: Error: ${error.message} }] }
if (!data) return { content: [{ type: 'text', text: 'Not found or access denied' }] }
return { content: [{ type: 'text', text: JSON.stringify(data) }] }
}
)
``
const transport = new WebStandardStreamableHTTPServerTransport({ path: '/mcp' })
await server.connect(transport)
return transport.handleRequest(req)
})
Two things worth flagging here. First, a fresh McpServer and transport instance per request is the stateless pattern the Edge Function runtime expects. Second, the Supabase client is created with SUPABASE_ANON_KEY, not the service-role key, and the JWT is forwarded in the request header so RLS sees the caller's identity.
Running locally
supabase start
supabase functions serve --no-verify-jwt mcp
Your server is available at http://localhost:54321/functions/v1/mcp . The --no-verify-jwt flag is fine for local testing. You will want JWT verification on in production.
Authenticate callers and enforce row access
This is where most tutorials go wrong. They reach for the service-role key because it is simpler. But that bypasses RLS and means your tool surface, however narrow, has admin-level read access to every row. That is not acceptable for a caller-facing server.

The pattern shown above (pass the caller's JWT, use the anon key) means Supabase Auth evaluates the token and sets the auth.uid() context inside Postgres for every query. Your RLS policy can then use that:
alter table public.products enable row level security;
``
create policy "owners can read own products"
on public.products
for select
using (owner_id = auth.uid());
Now alice 's JWT can only ever return rows where owner_id matches her UID. bob calling get_product with one of Alice's product IDs gets Not found or access denied, not an error, not Alice's data. That is the correct behaviour.
For a deeper look at writing RLS policies, the Supabase RLS guide covers the common patterns and pitfalls in detail.
Access isolation check (illustrative)
The two-user check is straightforward to script. Get a JWT for Alice (via supabase.auth.signInWithPassword ), call list_products , confirm all returned owner_id values match Alice's UID. Repeat with Bob's JWT. Confirm zero cross-contamination. If your policies are correct, this passes by construction. If you have made the service-role mistake, it will surface here because both users will see all rows.
If your Supabase project is already carrying complex Next.js data fetching needs alongside this, the team at /solutions/nextjs-supabase-development/ can help you structure the database layer without the service-role shortcuts that create these problems.
Connect Claude Code and verify error cases
Once your function is running locally, add it to Claude Code:
claude mcp add products-mcp -t http http://localhost:54321/functions/v1/mcp
Then, from a Claude Code session, you can call your tools directly. You should see list_products and get_product in the available tool list. If you do not, run claude mcp list to confirm the server registered correctly.
Inspector transcript walkthrough
The MCP Inspector (npx @modelcontextprotocol/inspector) gives you a browser UI to exercise tools without a full client. As noted in the mcp-lite guide, you paste your endpoint URL into the Inspector and it discovers your tools automatically.
An illustrative Inspector session for list_products:
Request:
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "list_products",
"arguments": { "page": 1, "page_size": 5 }
},
"id": 1
}
``
Response (Alice's JWT):
{
"result": {
"content": [{ "type": "text", "text": "[{\"id\":\"...\",\"name\":\"Widget A\"}...]" }]
}
}
Now test the error cases deliberately. Call get_product with a valid UUID that belongs to Bob, using Alice's JWT. You should get Not found or access denied . Call list_products with page_size: 200 (above your 100 cap). Zod should reject it before the query runs. Both are expected behaviours, and both are worth confirming before you deploy.
What happens when the JWT is missing?
Without an Authorization header, your createClient call passes an empty JWT. Supabase treats this as the anon role. If your RLS policy does not explicitly grant the anon role access, the query returns zero rows. Which is correct. The tool returns an empty array, not an error. You might prefer to return an explicit "unauthenticated" message. Either way, decide intentionally rather than discovering it in production.
Deploy, observe and version the server
Deployment is a single command:
supabase functions deploy mcp
Your function gets a stable URL at https://<project-ref>.supabase.co/functions/v1/mcp . Set the environment variables in the Supabase dashboard (Settings > Edge Functions > Secrets): SUPABASE_URL and SUPABASE_ANON_KEY. The function runtime injects those at invocation time.
Observability
Supabase provides built-in Edge Function logs in the dashboard. Filter by function name. You will see cold start times, execution duration, and any uncaught errors. For more structured observability, write console.log JSON from inside your tool handlers. The log stream picks it up.
For production use, think about:
- Logging the tool name and caller UID (not the full JWT) per request.
- Setting a timeout on your Supabase queries so a slow query does not burn your Edge Function execution budget.
- Returning deterministic error shapes so the AI client can handle failures consistently.
Versioning
Edge Functions do not have built-in versioning. The practical approach is path-based: deploy a v2 function as a separate Edge Function (supabase functions new mcp-v2), test it independently, then update your Claude Code registration. Old clients can keep pointing at the v1 URL until you cut them over.
If you are building a production MCP stack with multiple servers, the production MCP stack post covers server selection and architecture decisions that are out of scope here.
Deployment checklist
- Remove
--no-verify-jwtfrom local serve commands before deploying. - Confirm
SUPABASE_ANON_KEYis set in dashboard secrets, not service-role. - Run your two-user access isolation check against the production URL before announcing the endpoint.
- Set a
max_rowscap inside every tool to prevent unbounded queries. - Check the SDK version pinned in your import (
@modelcontextprotocol/sdk@1.25.3in the Supabase docs at time of writing) against current releases and update if needed.
FAQ
Can I use mcp-lite instead of the official SDK?
Yes. The Supabase docs explicitly say you can use mcp-lite or mcp-handler as alternatives to the official SDK. The WebStandardStreamableHTTPServerTransport used above comes from the official SDK, but mcp-lite is lighter and zero-dependency. Pick one and stick with it. The mcp-lite guide shows the scaffold command: npm create mcp-lite@latest. The architecture and RLS behaviour described above apply regardless of which framework you choose.
Do I need a paid Supabase plan to deploy Edge Functions?
Edge Functions are available on the free tier with some limits on invocations and execution time. Check the current Supabase pricing page for the numbers. For a low-traffic internal tool, the free tier is usually fine. For anything production-facing with real query volume, the Pro plan's limits are more appropriate.
What if my MCP server needs to write data, not just read?
Add tools that run insert , update , or delete queries. The RLS pattern still applies: your policy controls which rows the caller can modify based on auth.uid(). The main additional consideration is idempotency. If the AI client retries a failed tool call, do you want the insert to run twice? Using upsert with a stable primary key or checking existence before inserting are the standard mitigations.
Can I run this locally without a Supabase account?
Yes, supabase start runs the full Supabase stack locally via Docker, including Postgres, Auth, and Edge Functions. You get a local SUPABASE_URL and SUPABASE_ANON_KEY from the CLI output. Your function and RLS policies work exactly as they will in production. The only difference is the JWT issuer, which is the local GoTrue instance rather than Supabase's cloud Auth.
How do I handle rate limiting on the tools?
Edge Functions do not have built-in per-caller rate limiting. The practical options are: a Postgres table that records call counts per UID per time window (check and increment inside the tool), or an upstream API gateway (Supabase's own API gateway handles some of this at the project level). For most internal tools, hitting the Edge Function invocation limit on a free-tier project is the first constraint you will encounter, not deliberate abuse.
The single sharpest caveat from everything above: if you use a service-role key in a caller-facing tool, your RLS policies do nothing. The anon key plus JWT forwarding is not a nice-to-have, it is the mechanism that makes caller isolation work.