akko-mcp-trino
Provides tools for interacting with Trino, enabling AI agents to execute read-only SQL queries as the authenticated user, with access control enforced by Trino's policy engine.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@akko-mcp-trinoshow me last month's sales by region"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
akko-mcp-trino
Give your AI agents access to Trino without giving them your data.
akko-mcp-trino is a governed MCP server for Trino. Every tool call an agent makes carries the identity of the person it acts for; what that person may read is decided inside Trino, by the policy engine you already run (OPA, Ranger, or Trino's own access control). The server never reads on the user's behalf with a service account, and it never decides access on its own.
Why
An MCP server that connects with one technical account gives every agent, every user and every prompt the same broad rights; between a prompt injection and your customer table, all that is left is the model's good will.
This server takes the opposite stance. The agent brings the user's token. The
token becomes X-Trino-User. Trino applies that user's catalog scope, row
filters and column masks, exactly as it does for a BI tool or a notebook.
Same question, same server, two users:
$ python examples/agent.py "Give me three customer e-mails with their country"
as alice_admin marie.martin@example.com, FR thomas.devries@example.org, DE lea.dubois@example.net, ES
as carol_analyst ***@example.com, FR ***@example.org, FR ***@example.net, FRThat run is real: a Mistral model driving the tools, on a Trino behind Keycloak and OPA. The model did not know carol was restricted; it did not need to.
Related MCP server: Secure BigQuery MCP Gateway
Highlights
Identity, end to end. Verified JWT (JWKS, issuer, audience, expiry) →
X-Trino-User, or the same JWT forwarded to Trino's own authenticator (TRINO_IDENTITY_MODE=jwt), orX-Trino-Usersent by a server that proves who it is with a client certificate (TRINO_IDENTITY_MODE=certificate). No impersonation without a verified identity, no secret for Trino injwtmode (token exchange, when set up, uses a client secret at the identity provider), no user token sent to Trino incertificatemode.Read-only by construction. SQL is parsed into an AST and refused if a write appears anywhere in the tree, CTEs and subqueries included. Table functions (
TABLE(...), such as a JDBC connector'ssystem.query) and thesystemcatalog are refused too, including through a name without a catalog when the session catalog issystem.Two principals.
X-Agent-Keynames the calling product (Cursor, Claude, your own agent) for quotas and audit; it is never a user.Standard discovery and OAuth for hosted assistants. RFC 9728 metadata under the resource path and at the root,
WWW-Authenticatewith the scope to ask for on every401,403 insufficient_scopewhen the scope is missing, and an allow-list of the applications whose tokens are accepted (azp). A hosted assistant given only the URL finds the identity provider and signs the user in; the SDK's own OAuth client does it end to end in CI.Quotas, revocation, audit. Per-user and per-agent limits, optional RFC 7662 introspection, one JSON audit line per call keyed by
X-Request-Id— never the token — with the SQL fingerprint, the tables, the duration, the rows and the metadata served; the same id travels to Trino as a client tag, so Trino's query log and the policy engine's audit join it.Fine tools for an agent that chains its own steps.
visible_tables(one call per catalog, paged, descriptions asked once per schema of the page),search_metadata(business words in descriptions, glossary terms and business properties),validate_sql(EXPLAIN (TYPE VALIDATE), errors classified),execute_querybounded (truncated, a time budget). Every metadata answer passes the visibility gate first.Any OIDC provider, any MCP host, any model. Keycloak, Entra ID, Okta… Cursor, Claude Desktop, VS Code, the Python SDK… Mistral, or any OpenAI-compatible model through the example agent.
Small and proven. 4 500 lines, 574 tests at 100 % line and branch coverage (including in-process suites on the real SDK for all three transports and for its OAuth client), a product-neutral guard in CI, and eight live proofs (functional, adversarial, agent-driven, stdio, a real host, JWT passthrough on public https, context providers, OpenMetadata) on a real cluster.
Documentation
How it works, end to end: akko-ai.com/en/docs/how-it-works (also en français): the path of a call, the three ways to carry the identity to Trino, connecting a hosted assistant, what an agent sees, the audit line.
Guide, prerequisites, configuration, examples, proofs: akko-ai.com/docs/akko-mcp-trino (also in English) and akko-ai.com/docs/exemples.
Why we built it, and what the proofs taught us: the AKKO blog — start with Your AI agents see exactly what the user is allowed to see.
Reference, in this repository: this README,
examples/,CHANGELOG.md,CONTRIBUTING.md,SECURITY.md.
Contents
How it works
flowchart LR
Host["MCP host<br/>(Cursor, Claude, VS Code, an agent)"]
IdP["Identity provider<br/>(OIDC, JWKS)"]
Server["akko-mcp-trino"]
Trino["Trino"]
Policy["Policy engine<br/>(OPA, Ranger, built-in)"]
Data[("Data sources")]
Host -- "1. login" --> IdP
Host -- "2. tool call + Bearer JWT<br/>+ X-Agent-Key" --> Server
Server -. "verify signature<br/>against JWKS" .-> IdP
Server -- "3. SQL as X-Trino-User=alice" --> Trino
Trino -- "4. may alice read this?" --> Policy
Trino -- "5. rows, masked and filtered for alice" --> Server
Server -- "6. result" --> Host
Trino --> DataThe user logs in to the identity provider the platform already has. The MCP host sends the resulting JWT on every request. The server verifies it (signature against the provider's JWKS, issuer, audience, expiry), reads the subject, and runs the SQL in Trino as that subject. Trino asks its policy engine, applies the user's catalog scope, row filters and column masks, and returns only what the user could have read from any other client.
Two people asking the same question through the same server get two different answers.
Compatibility
Supported | Tested | |
Python | 3.12, 3.13 | 3.12, 3.13 (CI) |
Trino | 351 and later (the | 483 behind OPA, in-cluster http with impersonation and public https with JWT passthrough; client certificate in CI against an HTTPS server that requests one, not yet on a live Trino |
Policy engines | OPA ( | OPA with row filters and column masks |
Identity providers | any OIDC provider publishing a JWKS | Keycloak 26 |
MCP | protocol negotiated by the official | all three, official SDK client, in CI |
MCP hosts | remote: anything that sends a bearer header or runs the MCP OAuth flow (Cursor, Claude Desktop, VS Code, Mistral Vibe connectors); local: any stdio host | Python SDK (including its OAuth client with a pre-registered confidential client), the example agent |
Models | any, the server never talks to a model | Mistral Small 3.2 through OpenRouter, driving the tools |
Prerequisites
You need | Why | Notes |
Python 3.12 or later | runtime |
|
A reachable Trino coordinator | the engine | HTTP or HTTPS, any recent version (tested on 483) |
Trino configured for one of the three identity modes | identity forwarding |
|
A policy engine deciding for Trino | the governance | OPA ( |
An OIDC provider with a JWKS endpoint | identity | Keycloak, Entra ID, Okta, Dex… Tokens must carry |
An MCP host that can send a bearer header | the client | Cursor, Claude Desktop, VS Code, or any client built on the |
Optional: a Prometheus to scrape /metrics, and — for revocation before
expiry — an RFC 7662 introspection endpoint with a client registered for this
server.
Install and run
pip install akko-mcp-trino # or: pipx install akko-mcp-trino
uvx akko-mcp-trino --version # run it without installing, the way most MCP hosts do
docker run --rm ghcr.io/akko-p/akko-mcp-trino akko-mcp-trino --versionThe package is a plain PyPI package, so uv, pipx, poetry and pip all work; no separate distribution is needed for uv.
Or from source:
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 .The package installs a Python module (akko_mcp_trino) and a command
(akko-mcp-trino); python -m akko_mcp_trino is the same entrypoint.
Point it at your Trino and your identity provider, then serve:
export TRINO_HOST=trino.example.internal
export TRINO_PORT=8080
export TRINO_USER=mcp-trino # the account that connects and impersonates
export TRINO_CATALOG=iceberg
export MCP_AUTH_ENABLED=true
export MCP_AUTH_REQUIRED=true # refuse requests without a verified identity
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
python -m akko_mcp_trinoBefore serving, akko-mcp-trino --check prints the effective configuration
(never a secret) and exits with 2 if something would refuse to start; over
stdio it checks that MCP_USER_TOKEN is present, not that it is valid (the
real start verifies the token). Then:
INFO:__main__:serving transport=streamable-http port=3000 auth=True strict=TrueCheck it is alive and can reach Trino:
curl -s localhost:3001/health # {"status":"ok"} never touches Trino
curl -s localhost:3001/ready # {"status":"ready"} a bounded SELECT 1, or Trino's /v1/info (jwt, certificate)
curl -s localhost:3001/metrics # Prometheus expositionTo try it on a laptop without an identity provider, leave
MCP_AUTH_ENABLED unset: every query then runs as TRINO_USER. Never run it
that way where the data matters.
Container
A Dockerfile ships with the repository (non-root, health check,
OCI labels); releases publish the image to ghcr.io/akko-p/akko-mcp-trino.
Run it with the same environment variables, publishing ports 3000 (MCP) and
3001 (health).
Connect an MCP host
The host needs the user's access token from the identity provider. With
MCP_RESOURCE_URL set, hosts that implement OAuth discovery find the provider
on their own (see Discovery); otherwise paste the token.
A hosted assistant (a chat product's custom connector, for instance Mistral
Vibe or AI Studio) is typically declared once by an administrator with the URL
of the server (https://mcp.example.com/mcp) and a client registered by hand
at the identity provider: a confidential client, authorization code only, the
product's own callback as the single redirect URI. Each user then signs in once
and the product keeps that user's token. On the provider side, give the client
a scope (say data:query) whose audience mapper puts the server's canonical
URL in the token, since not every product sends the RFC 8707 resource
parameter. On the server side, set MCP_RESOURCE_URL to that same URL,
MCP_REQUIRED_SCOPES=data:query and MCP_ALLOWED_CLIENTS to the client ids you
registered.
Cursor, Claude Desktop, VS Code (mcp.json):
{
"mcpServers": {
"trino": {
"url": "http://localhost:3000/mcp",
"headers": {
"Authorization": "Bearer <the user's access token>",
"X-Agent-Key": "<the key registered for this product, if any>"
}
}
}
}From Python, with the official SDK:
import anyio
from mcp import ClientSession
from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
async def main(token: str):
headers = {"Authorization": f"Bearer {token}", "X-Agent-Key": "my-agent-key"}
http = create_mcp_http_client(headers=headers)
url = "http://localhost:3000/mcp"
async with streamable_http_client(url, http_client=http) 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) is the default and what current hosts expect. Set
MCP_TRANSPORT=sse for older hosts (/sse); the guard is the same on both.
Local hosts over stdio
Claude Desktop, Cursor and Mistral Vibe can launch the server themselves. There is no request then, so the identity comes from the environment: the user's own access token, verified exactly like a bearer header would be, and bound to the process. Strict mode refuses to start without it.
{
"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": "<the user's access token>"
}
}
}
}uvx akko-mcp-trino runs the published package without installing anything.
Use it from an agent
examples/agent.py is a complete agent in eighty lines:
any OpenAI-compatible model (Mistral on La Plateforme, through OpenRouter or
LiteLLM; or any other provider), the MCP tools, the user's token. The model
never sees the token; the server receives it on every tool call.
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=<the user's access token>
python examples/agent.py "Which catalogs can I see, and what is in them?"Run it twice with two users' tokens and compare. The examples folder has the details.
The tools
Tool | Arguments | Returns |
| — |
|
|
|
|
|
|
|
|
| every table and view the user sees, listed in one query, paged ( |
|
| columns, types, comments, |
|
|
|
|
| tables and columns whose description, tags, classifications, glossary terms or business properties hold every word, case, accents and ligatures ignored; only what the user sees; |
|
|
|
|
| what the table means: description, owner, tier, grain, joins, tags, glossary terms and business properties, plus a |
|
| Trino's plan for a read-only statement, without running it |
|
|
|
|
|
|
Every tool runs in Trino under the caller's identity, discovery included:
metadata is data, and a user who may not read a schema does not list it
either. Every description is written for a model and says what the tool
returns. The descriptions of the tools that return data (describe_table,
search_columns, profile_table, execute_query) also say that a masked
value or a missing row is the access policy, not an error to retry.
Every identifier is validated ([A-Za-z_][A-Za-z0-9_-]*) before it is placed
in SQL. execute_query accepts any SQL Trino accepts, as long as it is a
read: the statement is parsed into an AST and refused if it is more than one
statement, or if an INSERT, UPDATE, DELETE, MERGE, CREATE, DROP,
ALTER, GRANT, CALL or SET appears anywhere in the tree — including
inside a CTE, a subquery, or the statement an EXPLAIN explains. EXPLAIN ANALYZE is refused: it executes what it explains. Table functions and the
system catalog are refused, and so is any SHOW that names system. A name
without a catalog is read in the session catalog (TRINO_CATALOG, system by
default); when that is system, a table without a catalog is refused (a CTE
is not a table), and only SHOW CATALOGS and a SHOW SCHEMAS, SHOW TABLES
or SHOW COLUMNS whose FROM gives the full name, catalog included, get
through. So set TRINO_CATALOG to a data catalog; the engine's policy must
refuse system too, the guard being a second line. Results are capped at
TRINO_MAX_ROWS: one row more is read, so truncated says whether the answer
was cut, and the rest of the query is cancelled in Trino. Every listing says
it too: list_catalogs, list_schemas, list_tables, search_columns,
describe_table and profile_table return truncated, and the audit line
records it, so a list cut by the cap never reads as complete. With
TRINO_QUERY_TIMEOUT_SECONDS, a query that runs longer is cancelled in Trino
and the agent is told so. The budget is enforced by the server, not set as a
session property: a policy engine that forbids users to set session
properties would refuse every query otherwise. With MCP_CALL_TIMEOUT_SECONDS,
a whole tool call has a time budget, every query it runs included: past it no
query starts, the one running is cancelled (even when the budget ran out before
Trino named it), and the agent gets
{"error": "the tool call exceeded its time budget…"}, never what the call had
gathered so far (a search whose last visibility checks could not run would
otherwise look complete), nor an answer that came past the deadline. A plugin
that calls its own backend bounds that request with
akko_mcp_trino.audit.time_allowed(timeout): its own timeout, or what is left
of the call's budget when that is shorter.
search_metadata costs a bounded number of queries, whatever the words. One
word of the text must have at least MCP_SEARCH_MIN_WORD_CHARS characters (3
by default), so no search is a scan of every description. The providers are
asked for at most MCP_SEARCH_CANDIDATES hits plus one (200 by default). The
gate examines these candidates 100 at a time. For each catalog of a batch, it
asks one query about their tables, then one query about the very columns they
name, per limit visible hits, until the limit is reached. Whatever the
schemas the hits span and the width of their tables, a search therefore
usually costs two queries. When the limit is not reached and the providers had
more candidates than this budget, truncated is true: the caller learns that
the words appear more often, never where.
Errors come back to the agent as {"error": "..."} with Trino's whole message;
a permission refusal from Trino is an ordinary error, not a crash. The audit
line keeps only the error's code (see Audit).
Adding tools from another package
A product built on this server can add its own tools without forking it. A
package registers a registrar (mcp, client) -> None, or
(mcp, client, tools) -> None, under the entry-point group
akko_mcp_trino.tools and the operator names it in MCP_TOOL_PLUGINS:
[project.entry-points."akko_mcp_trino.tools"]
my-tools = "my_package.tools:register"from akko_mcp_trino.identity import current_bearer, current_subject
def register(mcp, client):
@mcp.tool()
def my_tool(text: str) -> str:
return client.query("SELECT ...", user=current_subject(), bearer=current_bearer())Nothing is loaded that is not named, and an unknown name refuses to start.
Tools added this way go through the same client, hence the same identity and
quotas as the built-in ones, and every tool a registrar adds through
mcp.tool() or mcp.add_tool() leaves the same audit line, with no change to
the plugin. build_server(extra_tool_registrars=...) does the same from Python.
A registrar with a third argument receives the ToolContext: tools.tool(title, governed=..., open_world=...) registers a tool with its MCP annotations,
tools.run(sql) queries Trino under the caller's identity, tools.note(...)
completes the audit line (the SQL it ran, the tables, the rows, whether they
were cut, the metadata it served), tools.context is the context provider
chain already behind the visibility gate, and tools.visibility the gate:
import json
def register(mcp, client, tools):
@tools.tool("Ask the data", governed=True, idempotent=False)
def ask_data(question: str) -> str:
answer = my_service.ask(question) # runs its SQL under the caller's identity
tools.note(sql=answer["sql"], tables=answer["tables"], rows=len(answer["rows"]))
return json.dumps(answer)A tool that serves metadata from outside Trino (a catalogue, a semantic model, examples) must not describe what the caller cannot see. It asks the server's visibility gate, the same one the built-in tools use, with the client it received:
from akko_mcp_trino.visibility import for_client
def register(mcp, client):
visibility = for_client(client) # the server's gate: same cache, same TTL
@mcp.tool()
def my_metadata(catalog: str, schema: str, table: str) -> str:
if not visibility.table_visible(catalog, schema, table):
return '{"known": false}'
columns = visibility.visible_columns(catalog, schema, table)
...Context: what Trino cannot say
DESCRIBE gives columns and types. It does not say what segment means, who
owns the table, whether it is trustworthy, which column joins it to another,
or that email is personal data. A model without that guesses, and guesses
wrong.
The server knows no catalogue. It knows an interface, ContextProvider, and a
chain of providers fills it, in priority order:
Provider | Where the knowledge comes from | Needs |
| the | nothing |
| a versioned JSON document kept with your code: description, owner, tier, grain, joins, tags, glossary terms, properties, column classification and values |
|
| descriptions, owners, tier, tags, primary key as grain, foreign keys as joins, column classifications such as |
|
| the steward's description, classifications such as |
|
your catalogue | DataHub, Collibra… a package that implements the same two methods (and | that package |
{"version": 1, "tables": {
"core_postgres.clients.customers": {
"description": "One row per customer", "owner": "Customer data team", "tier": "gold",
"grain": ["customer_id"],
"joins": [{"columns": ["customer_id"], "target": "core_postgres.clients.accounts", "target_columns": ["customer_id"]}],
"tags": ["pii"],
"terms": [{"name": "Customer", "definition": "A person or company that holds a contract", "status": "VALIDATED"}],
"properties": {"domain": "Retail"},
"columns": {"email": {"description": "Contact address", "classification": ["PII"], "terms": [{"name": "Contact email"}]},
"segment": {"description": "Commercial segment", "values": ["retail", "business", "premium"]}}}}}With MCP_CONTEXT_PROVIDERS=file,trino-comments, describe_table returns the
columns and a table block (description, owner, tier, grain, joins, tags,
terms, properties) and a column_context block; explain_table answers from
the same knowledge, with a columns block for the columns Trino lists for the
caller (a glossary term or a classification is usually attached to a column),
or {"known": false}. A term is {"name", "definition", "status"}; a property
is any other fact the catalogue keeps, such as business metadata. The file wins where both speak. A provider describes;
it never decides: knowing a column is PII changes nothing about the mask,
which the engine applies. A provider that fails never hides the columns.
A listing needs one short description per table, not the whole context. A
provider may implement descriptions(catalog, schema, tables), the
descriptions of several tables of one schema in one request; visible_tables
asks it once per schema of its page, through the chain and the cache, which
asks each provider only for the tables still undescribed and remembers each
table per user. trino-comments answers with one query on
system.metadata.table_comments (read on from the next row when the row cap
cuts it), the file
from memory, atlas with one bulk request per entity type. A provider without
it (openmetadata today) is asked table(...) for each table in turn.
A provider may also implement search(text, catalog, limit), which
search_metadata asks of the providers of the chain that have it, in priority
order, until limit hits are found: a provider returns at most limit hits and
reads no more than it needs for them. The file searches descriptions, tags,
classifications, terms and properties; trino-comments searches the table and
column comments of one catalog, in Trino, under the caller's identity, read on
past the row cap and stopped at the limit (LIMIT, then OFFSET for the next
page); atlas searches the glossaries. Every hit passes the gate below before
it is served.
Words are compared without case, accents or ligatures: « Réduction » finds
« reduction », « manœuvre » finds « manoeuvre ». trino-comments filters in Trino
first, folding the accented Latin letters of Western Europe and the ligatures
œ, æ and ß exactly as the Python check does; a letter outside that table
is compared as written.
Only what the caller can see
A file or a catalogue knows tables regardless of who asks; Trino's access
control does not reach it. So no provider answers directly: the whole chain,
built-in providers, plugins and a provider passed to build_server(context=...)
alike, sits behind a visibility gate. Before any provider is asked, the gate
asks Trino, under the caller's identity, whether the caller sees the table
(information_schema.tables of that catalog) and which columns
(information_schema.columns), which the engine's access control already
filters per user.
A table the caller cannot see answers
{"known": false}, byte for byte what a table nobody knows answers: the response does not reveal that it exists.A column the caller cannot see gets no context. When a grain column is invisible, the whole grain is removed: a grain cut down to its visible columns would claim a uniqueness the table does not have. A join that touches an invisible table or column is removed whole.
A search hit on a table or a column the caller cannot see is dropped; the gate clears the hits 100 at a time and stops once it has the hits it needs.
visible_tablesadds a description only to the tables Trino has just listed to the caller, under their identity.An error, a refusal or an invalid name counts as "not visible": the gate fails closed. A refusal (Trino's user error,
Access Denied) is remembered for the TTL like any answer; Trino not answering (a timeout, a dropped connection) is not, so the next call asks again.The server's row cap bounds what an agent reads, not what the gate knows: the gate's queries are not limited by
TRINO_MAX_ROWS, so a table wider than the cap keeps every column.Answers are cached per user, never shared (
MCP_VISIBILITY_TTL_SECONDS), and so is the context cache (MCP_CONTEXT_TTL_SECONDS): what alice was told is never served to carol. Each cache holds at mostMCP_CACHE_MAX_ENTRIESanswers; an expired one is dropped, and past the size the least recently used goes first.
The gate costs one short query per catalog for the tables asked about, and one
for their columns, per user per TTL, and none when no provider is configured.
One query names at most 100 tables, each by its schema and its own name:
Trino then looks up exactly those tables, where past 100 names (its
metadata.max-prefetched-information-schema-prefixes) it would list the whole
catalog. MCP_SEARCH_CANDIDATES, MCP_SEARCH_MIN_WORD_CHARS and
MCP_CACHE_MAX_ENTRIES below 1 refuse to start: such a bound would not bound. MCP_VISIBILITY_ENABLED=false turns it
off; only do that when every provider already reads Trino under the caller's
identity (trino-comments alone).
What happens on a request
sequenceDiagram
participant H as MCP host
participant G as Guard (ASGI middleware)
participant T as Tool
participant Tr as Trino
H->>G: POST /messages Authorization, X-Agent-Key, X-Request-Id
G->>G: request id: honoured or generated
alt agent products registered and key missing or unknown
G-->>H: 401 X-Reason: agent_key_missing | agent_key_unknown
end
G->>G: verify JWT (JWKS, iss, aud, exp) → Principal(subject, jti)
alt no verified identity and MCP_AUTH_REQUIRED
G-->>H: 401 WWW-Authenticate: Bearer resource_metadata=…, scope=… X-Reason: unauthenticated
end
opt MCP_ALLOWED_CLIENTS or MCP_REQUIRED_SCOPES set
G-->>H: 401 client_not_allowed (azp not listed) · 403 insufficient_scope
end
opt MCP_INTROSPECTION_URL set
G->>G: RFC 7662: is the token still active? (cached by jti)
G-->>H: 401 revoked · 503 introspection_unavailable
end
opt rate limits set
G-->>H: 429 Retry-After X-Reason: rate_limited
end
G->>T: Principal and agent in ContextVars
T->>T: validate identifiers · refuse writes
T->>Tr: SQL with X-Trino-User = subject, source, client tags request_id and tool
Tr-->>T: rows (masked, filtered by the policy engine)
T->>G: audit line {request_id, tool, subject, agent, token_id, ok, sql_sha256, tables, duration_ms, rows, truncated, metadata_served}
G-->>H: result X-Request-IdOrder matters. The agent key is checked before the token, so an unregistered product never triggers a JWKS lookup. Quotas are counted after authentication, so a forged token cannot consume the slot of the person it names. A refused request consumes nothing.
Configuration
Everything is read from the environment. Nothing is hardcoded.
Trino
Variable | Meaning | Default |
| the coordinator |
|
| the account the server connects as; it impersonates the caller |
|
| session catalog, where a name without a catalog is read; set it to a data catalog (with |
|
| refuse writes in |
|
| result cap; one more row is read to say whether the answer was |
|
| time budget of one query: past it, the server cancels the query in Trino; |
|
| time budget of one tool call, every query of the call included: past it, no query starts, the one running is cancelled and the agent gets an error; |
|
| the source Trino records for every query ( | the server name |
|
|
|
| password of | — |
| TLS verification: |
|
| per-request timeout on the Trino client |
|
|
|
|
|
| — |
|
| — |
Three ways to reach Trino as the user
Mode | How | What Trino needs | When |
| the server authenticates as | an impersonation rule allowing | Trino authenticates with passwords or Kerberos |
| the caller's own bearer, already verified by the guard, is sent to Trino as its JWT, or first exchanged for a token whose audience is Trino; the server holds no secret for Trino (the exchange, when set up, uses a client secret at the identity provider) | the | Trino already trusts your identity provider; this setup is proven live on a Trino behind Keycloak |
| the server presents a client certificate over https and sets | the | Trino does not trust your identity provider, or your security team wants service accounts proven by certificates from its own PKI |
All fail closed: a password never travels over plain http; neither jwt nor
certificate falls back to a service account when a request has no verified
caller; certificate refuses to start when a file is missing, the key does not
match the certificate, the key is encrypted, the scheme is not https or
TRINO_VERIFY=false.
Certificate mode, on Trino's side
Trino authenticates the server by its certificate, then asks its access control
whether that account may act as the user named in X-Trino-User
(checkCanImpersonateUser). A client that sends X-Trino-User without the
certificate authenticates as no one, so it impersonates no one.
# config.properties of the coordinator: the first authenticator that succeeds wins,
# so users and services that send a JWT are unaffected.
http-server.authentication.type=CERTIFICATE,JWT
# PEM or Java truststore: the authority that issues client certificates.
http-server.https.truststore.path=/etc/trino/client-ca.pem
# Subject DN -> account name (the whole DN must match; group 1 is the account).
http-server.authentication.certificate.user-mapping.pattern=CN=([^,]+).*Every certificate the truststore accepts becomes an account named after its
CN. Trust an authority that issues client certificates to services only, or
narrow the pattern to the one account, for example CN=(mcp-trino)(,.*)?.
Then allow that account to impersonate users, and nobody privileged. With file-based access control, the first matching rule applies:
{
"impersonation": [
{"original_user": "mcp-trino", "new_user": "admin|trino|mcp-trino|.*_admin|svc-.*", "allow": false},
{"original_user": "mcp-trino", "new_user": ".*"}
]
}With Ranger, the same pair is an allow policy (trinouser=*, access
impersonate) and a deny policy on the privileged names; a deny wins.
On the server, akko-mcp-trino --check prints the subject, the SHA-256
fingerprint and the expiry of the certificate, never the key. /ready reads
Trino's /v1/info over the same TLS, presenting the certificate, so a
certificate Trino rejects leaves the server not ready.
Identity
Variable | Meaning | Default |
| verify bearer tokens |
|
| refuse requests without a verified identity |
|
| the provider's JWKS endpoint | — (required when auth is enabled) |
| claims to enforce; a token whose audience is | — |
| tolerance on |
|
| canonical URL of this server, path included ( | — (off) |
| applications whose tokens are accepted ( | — (any) |
| scopes every token must carry, comma- or space-separated; announced in the metadata and the | — (none) |
|
| — (off) |
| Host header filter (the SDK's DNS rebinding protection): | — (off) |
Auth enabled without a JWKS URL refuses to start: an authentication layer that
cannot verify anything must not pretend to. A malformed MCP_AGENT_KEYS entry
refuses to start for the same reason.
Guards
Variable | Meaning | Default |
| requests per window per user and per agent product; |
|
| the sliding window |
|
| RFC 7662 endpoint; enables the revocation check | — (off) |
| credentials the provider expects | — (required with the URL) |
| how long a verdict is cached by |
|
Context providers
Variable | Meaning | Default |
| comma-separated, priority order: | — (none) |
| the JSON document for | — |
| how long an answer is cached, per user |
|
| the visibility gate: no context about a table or column Trino does not show the caller; |
|
| how long the gate remembers what a user sees; |
|
| answers the context cache and the gate's cache each hold, at most |
|
|
|
|
|
|
|
Tool plugins
Variable | Meaning | Default |
| comma-separated names of registrars installed under the entry-point group | — (none) |
Serving
Variable | Meaning | Default |
|
|
|
| listening ports |
|
| name announced to hosts |
|
| stdio only: the user's access token, verified like a bearer header | — (required in strict mode) |
Operations
Two principals
A workspace key from Cursor or Claude is not a user. When MCP_AGENT_KEYS is
set, every request must carry the product key, and in strict mode the user's
token as well:
Principal | Header | Says | Enforced by |
End user |
| for whom the query runs | JWKS here, then the policy engine in Trino |
Agent product |
| which product is calling | the registry here, for quotas and audit |
Discovery
With MCP_RESOURCE_URL set, the server publishes RFC 9728 metadata without a
token, and every 401 says where to go and which scope to ask for. The
metadata sits at the well-known URI with the resource path inserted after the
host (RFC 9728 §3.1) and at the root, since MCP clients try the first, then the
second:
curl -s https://mcp.example.com/.well-known/oauth-protected-resource/mcp # same document at the root
# {"resource":"https://mcp.example.com/mcp","authorization_servers":["https://idp.example.com/realms/data"],"bearer_methods_supported":["header"],"scopes_supported":["data:query"]}
curl -si -X POST https://mcp.example.com/mcp | grep -i -e www-auth -e x-reason
# WWW-Authenticate: Bearer realm="trino-mcp", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="data:query"
# X-Reason: unauthenticatedresource is MCP_RESOURCE_URL byte for byte: the identity provider must put
that exact string in the token's audience, and the server accepts it as one.
authorization_servers stays empty unless MCP_OIDC_ISSUER is set; the server
never guesses a provider. A refused token adds error="invalid_token" to the
challenge; a missing scope gets 403 with error="insufficient_scope" and the
scope, metadata or not (RFC 6750 §3).
Refusals
Every refusal carries X-Reason and the request id, never the token:
Status |
| Meaning |
401 |
| products are registered and the key is absent or wrong |
401 |
| no verified identity in strict mode |
401 |
| the token was issued to an application outside |
403 |
| the token lacks a scope of |
401 |
| the provider answers |
429 |
| a window is full; |
503 |
| the introspection call failed (endpoint unreachable, credentials refused); the guard fails closed |
Audit
Every response of the MCP endpoint carries X-Request-Id (the metadata
document does not). It is the one the gateway in front of the server sent,
when it is a plain token (letters, digits, ., _, :, -, at most 128), or
one the server generated. A tool call outside an HTTP request (stdio) gets its own id.
Every tool call, a plugin's included, writes one JSON line to the mcp.audit
logger keyed by that id:
{"request_id":"edge-42","tool":"execute_query","subject":"alice","agent":"cursor","token_id":"jti-9","ok":false,"error":"USER_ERROR:PERMISSION_DENIED","sql_sha256":"5f0c…","tables":["crm.clients.customers"],"duration_ms":84,"rows":null,"truncated":false,"metadata_served":[]}With the default logging configuration, the line comes out prefixed with
INFO:mcp.audit:; a collector that expects pure JSON strips that prefix.
Field | Meaning |
| the JWT's |
| the outcome; |
| SHA-256 of the statement sent to Trino for the caller ( |
| the tables that statement names, or the table the call is about |
| how long, how many rows or items returned, whether they were cut; for |
| the tables whose catalogue metadata (description, terms, owner, joins…) reached the caller: a table the caller cannot see never appears here |
The join. Every query the server sends carries the source (TRINO_SOURCE,
the server name by default) and two client tags, request_id=<id> and
tool=<name>: Trino's query log, its event listeners and /v1/query show them
next to the query text. A policy engine that records the query text (Ranger's
audit keeps it as the request data) hashes to the same sql_sha256. Gateway
log, audit line, Trino query and policy decision are four views of one call.
Revocation before expiry
A signature and an exp prove a token was valid. With MCP_INTROSPECTION_URL
set, the guard asks the provider (RFC 7662) and refuses a token whose active
is false. Verdicts are cached by jti for MCP_INTROSPECTION_TTL_SECONDS. If
the provider cannot answer, the request is refused with 503, because
"unknown" is not "still valid".
Keycloak note: introspection is accepted only from a client that appears in the
token's aud; otherwise Keycloak answers {"active": false} and the person
gets 401 revoked. Register this server as its own confidential client and add it
to the audience mapper; the client that issued the token is not enough.
Quotas
Two in-memory sliding windows, one per user subject and one per agent product, count HTTP requests, initialization and tool listing included. Counters live in the process: with several replicas the quota is per replica.
Health and metrics
/health on the health port never touches Trino, so a liveness probe cannot
kill the server because Trino is slow. /ready runs a bounded SELECT 1 in
impersonate mode; in jwt and certificate modes, which have no service
identity to query with, it reads Trino's public /v1/info.
/metrics exposes mcp_trino_queries_total, mcp_trino_query_errors_total
and mcp_trino_query_duration_seconds.
Development
pip install -e ".[dev]"
ruff check akko_mcp_trino tests examples && ruff format akko_mcp_trino tests examples
ruff check akko_mcp_trino --select D100,D101,D102,D103,D105,D107 # every public name documented
mypy akko_mcp_trino # the package ships py.typed and type-checks clean
pip-audit # no known vulnerability in the dependency tree
bash lint-vendor-neutral.sh # fails if the package imports anything product-specific
pytest # 574 tests; line or branch coverage below 100 % fails the run
python -m build && twine check dist/*
akko-mcp-trino --check # effective configuration, no secrets; exit 2 if it would not start (stdio token: presence only)CI runs the same steps on Python 3.12 and 3.13, then builds the container
image; CodeQL scans every push and Dependabot opens weekly update pull
requests for pip, Actions and the base image. A v* tag publishes the package to PyPI (trusted publishing) and the
image to GHCR — see release.yml.
Every module in akko_mcp_trino/ has one responsibility and its tests, in
tests/; transport_security.py, for instance, is tested in tests/test_app.py:
Module | Responsibility |
| read the environment, neutral defaults |
| JWT verification against JWKS, |
| the application ( |
| user and agent product in ContextVars |
| the guard: key, token, token policy, revocation, quotas, discovery, request id |
| one check of the guard each: RFC 9728 metadata, quotas, RFC 7662 introspection |
| the Host header filter ( |
| the audit line, the request id and the time budget of a call |
| identifier validation, read-only decision on the AST |
| context providers and their cache, the visibility gate |
| the twelve tools, loading tool plugins |
| the Trino connection, the three identity modes and the time budgets; RFC 8693 token exchange |
|
|
| assembly, transport, entrypoint and |
The README is tested too: every variable listed here is read by config.py,
and the defaults stated here are the defaults in the code.
Design notes
The guard is a pure ASGI middleware, not BaseHTTPMiddleware. The latter
runs the downstream in a separate task and breaks ContextVar propagation: the
identity would never reach the tools. This was verified with concurrent
sessions of two users on both transports — zero crossed responses.
Mounting is transport-independent. One code path builds the transport app and mounts the guard, whatever the transport. The guard once lived only on the branch the default transport never took; a test now asserts it for both.
Every tool carries the identity, not only execute_query. Version 0.1
ran discovery as the service account; a restricted user could list what the
engine would hide from them. Fixed in 0.2 with a test that walks every tool.
Read-only is decided on the AST, anywhere in the tree. A leading-keyword
check lets WITH w AS (DELETE FROM t) SELECT 1 through. So did a root-node
check, until a live adversarial proof caught it. The guard now walks the whole
tree.
Table functions are refused. TABLE(pg.system.query(query => '...')) parses
as a plain SELECT, yet it sends raw SQL to the source under the connector's
own account, so the engine applies neither row filters nor column masks. The
guard refuses every table function and the system catalog (which shows other
users' SQL in system.runtime.queries), including when a name without a
catalog would be read there because TRINO_CATALOG is system. The engine policy must refuse them as
well; the guard is a second line, not the only one.
In jwt mode, set up token exchange. Without TRINO_TOKEN_EXCHANGE_URL, the
caller's token goes to Trino as is, which the MCP specification calls token
passthrough. With it (and _CLIENT_ID, _CLIENT_SECRET, _AUDIENCE, optional
_SCOPE), the caller's bearer, verified for this server, is exchanged (RFC 8693)
for a token whose audience is Trino, and Trino accepts only tokens meant for it.
In certificate mode no user token travels at all: the certificate proves the
server, X-Trino-User names the verified caller, and Trino's access control
decides whether the server may act for that caller.
A valid token is not enough: which application asked for it matters.
Signature, issuer and audience prove the token was minted for this server. Any
client of the same identity provider can be given that audience by a mapper,
so MCP_ALLOWED_CLIENTS names the applications that may act for a user, and a
token that fails it is refused even in lenient mode rather than treated as
anonymous. The client is checked before the scope, so a foreign application is
never told which scope would let it in.
Identity comes from the token, never from a header. A caller sending
X-Trino-User or X-Forwarded-User changes nothing; the subject forwarded to
Trino is the verified JWT subject.
The server does not decide access. It carries identity. Putting policy in the server would duplicate — and eventually contradict — what the engine already enforces for every other client.
Contributing
Contributions are welcome — see CONTRIBUTING.md for the ground rules (tests first, 100 % coverage, product-neutral, fail closed) and SECURITY.md for reporting a vulnerability privately. Changes are tracked in CHANGELOG.md.
About AKKO
akko-mcp-trino is built and maintained by AKKO, a French company working on governed access for AI agents to enterprise data, in place, on the engines and identity providers customers already run. This server is the first brick of that work, extracted from the AKKO platform where it has run in production since July 2026 and released so that anyone running Trino can put identity in front of their agents today.
If you run Trino behind Ranger or OPA and want a hand wiring this up, or want to see the rest of the platform, say hello.
Maintained by: AKKO (contact@akko-ai.com) · Issues: github.com/AKKO-p/akko-mcp-trino/issues
License
Apache 2.0. See LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Give AI agents identity, scoped access, trusted context, and verifiable actions through MCP.
Query your org's data in natural language — read-only MCP access to SQL, NoSQL, files & warehouses.
Governed access to production AI-agent traces in an existing ClickHouse store.
Remote MCP for A2A caller identity, scope policy, verdict receipts, and audit history.
Related MCP Servers
- AlicenseAqualityCmaintenanceEnables executing SQL queries on Trino clusters via MCP, supporting multiple authentication methods and read/write operations with safety controls.8935 PyPI2MIT
- AlicenseNot gradedqualityBmaintenanceEnables AI assistants to query BigQuery read-only via MCP with enforced dataset boundaries, result limits, and audit labels.MIT

MCP DB Gatewayofficial
AlicenseNot gradedqualityBmaintenanceProvides governed, read-only PostgreSQL access for AI agents via MCP. Enforces schema/table allowlists, query limits, and audit events.MIT- AlicenseNot gradedqualityCmaintenanceEnables MCP clients to execute validated read-only warehouse queries through a guarded SQL API, with enforced row limits and secret-safe audit metadata.MIT