akko-mcp-trino

Un serveur MCP gouverné pour Trino. Chaque appel d'outil porte l'identité de l'utilisateur ; Trino et son moteur de politique décident.

version 0.3.0 Python 3.12 et 3.13 Trino 351 et plus, testé 483 260 tests, 100 % de couverture Apache 2.0

akko-mcp-trino donne à vos agents IA un accès à Trino sans leur donner vos données. Le serveur reçoit, à chaque appel d’outil, le jeton de l’utilisateur pour qui l’agent travaille. Il le vérifie, en tire l’identité, et exécute la requête dans Trino sous cette identité. Ce que cette personne a le droit de lire est décidé dans le moteur, par la politique que vous appliquez déjà (OPA, Ranger, ou le contrôle d’accès de Trino). Le serveur ne lit jamais avec un compte de service à la place de l’utilisateur, et ne décide jamais l’accès lui-même.

Dépôt : github.com/AKKO-p/akko-mcp-trino · Paquet : pip install akko-mcp-trino · Licence Apache 2.0.

En une minute

1. InstallerUn paquet Python, une commande.pip install akko-mcp-trino
2. PointerVotre Trino, votre fournisseur d'identité.TRINO_HOST=… MCP_JWKS_URL=… MCP_OIDC_ISSUER=… akko-mcp-trino
3. BrancherL'hôte envoie le jeton de l'utilisateur."url": "http://…:3000/mcp", "headers": {"Authorization": "Bearer …"}

Le détail de chaque étape est plus bas : prérequis, installation, hôtes.

Pourquoi

La plupart des serveurs MCP pour un moteur SQL se connectent avec un seul compte technique. Chaque agent, chaque utilisateur, chaque prompt lit alors avec les mêmes droits larges, et la seule chose entre une injection de prompt et votre table clients est la bonne volonté du modèle.

Ce serveur prend le parti inverse. L’agent apporte le jeton de l’utilisateur. Le jeton devient X-Trino-User. Trino applique le périmètre de catalogues, les filtres de lignes et les masques de colonnes de cet utilisateur, exactement comme il le fait pour un outil de BI ou un notebook.

Même question, même serveur, deux utilisateurs :

« Donne-moi trois emails de clients avec leur pays »alice_adminadministratricecarol_analystanalyste, périmètre FRakko-mcp-trinomême serveur, même SQLTrino + moteur de politiquemarie.martin0@gmail.com · FRthomas.devries1@outlook.com · DEléa.dubois2@proton.me · ES***@gmail.com · FR***@outlook.com · FR***@proton.me · FRle modèle n'a rien décidé

Ce résultat est réel : un modèle Mistral pilote les outils, sur un Trino derrière Keycloak et OPA. Le modèle ne savait pas que carol était restreinte ; il n’avait pas besoin de le savoir.

Comment ça marche

Hôte MCPCursor, Claude, VS Code, un agentFournisseur d'identitéOIDC, JWKSakko-mcp-trinovérifie le jeton, porte l'identitéTrinoexécute en X-Trino-UserMoteur de politiqueOPA, Ranger, intégréSources de données1. connexion2. appel d'outil + JWTvérifie la signature3. SQL sous l'identité4. peut-elle lire ceci ?5. lignes filtrées, colonnes masquées6. résultat

L’utilisateur se connecte au fournisseur d’identité que la plateforme a déjà. L’hôte MCP envoie le JWT obtenu à chaque requête. Le serveur le vérifie (signature contre le JWKS du fournisseur, émetteur, audience, expiration), lit le sujet, et exécute le SQL dans Trino sous ce sujet. Trino interroge son moteur de politique, applique le périmètre, les filtres et les masques de l’utilisateur, et ne rend que ce que cette personne aurait pu lire depuis n’importe quel autre client.

Concepts

Quatre mots suffisent pour lire le reste de cette page.

