El SDK de TypeScript de MCP, combinado con Supabase Edge Functions, te proporciona un servidor alojado y distribuido globalmente que se sitúa entre un cliente de IA y tu base de datos. El cliente de IA nunca obtiene acceso directo a la base de datos. Según la guía de despliegue de Supabase, el transporte recomendado para este stack es WebStandardStreamableHTTPServerTransport. Es la única implementación que se cubre a continuación. Lo que obtienes: un catálogo de productos de solo lectura sintético, herramientas de consulta paginadas, aislamiento de dos usuarios aplicado por RLS, un recorrido de transcripción del Inspector, y notas sobre despliegue y versionado.
El servidor y el flujo de datos
MCP se sitúa entre un cliente de IA (Claude Code, Claude Desktop, Cursor) y tu backend. El cliente se comunica mediante JSON-RPC sobre HTTP con tu servidor. Tu servidor expone herramientas. Las herramientas son funciones tipadas que el cliente puede invocar. El servidor decide qué pueden tocar esas herramientas.
Para Supabase, ese límite es crítico. Como lo explica el análisis de UI Bakery: el cliente de IA no debería obtener acceso directo ilimitado a tu base de datos. Tú diseñas las herramientas, defines las consultas, y Supabase Row Level Security (RLS) maneja el acceso por fila basado en la identidad del llamante.
El flujo de datos se ve así:
- Claude Code envía una solicitud JSON-RPC
tools/calla tu URL de Edge Function. - La Edge Function la recibe, extrae el JWT del encabezado
Authorization, y crea un cliente de Supabase limitado a esa identidad. - La herramienta ejecuta una consulta. Las políticas RLS en la tabla filtran las filas que el llamante puede ver.
- El resultado se serializa de vuelta como una respuesta JSON-RPC.
Observa que no hay una clave service-role en esa cadena. Un cliente service-role omite RLS por completo, así que si usas uno en una herramienta visible al llamante, tus políticas RLS son decoración. Guarda la clave service-role solo para tareas administrativas en segundo plano.
Datos de muestra sintéticos
Los ejemplos aquí usan una tabla de products sintética con tres columnas: id (uuid), owner_id (uuid, clave foránea a auth.users), y name (text). Dos usuarios sintéticos, alice y bob, cada uno posee un conjunto de filas disjunto. Esta configuración es explícitamente ilustrativa y no derivada de un proyecto cliente real.
create table public.products (
id uuid primary key default gen_random_uuid(),
owner_id uuid references auth.users(id),
name text not null
);
Poblala con, digamos, diez filas para cada usuario usando sus respectivos UUIDs.
Define una pequeña superficie de herramientas con paginación
Las herramientas estrechas son mejores que las amplias. Una herramienta que devuelve una tabla completa es un riesgo. La paginación mantiene los payloads predecibles y evita alcanzar los límites de respuesta de Edge Functions.
La superficie de herramientas para este servidor es deliberadamente pequeña:
- list_products: devuelve una página de productos del llamante, acepta parámetros
pageypage_size, por defecto página 1 con 20 filas por página. - get_product: devuelve un único producto por
id, falla elegantemente si el llamante no lo posee.
Eso es todo. Dos herramientas. No estás construyendo un motor de consultas. Si quieres entender cómo estructurar una capa de lectura más grande estilo directorio en Supabase, vale la pena leer primero el post del directorio de 25,000 páginas.
Definiendo el esquema con Zod
El MCP TypeScript SDK usa Zod para validación de entrada. Tus definiciones de herramientas se ven algo así (ilustrativo):
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 valida antes de que tu manejador se ejecute. El cliente recibe un error tipado en lugar de una excepción en tiempo de ejecución si envía basura. Es un buen comportamiento para construir desde el principio.
Implementa y ejecuta el servidor TypeScript
La guía de despliegue de Supabase usa @modelcontextprotocol/sdk@1.25.3 con WebStandardStreamableHTTPServerTransport. Esa es la versión fija al momento de escribir esto. Verifica la documentación actual antes de desplegar.
Andamiaje
mkdir my-mcp-server && cd my-mcp-server
supabase init
supabase functions new mcp
Tu función vive en supabase/functions/mcp/index.ts. Reemplaza el contenido predeterminado con algo parecido a esto (estructura ilustrativa):
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)
})
Dos cosas que vale la pena señalar aquí. Primero, una instancia nueva de McpServer y transport por solicitud es el patrón sin estado que el tiempo de ejecución de Edge Function espera. Segundo, el cliente de Supabase se crea con SUPABASE_ANON_KEY, no con la clave de service-role, y el JWT se reenvía en el encabezado de solicitud para que RLS vea la identidad de quien llama.
Ejecutando localmente
supabase start
supabase functions serve --no-verify-jwt mcp
Tu servidor está disponible en http://localhost:54321/functions/v1/mcp. La bandera --no-verify-jwt está bien para pruebas locales. Querrás verificación JWT activada en producción.
Autentica a quienes llaman y refuerza el acceso por filas
Aquí es donde la mayoría de tutoriales fallan. Recurren a la clave service-role porque es más simple. Pero eso elude RLS y significa que tu superficie de herramientas, sin importar lo estrecha, tiene acceso de lectura a nivel administrador a cada fila. Eso no es aceptable para un servidor orientado al usuario.

