Guide · MCP Database Integration

MCP Server Supabase — Postgres, auth, and realtime in a TypeScript MCP server

Supabase gives MCP servers managed Postgres, row-level security, realtime subscriptions, and a built-in auth system in a single hosted service. This guide covers wiring Supabase's JavaScript client into a TypeScript MCP server, designing tools that query and mutate Postgres tables, using RLS (row-level security) policies to gate tool access per user context, mirroring Supabase realtime events as MCP notifications, and exposing a /health/supabase endpoint that detects when your Supabase project is paused or its connection pool is exhausted.

TL;DR

Initialize the Supabase client with your service_role key for admin tools that bypass RLS, and swap in a per-request client initialized with the caller's JWT for user-context tools where RLS policies should apply. Check the .error property on every Supabase response — the client doesn't throw by default. Build a /health/supabase endpoint that runs a lightweight query to catch the most common silent failure: Supabase's free tier automatically pauses projects after 7 days of inactivity, and the Supabase API returns 503 while your MCP server process stays alive and healthy.

Supabase client setup in an MCP server

Supabase provides two credential types with fundamentally different access levels. Choosing the wrong one is a common security mistake when building MCP servers.

Key type Bypasses RLS? Use in MCP server? When to use
service_role Yes — full unrestricted access Yes, server-side only Admin tools, background jobs, seeding data
anon No — RLS policies apply Yes, for user-context tools Public read tools, operations scoped to authenticated user
import { createClient, SupabaseClient } from '@supabase/supabase-js';

const SUPABASE_URL = process.env.SUPABASE_URL!;
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;

// Admin client — bypasses RLS. Safe in a Node.js MCP server process.
// NEVER expose service_role key to client-side code or put it in a browser bundle.
export const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
  auth: {
    autoRefreshToken: false,
    persistSession: false
  }
});

// Create a per-request client that respects RLS via the caller's JWT
export function userClient(userJwt: string): SupabaseClient {
  const client = createClient(SUPABASE_URL, process.env.SUPABASE_ANON_KEY!, {
    auth: {
      autoRefreshToken: false,
      persistSession: false
    },
    global: {
      headers: {
        Authorization: `Bearer ${userJwt}`
      }
    }
  });
  return client;
}

// Utility: throw McpError from Supabase error response
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';

export function assertNoError<T>(
  result: { data: T | null; error: { message: string } | null }
): T {
  if (result.error) {
    throw new McpError(ErrorCode.InternalError, result.error.message);
  }
  return result.data!;
}

Basic CRUD tools

Supabase's JavaScript client returns { data, error } pairs rather than throwing on failure. Every tool handler must check .error before using .data. Forgetting this check is the most common source of silent null data bugs in Supabase integrations.

import { z } from 'zod';
import { adminClient, assertNoError } from './supabase.js';

// ---- list_rows ----
server.tool(
  'list_rows',
  {
    table: z.enum(['products', 'orders', 'customers']),
    filter_column: z.string().optional(),
    filter_value: z.string().optional(),
    limit: z.number().int().min(1).max(200).default(50),
    order_by: z.string().optional(),
    ascending: z.boolean().default(true)
  },
  async ({ table, filter_column, filter_value, limit, order_by, ascending }) => {
    let query = adminClient.from(table).select('*').limit(limit);
    if (filter_column && filter_value !== undefined) {
      query = query.eq(filter_column, filter_value);
    }
    if (order_by) {
      query = query.order(order_by, { ascending });
    }
    const rows = assertNoError(await query);
    return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
  }
);

// ---- get_row ----
server.tool(
  'get_row',
  {
    table: z.enum(['products', 'orders', 'customers']),
    id: z.union([z.string(), z.number()])
  },
  async ({ table, id }) => {
    const result = assertNoError(
      await adminClient.from(table).select('*').eq('id', id).maybeSingle()
    );
    if (!result) {
      throw new McpError(ErrorCode.InvalidParams, `Row with id=${id} not found in ${table}`);
    }
    return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
  }
);

// ---- insert_row ----
server.tool(
  'insert_row',
  {
    table: z.enum(['products', 'orders', 'customers']),
    data: z.record(z.unknown())
  },
  async ({ table, data }) => {
    const inserted = assertNoError(
      await adminClient.from(table).insert(data).select().single()
    );
    return { content: [{ type: 'text', text: JSON.stringify(inserted, null, 2) }] };
  }
);

