MCP auf ERP-Daten: Personendaten bleiben per Spaltenrecht draußen
Ein MCP-Server gibt einem KI-Assistenten Lesezugriff auf eine Datenbank. Liegt darin eine Buchhaltung, sind auch Personendaten dabei. Diese Feldnotiz beschreibt, wie man mit PostgreSQL-Spaltenrechten und Views verhindert, dass diese Daten das Modell erreichen, und wie man das Ganze betreibt und nutzt.
01 — Der Aufbau
Ein ERP exportiert jede Nacht rund 200 Tabellen in eine eigene PostgreSQL-Datenbank. Der Import legt dazu ein neues Schema an, befüllt es und tauscht es in einer Transaktion gegen den Vortag. Ein Sprachmodell soll Fragen an diese Zahlen beantworten, über MCP. Wer das Modell aufruft, ob ein Dashboard über Amazon Bedrock oder ein Desktop-Client, spielt für diese Notiz keine Rolle.
Als MCP-Server läuft Postgres MCP Pro, ein fertiges Open-Source-Produkt. Der Export hat gut 4.600 Spalten. Rund 230 davon enthalten Personendaten, und keines dieser Felder soll beim Modell ankommen.
02 — Spaltenrechte statt Prompt-Regel
Eine Anweisung im Prompt wie „Gib keine Namen aus“ verhindert nichts zuverlässig. Eine passend formulierte Frage umgeht sie, und das Modell hat die Daten zu diesem Zeitpunkt ohnehin schon erhalten. Die Einschränkung muss deshalb in der Datenbank liegen.
PostgreSQL vergibt SELECT auch auf einzelne Spalten. Eine Abfrage, die eine nicht freigegebene Spalte liest, scheitert mit permission denied. Das gilt auch für SELECT *, weil der Stern zu allen Spalten aufgelöst wird.
03 — Die Rollen anlegen
Der Aufbau nutzt aus, dass eine View in PostgreSQL die Rechte auf die zugrunde liegenden Tabellen mit ihrem Besitzer prüft und nicht mit der Rolle, die sie abfragt. Dafür braucht es drei Rollen.
- Import: besitzt das Schema
erpund lädt es jede Nacht neu. mcp_owner: besitzt das Schemamcpmit den Views und darf inerpnur freigegebene Spalten lesen.mcp_reader: der Login des MCP-Servers. Sie liestmcpund sonst nichts.
Angelegt werden die Rollen einmal, ihre Passwörter setzt der Import bei jedem Lauf aus dem Secret-Store neu:
CREATE ROLE mcp_owner LOGIN;
CREATE ROLE mcp_reader LOGIN;
SELECT format('ALTER ROLE mcp_owner PASSWORD %L', :'owner_pw') \gexec
SELECT format('ALTER ROLE mcp_reader PASSWORD %L', :'reader_pw') \gexec
CREATE SCHEMA IF NOT EXISTS mcp AUTHORIZATION mcp_owner;
GRANT USAGE ON SCHEMA erp TO mcp_owner; -- Spalten folgen in Abschnitt 04
GRANT USAGE ON SCHEMA mcp TO mcp_reader; -- kein USAGE auf erp
ALTER ROLE mcp_reader SET default_transaction_read_only = on;
ALTER ROLE mcp_reader SET statement_timeout = '30s';
ALTER ROLE mcp_reader SET log_statement = 'all';
mcp_reader hat auf erp kein USAGE und kommt damit an keine Tabelle dort heran. Sie liest nur Views, und die Views lesen mit den Rechten von mcp_owner. Legt jemand eine View mit security_invoker an, prüft PostgreSQL die Rechte von mcp_reader, und die Abfrage schlägt fehl.
04 — Spaltenrechte aus dem Manifest
Die Spaltenrechte entstehen jede Nacht neu, in derselben Transaktion wie der Import. Weil das Schema erp dabei neu angelegt wird, gibt es keine alten Rechte, die man entziehen müsste. Grundlage ist das Manifest des Exports, ein JSON mit jeder Tabelle und all ihren Spalten. Dazu kommen zwei Listen: die geprüften Tabellen und, je Tabelle, die Spalten mit Personendaten.
CREATE OR REPLACE FUNCTION erp_admin.grant_mcp_owner(
manifest jsonb, reviewed jsonb, pii jsonb) RETURNS integer
LANGUAGE plpgsql AS $$
DECLARE tbl jsonb; key text; cols text[]; n int := 0;
BEGIN
FOR tbl IN SELECT * FROM jsonb_array_elements(manifest->'tables') LOOP
key := tbl->>'name';
CONTINUE WHEN NOT (coalesce(reviewed, '[]') ? key);
SELECT array_agg(quote_ident(c->>'name')) INTO cols
FROM jsonb_array_elements(tbl->'columns') c
WHERE NOT (coalesce(pii->key, '[]') ? (c->>'name'));
CONTINUE WHEN cols IS NULL;
EXECUTE format('GRANT SELECT (%s) ON erp.%I TO mcp_owner',
array_to_string(cols, ', '), key);
n := n + array_length(cols, 1);
END LOOP;
RETURN n;
END $$;
Aufgerufen wird sie am Ende des Imports, nach dem Tausch der Schemas und vor dem COMMIT:
SELECT erp_admin.grant_mcp_owner(:'manifest'::jsonb,
:'reviewed'::jsonb,
:'pii'::jsonb) AS spalten_freigegeben;
quote_ident und %I sorgen dafür, dass auch Spaltennamen mit Großbuchstaben oder Sonderzeichen korrekt im GRANT landen. Eine Tabelle, die nach einem ERP-Update neu im Export auftaucht, steht auf keiner Liste und bekommt deshalb kein Recht. Sie wird erst freigegeben, wenn jemand sie geprüft und eingetragen hat.
05 — Die zwei Listen
Die Listen liegen im Deployment, versioniert und über Merge Requests geändert. Ein Ausschnitt:
mcp:
reviewedTables:
- buchungen
- konten
- adressen
- belege
piiColumns:
adressen: [name, vorname, strasse, telefon, email, geburtstag, notiz]
buchungen: [buchungstext, bemerkung, erfasst_von]
konten: [kontobezeichnung]
belege: [kunde_name, lieferanschrift, bearbeiter]
Bevor eine Liste ins Deployment geht, prüft ein kleines Skript jeden Namen gegen das Manifest. Ein Tippfehler in einer Spalte würde sonst still eine Personenspalte freigeben, weil sie nicht auf der Liste steht.
Auf die Liste gehören Name, Anschrift, Telefon, E-Mail, IBAN, Geburtstag, Ansprechpartner sowie Benutzer- und Bearbeiternamen. Schwieriger sind Freitextfelder. Ein Buchungstext lautet oft „Rechnung Müller 4711“. Ein Spaltenrecht gilt immer für die ganze Spalte, also bleibt nur, sie komplett zu sperren. Bei Kontobezeichnungen ist es genauso: Auf einem Debitorenkonto steht der Kundenname, auf einem Sachkonto „Erlöse 19 %“.
Dadurch fehlen dem Modell Informationen, die für manche Auswertungen nützlich wären. Das sollte man vorher mit den Nutzern klären. Im konkreten Fall standen am Ende 227 Spalten in 33 Tabellen auf der Liste, darunter auch Kassen-PINs und Passwortfelder.
06 — Views schreiben und einspielen
Das Modell fragt nicht die Rohdaten ab, sondern Views im Schema mcp. Die Views sollten aggregieren und kleine Gruppen weglassen:
CREATE VIEW mcp.umsatz_monat AS
SELECT date_trunc('month', belegdatum)::date AS monat,
konto_haben AS erloeskonto,
sum(betrag) AS umsatz,
count(*) AS buchungen
FROM erp.buchungen
WHERE konto_haben BETWEEN '8000' AND '8999'
GROUP BY 1, 2
HAVING count(*) >= 5;
COMMENT ON VIEW mcp.umsatz_monat IS
'Erlöse je Monat und Erlöskonto, netto, ab 5 Buchungen';
Postgres MCP Pro gibt den Kommentar nicht automatisch an das Modell weiter. Das Modell kann ihn aber mit obj_description('mcp.umsatz_monat'::regclass) abfragen. Ein Hinweis darauf im Systemprompt hilft ihm, die passende View zu finden.
Alle Views stehen in einer Datei views.sql. Ein Job spielt sie als mcp_owner ein, in einer einzigen Transaktion:
psql -v ON_ERROR_STOP=1 --single-transaction <<'SQL'
SELECT format('DROP VIEW IF EXISTS mcp.%I CASCADE', viewname)
FROM pg_views WHERE schemaname = 'mcp' \gexec
\i /views/views.sql
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_class WHERE relnamespace = 'mcp'::regnamespace
AND relkind <> 'v') THEN
RAISE EXCEPTION 'schema mcp may hold views only';
END IF;
IF EXISTS (SELECT 1 FROM pg_proc WHERE pronamespace = 'mcp'::regnamespace) THEN
RAISE EXCEPTION 'schema mcp may hold no functions';
END IF;
END $$;
SELECT format('SELECT * FROM mcp.%I LIMIT 0', viewname)
FROM pg_views WHERE schemaname = 'mcp' \gexec
GRANT SELECT ON ALL TABLES IN SCHEMA mcp TO mcp_reader;
SQL
Entscheidend ist der Schritt mit LIMIT 0. CREATE VIEW prüft keine Rechte auf die Tabellen darunter, erst die Abfrage tut das. Liest eine View eine gesperrte Spalte, endet sie mit permission denied. Die Transaktion rollt zurück, und die vorherigen Views bleiben aktiv.
Der Job läuft zweimal: als Init-Container vor dem Start des MCP-Servers und als CronJob nach dem nächtlichen Import. Der Import legt erp neu an, und CASCADE nimmt die Views dabei mit.
07 — Den MCP-Server betreiben
Postgres MCP Pro läuft als Container neben der Datenbank. Wichtig sind drei Dinge: der Modus restricted, eine Verbindung als mcp_reader und TLS mit Zertifikatsprüfung.
containers:
- name: postgres-mcp
image: crystaldba/postgres-mcp # in der Praxis per Digest gepinnt
args: ["--access-mode=restricted", "--transport=sse",
"--sse-host=0.0.0.0", "--sse-port=8000"]
env:
- name: MCP_READER_PASSWORD
valueFrom: { secretKeyRef: { name: mcp-db, key: reader_password } }
- name: DATABASE_URI
value: "postgresql://mcp_reader:$(MCP_READER_PASSWORD)@db:5432/workspace?sslmode=verify-full&sslrootcert=/run/tls/ca.crt"
securityContext:
runAsNonRoot: true
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
restricted führt jede Abfrage in einer lesenden Transaktion aus und bricht sie nach 30 Sekunden ab. Die Rolle mcp_reader hat dieselben Einstellungen. Damit greifen die Beschränkungen auch dann, wenn eine der beiden Ebenen falsch konfiguriert ist.
Der Server hat keinen öffentlichen Endpunkt. Eine NetworkPolicy lässt nur die Pods zu, die ihn nutzen sollen:
kind: NetworkPolicy
spec:
podSelector: { matchLabels: { app.kubernetes.io/name: postgres-mcp } }
policyTypes: [Ingress]
ingress:
- from: [{ podSelector: { matchLabels: { app.kubernetes.io/name: dashboard } } }]
ports: [{ port: 8000 }]
Zu beachten ist der Schutz gegen DNS-Rebinding: Der Server beantwortet nur Anfragen mit einem Host-Header auf localhost, alle anderen bekommen 421. Clients im Cluster senden deshalb Host: localhost:8000.
Durch log_statement = 'all' steht jede Abfrage von mcp_reader im Log der Datenbank, und das Log landet zentral in Loki. So lässt sich jederzeit nachvollziehen, welche Abfragen das Modell gestellt hat.
08 — Den Server nutzen
Der Server bietet seine Werkzeuge über MCP an. Für diesen Aufbau zählen vier: list_schemas, list_objects, get_object_details und execute_sql. Die übrigen Werkzeuge, etwa für Index-Empfehlungen, brauchen Rechte, die mcp_reader nicht hat, und sind hier nicht relevant.
Zum Ausprobieren reichen ein Port-Forward und Claude Code. Über den Forward stimmt auch der Host-Header:
kubectl -n app port-forward svc/postgres-mcp 8000:8000 &
claude mcp add --transport sse erp localhost/sse
Danach kann man dem Assistenten Fragen stellen wie „Wie haben sich die Erlöse seit Januar entwickelt?“. Er listet die Views in mcp, liest ihre Spalten und schickt eine Abfrage:
SELECT monat, sum(umsatz) AS umsatz
FROM mcp.umsatz_monat
WHERE monat >= date '2026-01-01'
GROUP BY monat ORDER BY monat;
Aus eigenem Code geht es mit dem MCP-SDK für Python:
from mcp import ClientSession
from mcp.client.sse import sse_client
URL = "http://postgres-mcp.app.svc:8000/sse"
async def frage(sql: str):
async with sse_client(URL, headers={"Host": "localhost:8000"}) as (rd, wr):
async with ClientSession(rd, wr) as s:
await s.initialize()
return await s.call_tool("execute_sql", {"sql": sql})
In einer Anwendung mit Bedrock wird daraus eine Schleife: list_tools liefert die Werkzeugbeschreibungen für die toolConfig der Converse-API, und jedes toolUse des Modells geht als call_tool an den Server. Das Modell erhält nur die Spalten, die die View liefert.
09 — Prüfen, dass die Grenze hält
Ob die Rechte stimmen, lässt sich mit drei Abfragen prüfen. Sie sollten nach jeder Änderung an den Listen erneut laufen:
-- 1. Der Reader kommt an keine Rohdaten.
SET ROLE mcp_reader;
SELECT count(*) FROM erp.adressen;
-- ERROR: permission denied for schema erp
RESET ROLE;
-- 2. Der Owner liest freigegebene Spalten, aber keine gesperrten.
SET ROLE mcp_owner;
SELECT ort FROM erp.adressen LIMIT 1; -- geht
SELECT name FROM erp.adressen LIMIT 1;
-- ERROR: permission denied for table adressen
RESET ROLE;
-- 3. Keine Spalte der Liste hat ein Recht.
SELECT table_name, column_name
FROM information_schema.column_privileges
WHERE grantee = 'mcp_owner' AND table_schema = 'erp'
AND column_name IN ('name', 'vorname', 'strasse', 'telefon', 'email');
-- (0 rows)
Die dritte Abfrage lässt sich aus der Liste erzeugen und nach dem Import als Check laufen. Liefert sie eine Zeile, sollte der Job fehlschlagen.
Welche Abfragen das Modell tatsächlich gestellt hat, zeigt das Datenbank-Log, zum Beispiel in Loki:
{namespace="db"} |= "mcp_reader" |= "statement:"10 — Was Spaltenrechte nicht leisten
Spaltenrechte verhindern, dass das Modell bestimmte Felder liest. Sie verhindern nicht, dass es aus erlaubten Daten Rückschlüsse zieht. Wer Umsätze je Kundennummer abfragt, kann bei einem Einzelunternehmer auf eine Person schließen. Dagegen helfen nur Aggregation in den Views und ein HAVING für kleine Gruppen. Das muss beim Review der views.sql geprüft werden.
Noch nicht umgesetzt ist der Zugriff aus öffentlichen Assistenten wie claude.ai. Dafür braucht der Server einen Login nach OAuth 2.1 mit Dynamic Client Registration. Bis dahin ist er nur im Cluster erreichbar. Unabhängig vom Weg gilt: Kundendaten gehen nur an ein Modell, wenn das vertraglich für genau diesen Anwendungsfall geregelt ist.
Fazit
Personendaten lassen sich zuverlässig von einem MCP-Server fernhalten, wenn die Datenbank die Rechte durchsetzt und nicht der Prompt. Dafür reichen drei Rollen, eine Funktion, die die Spaltenrechte aus dem Manifest vergibt, zwei Listen und ein Job, der die Views prüft. Neue Tabellen bleiben gesperrt, bis sie geprüft sind, und jede Abfrage steht im Log. Der Nachteil: Freitextfelder fallen komplett weg. Das sollte vorher mit den Nutzern besprochen werden.
Soll KI mit deinen Geschäftsdaten arbeiten, ohne Personendaten zu sehen?
Ich sehe mir an, welche Daten dein Assistent braucht, und ziehe die Grenze dort, wo sie hält: in der Datenbank, nicht im Prompt.
Über den Autor

Kai Ole Hartwig
Programmiert seit 2002 – autodidaktisch gelernt, 2012 mit KO-Web selbständig gemacht. Über 100 Projekte, Fokus auf Security, Performance, Automatisierung und Qualität. Heute freiberuflich: DevSecOps-Beratung, Schulungen und Softwareentwicklung.