O SDK TypeScript do MCP, combinado com Supabase Edge Functions, oferece um servidor hospedado e distribuído globalmente que fica entre um cliente de IA e seu banco de dados. O cliente de IA nunca obtém acesso direto ao banco de dados. Conforme o guia de deploy do Supabase, o transporte recomendado para essa pilha é o WebStandardStreamableHTTPServerTransport. Essa é a única implementação coberta abaixo. O que você obtém abaixo: um catálogo de produtos sintético somente leitura, ferramentas de consulta paginadas, isolamento de dois usuários aplicado por RLS, um passo a passo da transcrição do Inspector, e notas sobre deploy e versionamento.
O servidor e o fluxo de dados
O MCP fica entre um cliente de IA (Claude Code, Claude Desktop, Cursor) e seu backend. O cliente fala JSON-RPC via HTTP para seu servidor. Seu servidor expõe ferramentas. Ferramentas são funções tipadas que o cliente pode chamar. O servidor decide o que essas ferramentas podem acessar.
Para Supabase, essa fronteira é crítica. Conforme o resumo do UI Bakery: o cliente de IA não deve obter acesso direto e ilimitado ao seu banco de dados. Você projeta as ferramentas, você define as consultas, e Supabase Row Level Security (RLS) lida com acesso por linha baseado na identidade do chamador.
O fluxo de dados é assim:
- Claude Code envia uma requisição JSON-RPC
tools/callpara a URL da sua Edge Function. - A Edge Function a recebe, extrai o JWT do cabeçalho
Authorization, e cria um cliente Supabase com escopo naquela identidade. - A ferramenta executa uma consulta. Políticas RLS na tabela filtram as linhas que o chamador pode ver.
- O resultado é serializado de volta como uma resposta JSON-RPC.
Observe que não há uma chave service-role nessa corrente. Um cliente service-role contorna RLS inteiramente, então se você usar um em uma ferramenta voltada ao chamador, suas políticas RLS são apenas decoração. Mantenha a chave service-role apenas para tarefas administrativas em background.
Dados de amostra sintética
Os exemplos aqui usam uma tabela de products sintética com três colunas: id (uuid), owner_id (uuid, chave estrangeira para auth.users), e name (text). Dois usuários sintéticos, alice e bob, cada um possui um conjunto disjunto de linhas. Essa configuração é explicitamente ilustrativa e não é derivada de um projeto real de cliente.
create table public.products (
id uuid primary key default gen_random_uuid(),
owner_id uuid references auth.users(id),
name text not null
);
Popule com, digamos, dez linhas para cada usuário usando seus respectivos UUIDs.
Defina uma pequena superfície de ferramentas com paginação
Ferramentas estreitas são melhores que amplas. Uma ferramenta que retorna uma tabela inteira é um passivo. Paginação mantém payloads previsíveis e evita atingir limites de resposta de Edge Functions.
A superfície de ferramentas para esse servidor é deliberadamente pequena:
- list_products: retorna uma página de produtos de propriedade do chamador, aceita parâmetros
pageepage_size, com padrão na página 1 com 20 linhas por página. - get_product: retorna um único produto por
id, falha com elegância se o chamador não o possuir.
É isso. Duas ferramentas. Você não está construindo um mecanismo de consultas. Se quer entender como estruturar uma camada de leitura maior no estilo diretório no Supabase, vale a pena ler primeiro o post do diretório de 25 mil páginas.
Definindo o schema com Zod
O MCP TypeScript SDK usa Zod para validação de entrada. Suas definições de ferramenta ficam mais ou menos assim (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 do seu handler executar. O cliente recebe um erro tipado em vez de uma exceção em tempo de execução se enviar dados inválidos. É um bom comportamento para estabelecer desde o início.
Implemente e execute o servidor TypeScript
O guia de deploy do Supabase usa @modelcontextprotocol/sdk@1.25.3 com WebStandardStreamableHTTPServerTransport. Essa é a versão fixa no momento da escrita. Verifique a documentação atual antes de colocar em produção.
Scaffolding
mkdir my-mcp-server && cd my-mcp-server
supabase init
supabase functions new mcp
Sua função fica em supabase/functions/mcp/index.ts. Substitua o conteúdo padrão por algo parecido com isto (estrutura 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)
})
Dois pontos valem destaque aqui. Primeiro, uma instância nova de McpServer e transport por requisição é o padrão sem estado que o runtime da Edge Function espera. Segundo, o cliente Supabase é criado com SUPABASE_ANON_KEY, não a chave de service-role, e o JWT é encaminhado no header da requisição para que o RLS veja a identidade de quem chamou.
Executando localmente
supabase start
supabase functions serve --no-verify-jwt mcp
Seu servidor está disponível em http://localhost:54321/functions/v1/mcp . A flag --no-verify-jwt é aceitável para testes locais. Em produção, você vai querer a verificação de JWT ativada.
Autentique chamadores e aplique acesso por linha
É aqui que a maioria dos tutoriais erra. Eles recorrem à chave de service-role porque é mais simples. Mas isso contorna o RLS e significa que sua superfície de ferramenta, por mais estreita que seja, tem acesso de leitura em nível de admin a cada linha. Isso não é aceitável para um servidor voltado para o chamador.