// ---- update_row ----
server.tool(
  'update_row',
  {
    table: z.enum(['products', 'orders', 'customers']),
    id: z.union([z.string(), z.number()]),
    data: z.record(z.unknown())
  },
  async ({ table, id, data }) => {
    const updated = assertNoError(
      await adminClient.from(table).update(data).eq('id', id).select().single()
    );
    return { content: [{ type: 'text', text: JSON.stringify(updated, null, 2) }] };
  }
);

// ---- delete_row ----
server.tool(
  'delete_row',
  {
    table: z.enum(['products', 'orders', 'customers']),
    id: z.union([z.string(), z.number()])
  },
  async ({ table, id }) => {
    assertNoError(await adminClient.from(table).delete().eq('id', id));
    return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, id }) }] };
  }
);

Row-level security in tools

RLS policies in Supabase are enforced by the Postgres database based on the current role and session variables. When you use the service_role key, RLS is bypassed entirely. To have RLS apply, you must pass the user's JWT to Supabase so it can set auth.uid() in the Postgres session.

In an MCP server, the user's JWT typically arrives via a custom HTTP header (if you're running an HTTP MCP transport) or as a parameter in the session initialization metadata. The pattern below reads it from an Express request header:

import { userClient, assertNoError } from './supabase.js';

// Tool that enforces RLS — user can only see their own orders
server.tool(
  'list_my_orders',
  {
    status: z.enum(['pending', 'shipped', 'delivered', 'cancelled']).optional(),
    limit: z.number().int().min(1).max(50).default(20)
  },
  async ({ status, limit }, { meta }) => {
    // Extract user JWT from tool call metadata (passed by MCP client)
    const userJwt = meta?.userJwt as string | undefined;
    if (!userJwt) {
      throw new McpError(ErrorCode.InvalidRequest, 'Authentication required');
    }

    // Build a client using the user's JWT — RLS policies apply
    const client = userClient(userJwt);
    let query = client.from('orders').select('*').limit(limit);
    if (status) query = query.eq('status', status);

    // RLS policy: "auth.uid() = user_id" on the orders table
    // ensures this only returns the authenticated user's orders
    const orders = assertNoError(await query);
    return { content: [{ type: 'text', text: JSON.stringify(orders, null, 2) }] };
  }
);

// Alternative: use the admin client but add explicit user_id filter
// (useful when you don't have a JWT but do have a trusted user identifier)
server.tool(
  'list_orders_for_user',
  {
    user_id: z.string().uuid(),
    limit: z.number().int().min(1).max(200).default(50)
  },
  async ({ user_id, limit }) => {
    // Only safe because this is an admin-context tool — not exposed to end users
    const orders = assertNoError(
      await adminClient.from('orders').select('*').eq('user_id', user_id).limit(limit)
    );
    return { content: [{ type: 'text', text: JSON.stringify(orders, null, 2) }] };
  }
);

A critical RLS pitfall: when RLS policies are misconfigured (e.g., a policy that always evaluates to false), queries succeed and return empty result sets rather than errors. This is indistinguishable from "no matching rows" at the API layer. Test RLS policies explicitly by querying with a known user JWT and verifying row counts against expected data.

Realtime events as MCP notifications

Supabase Realtime delivers Postgres CDC (change data capture) events over a WebSocket. You can listen for INSERT, UPDATE, and DELETE events on any table and forward them as MCP resource list or resource update notifications. The key architectural point: Supabase Realtime uses its own WebSocket connection, independent of whatever MCP transport (SSE or stdio) your server is using. You must manage both lifecycles separately.

import { RealtimeChannel } from '@supabase/supabase-js';
import { adminClient } from './supabase.js';

let realtimeChannel: RealtimeChannel | null = null;

