developmentTechArticle

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.

typescript
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.

M
MCPserver Team

MCP documentation and protocol implementation team

Published: 2026-07-21
Updated: 2026-07-21