Building a Supabase MCP Server
Query Supabase's hosted Postgres through its client library as an MCP tool, scoped with row-level security rather than the service-role key.
Quick Answer / TL;DR
A Supabase MCP server should authenticate with the anon key and rely on the project's row-level security policies to scope what data is reachable, rather than using the service-role key, which bypasses RLS entirely.
Key Takeaways
- Supabase is Postgres underneath - the same parameterized-query and read-only discipline that applies to a plain Postgres MCP server applies here too.
- The service-role key bypasses row-level security entirely - using it in an MCP tool means the model can see every row in every table, regardless of any RLS policy.
- Prefer the anon key plus RLS policies scoped to a dedicated read-only database role, so access control is enforced by Postgres itself, not just by the tool's application code.
Why not just copy a reference implementation?
The official MCP servers repository states plainly: "The servers in this repository are intended as reference implementations to demonstrate MCP features and SDK usage... not as production-ready solutions." The easiest way to build a Supabase tool the same way is to reach for the service-role key because it "just works" past RLS - which is exactly the shortcut avoided below.
Querying through the client, not raw SQL
The supabase-js client's query builder (.from().select().match()) is the idiomatic interface and composes cleanly with RLS. Reaching for supabase.rpc() to run arbitrary SQL should be avoided for the same reason string-interpolated SQL is avoided in a plain Postgres tool.
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!, // not the service-role key
);
async function queryTable(table: string, filters: Record<string, string>, limit = 100) {
const ALLOWED_TABLES = ["products", "orders", "support_tickets"];
if (!ALLOWED_TABLES.includes(table)) {
throw new Error(`Table "${table}" is not exposed to this tool.`);
}
let query = supabase.from(table).select("*").limit(limit);
for (const [column, value] of Object.entries(filters)) {
query = query.eq(column, value);
}
const { data, error } = await query;
if (error) throw new Error(error.message);
return data;
}Building a Supabase MCP Server FAQs
Direct answers for developers, operators, and Indian teams evaluating MCP.