O padrão mostrado acima (passar o JWT do chamador, usar a chave anon) significa que o Supabase Auth avalia o token e define o contexto auth.uid() dentro do Postgres para cada consulta. Sua política de RLS pode então usar isso:
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());
Agora o JWT de alice só pode retornar linhas onde owner_id corresponde ao seu UID. bob chamando get_product com um dos IDs de produtos de Alice recebe Not found ou access denied, não um erro, não os dados de Alice. Esse é o comportamento correto.
Para uma análise mais profunda sobre escrita de políticas de RLS, o guia de RLS do Supabase cobre os padrões comuns e armadilhas em detalhes.
Verificação de isolamento de acesso (ilustrativa)
A verificação de dois usuários é direta para programar. Obtenha um JWT para Alice (via supabase.auth.signInWithPassword), chame list_products, confirme que todos os valores owner_id retornados correspondem ao UID de Alice. Repita com o JWT de Bob. Confirme zero contaminação cruzada. Se suas políticas estão corretas, isso passa por construção. Se você cometeu o erro de service-role, isso aparecerá aqui porque ambos os usuários verão todas as linhas.
Se seu projeto Supabase já carrega necessidades complexas de busca de dados Next.js junto com isso, o time em /solutions/nextjs-supabase-development/ pode ajudar você a estruturar a camada de banco de dados sem os atalhos de service-role que criam esses problemas.
Conecte Claude Code e verifique casos de erro
Assim que sua função estiver rodando localmente, adicione-a ao Claude Code:
claude mcp add products-mcp -t http http://localhost:54321/functions/v1/mcp
Então, a partir de uma sessão Claude Code, você pode chamar suas ferramentas diretamente. Você deve ver list_products e get_product na lista de ferramentas disponíveis. Se não ver, execute claude mcp list para confirmar que o servidor foi registrado corretamente.
Passo a passo da transcrição do Inspector
O MCP Inspector (npx @modelcontextprotocol/inspector) oferece uma UI no navegador para exercitar ferramentas sem um cliente completo. Conforme observado no guia mcp-lite, você cola a URL do seu endpoint no Inspector e ele descobre suas ferramentas automaticamente.
Uma sessão ilustrativa do Inspector 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\"}...]" }]
}
}
Agora teste os casos de erro deliberadamente. Chame get_product com um UUID válido que pertença a Bob, usando o JWT de Alice. Você deve obter Not found or access denied. Chame list_products com page_size: 200 (acima do seu limite de 100). O Zod deve rejeitar antes da query rodar. Ambos são comportamentos esperados, e ambos valem a pena confirmar antes de fazer deploy.
O que acontece quando o JWT está faltando?
Sem um cabeçalho Authorization, sua chamada createClient passa um JWT vazio. Supabase o trata como a role anon. Se sua política RLS não conceder explicitamente ao role anon acesso, a query retorna zero linhas. O que está correto. A ferramenta retorna um array vazio, não um erro. Você pode preferir retornar uma mensagem explícita de "não autenticado". De qualquer forma, decida intencionalmente em vez de descobrir isso em produção.
Faça deploy, observe e versione o servidor
O deployment é um comando único:
supabase functions deploy mcp
Sua função recebe uma URL estável em https://<project-ref>.supabase.co/functions/v1/mcp. Configure as variáveis de ambiente no dashboard do Supabase (Settings > Edge Functions > Secrets): SUPABASE_URL e SUPABASE_ANON_KEY. O runtime da função as injeta no momento da invocação.
Observabilidade
Supabase fornece logs de Edge Function integrados no dashboard. Filtre por nome de função. Você verá tempos de cold start, duração da execução e quaisquer erros não capturados. Para observabilidade mais estruturada, escreva console.log JSON dentro de seus manipuladores de ferramentas. O stream de logs capta isso.
Para uso em produção, pense em:
- Registrar o nome da ferramenta e UID do chamador (não o JWT completo) por requisição.
- Definir um timeout em suas queries Supabase para que uma query lenta não queime seu orçamento de execução de Edge Function.
- Retornar formas de erro determinísticas para que o cliente de IA possa lidar com falhas consistentemente.
Versionamento
Edge Functions não têm versionamento integrado. A abordagem prática é baseada em caminho: faça deploy de uma função v2 como uma Edge Function separada (supabase functions new mcp-v2), teste-a independentemente e atualize seu registro Claude Code. Clientes antigos podem continuar apontando para a URL v1 até você fazê-los migrar.
Se você está construindo um stack MCP de produção com múltiplos servidores, o post production MCP stack cobre seleção de servidor e decisões de arquitetura fora do escopo deste artigo.
Checklist de deployment
- Remova
--no-verify-jwtdos comandos de serve local antes de fazer deploy. - Confirme que
SUPABASE_ANON_KEYestá definida nos secrets do dashboard, não service-role. - Execute sua verificação de isolamento de acesso de dois usuários contra a URL de produção antes de anunciar o endpoint.
- Defina um limite de
max_rowsdentro de cada ferramenta para evitar consultas sem limite. - Verifique a versão do SDK fixada na sua importação (
@modelcontextprotocol/sdk@1.25.3na documentação do Supabase no momento da redação) contra as versões atuais e atualize se necessário.
FAQ
Posso usar mcp-lite em vez do SDK oficial?
Sim. A documentação do Supabase diz explicitamente que você pode usar mcp-lite ou mcp-handler como alternativas ao SDK oficial. O WebStandardStreamableHTTPServerTransport usado acima vem do SDK oficial, mas mcp-lite é mais leve e sem dependências. Escolha um e mantenha-se consistente. O guia mcp-lite mostra o comando de scaffold: npm create mcp-lite@latest. A arquitetura e o comportamento de RLS descritos acima se aplicam independentemente do framework que você escolher.
Preciso de um plano Supabase pago para implantar Edge Functions?
Edge Functions estão disponíveis no plano gratuito com alguns limites de invocações e tempo de execução. Consulte a página de preços atual do Supabase para os números. Para uma ferramenta interna com pouco tráfego, o plano gratuito geralmente é suficiente. Para qualquer coisa voltada para produção com volume real de consultas, os limites do plano Pro são mais apropriados.
E se meu servidor MCP precisar escrever dados, não apenas ler?
Adicione ferramentas que executem consultas insert, update ou delete. O padrão de RLS ainda se aplica: sua política controla quais linhas o chamador pode modificar com base em auth.uid(). A consideração adicional principal é idempotência. Se o cliente de IA repetir uma chamada de ferramenta que falhou, você quer que o insert seja executado duas vezes? Usar upsert com uma chave primária estável ou verificar a existência antes de inserir são as mitigações padrão.
Posso executar isso localmente sem uma conta Supabase?
Sim, supabase start executa a stack completa do Supabase localmente via Docker, incluindo Postgres, Auth e Edge Functions. Você obtém uma SUPABASE_URL e SUPABASE_ANON_KEY locais na saída da CLI. Sua função e políticas de RLS funcionam exatamente como funcionarão em produção. A única diferença é o emissor do JWT, que é a instância local do GoTrue em vez da Auth na nuvem do Supabase.
Como lido com limitação de taxa nas ferramentas?
Edge Functions não têm limitação de taxa integrada por chamador. As opções práticas são: uma tabela Postgres que registra contagens de chamadas por UID por janela de tempo (verifique e incremente dentro da ferramenta), ou um gateway de API upstream (o próprio gateway de API do Supabase controla parte disso em nível de projeto). Para a maioria das ferramentas internas, atingir o limite de invocação de Edge Function em um projeto de plano gratuito é a primeira restrição que você encontrará, não abuso deliberado.
A única ressalva mais afiada de tudo acima: se você usar uma chave de service-role em uma ferramenta voltada para chamadores, suas políticas de RLS não fazem nada. A chave anon mais encaminhamento de JWT não é um bom adicional, é o mecanismo que faz o isolamento de chamadores funcionar.
