← zurück Blaupause mit Strichzeichnungen von Serverknoten, verbunden mit einer zylindrischen Datenbank über Rohre mit Durchflussventilen und einem Druckmanometer

Erstelle einen MCP-Server in TypeScript auf Supabase

Das MCP TypeScript SDK, kombiniert mit Supabase Edge Functions, liefert dir einen gehosteten, global verteilten Server, der zwischen einem KI-Client und deiner Datenbank sitzt. Der KI-Client erhält keinen direkten Datenbankzugriff. Nach Supabase-Deployment-Leitfaden ist WebStandardStreamableHTTPServerTransport das empfohlene Transport-Protokoll für diesen Stack. Es ist die einzige hier behandelte Implementierung. Was du unten erhältst: einen synthetischen schreibgeschützten Produktkatalog, paginierte Abfrage-Tools, RLS-erzwungene Zwei-Benutzer-Isolierung, einen Inspector-Transkript-Walkthrough und Hinweise zu Deployment und Versionierung.

Der Server und der Datenfluss

MCP sitzt zwischen einem KI-Client (Claude Code, Claude Desktop, Cursor) und deinem Backend. Der Client spricht JSON-RPC über HTTP mit deinem Server. Dein Server stellt Tools bereit. Tools sind typisierte Funktionen, die der Client aufrufen kann. Der Server bestimmt, was diese Tools anfassen dürfen.

Bei Supabase ist diese Grenze entscheidend. Wie UI Bakery's Analyse zeigt: Der KI-Client sollte keinen unbegrenzten direkten Zugriff auf deine Datenbank haben. Du entwirfst die Tools, du definierst die Abfragen, und Supabase Row Level Security (RLS) handhabt Pro-Zeile-Zugriff basierend auf der Identität des Aufrufers.

Der Datenfluss sieht so aus:

  1. Claude Code sendet eine JSON-RPC tools/call Anfrage an deine Edge Function URL.
  2. Die Edge Function empfängt sie, extrahiert das JWT aus dem Authorization-Header und erstellt einen Supabase-Client mit der Identität dieses Aufrufers.
  3. Das Tool führt eine Abfrage aus. RLS-Richtlinien in der Tabelle filtern die Zeilen, die der Aufrufer sehen kann.
  4. Das Ergebnis wird als JSON-RPC-Antwort zurück serialisiert.

Beachte, dass in dieser Kette kein Service-Role-Key vorhanden ist. Ein Service-Role-Client umgeht RLS vollständig. Wenn du einen in einem aufrufergerichteten Tool verwendest, sind deine RLS-Richtlinien nur Dekoration. Halte den Service-Role-Key nur für Admin-Hintergrundaufgaben.

Synthetische Beispieldaten

Die Beispiele hier verwenden eine synthetische Tabelle „products" mit drei Spalten: „id" (uuid), „owner_id" (uuid, Fremdschlüssel zu auth.users) und „name" (text). Zwei synthetische Benutzer, alice und bob, besitzen jeweils einen disjunkten Satz von Zeilen. Dieses Setup ist ausdrücklich illustrativ und nicht von einem echten Client-Projekt abgeleitet.

create table public.products (

id uuid primary key default gen_random_uuid(),

owner_id uuid references auth.users(id),

name text not null

);

Fülle es mit, sagen wir, zehn Zeilen pro Benutzer unter Verwendung ihrer jeweiligen UUIDs auf.

Definiere eine kleine Tool-Oberfläche mit Paginierung

Enge Tools sind besser als breite. Ein Tool, das eine ganze Tabelle zurückgibt, ist ein Risiko. Paginierung hält Payloads vorhersehbar und vermeidet, dass Edge Function Response Limits überschritten werden.

Die Tool-Oberfläche für diesen Server ist bewusst klein:

  • list_products: gibt eine Seite von Produkten zurück, die dem Aufrufer gehören, akzeptiert Parameter „page" und „page_size", Standard ist Seite 1 mit 20 Zeilen pro Seite.
  • get_product: gibt ein einzelnes Produkt nach „id" zurück, schlägt fehl, wenn der Aufrufer es nicht besitzt.