Utilisateur (le sujet)
La personne pour qui l'agent travaille. Elle se connecte à votre fournisseur d'identité et en reçoit un jeton JWT. Le serveur vérifie ce jeton et en tire le sujet (preferred_username ou sub), qui devient X-Trino-User.
Produit agent
Le logiciel qui appelle : Cursor, Claude Desktop, un agent maison. Il s'identifie par une clé (X-Agent-Key) qui sert aux quotas et à l'audit. Un produit n'est jamais un utilisateur et n'a aucun droit sur les données.
Garde
Le middleware qui reçoit chaque requête MCP et applique, dans l'ordre, la clé du produit, le jeton, la révocation, les quotas. Il ne connaît aucune règle d'accès aux données ; il porte l'identité.
Moteur de politique
Ce qui décide vraiment : OPA, Ranger ou le contrôle d'accès de Trino. Il connaît l'utilisateur, ses groupes, les filtres de lignes et les masques de colonnes. Le serveur ne le remplace pas et ne le duplique pas.
Note Si vous retenez une seule phrase : le serveur porte l'identité, le moteur décide. Tout le reste en découle.

Compatibilité

Supporté Testé
Python 3.12, 3.13 3.12, 3.13 (CI)
Trino 351 et suivants ; http ou https ; mot de passe, ou transmission du JWT 483 derrière OPA, en http dans le cluster avec impersonation et en https public avec transmission du JWT
Moteurs de politique OPA (trino-opa), Ranger (plugin Trino), contrôle d’accès fichier de Trino OPA avec filtres de lignes et masques de colonnes
Fournisseurs d’identité tout fournisseur OIDC publiant un JWKS Keycloak 26
MCP protocole 2025-06-18 via le SDK mcp 1.30 ; transports streamable-http, sse et stdio les trois, client du SDK officiel, en CI
Hôtes MCP à distance : tout ce qui envoie un en-tête Bearer (Cursor, Claude Desktop, VS Code, connecteurs Le Chat) ; en local : tout hôte stdio SDK Python, l’agent d’exemple
Modèles tous, le serveur ne parle jamais à un modèle Mistral Small 3.2 via OpenRouter, pilotant les outils

Prérequis

Il vous faut Pourquoi Détail
Python 3.12 ou plus l’exécution pip et un environnement virtuel
Un coordinateur Trino joignable le moteur HTTP ou HTTPS, toute version récente (testé sur 483)
Trino configuré pour l’un des deux modes d’identité le passage d’identité impersonate : TRINO_USER autorisé à impersonner (règles impersonation, ou l’équivalent OPA / Ranger) ; jwt : l’authentificateur OAUTH2/JWT de Trino pointé sur votre fournisseur d’identité
Un moteur de politique qui décide pour Trino la gouvernance OPA (opa.policy.uri), Ranger, ou les règles fichier de Trino. Sans lui, tout le monde lit tout
Un fournisseur OIDC avec un point JWKS l’identité Keycloak, Entra ID, Okta, Dex… Les jetons doivent porter iss, aud, exp et un sujet (preferred_username ou sub)
Un hôte MCP capable d’envoyer un en-tête Bearer le client Cursor, Claude Desktop, VS Code, ou tout client construit sur le SDK mcp

En option : un Prometheus pour lire /metrics, et, pour la révocation avant expiration, un point d’introspection RFC 7662 avec un client enregistré pour ce serveur.

Installation et lancement

pip install akko-mcp-trino

Ou depuis les sources :

git clone https://github.com/AKKO-p/akko-mcp-trino.git
cd akko-mcp-trino
python -m venv .venv && source .venv/bin/activate
pip install -e .

Le paquet installe un module Python (akko_mcp_trino) et une commande (akko-mcp-trino) ; python -m akko_mcp_trino est le même point d’entrée.

Pointez-le vers votre Trino et votre fournisseur d’identité, puis lancez :

export TRINO_HOST=trino.example.internal
export TRINO_PORT=8080
export TRINO_USER=mcp-trino            # le compte qui se connecte et impersonne
export TRINO_CATALOG=iceberg