function startRealtimeSubscription(server: import('@modelcontextprotocol/sdk/server/index.js').Server): void {
  realtimeChannel = adminClient
    .channel('db-changes')
    .on(
      'postgres_changes',
      { event: 'INSERT', schema: 'public', table: 'products' },
      async (payload) => {
        console.log('New product inserted:', payload.new);
        // Notify MCP clients that the resource list has changed
        try {
          await server.sendResourceListChanged();
        } catch (e) {
          console.error('Failed to send resource list changed:', e);
        }
      }
    )
    .on(
      'postgres_changes',
      { event: 'UPDATE', schema: 'public', table: 'products' },
      async (payload) => {
        const productId = (payload.new as { id: string }).id;
        const uri = `supabase://products/${productId}`;
        try {
          await server.sendResourceUpdated({ uri });
        } catch (e) {
          console.error('Failed to send resource updated:', e);
        }
      }
    )
    .on(
      'postgres_changes',
      { event: 'DELETE', schema: 'public', table: 'products' },
      async () => {
        try {
          await server.sendResourceListChanged();
        } catch (e) {
          console.error('Failed to send resource list changed on delete:', e);
        }
      }
    )
    .subscribe((status, err) => {
      if (status === 'SUBSCRIBED') {
        console.log('Supabase Realtime subscribed');
      } else if (status === 'CHANNEL_ERROR') {
        console.error('Supabase Realtime channel error:', err);
      }
    });
}

// Clean up on MCP server shutdown
async function stopRealtimeSubscription(): Promise<void> {
  if (realtimeChannel) {
    await adminClient.removeChannel(realtimeChannel);
    realtimeChannel = null;
  }
}

Storage tools

Supabase Storage provides S3-compatible object storage with RLS-style bucket policies. MCP tools can upload, download, and list files using supabase.storage. For binary content in MCP tool responses, use base64 encoding with MIME type detection.

import { z } from 'zod';
import { adminClient } from './supabase.js';

server.tool(
  'upload_file',
  {
    bucket: z.string(),
    path: z.string(),
    content_base64: z.string(),
    content_type: z.string().default('application/octet-stream')
  },
  async ({ bucket, path, content_base64, content_type }) => {
    const buffer = Buffer.from(content_base64, 'base64');
    const { data, error } = await adminClient.storage
      .from(bucket)
      .upload(path, buffer, { contentType: content_type, upsert: true });

    if (error) throw new McpError(ErrorCode.InternalError, error.message);
    return { content: [{ type: 'text', text: JSON.stringify({ path: data.path }) }] };
  }
);

server.tool(
  'download_file',
  {
    bucket: z.string(),
    path: z.string()
  },
  async ({ bucket, path }) => {
    const { data, error } = await adminClient.storage.from(bucket).download(path);
    if (error) throw new McpError(ErrorCode.InternalError, error.message);

    const buffer = Buffer.from(await data.arrayBuffer());
    const mimeType = data.type || 'application/octet-stream';
    const isImage = mimeType.startsWith('image/');

    return {
      content: [{
        type: isImage ? 'image' : 'text',
        ...(isImage
          ? { data: buffer.toString('base64'), mimeType }
          : { text: buffer.toString('base64') })
      }]
    };
  }
);

server.tool(
  'list_files',
  {
    bucket: z.string(),
    prefix: z.string().default(''),
    limit: z.number().int().min(1).max(200).default(50)
  },
  async ({ bucket, prefix, limit }) => {
    const { data, error } = await adminClient.storage
      .from(bucket)
      .list(prefix, { limit });
    if (error) throw new McpError(ErrorCode.InternalError, error.message);
    return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
  }
);

Health endpoint: /health/supabase

The most important thing to check in a Supabase health endpoint is whether the project is paused. Supabase free tier projects pause after 7 days of inactivity — the MCP server process stays alive, but every Supabase query returns a 503. Without a health endpoint, this failure is invisible to uptime monitors.

import express from 'express';
import { adminClient } from './supabase.js';

const app = express();