Das ist alles. Zwei Tools. Du baust keine Query Engine. Wenn du verstehen möchtest, wie man eine größere verzeichnisähnliche Leseschicht auf Supabase strukturiert, ist der 25.000-Seiten-Post einen Blick wert.

Schema mit Zod definieren

Das MCP TypeScript SDK verwendet Zod zur Eingabevalidierung. Ihre Tool-Definitionen sehen ungefähr so aus (illustrativ):

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 validiert, bevor Ihr Handler ausgeführt wird. Der Client erhält einen typisierten Fehler zurück, statt einer Laufzeit-Exception, wenn er Unsinn sendet. Ein gutes Verhalten, das man von Anfang an einbauen sollte.

TypeScript-Server implementieren und ausführen

Das Supabase-Bereitstellungshandbuch verwendet @modelcontextprotocol/sdk@1.25.3 mit WebStandardStreamableHTTPServerTransport. Das ist die festgelegte Version zum Zeitpunkt des Schreibens. Prüfen Sie die aktuelle Dokumentation, bevor Sie ausrollen.

Strukturierung

mkdir my-mcp-server && cd my-mcp-server

supabase init

supabase functions new mcp

Ihre Funktion befindet sich unter supabase/functions/mcp/index.ts. Ersetzen Sie den Standardinhalt mit etwas in dieser Richtung (illustrative Struktur):

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)

})

Zwei Dinge sind hier erwähnenswert. Erstens: Eine frische McpServer- und Transport-Instanz pro Request ist das zustandslose Muster, das die Edge Function Runtime erwartet. Zweitens: Der Supabase-Client wird mit SUPABASE_ANON_KEY erstellt, nicht mit dem Service-Role-Schlüssel, und der JWT wird im Request-Header weitergeleitet, damit RLS die Identität des Aufrufers sieht.

Lokal ausführen

supabase start

supabase functions serve --no-verify-jwt mcp

Ihr Server ist unter http://localhost:54321/functions/v1/mcp verfügbar. Das Flag --no-verify-jwt ist für lokale Tests in Ordnung. In der Produktion sollten Sie JWT-Verifikation aktiviert haben.

Aufrufer authentifizieren und Zeilenzugriff erzwingen

Hier liegen die meisten Tutorials falsch. Sie greifen zum Service-Role-Schlüssel, weil er einfacher ist. Aber das umgeht RLS und bedeutet, dass Ihre Tool-Oberfläche, so eng auch immer, Admin-Level-Lesezugriff auf jede Reihe hat. Das ist für einen nach außen gewandten Server nicht akzeptabel.

Schemazeichnung konzentrische Datenbankzylinder, getrennt durch ein Filtergatter mit Richtungspfeilen für Zeilenzugriff-Partitionierung

Das oben gezeigte Muster (JTW des Aufrufers weitergeben, den anon Key verwenden) bedeutet, dass Supabase Auth das Token auswertet und den auth.uid()-Kontext in Postgres für jede Abfrage setzt. Ihre RLS-Richtlinie kann dann das nutzen:

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());

Alices JWT kann nur jemals Reihen zurückgeben, bei denen owner_id ihrer UID entspricht. bob, der get_product mit einer von Alices Produkt-IDs aufruft, bekommt „Nicht gefunden" oder „Zugriff verweigert", nicht einen Fehler, nicht Alices Daten. Das ist das korrekte Verhalten.

Für einen tieferen Blick auf das Schreiben von RLS-Richtlinien deckt das Supabase RLS-Handbuch die häufigen Muster und Fallstricke detailliert ab.

Zugriffsisolationsprüfung (illustrativ)

Die Zwei-Benutzer-Prüfung ist einfach zu scripten. Holen Sie sich einen JWT für Alice (über supabase.auth.signInWithPassword), rufen Sie list_products auf, bestätigen Sie, dass alle zurückgegebenen owner_id-Werte Alices UID entsprechen. Wiederholen Sie mit Bobs JWT. Bestätigen Sie keine Kreuzkontamination. Wenn Ihre Richtlinien korrekt sind, besteht dies konstruktionsbedingt. Wenn Sie den Service-Role-Fehler gemacht haben, wird er hier sichtbar, weil beide Benutzer alle Reihen sehen.