El patrón mostrado arriba (pasa el JWT del usuario, usa la clave anon) significa que Supabase Auth evalúa el token y establece el contexto auth.uid() dentro de Postgres para cada consulta. Tu política RLS puede entonces usar eso:
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());
Ahora el JWT de alice solo puede devolver filas donde owner_id coincida con su UID. bob llamando a get_product con uno de los IDs de producto de Alice obtiene No encontrado o acceso denegado, no un error, no los datos de Alice. Ese es el comportamiento correcto.
Para una mirada más profunda a escribir políticas RLS, la guía RLS de Supabase cubre los patrones y riesgos comunes en detalle.
Verificación de aislamiento de acceso (ilustrativa)
La verificación de dos usuarios es sencilla de programar. Obtén un JWT para Alice (vía supabase.auth.signInWithPassword), llama a list_products, confirma que todos los valores de owner_id devueltos coincidan con el UID de Alice. Repite con el JWT de Bob. Confirma cero contaminación cruzada. Si tus políticas son correctas, esto pasa por construcción. Si cometiste el error de service-role, se hará evidente aquí porque ambos usuarios verán todas las filas.
Si tu proyecto de Supabase ya lleva necesidades complejas de obtención de datos de Next.js junto con esto, el equipo en /solutions/nextjs-supabase-development/ puede ayudarte a estructurar la capa de base de datos sin los atajos de service-role que crean estos problemas.
Conecta Claude Code y verifica casos de error
Una vez que tu función se ejecuta localmente, agrégala a Claude Code:
claude mcp add products-mcp -t http http://localhost:54321/functions/v1/mcp
Luego, desde una sesión de Claude Code, puedes llamar tus herramientas directamente. Deberías ver list_products y get_product en la lista de herramientas disponibles. Si no las ves, ejecuta claude mcp list para confirmar que el servidor se registró correctamente.
Recorrido del transcript del Inspector
El MCP Inspector (npx @modelcontextprotocol/inspector) te proporciona una interfaz de navegador para usar herramientas sin un cliente completo. Como se señala en la guía mcp-lite, pegas la URL de tu endpoint en el Inspector y este descubre tus herramientas automáticamente.
Una sesión del Inspector ilustrativa para 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\"}...]" }]
}
}
Ahora prueba los casos de error deliberadamente. Llama get_product con un UUID válido que pertenezca a Bob, usando el JWT de Alice. Deberías obtener Not found o access denied. Llama list_products con page_size: 200 (por encima de tu límite de 100). Zod debería rechazarlo antes de que se ejecute la consulta. Ambos son comportamientos esperados y vale la pena confirmar ambos antes de desplegar.
¿Qué sucede cuando falta el JWT?
Sin un encabezado Authorization, tu llamada createClient pasa un JWT vacío. Supabase lo trata como el rol anon. Si tu política RLS no otorga explícitamente acceso al rol anon, la consulta devuelve cero filas. Lo cual es correcto. La herramienta devuelve un array vacío, no un error. Podrías preferir devolver un mensaje "unauthenticated" explícito. De cualquier forma, decide intencionalmente en lugar de descubrirlo en producción.
Despliega, observa y versiona el servidor
El despliegue es un comando único:
supabase functions deploy mcp
Tu función obtiene una URL estable en https://<project-ref>.supabase.co/functions/v1/mcp. Establece las variables de entorno en el panel de Supabase (Settings > Edge Functions > Secrets): SUPABASE_URL y SUPABASE_ANON_KEY. El runtime de la función inyecta esas variables en el momento de la invocación.
Observabilidad
Supabase proporciona logs de Edge Function integrados en el panel. Filtra por nombre de función. Verás tiempos de arranque en frío, duración de ejecución y cualquier error no capturado. Para observabilidad más estructurada, escribe console.log JSON desde dentro de tus manejadores de herramientas. El stream de logs lo captura.
Para uso en producción, considera:
- Registrar el nombre de la herramienta y el UID del llamante (no el JWT completo) por solicitud.
- Establecer un timeout en tus consultas de Supabase para que una consulta lenta no consuma tu presupuesto de ejecución de Edge Function.
- Devolver formas de error deterministas para que el cliente de IA pueda manejar fallos consistentemente.
Versionado
Edge Functions no tienen versionado integrado. El enfoque práctico es basado en ruta: despliega una función v2 como una Edge Function separada (supabase functions new mcp-v2), pruébala independientemente y luego actualiza tu registro de Claude Code. Los clientes antiguos pueden seguir apuntando a la URL v1 hasta que los cambies.
Si estás construyendo un stack MCP de producción con múltiples servidores, el post production MCP stack cubre decisiones de selección de servidor y arquitectura que quedan fuera del alcance aquí.
Lista de verificación de despliegue
- Elimina
--no-verify-jwtde los comandos de serve local antes de desplegar. - Confirma que
SUPABASE_ANON_KEYesté establecida en los secrets del panel, no en service-role. - Ejecuta tu verificación de aislamiento de acceso de dos usuarios contra la URL de producción antes de anunciar el endpoint.
- Establece un límite
max_rowsdentro de cada herramienta para evitar consultas sin límites. - Verifica la versión del SDK fijada en tu importación (
@modelcontextprotocol/sdk@1.25.3en la documentación de Supabase al momento de escribir esto) contra los lanzamientos actuales y actualiza si es necesario.
FAQ
¿Puedo usar mcp-lite en lugar del SDK oficial?
Sí. La documentación de Supabase dice explícitamente que puedes usar mcp-lite o mcp-handler como alternativas al SDK oficial. El WebStandardStreamableHTTPServerTransport usado arriba viene del SDK oficial, pero mcp-lite es más ligero y sin dependencias. Elige uno y mantente con él. La guía de mcp-lite muestra el comando de andamiaje: npm create mcp-lite@latest. La arquitectura y el comportamiento de RLS descritos arriba aplican sin importar qué framework elijas.
¿Necesito un plan pagado de Supabase para desplegar Edge Functions?
Edge Functions están disponibles en el nivel gratuito con algunos límites en invocaciones y tiempo de ejecución. Consulta la página de precios actual de Supabase para los números. Para una herramienta interna de bajo tráfico, el nivel gratuito suele ser suficiente. Para cualquier cosa orientada a producción con volumen real de consultas, los límites del plan Pro son más apropiados.
¿Qué pasa si mi servidor MCP necesita escribir datos, no solo leerlos?
Agrega herramientas que ejecuten consultas insert, update o delete. El patrón de RLS sigue aplicando: tu política controla qué filas el llamador puede modificar según auth.uid(). La consideración adicional principal es la idempotencia. Si el cliente de IA reintenta una llamada de herramienta fallida, ¿quieres que el insert se ejecute dos veces? Usar upsert con una clave primaria estable o verificar la existencia antes de insertar son las mitigaciones estándar.
¿Puedo ejecutar esto localmente sin una cuenta de Supabase?
Sí, supabase start ejecuta el stack completo de Supabase localmente a través de Docker, incluyendo Postgres, Auth y Edge Functions. Obtienes un SUPABASE_URL y SUPABASE_ANON_KEY locales de la salida de la CLI. Tu función y políticas de RLS funcionan exactamente como lo harán en producción. La única diferencia es el emisor de JWT, que es la instancia local de GoTrue en lugar de la Auth en la nube de Supabase.
¿Cómo manejo la limitación de velocidad en las herramientas?
Edge Functions no tienen limitación de velocidad incorporada por llamador. Las opciones prácticas son: una tabla de Postgres que registra conteos de llamadas por UID por ventana de tiempo (verificar e incrementar dentro de la herramienta), o una puerta de enlace de API ascendente (la propia puerta de enlace de API de Supabase maneja algo de esto a nivel de proyecto). Para la mayoría de herramientas internas, alcanzar el límite de invocación de Edge Function en un proyecto de nivel gratuito es la primera restricción que encontrarás, no abuso deliberado.
La advertencia más aguda de todo lo anterior: si usas una clave de rol de servicio en una herramienta orientada al llamador, tus políticas de RLS no hacen nada. La clave anon más el reenvío de JWT no es un complemento, es el mecanismo que hace funcionar el aislamiento de llamadores.