app.get('/health/supabase', async (_req, res) => {
  const start = Date.now();
  const checks: Record<string, unknown> = {};

  // 1. Database connectivity — lightweight SELECT 1 via RPC or any small table
  try {
    const dbStart = Date.now();
    const { error } = await adminClient
      .from('_health_probe')   // create this table: CREATE TABLE _health_probe (id int);
      .select('id')
      .limit(1);
    checks.database = {
      status: error ? 'error' : 'ok',
      latency_ms: Date.now() - dbStart,
      error: error?.message ?? null
    };
  } catch (e) {
    checks.database = { status: 'error', error: (e as Error).message };
  }

  // 2. Realtime subscription status
  checks.realtime = {
    status: realtimeChannel?.state === 'joined' ? 'ok' : 'disconnected',
    channel_state: realtimeChannel?.state ?? 'none'
  };

  // 3. Storage accessibility
  try {
    const storageStart = Date.now();
    const { error } = await adminClient.storage.listBuckets();
    checks.storage = {
      status: error ? 'error' : 'ok',
      latency_ms: Date.now() - storageStart,
      error: error?.message ?? null
    };
  } catch (e) {
    checks.storage = { status: 'error', error: (e as Error).message };
  }

  const allOk = Object.values(checks).every(
    (c) => (c as { status: string }).status === 'ok'
  );

  res.status(allOk ? 200 : 503).json({
    status: allOk ? 'ok' : 'degraded',
    elapsed_ms: Date.now() - start,
    checks
  });
});

Silent failure modes

Failure mode Symptom Detected by naive HTTP check? How to detect
Project paused (free tier, 7-day idle) All Supabase API calls return 503. MCP server process is running; tool calls fail silently. No — MCP server returns 200 /health/supabase database check returns 503
RLS misconfiguration Queries succeed, return empty result sets. No errors logged. No — all checks pass Canary query with known-user JWT that expects >0 rows
Realtime WebSocket disconnection MCP resource update notifications stop being emitted. Tool calls still work. No realtimeChannel.state !== 'joined' in health check
Connection pool exhaustion (pgBouncer) Queries queue, then time out. Supabase returns remaining connection slots reserved. No — MCP process alive Database latency check in health endpoint exceeds threshold

Frequently asked questions

When should I use service_role vs the anon key?

Use service_role for admin tools that your MCP server runs on behalf of the system — data migrations, background sync, cross-user operations, seeding. Use anon (with the caller's JWT passed in the Authorization header) for tools that should respect your RLS policies, i.e., the tool operates on behalf of an end user and should only see that user's data. Never use service_role for user-facing tools unless you're implementing access control logic entirely in the tool handler (not recommended — RLS is more reliable). The service_role key must never appear in client-side code, browser bundles, or mobile apps. In a Node MCP server running in a controlled environment, it's safe.

Should I use Supabase's JavaScript client or connect directly to Postgres?

The Supabase JavaScript client is a thin wrapper around PostgREST (for database queries) and direct WebSocket (for realtime). For most MCP tools, using the client is simpler and handles auth automatically. However, if you need complex SQL — window functions, CTEs, lateral joins, or stored procedures — PostgREST's query syntax becomes limiting. In that case, use the Supabase rpc() method to call a Postgres function, or connect directly with the pg driver using your Supabase database's connection string (available in project settings under Database > Connection String). Direct pg connections bypass PostgREST entirely and support arbitrary SQL, but you must implement auth and RLS enforcement yourself using SET LOCAL role and SET LOCAL request.jwt.claims.

How do I handle Supabase project pauses in production?

Upgrade to a paid plan — free tier projects pause after 7 days of inactivity. If you're on the free tier intentionally (staging, demos), set up an AliveMCP monitor on /health/supabase so you know when a pause happens. You can also add a keep-alive: send a lightweight Supabase query every 24 hours via a cron job (setInterval or an external scheduler). This prevents the idle timer from triggering, though it doesn't count as "user activity" in Supabase's pause logic — only API requests with valid credentials reset the timer. Alternatively, set up a Supabase scheduled function (Edge Function with cron) that queries the database, which qualifies as project activity.

Should I use Supabase Edge Functions instead of an MCP server?

Supabase Edge Functions (Deno-based, globally distributed) and MCP servers serve different roles. Edge Functions are good for lightweight HTTP request handlers close to the user. MCP servers are stateful, long-running processes that maintain tool registries, session state, and possibly background subscriptions. You could deploy your MCP server as an Edge Function for the HTTP transport, but you lose persistent connections (no Realtime subscriptions that survive between invocations) and face Deno compatibility issues with some Node.js packages. A common hybrid: run your MCP server as a traditional Node.js process, and use Supabase Edge Functions for ancillary webhooks or background jobs that feed data into the database that your MCP tools read.

Further reading

Detect Supabase pauses before your users do

AliveMCP probes your /health/supabase endpoint every 60 seconds, alerting you the moment your Supabase project pauses or its connection pool fills.

Start monitoring free