Wenn Ihr Supabase-Projekt bereits komplexe Next.js-Datenabruf-Anforderungen neben diesem trägt, kann das Team unter /solutions/nextjs-supabase-development/ Ihnen helfen, die Datenbankschicht zu strukturieren, ohne die Service-Role-Abkürzungen, die diese Probleme verursachen.

Claude Code verbinden und Fehlerfälle prüfen

Sobald deine Funktion lokal läuft, füge sie zu Claude Code hinzu:

claude mcp add products-mcp -t http http://localhost:54321/functions/v1/mcp

Dann kannst du aus einer Claude Code-Sitzung deine Tools direkt aufrufen. Du solltest list_products und get_product in der Liste verfügbarer Tools sehen. Falls nicht, führe claude mcp list aus, um zu bestätigen, dass sich der Server korrekt registriert hat.

Inspector-Transkript-Walkthrough

Der MCP Inspector (npx @modelcontextprotocol/inspector) bietet dir eine Browser-UI zum Testen von Tools ohne vollständigen Client. Wie im mcp-lite guide erwähnt, fügst du deine Endpoint-URL in den Inspector ein und er erkennt deine Tools automatisch.

Eine beispielhafte Inspector-Sitzung für 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\"}...]" }]

}

}

Teste nun die Fehlerfälle gezielt. Rufe get_product mit einer gültigen UUID auf, die Bob gehört, aber mit Alices JWT. Du solltest Not found oder access denied erhalten. Rufe list_products mit page_size: 200 auf (über deinem 100er-Limit). Zod sollte dies ablehnen, bevor die Abfrage läuft. Beides ist erwartetes Verhalten und beides solltest du vor dem Deployment bestätigen.

Was passiert, wenn das JWT fehlt?

Ohne Authorization-Header übergibt dein createClient-Aufruf ein leeres JWT. Supabase behandelt dies als die anon-Rolle. Falls deine RLS-Richtlinie der anon-Rolle nicht explizit Zugriff gewährt, gibt die Abfrage null Zeilen zurück. Das ist korrekt. Das Tool gibt ein leeres Array zurück, keinen Fehler. Du könntest stattdessen eine explizite „unauthenticated"-Meldung zurückgeben. In jedem Fall entscheide bewusst, statt dies in der Produktion zu entdecken.

Stelle den Server bereit, beobachte ihn und versioniere ihn

Die Bereitstellung ist ein einzelner Befehl:

supabase functions deploy mcp

Deine Funktion erhält eine stabile URL unter https://<project-ref>.supabase.co/functions/v1/mcp. Setze die Umgebungsvariablen im Supabase-Dashboard (Settings > Edge Functions > Secrets): SUPABASE_URL und SUPABASE_ANON_KEY. Die Funktionslaufzeit injiziert diese bei jedem Aufruf.

Observabilität

Supabase bietet integrierte Edge Function-Logs im Dashboard. Filtere nach Funktionsname. Du wirst Cold Start-Zeiten, Ausführungsdauer und alle unerwarteten Fehler sehen. Für strukturiertere Beobachtbarkeit schreibe console.log JSON aus deinen Tool-Handlern. Der Log-Stream erfasst dies.

Für die Produktionsnutzung solltest du bedenken:

  • Den Tool-Namen und die Aufrufer-UID (nicht das vollständige JWT) pro Anfrage protokollieren.
  • Ein Timeout für deine Supabase-Abfragen setzen, damit eine langsame Abfrage dein Edge Function-Ausführungsbudget nicht aufbraucht.
  • Deterministische Fehlerformen zurückgeben, damit der AI-Client Fehler konsistent behandeln kann.

Versionierung

Edge Functions haben keine integrierte Versionierung. Der praktische Ansatz ist pfadbasiert: Stelle eine v2-Funktion als separate Edge Function bereit (supabase functions new mcp-v2), teste sie unabhängig, aktualisiere dann deine Claude Code-Registrierung. Alte Clients können weiterhin auf die v1-URL verweisen, bis du sie umschaltest.

Falls du einen produktiven MCP-Stack mit mehreren Servern aufbaust, behandelt der production MCP stack post Serverauswahl und Architekturentscheidungen, die hier außerhalb des Umfangs liegen.

