Skip to main content
Glama
AKKO-p

akko-mcp-trino

by AKKO-p

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, FR

That 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), or X-Trino-User sent 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 in jwt mode (token exchange, when set up, uses a client secret at the identity provider), no user token sent to Trino in certificate mode.

  • 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's system.query) and the system catalog are refused too, including through a name without a catalog when the session catalog is system.

  • Two principals. X-Agent-Key names 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-Authenticate with the scope to ask for on every 401, 403 insufficient_scope when 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_query bounded (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

Contents

  1. How it works

  2. Compatibility

  3. Prerequisites

  4. Install and run

  5. Connect an MCP host

  6. Use it from an agent

  7. The tools

  8. What happens on a request

  9. Configuration

  10. Operations

  11. Development

  12. Design notes

  13. Contributing

  14. About AKKO

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 --> Data

The 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 X-Trino-User protocol header); http or https; password, JWT passthrough, or a client certificate

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 (trino-opa), Ranger (Trino plugin), Trino file-based access control

OPA with row filters and column masks

Identity providers

any OIDC provider publishing a JWKS

Keycloak 26

MCP

protocol negotiated by the official mcp SDK 2.2 (versions up to 2025-11-25); discovery follows the MCP authorization specification 2025-11-25; transports streamable-http, sse and stdio

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

pip and a virtual environment

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

impersonate: TRINO_USER allowed to impersonate in your access control (impersonation rules, or the equivalent in OPA / Ranger); jwt: Trino's OAUTH2/JWT authenticator pointed at your identity provider; certificate: Trino's CERTIFICATE authenticator trusting the authority of the server's certificate, and the certificate's account allowed to impersonate

A policy engine deciding for Trino

the governance

OPA (opa.policy.uri), Ranger, or Trino file-based rules. Without one, every user reads everything

An OIDC provider with a JWKS endpoint

identity

Keycloak, Entra ID, Okta, Dex… Tokens must carry iss, aud, exp, and a subject (preferred_username or sub)

An MCP host that can send a bearer header

the client

Cursor, Claude Desktop, VS Code, or any client built on the mcp SDK

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 --version

The 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_trino

Before 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=True

Check 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 exposition

To 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

list_catalogs

—

{"catalogs": [...], "truncated": bool}: the catalogs the user can see

list_schemas

catalog

{"schemas": [...], "truncated": bool}: schemas in that catalog

list_tables

catalog, schema

{"tables": [...], "truncated": bool}: tables and views in that schema

visible_tables

catalog, schema (optional), limit (1–500), offset, with_descriptions

every table and view the user sees, listed in one query, paged (next_offset), with a short description the context providers give once per schema of the page

describe_table

catalog, schema, table, sample_rows (0–20)

columns, types, comments, truncated; a governed sample when asked

search_columns

pattern (SQL LIKE), catalog (optional)

{"columns": [...], "truncated": bool}: tables having a column matching the pattern

search_metadata

text (words, one of them at least MCP_SEARCH_MIN_WORD_CHARS long), catalog (optional), limit (1–100)

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; truncated when the limit is not reached and the providers had more candidates than the search examines

profile_table

catalog, schema, table

SHOW STATS: row count, distinct values, null fraction, ranges

explain_table

catalog, schema, table

what the table means: description, owner, tier, grain, joins, tags, glossary terms and business properties, plus a columns block for the columns the caller sees and the providers describe, only for a table the caller sees in Trino

explain_query

sql

Trino's plan for a read-only statement, without running it

validate_sql

sql

EXPLAIN (TYPE VALIDATE), no data read: {"valid": true}, or what is wrong (syntax, catalog, schema, table, column, function, type, permission, other) and the name at fault, or {"valid": null} when Trino could not check

execute_query

sql, max_rows (optional, lower than the server cap)

{"columns": [...], "rows": [...], "row_count": n, "truncated": bool}

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

trino-comments

the COMMENT ON Trino already carries, read under the caller's identity

nothing

file

a versioned JSON document kept with your code: description, owner, tier, grain, joins, tags, glossary terms, properties, column classification and values

MCP_CONTEXT_FILE

openmetadata

descriptions, owners, tier, tags, primary key as grain, foreign keys as joins, column classifications such as PII.Sensitive, from OpenMetadata

pip install akko-mcp-trino-openmetadata, OPENMETADATA_URL, OPENMETADATA_TOKEN, OPENMETADATA_SERVICE

atlas

the steward's description, classifications such as PII, glossary terms with their definition and status, business metadata, for Hive and Iceberg tables of a metastore catalogued in Apache Atlas

pip install akko-mcp-trino-atlas, ATLAS_URL, ATLAS_USER, ATLAS_PASSWORD, ATLAS_CLUSTERS

