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, under a configured identity or under each user's own.

The core of akko-mcp-trino is an MCP server for Trino, under the Apache 2.0 licence, at version 0.5.3. It gives an AI agent six tools to explore a Trino and read data from it, with a read-only guard decided on the SQL tree, bounds on every call and one audit line per call. Every query runs either under the identity the administrator configures, or under the identity of the user the agent works for, whose token Trino verifies itself. Either way, Trino and its policy engine decide what the agent may read.

The core, and what AKKO and ARCHE deliver

Open source core In AKKO and ARCHE
Identity in Trino an identity configured by the administrator, or each user’s own when Trino trusts the same identity provider the same, and other ways to carry the user’s identity
Read-only on the SQL tree yes yes
Row and time bounds yes 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 an agent that reads under an account whose rights your rules bound, as well as agents that each read under the person’s own rights, on a Trino that verifies the tokens of your identity provider. 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.5 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 TRINO_IDENTITY_MODE=jwt \
  -e MCP_AUTH_ENABLED=true -e MCP_AUTH_REQUIRED=true \
  -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=data-platform \
  akkoai/akko-mcp-trino:0.5

The 0.5 tag follows the latest release of the 0.5 line; 0.5.3 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 identity provider, then start it. This example uses the default mode, service, where every query runs as TRINO_USER.

export TRINO_HOST=trino.example.internal
export TRINO_PORT=8443
export TRINO_HTTP_SCHEME=https
export TRINO_CATALOG=iceberg
export TRINO_USER=agent_reader           # the identity the server reads as
export TRINO_PASSWORD=...                # Basic authentication, https only

export MCP_AUTH_ENABLED=true
export MCP_AUTH_REQUIRED=true            # refuse a request 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

akko-mcp-trino --check                   # the effective configuration, no secret
akko-mcp-trino                           # serves streamable-http on port 3000

--check prints the effective configuration without ever printing a secret, and 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 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: a bounded SELECT 1 as TRINO_USER in service mode, Trino’s public /v1/info in jwt mode. /metrics exposes mcp_trino_queries_total, mcp_trino_query_errors_total and mcp_trino_query_duration_seconds.

Identity in Trino

One request step by step: token verified by the MCP server, read-only SQL checked, token verified again by Trino, OPA decides, rows returned; and the two refusals (401, Access Denied).

The server first verifies the caller’s token (signature against the provider’s JWKS, issuer, audience, expiry). In strict mode (MCP_AUTH_REQUIRED=true), a request without a verified token is refused before any query. The TRINO_IDENTITY_MODE variable then says under which identity Trino runs it.

Mode Who Trino sees What Trino needs Who decides
service (default) TRINO_USER, the same for every caller a password, or nothing for a local Trino the rights of that one identity
jwt the caller, by their own verified token its JWT authenticator, trusting the same identity provider the rights of each user

One configured identity (service)

Every call of every agent using this server reads with the same rights. Give that account a narrow scope in your policy engine (OPA, Ranger or Trino’s own access control): the catalogs, schemas and columns every caller needs, and nothing more. The caller’s token never leaves the server. An identity sent by the caller, in a header for instance, changes nothing.

Each user under their own rights (jwt)

When Trino trusts the same identity provider, let it decide for each person.

export TRINO_IDENTITY_MODE=jwt           # the caller's verified token goes to Trino
export TRINO_HTTP_SCHEME=https           # required: a token never travels over http
export MCP_AUTH_ENABLED=true
export MCP_AUTH_REQUIRED=true
# TRINO_USER and TRINO_PASSWORD are not used

On the Trino side, set http-server.authentication.type=JWT, http-server.authentication.jwt.key-file to the provider’s JWKS URL, and 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 provider and to Trino; the token’s audience must be one Trino accepts.

The server sends Trino the token it has verified, over https only, and never into the audit line. Trino verifies that token again and runs the query under the person’s name: Ranger or OPA decide for that person, with no service account, no password and no right to act for someone else. A request without a verified token is refused; it never runs as TRINO_USER. The server refuses to start in jwt mode over http, and an unknown mode stops it from starting.

This mode has been proven on a real cluster: two accounts ask the same question and get two answers, emails masked for one and in clear for the other; a request without a token and a request with a forged token are refused.

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.

Read-only

execute_query accepts any SQL Trino accepts, as long as it is a read. The statement is parsed into a syntax tree, and 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 the engine’s policy. 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.

The guard is a second line. The first remains the engine: give the server’s account, or the users, read rights only, and refuse system in your policy. The blog tells how this guard took its shape.

Bounds

Bound Variable Effect
Row cap TRINO_MAX_ROWS 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 a longer query is cancelled in Trino, and the agent is told so
Call budget MCP_CALL_TIMEOUT_SECONDS past it, no query starts, the running one is cancelled, and the agent gets an error rather than a partial answer

The budget is held by the server, not set as a session property, so that a policy 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. Every refusal carries an X-Reason header; in strict mode, a request without a verified identity gets 401 unauthenticated.

Connect an MCP host

Remotely, the host connects to the server’s address (/mcp over streamable-http, the default transport; /sse for an older host) and sends the user’s access token.

{
  "mcpServers": {
    "trino": {
      "url": "https://mcp.example.internal/mcp",
      "headers": {"Authorization": "Bearer <the user's access token>"}
    }
  }
}

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, verified like a header. In strict mode, the server refuses to start without it.

{
  "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", "TRINO_IDENTITY_MODE": "jwt",
        "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>"
      }
    }
  }
}

In service mode, replace TRINO_IDENTITY_MODE with TRINO_USER and TRINO_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, guards at the door, routes or log fields, with one rule: it can refuse, never widen.

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 service (every query as TRINO_USER) or jwt (every query as the caller, https only); an unknown mode stops the server from starting service
TRINO_USER the identity the server reads as in service mode trino
TRINO_PASSWORD its password, sent by Basic authentication, refused over http 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, 0, 0

Identity

Variable Role Default
MCP_AUTH_ENABLED verify the callers’ tokens false
MCP_AUTH_REQUIRED refuse a request without a verified identity false
MCP_JWKS_URL the provider’s JWKS; required when verification is on none
MCP_OIDC_ISSUER, MCP_OIDC_AUDIENCE the required issuer and audience 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

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 hosts trino-mcp
MCP_USER_TOKEN stdio only, the user’s access token none
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; in jwt mode, https with the JWT authenticator
Policy engines OPA, Ranger, Trino file-based access control
Identity providers any OIDC provider publishing a JWKS (Keycloak, Entra ID, Okta, Dex…)
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.