Bereitstellungs-Checkliste

  1. Entferne --no-verify-jwt aus lokalen serve-Befehlen vor der Bereitstellung.
  2. Bestätige, dass SUPABASE_ANON_KEY in Dashboard-Secrets gesetzt ist, nicht service-role.
  3. Führe deine Zugriffsisolationsprüfung für zwei Benutzer gegen die produktive URL durch, bevor du den Endpoint ankündigst.
  4. Setzen Sie eine max_rows-Obergrenze in jedem Tool, um unbegrenzte Abfragen zu verhindern.
  5. Überprüfen Sie die SDK-Version, die in Ihrem Import festgelegt ist (@modelcontextprotocol/sdk@1.25.3 in der Supabase-Dokumentation zum Zeitpunkt des Verfassens), gegen aktuelle Releases und aktualisieren Sie diese bei Bedarf.

FAQ

Kann ich mcp-lite statt des offiziellen SDK verwenden?

Ja. Die Supabase-Dokumentation besagt explizit, dass Sie mcp-lite oder mcp-handler als Alternativen zum offiziellen SDK verwenden können. Das WebStandardStreamableHTTPServerTransport, das oben verwendet wird, stammt aus dem offiziellen SDK, aber mcp-lite ist leichter und ohne Abhängigkeiten. Wählen Sie eines und bleiben Sie dabei. Das mcp-lite-Guide zeigt den Scaffold-Befehl: npm create mcp-lite@latest. Die oben beschriebene Architektur und RLS-Verhalten gelten unabhängig davon, welches Framework Sie wählen.

Benötige ich einen bezahlten Supabase-Plan, um Edge Functions bereitzustellen?

Edge Functions sind im kostenlosen Plan mit einigen Grenzen bei Aufrufen und Ausführungszeit verfügbar. Überprüfen Sie die aktuelle Supabase-Preisseite für die genauen Zahlen. Für ein internes Tool mit wenig Traffic ist der kostenlose Plan normalerweise ausreichend. Für alles, das nach außen hin produktiv ist und ein reales Abfragevolumen aufweist, sind die Grenzen des Pro-Plans angemessener.

Was ist, wenn mein MCP-Server Daten schreiben muss, nicht nur lesen?

Fügen Sie Tools hinzu, die insert-, update- oder delete-Abfragen ausführen. Das RLS-Muster gilt weiterhin: Ihre Policy steuert, welche Zeilen der Aufrufer basierend auf auth.uid() ändern kann. Die wichtigste zusätzliche Überlegung ist Idempotenz. Wenn der AI-Client einen fehlgeschlagenen Tool-Aufruf erneut versucht, soll das Insert zweimal ausgeführt werden? Mit upsert mit stabilem Primary Key oder durch Überprüfung der Existenz vor dem Einfügen sind die Standard-Lösungsansätze.

Kann ich dies lokal ohne Supabase-Konto ausführen?

Ja, supabase start führt den vollständigen Supabase-Stack lokal über Docker aus, einschließlich Postgres, Auth und Edge Functions. Sie erhalten SUPABASE_URL und SUPABASE_ANON_KEY aus der CLI-Ausgabe. Ihre Funktion und RLS-Policies funktionieren genau so wie in der Produktion. Der einzige Unterschied ist der JWT-Aussteller, der die lokale GoTrue-Instanz statt Supabase's Cloud Auth ist.

Wie handle ich Rate Limiting bei den Tools?

Edge Functions haben kein integriertes Rate Limiting pro Aufrufer. Die praktischen Optionen sind: eine Postgres-Tabelle, die Aufrufe pro UID pro Zeitfenster erfasst (Prüfung und Inkrementierung innerhalb des Tools), oder ein vorgelagertes API-Gateway (Supabase's eigenes API-Gateway verwaltet einiges davon auf Projektebene). Für die meisten internen Tools ist das Erreichen des Edge-Function-Aufruflimits in einem kostenlosen Projekt die erste Grenze, auf die Sie stoßen, nicht absichtlicher Missbrauch.

Der schärfste Vorbehalt von allem oben: Wenn Sie einen service-role-Schlüssel in einem benutzerseitigen Tool verwenden, tun Ihre RLS-Policies nichts. Der anon-Schlüssel plus JWT-Weiterleitung ist kein nice-to-have, es ist der Mechanismus, der die Aufrufer-Isolation zum Funktionieren bringt.

← zurück