your catalogue

DataHub, Collibra… a package that implements the same two methods (and search, to answer search_metadata) and registers itself under the entry-point group akko_mcp_trino.context; or a provider passed to build_server(context=...)

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_tables adds 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 most MCP_CACHE_MAX_ENTRIES answers; 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-Id

Order 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

TRINO_HOST, TRINO_PORT

the coordinator

localhost, 8080

TRINO_USER

the account the server connects as; it impersonates the caller

trino

TRINO_CATALOG

session catalog, where a name without a catalog is read; set it to a data catalog (with system, such names are refused)

system

TRINO_READ_ONLY

refuse writes in execute_query

true

TRINO_MAX_ROWS

result cap; one more row is read to say whether the answer was truncated

100

TRINO_QUERY_TIMEOUT_SECONDS

time budget of one query: past it, the server cancels the query in Trino; 0 leaves the limit to Trino

0

MCP_CALL_TIMEOUT_SECONDS

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; 0 sets none

0

TRINO_SOURCE

the source Trino records for every query (X-Trino-Source); the request id and the tool travel as client tags

the server name

TRINO_HTTP_SCHEME

http or https

http

TRINO_PASSWORD

password of TRINO_USER (Basic auth); refused over plain http

—

TRINO_VERIFY

TLS verification: true, false, or a path to a CA bundle (the authority of Trino's certificate)

true

TRINO_REQUEST_TIMEOUT_SECONDS

per-request timeout on the Trino client

30

TRINO_IDENTITY_MODE

impersonate (connect as TRINO_USER, set X-Trino-User to the caller), jwt (send the caller's verified bearer to Trino; needs https, no service password) or certificate (authenticate with a client certificate, set X-Trino-User to the caller; needs https)

impersonate

TRINO_CLIENT_CERT

certificate mode: PEM file of the client certificate (its chain may follow)

—

TRINO_CLIENT_KEY

certificate mode: PEM file of its private key, unencrypted (protect it with file permissions)

—

Three ways to reach Trino as the user

Mode

How

What Trino needs

When

impersonate (default)

the server authenticates as TRINO_USER (Basic over https when TRINO_PASSWORD is set) and sets X-Trino-User to the verified caller

an impersonation rule allowing TRINO_USER → users (file-based access control, OPA or Ranger)

Trino authenticates with passwords or Kerberos

jwt

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 OAUTH2 or JWT authenticator pointed at the same issuer

Trino already trusts your identity provider; this setup is proven live on a Trino behind Keycloak

certificate

the server presents a client certificate over https and sets X-Trino-User to the verified caller; the caller's bearer stays in the server

the CERTIFICATE authenticator, a truststore holding the authority of the server's certificate, a user mapping, and an impersonation rule for the certificate's account

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

MCP_AUTH_ENABLED

verify bearer tokens

false

MCP_AUTH_REQUIRED

refuse requests without a verified identity

false

MCP_JWKS_URL

the provider's JWKS endpoint

— (required when auth is enabled)

MCP_OIDC_ISSUER, MCP_OIDC_AUDIENCE

claims to enforce; a token whose audience is MCP_RESOURCE_URL is accepted too

—

MCP_JWT_LEEWAY_SECONDS

tolerance on exp/nbf/iat for clock drift between the issuer and this server

30

MCP_RESOURCE_URL

canonical URL of this server, path included (https://mcp.example.com/mcp); enables RFC 9728 discovery and is an accepted audience

— (off)

MCP_ALLOWED_CLIENTS

applications whose tokens are accepted (azp, or client_id in RFC 9068), comma-separated; a token naming another one, or none, gets 401 client_not_allowed

— (any)

MCP_REQUIRED_SCOPES

scopes every token must carry, comma- or space-separated; announced in the metadata and the 401 challenge; a missing one gets 403 insufficient_scope

— (none)

MCP_AGENT_KEYS

name:key,name:key — registered agent products; empty disables the check

— (off)

MCP_ALLOWED_HOSTS

Host header filter (the SDK's DNS rebinding protection): host or host:port entries, comma-separated, host:* for any port (the only wildcard); once the filter is on, a request that carries an Origin header is refused (403); empty serves any Host

— (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

MCP_RATE_LIMIT_USER, MCP_RATE_LIMIT_AGENT

requests per window per user and per agent product; 0 disables

0, 0

MCP_RATE_LIMIT_WINDOW_SECONDS

the sliding window

60

MCP_INTROSPECTION_URL

RFC 7662 endpoint; enables the revocation check

— (off)

MCP_INTROSPECTION_CLIENT_ID, MCP_INTROSPECTION_CLIENT_SECRET

credentials the provider expects

— (required with the URL)

MCP_INTROSPECTION_TTL_SECONDS

how long a verdict is cached by jti

30

Context providers

Variable

Meaning

Default

MCP_CONTEXT_PROVIDERS

comma-separated, priority order: trino-comments, file, none, or an installed plugin (openmetadata, atlas)

— (none)

MCP_CONTEXT_FILE

the JSON document for file (format below)

—

MCP_CONTEXT_TTL_SECONDS

how long an answer is cached, per user

60

MCP_VISIBILITY_ENABLED

the visibility gate: no context about a table or column Trino does not show the caller; false turns it off

true

MCP_VISIBILITY_TTL_SECONDS

how long the gate remembers what a user sees; 0 asks Trino every time

60

MCP_CACHE_MAX_ENTRIES

answers the context cache and the gate's cache each hold, at most

10000

MCP_SEARCH_CANDIDATES

search_metadata: hits of the providers the gate examines, at most

200

MCP_SEARCH_MIN_WORD_CHARS

search_metadata: one word of the text must be at least this long

3

Tool plugins

Variable

Meaning

Default

MCP_TOOL_PLUGINS

comma-separated names of registrars installed under the entry-point group akko_mcp_trino.tools

— (none)

Serving

Variable

Meaning

Default

MCP_TRANSPORT

streamable-http, sse or stdio

streamable-http

MCP_PORT, MCP_HEALTH_PORT

listening ports

3000, 3001

MCP_SERVER_NAME

name announced to hosts

trino-mcp

MCP_USER_TOKEN

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

Authorization: Bearer <JWT>

for whom the query runs

JWKS here, then the policy engine in Trino

Agent product

X-Agent-Key: <key>

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: unauthenticated

resource 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

X-Reason

Meaning

401

agent_key_missing, agent_key_unknown

products are registered and the key is absent or wrong

401

unauthenticated

no verified identity in strict mode

401

client_not_allowed

the token was issued to an application outside MCP_ALLOWED_CLIENTS, in strict and lenient mode alike

403

insufficient_scope

the token lacks a scope of MCP_REQUIRED_SCOPES; the challenge names it

401

revoked

the provider answers {"active": false}: the token is no longer active

429

rate_limited

a window is full; Retry-After says when

503

introspection_unavailable

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

token_id

the JWT's jti; the record has no field that could hold the bearer

ok, error

the outcome; error is a code, never a message, because a message from Trino may quote a value read from a row (Cannot cast '<value>' to INT): Trino's error type and name for a Trino error (USER_ERROR:INVALID_CAST_ARGUMENT), the class of any other exception (TimeoutError, CallTimeout past the call's budget), the code a tool names for its own refusal (read_only, invalid_argument) or a plugin with note(error=...), else tool_error. The agent reads the whole message

sql_sha256

SHA-256 of the statement sent to Trino for the caller (execute_query, explain_query, validate_sql, the sample of describe_table, a plugin that notes it); the SQL text itself, which may carry values, is never logged

tables

the tables that statement names, or the table the call is about

duration_ms, rows, truncated

how long, how many rows or items returned, whether they were cut; for describe_table with sample_rows, rows counts the rows of the sample, the data it read

metadata_served

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

config.py

read the environment, neutral defaults

auth.py

JWT verification against JWKS, Principal

token_policy.py

the application (azp) and the scopes the token must carry

identity.py, agents.py

user and agent product in ContextVars

middleware.py

the guard: key, token, token policy, revocation, quotas, discovery, request id

discovery.py, ratelimit.py, revocation.py

one check of the guard each: RFC 9728 metadata, quotas, RFC 7662 introspection

transport_security.py

the Host header filter (MCP_ALLOWED_HOSTS)

audit.py

the audit line, the request id and the time budget of a call

sql_guard.py

identifier validation, read-only decision on the AST

context.py, visibility.py

context providers and their cache, the visibility gate

tools.py, plugins.py

the twelve tools, loading tool plugins

trino_client.py, token_exchange.py

the Trino connection, the three identity modes and the time budgets; RFC 8693 token exchange

health.py, metrics.py

/health, /ready and /metrics; the Prometheus counters

server.py, app.py, __main__.py

assembly, transport, entrypoint and --check

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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    Enables executing SQL queries on Trino clusters via MCP, supporting multiple authentication methods and read/write operations with safety controls.
    8
    935 PyPI
    2
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Provides governed, read-only PostgreSQL access for AI agents via MCP. Enforces schema/table allowlists, query limits, and audit events.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables MCP clients to execute validated read-only warehouse queries through a guarded SQL API, with enforced row limits and secret-safe audit metadata.
    MIT