export MCP_AUTH_ENABLED=true
export MCP_AUTH_REQUIRED=true          # refuser les requêtes sans identité vérifiée
export MCP_JWKS_URL=https://idp.example.com/realms/data/protocol/openid-connect/certs
export MCP_OIDC_ISSUER=https://idp.example.com/realms/data
export MCP_OIDC_AUDIENCE=data-platform

akko-mcp-trino

Vous devez voir :

INFO:__main__:serving transport=streamable-http port=3000 auth=True strict=True

Vérifiez qu’il est vivant et qu’il joint Trino :

curl -s localhost:3001/health   # {"status":"ok"}       ne touche jamais Trino
curl -s localhost:3001/ready    # {"status":"ready"}    exécute un SELECT 1 borné
curl -s localhost:3001/metrics  # exposition Prometheus
Attention Pour essayer sur un poste sans fournisseur d'identité, laissez `MCP_AUTH_ENABLED` vide : chaque requête tourne alors en `TRINO_USER`. Ne le faites jamais là où les données comptent.

Conteneur

Un Dockerfile est fourni dans le dépôt (non-root, contrôle de santé, étiquettes OCI) ; les versions publient l’image sur ghcr.io/akko-p/akko-mcp-trino. Lancez-la avec les mêmes variables, en publiant les ports 3000 (MCP) et 3001 (santé).

Brancher un hôte MCP

L’hôte a besoin du jeton d’accès de l’utilisateur. Avec MCP_RESOURCE_URL défini, les hôtes qui implémentent la découverte OAuth trouvent le fournisseur seuls (voir Découverte) ; sinon collez le jeton.

Cursor, Claude Desktop, VS Code (mcp.json) :

{
  "mcpServers": {
    "trino": {
      "url": "http://localhost:3000/mcp",
      "headers": {
        "Authorization": "Bearer <le jeton d'accès de l'utilisateur>",
        "X-Agent-Key": "<la clé enregistrée pour ce produit, s'il y en a une>"
      }
    }
  }
}

Depuis Python, avec le SDK officiel :

import anyio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

