The open source core
The core of akko-mcp-trino, under the Apache 2.0 licence, gives an AI agent read-only MCP access to Trino, by default under each user's own identity, without ever passing their token on.
The core of akko-mcp-trino is an MCP server for Trino, under the Apache 2.0 licence, at version 0.6.0. It gives an AI agent six tools to explore a Trino and read data from it. The read-only guard is decided on the SQL tree. Every call is bounded and leaves one audit line.
By default, every query runs as the user the agent works for. The server verifies the user’s token, issued for this server. It exchanges it at the identity provider (IdP) for a separate token, issued for Trino, through OAuth 2.0 Token Exchange (RFC 8693). The user’s own token never leaves the server. Trino and its policy then decide what the agent may read.
Coming from 0.5? 0.6.0 changes the defaults. A verified caller is required, and
jwtmode exchanges the caller’s token instead of passing it on, as the MCP specification requires. A 0.5 configuration no longer starts as it is:akko-mcp-trino --checksays what is missing, and MIGRATING.md walks through each step. The 0.5.x releases are no longer supported.
The core, and what AKKO and ARCHE deliver
| Open source core | In AKKO and ARCHE | |
|---|---|---|
| Identity in Trino | each user’s own by default, through token exchange or impersonation; one identity only when the administrator names it | the same, and other ways to carry the user’s identity |
| Read-only on the SQL tree | yes | yes |
| Row and time bounds | yes, on by default | yes |
| Traces | one audit line per call, naming the caller | an audit linked to the engine’s log |
| Tools | six tools to explore and read | the same, and further tools |
| Business context of the catalogue | no | yes, never describing what the person cannot see |
| Agent and access management | no | yes |
| Enterprise operations | up to you | installed and monitored with the platform |
The core suits agents that each read under the person’s own rights, on a Trino that trusts your IdP. It also suits an agent that reads under one account whose rights your rules bound. The overview describes the complete server.
Install
pip install akko-mcp-trino # or: pipx install akko-mcp-trino
uvx akko-mcp-trino --version # without installing, the way most MCP hosts do
You can also install it 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 .
It needs Python 3.12 or later. The package installs a Python module (akko_mcp_trino) and a command (akko-mcp-trino); python -m akko_mcp_trino starts the same server.
In a container
The image is published on Docker Hub, for linux/amd64 and linux/arm64. It runs as a non-root user and carries a health check. It takes the same environment variables, on ports 3000 (MCP) and 3001 (health).
docker run --rm akkoai/akko-mcp-trino:0.6 akko-mcp-trino --version
docker run -d --name akko-mcp-trino -p 3000:3000 -p 3001:3001 \
-e TRINO_HOST=trino.example.internal -e TRINO_PORT=8443 -e TRINO_HTTP_SCHEME=https \
-e TRINO_CATALOG=iceberg \
-e MCP_JWKS_URL=https://idp.example.com/realms/data/protocol/openid-connect/certs \
-e MCP_OIDC_ISSUER=https://idp.example.com/realms/data \
-e MCP_OIDC_AUDIENCE=akko-mcp \
-e TRINO_TOKEN_EXCHANGE_URL=https://idp.example.com/realms/data/protocol/openid-connect/token \
-e TRINO_TOKEN_EXCHANGE_CLIENT_ID=akko-mcp \
-e TRINO_TOKEN_EXCHANGE_CLIENT_SECRET=... \
-e TRINO_TOKEN_EXCHANGE_AUDIENCE=trino \
akkoai/akko-mcp-trino:0.6
The 0.6 tag follows the latest release of the 0.6 line; 0.6.0 names one release.
The code, the tests and the examples are on GitHub; the changelog tracks every change, and a vulnerability is reported through the security policy.
Get started
The server reads its whole configuration from the environment. Point it at your Trino and at your IdP, then start it. The defaults are the safe ones: every query runs as the caller (jwt mode), and a request without a valid token is refused.
export TRINO_HOST=trino.example.internal
export TRINO_PORT=8443
export TRINO_HTTP_SCHEME=https # required: a token never travels over http
export TRINO_CATALOG=iceberg
# The door: tokens issued for this server, verified against the IdP's JWKS
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=akko-mcp # the audience of tokens issued for this server, never Trino's
# A token for Trino, by token exchange (RFC 8693), never the caller's own
export TRINO_TOKEN_EXCHANGE_URL=https://idp.example.com/realms/data/protocol/openid-connect/token
export TRINO_TOKEN_EXCHANGE_CLIENT_ID=akko-mcp
export TRINO_TOKEN_EXCHANGE_CLIENT_SECRET=...
export TRINO_TOKEN_EXCHANGE_AUDIENCE=trino
akko-mcp-trino --check # the effective configuration, no secret
akko-mcp-trino # serves streamable-http on port 3000
--check prints the effective configuration and any warning, without ever printing a secret. It exits with code 2 if the server would refuse to start. On start, the server writes a line like this one.
INFO:__main__:serving transport=streamable-http port=3000 auth=True strict=True identity=jwt extensions=none
Three endpoints then let you watch the server, on the health port.
curl -s localhost:3001/health # {"status":"ok"} never touches Trino
curl -s localhost:3001/ready # {"status":"ready"} Trino answers
curl -s localhost:3001/metrics # Prometheus metrics
/health never touches Trino, so a liveness probe cannot kill the server because Trino is slow. /ready asks Trino according to the identity mode. In jwt mode, it is Trino’s public /v1/info. In impersonation and service modes, it is a bounded SELECT 1 as TRINO_USER, impersonating no one. /metrics exposes mcp_trino_queries_total, mcp_trino_query_errors_total and mcp_trino_query_duration_seconds.
Identity in Trino
The server first verifies the caller’s token: signature against the IdP’s JWKS, issuer, audience, expiry. The audience must name this server (MCP_OIDC_AUDIENCE or MCP_RESOURCE_URL). A token issued for Trino is refused, even if it also names this server. By default, a request without a valid token gets a 401 before any query reaches Trino. The TRINO_IDENTITY_MODE variable then says under which identity Trino runs it.
| Mode | Who Trino sees | How Trino knows | When to use it |
|---|---|---|---|
jwt (default) |
the caller | a token for Trino the server obtained by token exchange (RFC 8693); Trino verifies it with its JWT authenticator | the IdP supports token exchange (RFC 8693); tested with Keycloak 26 |
impersonation |
the caller | the server’s own account (password or client certificate) asks Trino to run the query as the caller, under an impersonation rule only that account has | the IdP cannot exchange tokens |
service |
TRINO_USER, the same for every caller |
a password, a client certificate, or nothing for a local Trino | only when named: one identity for everyone, announced at start and to every MCP host |
In every mode, the caller’s own token never leaves the server. The caller’s name always comes from their verified token, never from a header they wrote. jwt and impersonation refuse to start unless the door requires a valid token, and an unknown mode stops the server from starting.
Each user under their own rights (jwt, the default)
The caller’s token is issued for this server. The server exchanges it at the IdP for a token whose audience is TRINO_TOKEN_EXCHANGE_AUDIENCE, keeps it in memory until shortly before it expires, and sends it to Trino. The token endpoint is https only.
On the IdP, give this server a confidential client allowed to exchange tokens, and make Trino’s audience available to it. With Keycloak 26, enable Standard token exchange on that client and add an audience mapper for the Trino client to it. The clients of your MCP hosts put this server in the audience of their tokens, with an audience mapper too. The repository’s integration/ folder holds such a realm, tested on every pull request.
On the Trino side, set http-server.authentication.type=JWT and http-server.authentication.jwt.key-file to the IdP’s JWKS URL. Set http-server.authentication.jwt.principal-field to the same claim the server reads (preferred_username or sub). required-issuer and required-audience pin the token to your IdP and to Trino’s audience.
The server never passes on the token it received. It exchanges it for a token issued for Trino, with a client of its own. Trino verifies that second token and runs the query under the person’s name: Ranger or OPA decide for that person. The server’s only secret is that client: grant it, and it alone, the right to exchange for Trino’s audience. A configuration that would accept Trino’s audience refuses to start.
Without token exchange (impersonation)
When the IdP cannot exchange tokens, the server authenticates to Trino as its own account, and Trino runs each query as the verified caller.
export TRINO_IDENTITY_MODE=impersonation
export TRINO_USER=mcp-service
export TRINO_CLIENT_CERT=/certs/mcp-service.crt # mTLS; or TRINO_PASSWORD over https
export TRINO_CLIENT_KEY=/certs/mcp-service.key
Trino authenticates that account (certificate or password authenticator, besides the one your users have) and allows it, and only it, to act as users. With Trino’s file-based access control, that is the following rule.
{"impersonation": [{"original_user": "mcp-service", "new_user": ".*", "allow": true}]}
Narrow new_user to the users the agents act for. With that rule, nobody else may act as someone else: a user who sends a header naming another user is refused by Trino. Keep the account’s credential as you would any service account’s: whoever holds it can act as any user the rule allows.
One identity (service, only when named)
export TRINO_IDENTITY_MODE=service
export TRINO_USER=mcp-reader
export TRINO_PASSWORD=... # or TRINO_CLIENT_CERT and TRINO_CLIENT_KEY; https only
Every query runs as TRINO_USER, whoever calls. Give that identity the rights every caller of this server may have, read-only, no more. The server announces it as a warning at start, in --check and to every MCP host, in the initialization instructions. The door stays on by default. To serve without one, locally only, also set MCP_AUTH_ENABLED=false and MCP_AUTH_REQUIRED=false; the warning says so.
What proves it
On every pull request, the repository runs a live integration, Keycloak and Trino in Docker. It covers token exchange, impersonation by client certificate, service mode and every refusal at the door. The sandbox replays it in five minutes on 0.6.0. Two accounts ask the same question and get two answers. A request without a token, a forged token and a token issued for Trino instead of the server all get a 401.
The six tools
| Tool | Arguments | What it returns |
|---|---|---|
list_catalogs |
none | {"catalogs": [...], "truncated": bool}, the visible catalogs |
list_schemas |
catalog |
{"schemas": [...], "truncated": bool}, the schemas in that catalog |
list_tables |
catalog, schema |
{"tables": [...], "truncated": bool}, the tables and views in that schema |
describe_table |
catalog, schema, table, sample_rows (0 to 20) |
the columns, their types and comments, truncated, and a governed sample when asked |
explain_query |
sql |
{"plan": "..."}, the plan of a read-only statement, without running it |
execute_query |
sql, max_rows (optional, lower than the cap) |
{"columns": [...], "rows": [...], "row_count": n, "truncated": bool} |
Every tool goes through Trino under the identity mode, the listing tools included: metadata is data, and Trino lists only what that identity may see. Every tool description is written for a model and says what the tool returns. Those of describe_table and execute_query say that a masked value or a missing row is the access policy, not an error to retry. Errors come back to the agent as {"error": "..."}, with Trino’s whole message; a permission refusal is an ordinary error, not a crash.
execute_query is annotated read-only (readOnlyHint: true) only while TRINO_READ_ONLY is true. With TRINO_READ_ONLY=false, its annotations and its description say it may write, so that the MCP host asks for the confirmation it would skip for a read-only tool.
Read-only
execute_query accepts any SQL Trino accepts, as long as it is a read. The statement is parsed into a syntax tree. It is refused if it holds more than one statement, or if a write (INSERT, UPDATE, DELETE, MERGE, CREATE, DROP, ALTER, GRANT, CALL, SET) appears anywhere in the tree, CTEs, subqueries and the statement an EXPLAIN explains included. EXPLAIN ANALYZE is refused, since it runs what it explains.
Table functions (TABLE(...)) are refused. A TABLE(pg.system.query(query => '...')) reads like a plain SELECT, but sends raw SQL to the source under the connector’s account, outside row filters and column masks. The system catalog is refused too. Every identifier is validated ([A-Za-z_][A-Za-z0-9_-]*) before it enters the SQL. A name without a catalog is read in the session catalog (TRINO_CATALOG): give it a data catalog.
What read-only covers
The guard refuses every write that passes through this server. It is not a guarantee about the user’s rights in Trino. In jwt and impersonation modes, Trino runs the query as the user, with the rights its policy gives that user. A user allowed to write can still write through another Trino client, with a token for Trino the IdP gives them directly. The token obtained by exchange stays in the server’s memory and is never returned to the agent or the MCP host.
For read-only end to end, the engine must hold the line too. Give read-only rules to the identities agents reach Trino with. Let only this server’s client exchange for Trino’s audience. Narrow the impersonation rule to the users agents act for. Refuse table functions and the system catalog in the policy too. The guard stays a second line. The blog tells how it took its shape.
Bounds
| Bound | Variable | Default | Effect |
|---|---|---|---|
| Row cap | TRINO_MAX_ROWS |
100 |
one more row is read to say whether the answer was cut (truncated), and the rest of the query is cancelled in Trino |
| Query budget | TRINO_QUERY_TIMEOUT_SECONDS |
60 |
a longer query is cancelled in Trino, and the agent is told so |
| Call budget | MCP_CALL_TIMEOUT_SECONDS |
120 |
past it, no query starts, the running one is cancelled, and the agent gets an error rather than a partial answer |
A question an agent asks should come back within a minute. A query that needs longer is usually a full scan the agent should narrow, and the error it gets says so. Two minutes per call leave room for a tool that runs two or three queries. 0 removes a budget’s limit, and --check prints both. The budget is held by the server, not set as a session property, so that an engine that forbids session properties does not refuse every query.
The log
Every response carries an X-Request-Id header: the gateway’s when it is a plain token, otherwise a generated one. Every tool call writes one JSON line to the mcp.audit logger, keyed by that id.
{"request_id":"edge-42","tool":"execute_query","subject":"alice","ok":false,"error":"USER_ERROR:PERMISSION_DENIED","duration_ms":84}
With the default logging configuration, the line comes out prefixed with INFO:mcp.audit:; a collector that expects pure JSON strips that prefix. The line says who called which tool, with which outcome and for how long. It has no field that could hold the token or the SQL text. An error appears as a code, never a message, because a message from Trino may quote a value read from a row (Cannot cast '<value>' to INT). The agent reads the whole message.
Refusals and warnings
Every refusal carries an X-Reason header and the request id, never the token. A request without a valid token (none, forged, expired, or issued for another audience, Trino’s included) gets 401 unauthenticated, with a body that says what to send. When MCP_RESOURCE_URL is set, the server publishes its protected resource metadata (RFC 9728) at /.well-known/oauth-protected-resource, and the 401 carries WWW-Authenticate: Bearer resource_metadata="…", as the MCP authorization specification asks.
A configuration that leaves something open is said three times: a WARNING line at start, in --check, and to every MCP host in the initialization instructions. The core warns of service mode and of a door that does not require a valid token.
Connect an MCP host
Remotely, the MCP host connects to the server’s address (/mcp over streamable-http, the default transport; /sse for an older MCP host). It sends the user’s access token, issued for this server. An MCP host that implements MCP authorization finds the IdP itself, through the metadata the server publishes when MCP_RESOURCE_URL is set.
{
"mcpServers": {
"trino": {
"url": "https://mcp.example.internal/mcp",
"headers": {"Authorization": "Bearer <the user's access token, issued for this server>"}
}
}
}
Locally, Claude Desktop, Cursor or Mistral Vibe start the server themselves over stdio. There is no HTTP request: the caller comes from the environment, through the user’s access token in MCP_USER_TOKEN, issued for this server and verified like a header. In strict mode, the default, the server refuses to start without it. In jwt mode, that token is exchanged for a token for Trino, as over http.
{
"mcpServers": {
"trino": {
"command": "uvx",
"args": ["akko-mcp-trino"],
"env": {
"MCP_TRANSPORT": "stdio",
"TRINO_HOST": "trino.example.internal", "TRINO_PORT": "8443", "TRINO_HTTP_SCHEME": "https",
"TRINO_CATALOG": "iceberg",
"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": "akko-mcp",
"TRINO_TOKEN_EXCHANGE_URL": "https://idp.example.com/realms/data/protocol/openid-connect/token",
"TRINO_TOKEN_EXCHANGE_CLIENT_ID": "akko-mcp",
"TRINO_TOKEN_EXCHANGE_CLIENT_SECRET": "<this server's client secret>",
"TRINO_TOKEN_EXCHANGE_AUDIENCE": "trino",
"MCP_USER_TOKEN": "<the user's access token, issued for this server>"
}
}
}
}
In impersonation mode, replace the TRINO_TOKEN_EXCHANGE_* variables with TRINO_IDENTITY_MODE=impersonation, TRINO_USER and its certificate or password. Filter the Host header with MCP_ALLOWED_HOSTS when a browser can reach the server.
Extend without modifying
A Python package can add tools or behaviour around the core, without modifying it. It registers under an entry-point group, and the operator names it at start.
[project.entry-points."akko_mcp_trino.tools"]
my-tools = "my_package.tools:register"
export MCP_TOOL_PLUGINS=my-tools
Nothing is loaded that is not named, and an unknown name stops the server from starting. An added tool goes through the same client, hence under the same identity mode, with the same read-only guard and the same log line.
A broader extension (MCP_EXTENSIONS, group akko_mcp_trino.extensions) can add identity modes, accepted audiences (never Trino’s), guards at the door, routes, tools or log fields. Its guards run around the token verification, never instead of it, and the read-only guard applies to every query. An identity mode that sends Trino a token for the caller must obtain one for Trino, never forward the caller’s.
Configuration
The whole configuration is read from the environment; nothing is hardcoded.
Trino
| Variable | Role | Default |
|---|---|---|
TRINO_HOST, TRINO_PORT |
the coordinator | localhost, 8080 |
TRINO_HTTP_SCHEME |
http or https; a password or a token is never sent over http |
http |
TRINO_VERIFY |
TLS verification: true, false, or the path to the CA bundle that checks Trino’s TLS chain |
true |
TRINO_IDENTITY_MODE |
jwt (every query as the caller, through token exchange), impersonation (every query as the caller, impersonated by TRINO_USER) or service (every query as TRINO_USER, announced as a warning); the first two need https and a door that requires a valid token; an unknown mode stops the server from starting |
jwt |
TRINO_TOKEN_EXCHANGE_URL |
jwt: the IdP’s token endpoint, https only |
none (required in jwt) |
TRINO_TOKEN_EXCHANGE_CLIENT_ID, TRINO_TOKEN_EXCHANGE_CLIENT_SECRET |
jwt: this server’s confidential client at the IdP; --check never prints the secret |
none (required in jwt) |
TRINO_TOKEN_EXCHANGE_AUDIENCE |
jwt: the audience of the token requested for Trino, the one Trino’s required-audience names; never an audience this server accepts |
none (required in jwt) |
TRINO_TOKEN_EXCHANGE_SCOPE |
jwt: scopes to request with it, space-separated |
none |
TRINO_TOKEN_EXCHANGE_VERIFY |
TLS verification of the token endpoint: true, false, or a CA bundle path |
true |
TRINO_USER |
the server’s own account: every query runs as it in service mode; in impersonation mode it authenticates and acts as the caller |
trino |
TRINO_PASSWORD |
its password, sent by Basic authentication, refused over http |
none |
TRINO_CLIENT_CERT, TRINO_CLIENT_KEY |
a client certificate and its key (PEM paths) the account authenticates with instead (mTLS); https only | none |
TRINO_CATALOG |
the session catalog, where a name without a catalog is read; give it a data catalog | system |
TRINO_READ_ONLY |
refuse writes in execute_query |
true |
TRINO_SOURCE |
the source Trino records for every query | the server name |
TRINO_REQUEST_TIMEOUT_SECONDS |
the timeout of one HTTP request to Trino | 30 |
TRINO_MAX_ROWS, TRINO_QUERY_TIMEOUT_SECONDS, MCP_CALL_TIMEOUT_SECONDS |
the bounds above | 100, 60, 120 |
Identity
| Variable | Role | Default |
|---|---|---|
MCP_AUTH_ENABLED |
verify the callers’ tokens (the door) | true |
MCP_AUTH_REQUIRED |
refuse with 401 a request without a valid token; jwt and impersonation refuse to start without it |
true |
MCP_JWKS_URL |
the IdP’s JWKS | none (required when verification is on) |
MCP_OIDC_ISSUER |
the required issuer | none |
MCP_OIDC_AUDIENCE |
the required audience, the one of tokens issued for this server (RFC 8707); never Trino’s | none (this or MCP_RESOURCE_URL is required when verification is on) |
MCP_RESOURCE_URL |
this server’s canonical URL, such as https://mcp.example.com/mcp: accepted as an audience, named in the protected resource metadata (RFC 9728) and in the 401 |
none |
MCP_JWT_LEEWAY_SECONDS |
the tolerance on exp, nbf and iat, for clock drift |
30 |
MCP_ALLOWED_HOSTS |
the Host header filter, comma-separated entries; empty, everything is served |
none |
Verification on without a JWKS URL, or without an audience of this server’s own, refuses to start. So does MCP_AUTH_REQUIRED=true with MCP_AUTH_ENABLED=false, since the server would refuse every request.
Serving
| Variable | Role | Default |
|---|---|---|
MCP_TRANSPORT |
streamable-http, sse or stdio |
streamable-http |
MCP_PORT, MCP_HEALTH_PORT |
the listening ports | 3000, 3001 |
MCP_SERVER_NAME |
the name announced to MCP hosts | trino-mcp |
MCP_USER_TOKEN |
stdio only, the user’s access token, issued for this server |
none (required in strict mode) |
MCP_TOOL_PLUGINS, MCP_EXTENSIONS |
the plugins and extensions to load, by name | none |
Compatibility
| Supported | |
|---|---|
| Python | 3.12, 3.13 |
| Trino | 351 and later; https with the JWT authenticator in jwt mode, with a password or certificate authenticator and impersonation rules in impersonation mode |
| Policy engines | OPA, Ranger, Trino file-based access control |
| Identity providers | any OIDC provider publishing a JWKS, for the door; for jwt mode, one that can exchange tokens (RFC 8693), such as Keycloak 26; otherwise, impersonation mode |
| MCP | transports streamable-http, sse and stdio, through the official SDK |
| Models | any; the server never talks to a model |
Going further
For other ways to carry the user’s identity, an audit linked to the engine’s log, the business context of the catalogue and agent and access management, see the server delivered in AKKO and ARCHE. For a question, write to us. To report a problem with the core, open an issue.