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.
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
pip install akko-mcp-trinoTRINO_HOST=…
MCP_JWKS_URL=…
MCP_OIDC_ISSUER=…
akko-mcp-trino"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 :
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
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_usernameousub), qui devientX-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.
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
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
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.
Ce qui se passe sur une requête
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 ».
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.