async def main(token: str):
    headers = {"Authorization": f"Bearer {token}", "X-Agent-Key": "my-agent-key"}
    async with streamablehttp_client("http://localhost:3000/mcp", headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            result = await session.call_tool("execute_query", {"sql": "SELECT 1"})
            print(result.content[0].text)

anyio.run(main, "<token>")

streamable-http (/mcp) est le transport par défaut, celui qu’attendent les hôtes actuels. MCP_TRANSPORT=sse sert /sse pour les hôtes plus anciens ; la garde est la même sur les deux.

Hôtes locaux en stdio

Hôte localClaude Desktop, Cursor, Vibeakko-mcp-trino (stdio)vérifie le jeton au démarrage, une identité pour le processusTrinoX-Trino-User = l'utilisateur du posteFournisseur d'identitéJWKSlance le serveur (uvx akko-mcp-trino)JSON-RPC sur stdin / stdoutvérifie la signatureMCP_USER_TOKEN dans l'environnementmode strict : pas de jeton valide, pas de démarragele modèle n'a rien décidé

Claude Desktop, Cursor et Mistral Vibe peuvent lancer le serveur eux-mêmes. Il n’y a alors pas de requête, donc pas d’en-tête : l’identité vient de l’environnement, le jeton d’accès de l’utilisateur, vérifié exactement comme un en-tête Bearer le serait, et lié au processus. Le mode strict refuse de démarrer sans lui.

{
  "mcpServers": {
    "trino": {
      "command": "uvx",
      "args": ["akko-mcp-trino"],
      "env": {
        "MCP_TRANSPORT": "stdio",
        "TRINO_HOST": "trino.example.internal",
        "MCP_AUTH_ENABLED": "true", "MCP_AUTH_REQUIRED": "true",
        "MCP_JWKS_URL": "https://idp.example.com/realms/data/protocol/openid-connect/certs",
        "MCP_OIDC_ISSUER": "https://idp.example.com/realms/data",
        "MCP_OIDC_AUDIENCE": "data-platform",
        "MCP_USER_TOKEN": "<le jeton d'accès de l'utilisateur>"
      }
    }
  }
}

uvx akko-mcp-trino exécute le paquet publié sans rien installer.

L’utiliser depuis un agent

examples/agent.py dans le dépôt est un agent complet en quatre-vingts lignes : n’importe quel modèle compatible OpenAI (Mistral sur La Plateforme, via OpenRouter ou LiteLLM ; ou tout autre fournisseur), les outils MCP, le jeton de l’utilisateur. Le modèle ne voit jamais le jeton ; le serveur le reçoit à chaque appel d’outil.

pip install akko-mcp-trino openai
export LLM_BASE_URL=https://api.mistral.ai/v1
export LLM_API_KEY=...
export LLM_MODEL=mistral-small-latest
export MCP_URL=http://localhost:3000/mcp
export USER_TOKEN=<le jeton d'accès de l'utilisateur>
python examples/agent.py "Quels catalogues puis-je voir, et que contiennent-ils ?"

Lancez-le deux fois avec les jetons de deux utilisateurs et comparez.

Les outils

Outil Arguments Rend
list_catalogs aucun les catalogues visibles par l’utilisateur
list_schemas catalog les schémas de ce catalogue
list_tables catalog, schema les tables et vues de ce schéma
describe_table catalog, schema, table, sample_rows (0 à 20) colonnes, types, commentaires ; un échantillon gouverné à la demande
search_columns pattern (LIKE SQL), catalog (optionnel) les tables ayant une colonne dont le nom correspond
profile_table catalog, schema, table SHOW STATS : nombre de lignes, valeurs distinctes, part de nuls, bornes
explain_table catalog, schema, table ce que la table veut dire : description, propriétaire, tier, grain, jointures, tags (depuis les fournisseurs de contexte)
explain_query sql le plan de Trino pour une lecture, sans l’exécuter
execute_query sql {"columns": [...], "rows": [...], "row_count": n}

Chaque outil porte des annotations MCP (lecture seule, non destructif, monde fermé) que les hôtes utilisent pour ne pas demander de confirmation. Chaque outil, découverte comprise, tourne dans Trino sous l’identité de l’appelant : la métadonnée est une donnée, et un utilisateur qui n’a pas le droit de lire un schéma ne le liste pas non plus. Chaque description est écrite pour un modèle : ce que l’outil rend, et qu’une valeur masquée ou une ligne absente est la politique d’accès, pas une erreur à réessayer.

Chaque identifiant est validé ([A-Za-z_][A-Za-z0-9_-]*) avant d’entrer dans le SQL. execute_query accepte tout SQL que Trino accepte, tant que c’est une lecture : la requête est analysée en arbre syntaxique et refusée si elle contient plus d’une instruction, ou si un INSERT, UPDATE, DELETE, MERGE, CREATE, DROP, ALTER, GRANT, CALL ou SET apparaît n’importe où dans l’arbre, CTE et sous-requêtes compris. Un point-virgule final est retiré (Trino le refuse). Les résultats sont plafonnés à TRINO_MAX_ROWS ; dates, horodatages, décimaux et binaires sont rendus en ISO 8601, chiffres exacts et hexadécimal.

Les erreurs reviennent à l’agent en {"error": "..."} ; un refus de Trino est une erreur ordinaire, pas un plantage.

Le contexte : ce que Trino ne sait pas dire

DESCRIBE donne les colonnes et les types. Il ne dit pas ce que veut dire segment, qui est propriétaire de la table, si elle est fiable, quelle colonne la joint à une autre, ni qu’email est une donnée personnelle. Un modèle sans ça devine, et devine mal.

Le serveur ne connaît aucun catalogue. Il connaît une interface, ContextProvider, et une chaîne de fournisseurs la remplit, par ordre de priorité :

Fournisseur D’où vient le savoir Il faut
trino-comments les COMMENT ON que Trino porte déjà, lus sous l’identité de l’appelant rien
file un document JSON versionné avec votre code : description, propriétaire, tier, grain, jointures, tags, classification et valeurs des colonnes MCP_CONTEXT_FILE
openmetadata descriptions, propriétaires, tier, tags, clé primaire (grain), clés étrangères (jointures), classifications de colonnes comme PII.Sensitive, depuis OpenMetadata pip install akko-mcp-trino-openmetadata, OPENMETADATA_URL, OPENMETADATA_TOKEN, OPENMETADATA_SERVICE
votre catalogue DataHub, Atlas, Collibra… un paquet qui implémente les deux mêmes méthodes et s’enregistre sous le groupe d’entry points akko_mcp_trino.context ce paquet
{"version": 1, "tables": {
  "core_postgres.clients.customers": {
    "description": "Une ligne par client", "owner": "Équipe données clients", "tier": "gold",
    "grain": ["customer_id"],
    "joins": [{"columns": ["customer_id"], "target": "core_postgres.clients.accounts", "target_columns": ["customer_id"]}],
    "tags": ["pii"],
    "columns": {"email": {"description": "Adresse de contact", "classification": ["PII"]},
                "segment": {"description": "Segment commercial", "values": ["retail", "business", "premium"]}}}}}

Avec MCP_CONTEXT_PROVIDERS=openmetadata,trino-comments, describe_table rend les colonnes et un bloc table (description, propriétaire, tier, grain, jointures, tags) et un bloc column_context ; explain_table répond depuis le même savoir, ou {"known": false}. Le premier fournisseur qui parle gagne, champ par champ. Un fournisseur décrit, il ne décide jamais : savoir qu’une colonne est PII ne change rien au masque, que le moteur applique. Un fournisseur en panne ne cache jamais les colonnes.

Note Le savoir est lui aussi gouverné : `trino-comments` lit `system.metadata` sous l'identité de l'utilisateur, et un analyste à qui Trino refuse ce catalogue n'obtient pas la description. La métadonnée est une donnée.

Ce qui se passe sur une requête

1Identifiant de requêteX-Request-Id honoré, ou généré ; renvoyé sur toute réponse2Produit agentX-Agent-Key comparé au registresi refusé401 agent_key_missing · _unknown3Jeton de l'utilisateursignature JWKS, émetteur, audience, expirationsi refusé401 unauthenticated, WWW-Authenticate4Révocation (option)introspection RFC 7662, cache par jtisi refusé401 revoked · 503 émetteur muet5Quotasfenêtres par utilisateur et par produitsi refusé429 rate_limited, Retry-After6Outilidentifiants validés, écritures refusées sur l'arbre SQLsi refusé{"error": …} rendu à l'agent7TrinoSQL avec X-Trino-User = sujet ; le moteur de politique décidesi refusérefus Trino rendu tel quel8Auditune ligne JSON : request_id, tool, subject, agent, jti, oktoujoursjamais le jeton

L’ordre compte. La clé d’agent est vérifiée avant le jeton, donc un produit non enregistré ne déclenche jamais de lecture du JWKS. Les quotas sont comptés après l’authentification, donc un jeton forgé ne consomme pas le créneau de la personne qu’il nomme. Une requête refusée ne consomme rien.

Configuration

Tout vient de l’environnement. Rien n’est codé en dur.

Trino

Variable Sens Défaut
TRINO_HOST, TRINO_PORT le coordinateur localhost, 8080
TRINO_USER le compte avec lequel le serveur se connecte ; il impersonne l’appelant trino
TRINO_CATALOG catalogue par défaut system
TRINO_READ_ONLY refuser les écritures dans execute_query true
TRINO_MAX_ROWS plafond de lignes 100
TRINO_HTTP_SCHEME http ou https http
TRINO_PASSWORD mot de passe de TRINO_USER (Basic) ; refusé en http clair vide
TRINO_VERIFY vérification TLS : true, false, ou le chemin d’un paquet de CA true
TRINO_REQUEST_TIMEOUT_SECONDS délai par requête du client Trino 30
TRINO_IDENTITY_MODE impersonate (connexion en TRINO_USER, X-Trino-User = l’appelant) ou jwt (le jeton vérifié de l’appelant est envoyé à Trino ; https obligatoire, aucun mot de passe de service) impersonate

Deux façons d’atteindre Trino sous l’identité de l’utilisateur

Mode Comment Ce que Trino doit avoir Quand
impersonate (défaut) le serveur s’authentifie en TRINO_USER (Basic en https si TRINO_PASSWORD est défini) et pose X-Trino-User à l’appelant vérifié une règle d’impersonation autorisant TRINO_USER → utilisateurs (contrôle fichier, OPA ou Ranger) Trino authentifie par mot de passe, certificat ou Kerberos
jwt le jeton de l’appelant, déjà vérifié par la garde, est envoyé à Trino comme son JWT ; le serveur ne détient aucun identifiant l’authentificateur OAUTH2 ou JWT pointé sur le même émetteur Trino fait déjà confiance à votre fournisseur d’identité : le montage le plus propre, prouvé en live sur un Trino derrière Keycloak

Les deux ferment par défaut : un mot de passe ne voyage jamais en http clair, et le mode jwt ne retombe jamais sur le compte de service quand une requête n’a pas de jeton.

Identité

Variable Sens Défaut
MCP_AUTH_ENABLED vérifier les jetons Bearer false
MCP_AUTH_REQUIRED refuser les requêtes sans identité vérifiée false
MCP_JWKS_URL le point JWKS du fournisseur vide (obligatoire quand l’authentification est activée)
MCP_OIDC_ISSUER, MCP_OIDC_AUDIENCE les revendications à imposer vide
MCP_RESOURCE_URL l’URL publique de ce serveur ; active la découverte RFC 9728 vide (désactivée)
MCP_AGENT_KEYS nom:clé,nom:clé, les produits agents enregistrés ; vide désactive le contrôle vide (désactivé)

L’authentification activée sans URL JWKS refuse de démarrer : une couche d’authentification qui ne peut rien vérifier ne doit pas faire semblant. Une entrée MCP_AGENT_KEYS malformée refuse de démarrer pour la même raison.

Gardes

Variable Sens Défaut
MCP_RATE_LIMIT_USER, MCP_RATE_LIMIT_AGENT requêtes par fenêtre par utilisateur et par produit agent ; 0 désactive 0, 0
MCP_RATE_LIMIT_WINDOW_SECONDS la fenêtre glissante 60
MCP_INTROSPECTION_URL point RFC 7662 ; active le contrôle de révocation vide (désactivé)
MCP_INTROSPECTION_CLIENT_ID, MCP_INTROSPECTION_CLIENT_SECRET les identifiants attendus par le fournisseur vide (obligatoires avec l’URL)
MCP_INTROSPECTION_TTL_SECONDS durée de cache d’un verdict par jti 30

Fournisseurs de contexte

Variable Sens Défaut
MCP_CONTEXT_PROVIDERS liste séparée par des virgules, par priorité : trino-comments, file, openmetadata, none vide (aucun)
MCP_CONTEXT_FILE le document JSON du fournisseur file vide
MCP_CONTEXT_TTL_SECONDS durée de cache d’une réponse 60
MCP_JWT_LEEWAY_SECONDS tolérance sur exp, nbf, iat pour la dérive d’horloge entre l’émetteur et le serveur 30

Service

Variable Sens Défaut
MCP_TRANSPORT streamable-http, sse ou stdio streamable-http
MCP_PORT, MCP_HEALTH_PORT ports d’écoute 3000, 3001
MCP_SERVER_NAME le nom annoncé aux hôtes trino-mcp
MCP_USER_TOKEN stdio seulement : le jeton d’accès de l’utilisateur, vérifié comme un en-tête Bearer vide (obligatoire en mode strict)

Exploitation

Deux principaux

Une clé d’espace de travail Cursor ou Claude n’est pas un utilisateur. Quand MCP_AGENT_KEYS est défini, chaque requête doit porter les deux :

Principal En-tête Dit Contrôlé par
Utilisateur final Authorization: Bearer <JWT> pour qui la requête tourne le JWKS ici, puis le moteur de politique dans Trino
Produit agent X-Agent-Key: <clé> quel produit appelle le registre ici, pour les quotas et l’audit

Découverte

Avec MCP_RESOURCE_URL défini, le serveur publie les métadonnées RFC 9728 sans jeton, et chaque 401 dit où aller :

curl -s https://mcp.example.com/.well-known/oauth-protected-resource
# {"resource":"https://mcp.example.com","authorization_servers":["https://idp.example.com/realms/data"],"bearer_methods_supported":["header"]}

curl -si https://mcp.example.com/mcp | grep -i -e www-auth -e x-reason
# WWW-Authenticate: Bearer realm="trino", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
# X-Reason: unauthenticated

authorization_servers reste vide tant que MCP_OIDC_ISSUER n’est pas défini ; le serveur ne devine jamais un fournisseur.

Refus

Chaque refus porte X-Reason et l’identifiant de requête, jamais le jeton :

Statut X-Reason Sens
401 agent_key_missing, agent_key_unknown des produits sont enregistrés et la clé est absente ou fausse
401 unauthenticated aucune identité vérifiée en mode strict
401 revoked le fournisseur dit que le jeton n’est plus actif
429 rate_limited une fenêtre est pleine ; Retry-After dit quand
503 introspection_unavailable le fournisseur n’a pas pu être interrogé ; la garde ferme

Audit

Chaque réponse porte X-Request-Id, celui envoyé par la bordure ou un généré par le serveur. Chaque appel d’outil écrit une ligne JSON dans le journal mcp.audit, indexée par cet identifiant :

{"request_id":"edge-42","tool":"execute_query","subject":"alice","agent":"cursor","token_id":"jti-9","ok":false,"error":"Access Denied: Cannot select from columns [email] in table customers"}

token_id est le jti du JWT. La structure n’a aucun champ qui puisse contenir le jeton. Joignez-la aux journaux de votre passerelle et au journal des requêtes de Trino sur l’identifiant de requête.

Révocation avant expiration

Une signature et un exp prouvent qu’un jeton a été valide. Avec MCP_INTROSPECTION_URL défini, la garde interroge le fournisseur (RFC 7662) et refuse un jeton dont active est faux. Les verdicts sont mis en cache par jti pendant MCP_INTROSPECTION_TTL_SECONDS. Si le fournisseur ne peut pas répondre, la requête est refusée en 503, parce que « inconnu » n’est pas « encore valide ».

Note Keycloak : l'introspection n'est acceptée que d'un client présent dans l'`aud` du jeton. Enregistrez ce serveur comme client confidentiel et ajoutez-le au mapper d'audience ; le client qui a émis le jeton ne suffit pas.

Quotas

Deux fenêtres glissantes en mémoire, une par sujet utilisateur et une par produit agent. Les compteurs vivent dans le processus : avec plusieurs réplicas, le quota est par réplica.

Santé et métriques

/health sur le port de santé ne touche jamais Trino, donc une sonde de vie ne peut pas tuer le serveur parce que Trino est lent. /ready exécute un SELECT 1 borné. /metrics expose mcp_trino_queries_total, mcp_trino_query_errors_total et mcp_trino_query_duration_seconds.

Ce qui a été prouvé

Huit preuves rejouables tournent contre un cluster réel (Trino 483 derrière Keycloak 26 et OPA), sans rien redéployer :

Preuve Ce qu’elle vérifie Résultat
Fonctionnelle les cinq gardes (double identité, RFC 9728, quotas, audit, révocation) et deux comptes, deux réponses sur la même requête 21/21
Adverse deux utilisateurs sur quatre sessions chacun, trois appels par session, en parallèle sur les deux transports, zéro réponse croisée ; écritures, instructions empilées, WITH enveloppant un DELETE, injections dans les identifiants, jetons signés par une autre clé, alg=none, expirés, en-tête X-Trino-User forcé, plafond de lignes, les huit outils, l’échantillon gouverné 35/35
Agent un modèle Mistral pilote l’agent d’exemple en alice puis en carol : emails en clair pour l’une, masqués pour l’autre 10/10
Hôte réel Claude Code (même pile MCP que Claude Desktop) enregistre le serveur en stdio, le lance lui-même sur le poste, et un modèle répond à la même question avec le jeton de carol puis d’alice : masqué pour l’une, en clair pour l’autre ; sans jeton, l’hôte ne se connecte pas 7/7
Transmission du JWT le serveur, en mode jwt, joint le Trino public en https sans aucun compte de service ; Trino lui-même rapporte current_user = carol puis alice ; sans jeton, 401 ; le mode refuse un Trino en http 8/8
Contexte file + trino-comments sur le Trino public : grain, jointures, PII et valeurs du fichier, descriptions et commentaires de colonnes de Trino, le fichier gagne, explain_table répond, carol reste masquée 12/12
OpenMetadata le greffon lit le catalogue de la plateforme : email classée PII.Sensitive, descriptions du catalogue ; jeton de bot refusé = Trino seul, jamais une erreur 14/14
stdio un hôte local (le client officiel, comme Claude Desktop) lance le serveur en sous-processus avec un vrai jeton dans MCP_USER_TOKEN ; huit outils, alice en clair, carol masquée ; sans jeton ou avec un jeton forgé, le serveur refuse de démarrer 10/10

Ces preuves ont trouvé six vrais défauts avant qu’ils ne sortent : un jeton dont l’iat avait une seconde d’avance (horloge de l’émetteur) était refusé une fois sur trois (tolérance d’horloge ajoutée) ; le lanceur ne passait pas leurs variables aux greffons de contexte ; WITH w AS (DELETE FROM t) SELECT 1 passait une garde qui ne regardait que la racine de l’arbre (elle parcourt maintenant tout l’arbre) ; le point-virgule final que les modèles ajoutent par habitude, refusé par Trino (retiré côté serveur) ; en 0.2, les outils de découverte qui interrogeaient Trino avec le compte de service au lieu de l’identité (un test parcourt désormais chaque outil) ; et une colonne DATE qui faisait échouer la sérialisation de tout résultat (dates, décimaux et binaires sont rendus proprement).

Notes de conception

La garde est un middleware ASGI pur, pas BaseHTTPMiddleware. Ce dernier exécute l’aval dans une tâche séparée et casse la propagation des ContextVar : l’identité n’atteindrait jamais les outils. Vérifié par des sessions concurrentes de deux utilisateurs sur les deux transports.

Le montage ne dépend pas du transport. Un seul chemin de code construit l’application de transport et monte la garde. La garde a un jour vécu sur la seule branche que le transport par défaut ne prenait jamais ; un test l’affirme désormais pour les deux.

Chaque outil porte l’identité, pas seulement execute_query. La version 0.1 faisait la découverte avec le compte de service ; un utilisateur restreint pouvait lister ce que le moteur lui aurait caché. Corrigé en 0.2, avec un test qui parcourt chaque outil.

La lecture seule est décidée sur l’arbre, n’importe où dans l’arbre. Un contrôle par mot-clé de tête laisse passer WITH w AS (DELETE FROM t) SELECT 1. Un contrôle du nœud racine aussi, jusqu’à ce que la preuve adverse l’attrape.

L’identité vient du jeton, jamais d’un en-tête. Un appelant qui envoie X-Trino-User ou X-Forwarded-User ne change rien ; le sujet transmis à Trino est celui du JWT vérifié.

Le serveur ne décide pas l’accès. Il porte l’identité. Mettre de la politique dans le serveur dupliquerait, puis contredirait, ce que le moteur applique déjà à tous les autres clients.

Contribuer, signaler

Le dépôt contient CONTRIBUTING.md (tests d’abord, 100 % de couverture, neutralité produit, fermeture par défaut) et SECURITY.md pour signaler une vulnérabilité en privé. Les changements sont suivis dans CHANGELOG.md.