AKKO MCP server for Trino

io.github.ab2dridiPublished by GitHub account ab2dridiv0.7.2Updated Nov 4, 2026

Read-only MCP server for Trino: queries run as the verified user, and Trino's own policies decide.

Overview

AI-generated overview

Read-only MCP server for Trino: an assistant can browse catalogs, schemas and tables and run read-only SQL as the verified caller, with Trino's own policies…

What you can do
  • Explore metadata: list catalogs, schemas, tables and views, and describe a schema or a single table with columns, types and comments
  • Find columns: search tables and columns by words, with paging and partial-match hints
  • Inspect SQL: check that a read-only statement is valid without running it
  • Run queries: execute read-only SQL with a row cap and a truncated flag
  • Sample data: request a governed sample of rows when describing a table
When to use it
Useful when an assistant should answer data questions against a Trino warehouse without write access, and when each user's queries must run under their own identity and access policies rather than a shared account.
Requirements
Local Python 3.12+ package (pip, pipx or uvx) or the published container image. Needs a reachable Trino coordinator, an OIDC provider with a JWKS endpoint, and an MCP host. Required settings include TRINO_HOST, TRINO_HTTP_SCHEME, MCP_JWKS_URL, MCP_OIDC_ISSUER, MCP_OIDC_AUDIENCE and MCP_USER_TOKEN; jwt mode also needs the TRINO_TOKEN_EXCHANGE_* settings, including the secret TRINO_TOKEN_EXCHANGE_CLIENT_SECRET. Impersonation and service modes need TRINO_USER with TRINO_PASSWORD or a client…
Before you install
It holds credentials: the token-exchange client secret, the user's bearer token, and in impersonation or service mode a Trino account password or client key. Whoever holds the impersonation account can act as any user the rule allows, so narrow that rule. Service mode runs every query as one identity and is announced as a warning. Opening extra read commands such as SHOW STATS or EXPLAIN can reveal data past row filters and column masks. Queries and metadata requests go to your Trino cluster…

Installation

In SourceWeft

  1. Open AKKO MCP server for Trino in the dashboard and add it to a workspace.
  2. Enable the server for the chats that should use its tools.

Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.

Other MCP clients

Follow the launch instructions in the repository.

README

[CI] [Tests] [Coverage] [Version] [Python] [Trino] [MCP] [License] [Ruff] [Status]

akko-mcp-trino

English · Français

Give your AI agents read-only access to Trino, and nothing more.

akko-mcp-trino is an MCP server for Trino. It lets an agent find the tables and columns that hold what it is looking for, describe a schema or a table, list catalogs, schemas and tables, explain a query and run it, and it refuses every write it is sent: each statement is parsed, and a write anywhere in it is refused before Trino. By default every query runs as the user the agent acts for. The server verifies the user's token, issued for this server, and obtains from the identity provider a separate token for Trino (OAuth 2.0 Token Exchange): the user's own token never leaves the server. Trino's access control (OPA, Ranger, or Trino's own rules) then decides what that user may read. Results are bounded in rows and in time, and every call leaves one audit line.

Upgrading from 0.7.0? 0.7.1 and 0.7.2 are a security release. execute_query refuses SHOW STATS, EXPLAIN, SHOW CREATE and the other reads that can show facts beyond a row filter or a column mask, and the user is read from sub. With Keycloak, set MCP_JWT_USER_CLAIM=preferred_username before upgrading. See MIGRATING.md.

Upgrading from 0.6? 0.7.0 removes and renames nothing: it adds search_columns and describe_schema, pages the listings, and gives every error a kind. Let the engine's policy read system.jdbc and system.metadata, which the two new tools read (0.6 advised refusing all of system). Keep MCP_CALL_TIMEOUT_SECONDS at 0 or above MCP_SEARCH_RESERVE_SECONDS (2 by default), or the server refuses to start. A truncated listing is no longer the end: read on with next_offset. Extension authors: one helper changes its signature, and in oauth2 mode the guards run on every request that is not discovery, no longer once at start.

From 0.5? 0.6.0 changed the defaults: a verified caller is required, and the jwt mode exchanges the caller's token instead of passing it on. See MIGRATING.md for both.

Understand in five steps

[Carol, an analyst, asks for the list of customers. In an illustrative setup with a service account with broad rights, Trino sees mcp-svc and returns 10 customers with e-mails in clear. With akko-mcp-trino in its default jwt mode, Trino sees carol_analyst and returns 5 European customers with e-mails masked, as in the sandbox.]

An agent connected through one service account with broad rights reaches Trino under that account, so the rules written for Carol no longer apply. The left panel is an illustration: the sandbox's policy would refuse such an account. akko-mcp-trino brings Carol's own identity to Trino, and the rules Trino already has do the rest.

[The same query by three sandbox users, through the same MCP client. alice_admin gets the 10 customers in clear. carol_analyst gets the 5 European customers with masked e-mails. dave_viewer is refused by Trino: Access Denied: Cannot select from columns [country, full_name, id, email] in table or view demo.crm.customers.]

The same query from the three users of the sandbox, through the same MCP client and the same server. Trino answers each of them with the sandbox's OPA policy; the server copies no rule.

[A real trial of Mistral on 0.7.0, in six steps. It asks which customer segment holds the largest total account balance, searches with four words and gets matches word by word, joins on a column that does not exist, reads the server's hint, reads the columns of customers with describe_table, fixes the join and gets business, 17620.75.]

A real trial, tool call by tool call: Mistral on 0.7.0 looks for the segment with the largest balance. The search matches word by word, the error on a wrong join names the table to read, and the agent fixes its join. Every tool answer shown is the server's, replayed in the sandbox.

[Three questions pick an identity mode: token exchange gives jwt, a Trino impersonation rule gives impersonation, a server on one person's machine with Trino's OAuth2 login gives oauth2, otherwise service. The four modes are drawn on the same grid of MCP host, server and Trino; service, one account for everyone, is marked in amber.]

Four identity modes, from the safest to the simplest. jwt is the default; service gives every caller the same account and is used only when you name it.

[Mistral answers right 62.5% of the time on 0.6.1 and 70% on 0.7.0, Claude 93.3% on both. Mistral goes from 5.8 to 3.0 tool calls per question, Claude from 8.5 to 4.9. Mistral varies more between runs than the gap between versions, and input tokens rise by about 41%.]

The same five business questions asked to Mistral and to Claude, on 0.6.1 and on 0.7.0. Mistral varies more from one run to the next than the gap between the versions, and 0.7.0 sends about 41% more input tokens per question, because its tool descriptions are longer. The harness and its reports are in akko-sandbox.

The interactive version of these five steps is on akko-ai.com/en/docs/understand (also en français).

Try it in five minutes

The sandbox starts Keycloak, Trino, OPA and the server on your machine, with the three users above:

bash
git clone https://github.com/AKKO-p/akko-sandbox.gitcd akko-sandboxdocker compose up -d --wait./demo.sh

Why

An agent that reads a lakehouse needs a small, predictable surface: a few tools whose descriptions a model understands, answers that never run away, and a guarantee that a prompt injection cannot turn a question into a DROP TABLE through it. This server is that surface, and nothing else. It does not decide access: Trino decides for each user with the rules it already has, or, in service mode, for the one identity you give it.

Highlights

  • Read-only by construction, through this server. 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. What a user may do in Trino by other means is Trino's rules: see What read-only covers.
  • The user's own rights, safe by default. In jwt mode, the default, the server verifies the caller's token (issued for this server, never for Trino), exchanges it at the identity provider for a token whose audience is Trino (RFC 8693), and Trino runs the query as that user. The caller's token is never passed on. A request without a valid token is refused with 401.
  • A fallback without token exchange. In impersonation mode the server authenticates to Trino as its own account (password or client certificate) and Trino runs the query as the verified caller, under an impersonation rule only that account has.
  • One identity, only when named. In service mode every query runs as TRINO_USER. It is never chosen by default, and the server says so at start and to every host in its initialization instructions.
  • Bounded by default. A row cap with a truncated flag (100), a time budget per query cancelled in Trino (60 s), a time budget per tool call (120 s).
  • One audit line per call. Keyed by X-Request-Id, never the token, never the SQL text: who called which tool, with which outcome, for how long.
  • Eight tools, annotated with what they can really do: execute_query is annotated read-only only while TRINO_READ_ONLY is true.
  • A guarded door. Bearer tokens verified against any OIDC provider's JWKS (issuer, audience bound to this server, expiry, clock tolerance), a token issued for Trino refused, protected resource metadata (RFC 9728), a Host header filter.
  • Built to be extended without being forked. Tool plugins and extensions are Python packages named at start: identity modes, guards at the door, audit fields, routes, tools. An extension can only refuse what the core allows, never widen it.
  • Any MCP host, any model. Cursor, Claude Desktop, VS Code, the Python SDK… Mistral, or any OpenAI-compatible model through the example agent.
  • Small and proven. 826 tests at 100 % line and branch coverage, including in-process suites on the real SDK for all three transports, a live run against Keycloak and Trino in CI (integration/), and a product-neutral guard.

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 read-only covers
  9. Extensions
  10. What happens on a request
  11. Configuration
  12. Operations
  13. Development
  14. Design notes
  15. Contributing
  16. About AKKO

How it works

mermaid
flowchart LR    Host["MCP host<br/>(Cursor, Claude, VS Code, an agent)"]    IdP["Identity provider<br/>(OIDC, JWKS, token exchange)"]    Server["akko-mcp-trino"]    Trino["Trino"]    Policy["Policy engine<br/>(OPA, Ranger, built-in)"]    Data[("Data sources")]
    Host -- "1. tool call + Bearer token<br/>(audience: this server)" --> Server    Server -. "2. verify signature, issuer,<br/>audience, expiry (JWKS)" .-> IdP    Server -. "3. exchange it for a token<br/>whose audience is Trino (RFC 8693)" .-> IdP    Server -- "4. read-only SQL, with that token" --> Trino    Trino -- "5. may this user read this?" --> Policy    Trino -- "6. rows, as that user may read them" --> Server    Server -- "7. result" --> Host    Trino --> Data

The host calls a tool with the user's access token. The server verifies it (signature against the provider's JWKS, issuer, an audience naming this server, expiry) and refuses a request without a valid one with 401; a token issued for Trino is refused too. The server then checks the statement (read-only, identifiers, table functions, the system catalog) and runs it in Trino under the identity mode TRINO_IDENTITY_MODE names. Trino asks its policy engine and returns what that identity may read, bounded in rows and time.

ModeWho Trino seesHow Trino knowsWhen to use it
jwt (default)the callera token for Trino the server obtained by token exchange (RFC 8693); Trino verifies it with its JWT authenticatorthe identity provider supports OAuth 2.0 Token Exchange (RFC 8693); tested with Keycloak 26
impersonationthe callerthe server's own account (password or client certificate) asks Trino to run the query as the caller; Trino allows it under an impersonation rule only that account hasthe identity provider cannot exchange tokens
oauth2the local user, checked with SELECT current_userTrino's native OAuth2 browser login; the driver caches tokens and reauthenticateslocal stdio hosts only, with a Trino coordinator configured for OAuth2
serviceTRINO_USER, the same for every callera password, a client certificate, or nothing, locallyonly when named: one identity for everyone, announced at start and to every host

The caller's own token never leaves the server, in any mode: the MCP authorization specification forbids passing it on ("token passthrough"). The caller's name comes from their verified token, or from Trino's SELECT current_user in oauth2, never from an unverified header. The jwt and impersonation modes refuse to start unless the door requires a valid token. An extension (see Extensions) may add other identity modes.

Compatibility

SupportedTested
Python3.12, 3.133.12, 3.13 (CI)
Trino351 and later; https with the JWT authenticator for jwt, with a password or certificate authenticator and impersonation rules for impersonation, with OAuth2 for oauth2 (stdio); http (local) or https for servicea recent release with the JWT and certificate authenticators, behind OPA and behind file-based rules, with row filters and column masks
Policy enginesOPA (trino-opa), Ranger (Trino plugin), Trino file-based access controlOPA and file-based rules, with row filters and column masks
Identity providersany OIDC provider publishing a JWKS (the door); OAuth 2.0 Token Exchange, RFC 8693 (jwt mode)Keycloak 26 (standard token exchange)
MCPprotocol negotiated by the official mcp SDK 2.2 (initialize handshake revisions 2024-11-05 to 2025-11-25, and the stateless 2026-07-28 revision); transports streamable-http, sse and stdioall three transports, official SDK client at its default revision, in CI
MCP hostsremote: anything that sends a bearer header; local: any stdio hostPython SDK, the example agent
Modelsany, the server never talks to a modelMistral Small 3.2 through OpenRouter, driving the tools

Prerequisites

You needWhyNotes
Python 3.12 or laterruntimepip and a virtual environment
A reachable Trino coordinatorthe engineany recent version; https for jwt and impersonation, and whenever a secret is sent
An OIDC provider with a JWKS endpointthe doorKeycloak, Entra ID, Okta, Dex… Tokens must carry iss, an aud naming this server, exp, and the claim that names the user (sub by default, MCP_JWT_USER_CLAIM)
jwt: token exchange at the provider, and Trino's JWT authenticatoreach user reads with their own rightsa confidential client for this server, allowed to exchange a token issued for it for one whose audience is Trino; http-server.authentication.type=JWT with the provider's JWKS and required-audience set to that audience
Or impersonation: an account for this server, and an impersonation ruleeach user reads with their own rights, without token exchangeTRINO_USER with TRINO_PASSWORD or a client certificate; a Trino rule letting that account, and only it, impersonate users
Or service: one Trino identitythe rights of every callerTRINO_USER, and TRINO_PASSWORD or a client certificate; give it read rights only on what every caller may read
A policy engine deciding for Trinothe governanceOPA (opa.policy.uri), Ranger, or Trino file-based rules
An MCP hostthe clientCursor, Claude Desktop, VS Code, or any client built on the mcp SDK

Optional: a Prometheus to scrape /metrics.

Install and run

bash
pip install akko-mcp-trino        # or: pipx install akko-mcp-trinouvx akko-mcp-trino --version      # run it without installing, the way most MCP hosts do

Or from source:

bash
git clone https://github.com/AKKO-p/akko-mcp-trino.gitcd akko-mcp-trinopython -m venv .venv && source .venv/bin/activatepip 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. The defaults are the safe ones: each query runs as the caller (jwt), and a request without a valid token is refused.

bash
export TRINO_HOST=trino.example.internalexport TRINO_PORT=8443export TRINO_HTTP_SCHEME=https          # required: a token never travels over httpexport TRINO_CATALOG=iceberg
# The door: tokens issued for this server, verified against the provider's JWKSexport MCP_JWKS_URL=https://idp.example.com/realms/data/protocol/openid-connect/certsexport MCP_OIDC_ISSUER=https://idp.example.com/realms/dataexport MCP_OIDC_AUDIENCE=akko-mcp        # the audience the provider gives tokens for this serverexport MCP_RESOURCE_URL=https://mcp.example.com/mcp   # optional: this server's canonical URL
# A token for Trino, by token exchange (RFC 8693), never the caller's ownexport TRINO_TOKEN_EXCHANGE_URL=https://idp.example.com/realms/data/protocol/openid-connect/tokenexport TRINO_TOKEN_EXCHANGE_CLIENT_ID=akko-mcpexport TRINO_TOKEN_EXCHANGE_CLIENT_SECRET=...export TRINO_TOKEN_EXCHANGE_AUDIENCE=trino
python -m akko_mcp_trino

Before serving, akko-mcp-trino --check prints the effective configuration (never a secret), any warning, 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 identity=jwt extensions=none

Check it is alive and can reach Trino:

bash
curl -s localhost:3001/health   # {"status":"ok"}       never touches Trinocurl -s localhost:3001/ready    # {"status":"ready"}    jwt: Trino's /v1/info; impersonation and service: a bounded SELECT 1 as the server's accountcurl -s localhost:3001/metrics  # Prometheus exposition

Each user with their own rights (jwt, the default)

The caller's token is issued for this server: its audience is MCP_OIDC_AUDIENCE (or MCP_RESOURCE_URL). The server exchanges it at the provider for a token whose audience is TRINO_TOKEN_EXCHANGE_AUDIENCE, keeps that token in memory until shortly before it expires, and sends it to Trino. A token whose audience is Trino's is refused at the door, and a configuration that would accept one refuses to start.

On the provider, 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 the server's client, add an audience mapper for the Trino client to it, and have the clients of your MCP hosts put the server's client in the audience of the tokens they get (an audience mapper too). Keycloak's sub is a UUID that Trino's rules do not know: set MCP_JWT_USER_CLAIM=preferred_username on the server, and point Trino's principal-field at the same claim. Entra ID, Okta and Dex give sub an opaque value too; name the claim your access rules know (upn, preferred_username, uid...). The integration/ folder holds such a realm, tested on every pull request.

On the Trino side, http-server.authentication.type=JWT, http-server.authentication.jwt.key-file set to the provider's JWKS URL, http-server.authentication.jwt.principal-field naming the same claim the server reads (MCP_JWT_USER_CLAIM, sub by default), and required-issuer and required-audience pinning the token to your provider and to Trino's audience. Two users asking the same question then get the answers their own rights allow. /ready asks Trino's public /v1/info, since there is no identity of the server's own to query with.

Without token exchange (impersonation)

When the provider cannot exchange tokens, the server reaches Trino as its own account, and Trino runs each query as the verified caller:

bash
export TRINO_IDENTITY_MODE=impersonationexport TRINO_USER=mcp-serviceexport TRINO_CLIENT_CERT=/certs/mcp-service.crt   # mTLS; or TRINO_PASSWORD over httpsexport TRINO_CLIENT_KEY=/certs/mcp-service.key

Trino must authenticate that account (the certificate or password authenticator, besides the one your users have) and allow it, and only it, to impersonate users. With file-based access control:

json
{"impersonation": [{"original_user": "mcp-service", "new_user": ".*", "allow": true}]}

Narrow new_user to the users the agents may act for. Nobody else may impersonate: a user who sends a header naming someone else is refused by Trino, and the server never takes the caller's name from a header, only from their verified token. Keep the account's credential as you would any service credential: whoever holds it can act as any user the rule allows.

One identity for every caller (service, only when named)

bash
export TRINO_IDENTITY_MODE=serviceexport TRINO_USER=mcp-readerexport 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, no more, and read-only rights only. The server logs a warning at start, --check prints it, and every host receives it in the instructions of the MCP initialization. The door stays on by default; to run without one, locally, set MCP_AUTH_ENABLED=false and MCP_AUTH_REQUIRED=false too, and the warning says so.

Native browser login (oauth2, stdio only)

With Trino OAuth2 configured, keep your coordinator settings and run:

bash
MCP_TRANSPORT=stdio TRINO_IDENTITY_MODE=oauth2 TRINO_HTTP_SCHEME=https \MCP_AUTH_ENABLED=false MCP_AUTH_REQUIRED=false python -m akko_mcp_trino

The server sends Trino no user name: Trino runs every query as the user who signed in, and TRINO_USER is ignored. Startup and discovery (initialize, ping, tools/list, resources/list, resources/templates/list, prompts/list, notifications) never log in, so the host does not time out waiting for the browser. Every other request, a tool call or a resource read, logs in first: the URL is logged to stderr only, and SELECT current_user must succeed before the request runs; its result names the admission/audit principal. That is the only query that reaches Trino without an admitted user. A request the extensions' guards refuse, or one for which Trino names no user, is answered with an INVALID_REQUEST error that says why. The first call may time out at the host while the browser login waits; once signed in, call again. The user Trino names first stays the user of the process until it exits: a reauthentication by the driver does not change who admission and audit name, so sign in again as the same user, or restart the server to switch. HTTP MCP transports are rejected. MCP_AUTH_REQUIRED=true requires a verified MCP_USER_TOKEN in addition; false never skips Trino login. The driver reuses tokens and reauthenticates, with memory caching or optional keyring. --check does not log in.

Container

A Dockerfile ships with the repository (non-root, health check, OCI labels). The image is published for linux/amd64 and linux/arm64 as akkoai/akko-mcp-trino and ghcr.io/akko-p/akko-mcp-trino. Run it with the same environment variables, publishing ports 3000 (MCP) and 3001 (health):

bash
docker run --rm akkoai/akko-mcp-trino:0.7 akko-mcp-trino --version

Connect an MCP host

The host sends the user's access token from the identity provider, issued for this server; the server verifies it and names the caller in the audit line. A host that supports MCP authorization finds the provider through the protected resource metadata the server publishes when MCP_RESOURCE_URL is set.

Claude Desktop and Cursor, step by step: the sandbox in five minutes, your own Trino over stdio with Trino's browser login, and a shared server with a token per user, each configuration checked with an MCP client: docs/guides/connect-claude-desktop-cursor.md.

Cursor reads this block in ~/.cursor/mcp.json, as do the hosts that read the same mcpServers format:

json
{  "mcpServers": {    "trino": {      "url": "http://localhost:3000/mcp",      "headers": {"Authorization": "Bearer <the user's access token>"}    }  }}

From Python, with the official SDK:

python
import anyiofrom mcp import ClientSessionfrom mcp.client.streamable_http import create_mcp_http_client, streamable_http_client

async def main(token: str):    http = create_mcp_http_client(headers={"Authorization": f"Bearer {token}"})    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>")

Claude Desktop's configuration file starts local servers only: it reaches an HTTP server through a local bridge, as the guide shows.

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 caller comes from the environment: the user's own access token, issued for this server, verified exactly like a bearer header would be, and bound to the process. Strict mode, the default, refuses to start without it. Queries run as the user in jwt mode (the token is exchanged for one for Trino, as over http) and in impersonation mode, and as TRINO_USER in service mode.

json
{  "mcpServers": {    "trino": {      "command": "uvx",      "args": ["akko-mcp-trino==0.7.0"],      "env": {        "MCP_TRANSPORT": "stdio",        "TRINO_HOST": "trino.example.internal", "TRINO_PORT": "8443", "TRINO_HTTP_SCHEME": "https",        "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": "...",        "TRINO_TOKEN_EXCHANGE_AUDIENCE": "trino",        "MCP_USER_TOKEN": "<the user's access token>"      }    }  }}

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 and verifies it.

bash
pip install akko-mcp-trino openaiexport LLM_BASE_URL=https://api.mistral.ai/v1export LLM_API_KEY=...export LLM_MODEL=mistral-small-latestexport MCP_URL=http://localhost:3000/mcpexport USER_TOKEN=<the user's access token>python examples/agent.py "Which catalogs can I see, and what is in them?"

The examples folder has the details.

The tools

ToolArgumentsReturns
search_columnstext, catalog, schema, cursor (all but text optional){"tables": [{"catalog", "schema", "table", "comment"}], "matches": [{"catalog", "schema", "table", "column", "type", "comment"}], "schemas_searched": n, "not_searched": [{"catalog", "schema", "reason"}], "complete": bool, "next_cursor": token or null}: the tables and columns that hold every word of the text. When nothing holds every word, a page of the per-word pass adds "partial": true and gives each row its "words". "hint", a fixed sentence for the agent, comes on such a page (follow next_cursor while there is one) and on a complete search that found nothing
list_catalogsoffset (optional, 0 by default){"catalogs": [...], "next_offset": n or null, "truncated": bool}: one page of the catalogs Trino shows
list_schemascatalog, offset (optional){"schemas": [...], "next_offset": n or null, "truncated": bool}: one page of the schemas in that catalog
list_tablescatalog, schema, offset (optional){"tables": [...], "next_offset": n or null, "truncated": bool}: one page of the tables and views in that schema
describe_schemacatalog, schema, offset (optional){"catalog", "schema", "tables": [{"table", "type", "comment", "columns": [{"column", "type", "comment"}]}], "next_offset": n or null, "shortened": bool}: one page of whole tables, with their comments and columns
describe_tablecatalog, schema, table, sample_rows (0 to 20)columns, types, comments, truncated; a governed sample when asked; what an extension's describers add
explain_querysql{"valid": true}: Trino analyzed a read-only statement as the caller, without running it; {"plan": "..."} only once EXPLAIN is in MCP_EXTRA_READ_COMMANDS
execute_querysql, max_rows (optional, lower than the server cap){"columns": [...], "rows": [...], "row_count": n, "truncated": bool}

Every tool runs in Trino through the identity mode, the listing tools included: metadata is data, and Trino lists only what that identity may see. Every description is written for a model and says what the tool returns. The descriptions of describe_table and 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. Commands go by an allow-list: SELECT, DESCRIBE of a table, and SHOW CATALOGS, SHOW SCHEMAS, SHOW TABLES, SHOW COLUMNS and SHOW FUNCTIONS. Any other command is refused, including one a later Trino adds. Some reads, such as SHOW STATS or EXPLAIN, can show facts beyond a row filter or a column mask; they are refused unless the administrator names them in MCP_EXTRA_READ_COMMANDS (EXPLAIN, SHOW STATS, SHOW CREATE, SHOW GRANTS, SHOW ROLES, SHOW SESSION). An answer carries only columns and rows, never Trino's statistics of the query. 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. The metadata tools refuse system as a catalog name too, and list_catalogs does not list it. So set TRINO_CATALOG to a data catalog; the engine's policy must refuse system too, except reading system.jdbc and system.metadata (see What read-only covers), the guard being a second line.

Results are capped at TRINO_MAX_ROWS (100 by default, 1 at least): one row more is read, so truncated says whether the answer was cut, and the rest of the query is cancelled in Trino. With TRINO_QUERY_TIMEOUT_SECONDS (60 s by default), 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 (120 s by default), 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, 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.

The three listing tools page their answer: a page holds TRINO_MAX_ROWS names, sorted by name, and next_offset says where the next page starts, or is null on the last one. The agent calls again with offset=next_offset to read on; truncated is true exactly when next_offset is not null. Schemas and tables are paged in Trino (ORDER BY with OFFSET on information_schema.schemata and information_schema.tables), so Trino decides what each page holds. Catalogs are read with SHOW CATALOGS, which works even under a policy that refuses all of system, whole, past the row cap, up to MCP_MAX_CATALOGS (10000 by default), then paged by the server; system does not count against the bound. Above it list_catalogs answers an error, never a cut list. list_tables on a schema that does not exist, or that the caller may not see, answers kind schema ("not found or not visible", the same for both), never an empty list for a missing or hidden schema. An offset that is not a whole number from 0 is refused (invalid_argument) before Trino.

describe_schema gives an agent a schema's context in one call before it writes SQL: its tables and views, each with its type, its comment and all its columns (name, type, comment). It pages by tables, in Trino (ORDER BY with OFFSET on information_schema.tables): a page holds up to MCP_DESCRIBE_SCHEMA_TABLES tables (20 by default, 100 at most), always whole, and only while their columns add up to MCP_DESCRIBE_SCHEMA_COLUMNS at most (1000 by default). The first table of a page always comes whole, however wide; a page that holds fewer tables for the column budget says so (shortened: true), and next_offset starts the next page at the table left out. The comments come from system.metadata.table_comments and the columns from system.jdbc.columns, two views of Trino's system catalog that Trino filters for each user, read by statements the server builds, never by the agent's SQL (see What read-only covers). A page costs four statements: the schema check, the page of table names, one read of the columns, and the comments of the tables the page keeps. The columns are read once, at most MCP_DESCRIBE_SCHEMA_COLUMNS plus one rows: the tables that arrive whole within the budget are kept, the table the budget cuts starts the next page. A first table wider than the budget is read again alone, by its own query, in parts of MCP_DESCRIBE_SCHEMA_COLUMNS columns: one statement more per part. The read of the columns and that of the comments hold one UNION ALL branch per table, each filtered by equality on the table's name, which Trino pushes down into these views: a page reads the comments and columns of its own tables, never those of the whole schema (a list, IN or OR, is not pushed down and would read every table of the schema). Trino loads the metadata of every table of the page for that read, so a page costs about the same whether its tables are narrow or wide. A schema that does not exist and one the caller may not see answer the same error, kind schema; a visible empty schema answers an empty list. Comments are text the owners of the data wrote: the tool's description tells the model to read them as data, never as instructions, and the server never places them in its own MCP instructions. describe_table stays the tool for sample rows and what extensions add.

search_columns is where an agent starts when it does not know which table holds what it needs. It takes one word or several from column or table names and comments, never a value (a date, an amount, a code: values are in the rows, not in the names). Whitespace and punctuation split the text into words, _ belongs to a word (customer_id is one word), and each word is looked for ignoring case, lower-cased one code point at a time as Trino's lower() does. A column matches when every word appears in its name, its comment, its table's name or its table's comment, and one word at least in its own name or comment: so "customer segment" finds customers.segment, and a word found only in a table's name does not list every column of that table. tables lists, with their comment, the tables whose own name or comment holds every word and the tables that hold a column listed: "archived 2019" finds a table transactions_2019 commented "Archived transactions" without its columns, which describe_table then reads. It searches every catalog the caller sees (system never), one catalog, or one schema. A named schema is checked first: one that does not exist and one the caller may not see answer the same error, kind schema, never an empty search said to be complete; a catalog Trino does not know or refuses answers Trino's error. Each word is one escaped LIKE literal: a word holds no quote and no %, and its _ is a plain character. A word shorter than MCP_SEARCH_MIN_LENGTH (3 by default) is ignored, and a text with no word left is refused and says so, so a letter or two does not read every column. A text of more than MCP_SEARCH_MAX_WORDS words (8 by default), or with a word longer than MCP_SEARCH_MAX_WORD_LENGTH characters (64 by default), is refused before Trino is asked anything, with the bound in the message: each word adds a few LIKE to every statement. The search reads system.metadata.table_comments and system.jdbc.columns, which Trino filters for each user, one schema at a time in a stable order (catalogs, then their schemas, sorted; in a schema its tables, then its columns). A schema costs two statements, its tables then its columns, which read system.metadata.table_comments three times and system.jdbc.columns twice in all: the tables' statement reads the comments for the tables' own text and again for the columns a table holds, joined to their table's comment, and the columns' statement reads both views once. On Hive the comment view is the expensive one, a metastore call per table. Every read filters on the catalog and the schema by equality, which Trino pushes down to the views, so a catalog of many thousand tables answers schema by schema. It stays within the call's budget (MCP_CALL_TIMEOUT_SECONDS): before each schema it keeps MCP_SEARCH_RESERVE_SECONDS (2 by default) in reserve, each query is stopped in Trino at what is left, and the search then answers what it searched rather than the call's own error. tables and matches together stop at the row cap (TRINO_MAX_ROWS). The answer says exactly what was searched: schemas_searched, not_searched with a reason for each part left (time_budget, row_cap, or the kind of Trino's error; a schema of null is a whole catalog), and complete, true only when everything in scope was searched, over every call of the search. next_cursor resumes the same search where it stopped (that schema and the rest of its catalog, then every catalog not opened), inside a schema's tables or columns when the row cap stopped it there. When a search of several words is complete and nothing holds every word on any page of its chain of cursors, the same call goes on with a per-word pass: each word alone, by the same rules, over the same scope, within the same row cap, time budget and rights. Its rows carry words, the words that found each one, the rows most words found first, and the answer carries partial: true; one pass reads all the words, two statements per schema as the first, so it is bounded as the search is. complete, not_searched, schemas_searched and next_cursor then speak of that pass alone: when it cannot finish, it says what it left and its cursor resumes it, never complete. A page of the per-word pass adds hint, a fixed sentence for the agent: while the page has a next_cursor, that the pass is not finished and to call again with it; on its last page, that these are partial matches, to pick the right one or search again with one word from a column or table name, then describe_table. A complete search that found nothing at all, and the last page of a per-word pass that found nothing on any page, add the other fixed hint: search again with one word from a column or table name, never a value, or browse with list_tables and describe_schema. An agent should not conclude from an empty search that the data does not exist. A schema, or the schema list of a catalog, that takes longer than one query may (TRINO_QUERY_TIMEOUT_SECONDS) while the call still has time is named (time_budget) and skipped: resuming it would stop every later call at the same place. So is one that takes longer than a whole call, when Trino's own limit is left in place (TRINO_QUERY_TIMEOUT_SECONDS=0) or is as long as the call: a call that runs out of time exactly where it resumed, having moved nothing, skips it, so every call advances. A cursor is refused (invalid_argument) for another text or scope, for a catalog the caller does not see, or when it was altered; every cursor the server gives resumes, a schema such as Hive's 2024_sales included, since a schema in a cursor only ever enters a string literal. Without a call budget (MCP_CALL_TIMEOUT_SECONDS=0) a search of every catalog would have no bound: it is refused, a named catalog is searched, and --check warns of it. --check also warns of MCP_SEARCH_RESERVE_SECONDS=0 with a call budget: a search may then run to the end of the call, which answers its own time_budget error. The audit line says how many tables and columns came and whether the search was cut, never the text, which may be a value. Comments are data, never instructions, as for describe_schema.

The path an agent is told to follow: search_columns to find where the data lives, describe_schema (or describe_table for sample rows) to learn the tables it found, then execute_query. list_catalogs, list_schemas and list_tables are for browsing, when the agent wants to see what there is rather than find something; the tool descriptions and examples/agent.py say the same.

The defaults are chosen for an agent, not for a batch job. 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, without letting an agent's turn hang on one tool. Set either to 0 to remove the limit (the query then runs as long as Trino's own query.max-execution-time allows); --check prints both.

execute_query is annotated readOnlyHint: true only while TRINO_READ_ONLY is true. With TRINO_READ_ONLY=false, its annotations say it may write (readOnlyHint: false, destructiveHint: true) and so does its description, so that a host asks for the confirmation it would skip for a read-only tool. The seven other tools only read, whatever the setting.

Errors come back to the agent as {"error": "...", "kind": "..."}. error is Trino's whole message; a permission refusal from Trino is an ordinary error, not a crash. kind says what to do about it, and identifier names the column, table, schema or catalog when Trino names the one it could not resolve. A column Trino could not find (COLUMN_NOT_FOUND, named) in execute_query or explain_query adds hint, which names the table, or each table, of the agent's own statement the column was written against (c.customer_id names the table aliased c; a column without a qualifier names every table read, unless the statement has a CTE or a subquery) and advises describe_table on it. It names only the tables the caller sees: each candidate, at most four, is looked up in its catalog's information_schema.tables by equality, as the caller, each part exactly as written (a quoted part may hold a dot), in what is left of the call minus two seconds, at most five seconds, so a slow lookup never costs the agent its error; a name without catalog is looked up in TRINO_CATALOG, and a name without schema (FROM accounts) gets no advice, a known limit; a hidden table, a refusal, an error or less than two seconds left means no advice for it; an ambiguous or unnamed column gets no hint, and a statement the parser cannot read gets none either, never an error of its own: the name comes from the statement, never from Trino, so it shows nothing the caller could not already name:

kindMeaning
syntax, column, table, schema, catalog, function, typeTrino refused the statement; fix that part of it
permissionTrino or the identity provider refused the right; report it, do not retry
invalid_argumentthe server refused an argument (an identifier, an offset, a cursor, a search text out of bounds) before reaching Trino
read_onlythe statement writes and the server is read-only
time_budgetthe call ran out of its time budget; narrow the request
unavailableTrino could not answer (internal error, connection lost): the statement is neither valid nor invalid, try again later
otherany other error

The audit line keeps only the error's code (see Audit); it is unchanged.

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:

toml
[project.entry-points."akko_mcp_trino.tools"]my-tools = "my_package.tools:register"
python
from akko_mcp_trino.identity import current_subject

def register(mcp, client):    @mcp.tool()    def my_tool(text: str) -> str:        return client.query("SELECT ...", user=current_subject())

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 mode 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 through the identity mode, tools.note(...) records what the call did (the SQL it ran, the tables, the rows, whether they were cut), and tools.add_describer(fn) adds to what describe_table answers:

python
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 through tools.run        tools.note(sql=answer["sql"], tables=answer["tables"], rows=len(answer["rows"]))        return json.dumps(answer)

What read-only covers

The read-only guard covers what passes through this server: every statement an agent sends is refused if a write appears anywhere in it, before Trino sees it. It is not a guarantee about what a user may do in Trino by other means.

In jwt and impersonation modes, Trino runs each query as the user, with the rights its policy gives that user. If that policy lets the user write, the user can still write through any other Trino client (the CLI, a notebook, a BI tool), with a token for Trino the provider gives them directly. The token the server obtains by exchange is held in memory and never returned to the agent or the host, but the user's own rights in Trino are what they are. In service mode the same holds for TRINO_USER, for whoever holds its credential.

For read-only end to end, make the engine hold the line too:

  • service: give TRINO_USER read rights only. With file-based access control, {"catalogs": [{"user": "mcp-reader", "catalog": ".*", "allow": "read-only"}]}; with OPA or Ranger, a policy granting that identity SELECT and nothing else.
  • impersonation: limit the impersonation rule to the users the agents may act for, and, if those users may write elsewhere, give the server's account no rights of its own beyond SELECT 1.
  • jwt: let only this server's client obtain tokens for Trino's audience by exchange, so that a token the agent's host holds never opens Trino; and give the users reached through agents read-only rules on the catalogs agents may reach, in the policy engine. Trino applies the same rules to a query whatever client sent it, so the policy is where a user's rights are read-only for every path, this one included.

The guard stays a second line, not the only one: the engine's policy must refuse table functions and the system catalog too (see Design notes), except reading system.jdbc and system.metadata, which Trino filters for each user, as it does information_schema: a user sees there only the tables and columns they may see elsewhere. describe_schema reads system.jdbc.columns and system.metadata.table_comments, and so does search_columns, by statements the server builds; a policy that refuses them makes them answer an error that says so, with no other way tried. The agent's own SQL stays refused on all of system, these views included.

Set hide-inaccessible-columns=true in Trino's config.properties too. Without it, a SELECT * over a table with a column the policy hides is refused, and the refusal names that column; with it, the hidden column is dropped from the result and named nowhere.

Extensions

A tool plugin adds tools. An extension may add more, around the core and never in place of it. A package registers a factory (config, env) -> Extension under the entry-point group akko_mcp_trino.extensions, and the operator names it in MCP_EXTENSIONS (comma-separated, in order):

toml
[project.entry-points."akko_mcp_trino.extensions"]my-extension = "my_package.extension:create"
python
from akko_mcp_trino.extensions import Extension, Refusal

class MyExtension(Extension):    name = "my-extension"
    def after_auth(self, admission):        if admission.principal is not None and admission.principal.subject.startswith("svc-"):            return Refusal("service_accounts_refused", status=403)        return None

def create(config, env):    return MyExtension()

Nothing is loaded that is not named. An unknown name refuses to start, and so does a factory that raises ValueError on a setting it cannot serve; --check reports both. akko_mcp_trino.extensions.Extension is the base class: every hook does nothing, so an extension overrides only what it adds.

HookWhat it adds
check_lines()lines --check prints, never a secret
identity_modes()identity modes by TRINO_IDENTITY_MODE name, each an IdentityMode (describe, connection(user), ping); one named like a core mode (jwt, impersonation, oauth2, service) replaces it
audiences()token audiences accepted besides MCP_OIDC_AUDIENCE and MCP_RESOURCE_URL; never Trino's
before_auth(admission)a refusal before the token is looked at, or None
after_auth(admission)a refusal once the core has verified the token (or found none), or None
public_documents()JSON documents served without a token, by request path; they take precedence over the core's
challenge(refusal)the WWW-Authenticate value of a refusal; it takes precedence over the core's
routes()Starlette routes added to the served app, behind the identity guard
wrap_audit(audit)the audit sink the server writes to, given the one it would write to otherwise
client_tags()client tags of a query run now (X-Trino-Client-Tags)
describe_table_doc()text appended to the description of describe_table
tool_registrars()tool registrars, called like a tool plugin's, before the plugins'

A guard answers a Refusal(reason, status=401, error="", headers={}, message=""): the reason goes into X-Reason and the body, error is the RFC 6750 code a challenge may carry, headers are added to the response (Retry-After), and message, when set, tells the client in the body what to do. It reads an Admission: the request headers, the token the request presented, the verified principal (None before authentication or without a token), stdio (true for a stdio admission); admission.bind(var, value) sets a context variable for the duration of the request, reset afterwards. Over stdio a process is admitted once, at start, except with TRINO_IDENTITY_MODE=oauth2: there the guards run on every request that is not discovery (see Native browser login), never at start or for discovery, and always after Trino named the user, so before_auth already sees that principal and a binding lasts one request. A guard that counts sessions, or refuses at start to keep the server from serving, must account for it.

The rule: an extension can only refuse, never widen. Its guards run around the core's token verification, never instead of it; strict mode still refuses a request without a verified identity whatever a guard answers, and the read-only guard on SQL applies to every query. An identity mode decides who the server is for Trino, query by query (akko_mcp_trino.trino_client.resolve_identity picks the mode TRINO_IDENTITY_MODE names among the core's and the extensions', and refuses an unknown one: there is no fallback to service). A mode that sends Trino a token for the caller must obtain one for Trino, never forward the caller's: JwtIdentity.trino_token(bearer) is the hook. A plugin that calls its own backend for the caller reads the verified bearer with akko_mcp_trino.identity.current_bearer(), and must exchange it the same way rather than send it on. Trino decides what that identity may read.

What happens on a request

mermaid
sequenceDiagram    participant H as MCP host    participant G as Guard (ASGI middleware)    participant T as Tool    participant Tr as Trino
    H->>G: POST /mcp  Authorization, X-Request-Id    G->>G: request id: honoured or generated    opt an extension's guard before the token        G-->>H: its refusal  X-Reason    end    G->>G: verify JWT (JWKS, iss, aud = this server, exp) → Principal(subject)    alt no valid token (none, forged, expired, issued for Trino)        G-->>H: 401  X-Reason: unauthenticated, what to send    end    opt an extension's guard after the token        G-->>H: its refusal  X-Reason    end    G->>T: Principal in a ContextVar    T->>T: validate identifiers · refuse writes    opt jwt mode, no valid exchanged token in memory        T->>T: exchange the caller's token at the provider (RFC 8693)    end    T->>Tr: SQL as the caller (jwt: exchanged token; impersonation: server account), source    Tr-->>T: rows (masked, filtered by the policy engine)    T->>G: audit line {request_id, tool, subject, ok, error, duration_ms}    G-->>H: result  X-Request-Id

Configuration

Everything is read from the environment. Nothing is hardcoded. An extension reads its own variables from the same environment.

Trino

VariableMeaningDefault
TRINO_HOST, TRINO_PORTthe coordinatorlocalhost, 8080
TRINO_USERthe server's own Trino account: every query runs as it in service mode; in impersonation mode it authenticates and impersonates the caller; ignored in oauth2 modetrino
TRINO_PASSWORDits password, sent by Basic authentication; refused over http; --check says whether one is set, never whichnone
TRINO_CLIENT_CERT, TRINO_CLIENT_KEYa client certificate and its key (PEM paths) the account authenticates with instead (mTLS); https onlynone
TRINO_CATALOGsession catalog, where a name without a catalog is read; set it to a data catalog (with system, such names are refused)system
TRINO_READ_ONLYrefuse writes in execute_querytrue
MCP_EXTRA_READ_COMMANDSread commands the allow-list refuses, opened one by one, comma-separated: EXPLAIN (and the plan in explain_query), SHOW STATS, SHOW CREATE, SHOW GRANTS, SHOW ROLES (with SHOW CURRENT ROLES and SHOW ROLE GRANTS), SHOW SESSION. Trino answers them past a row filter or a column mask: open one only where no row filter or mask needs to hold; said at every start and to every host; an unknown name refuses to startnone
TRINO_MAX_ROWSresult cap, 1 at least; one more row is read to say whether the answer was truncated; also the size of one page of a listing100
MCP_MAX_CATALOGSthe most catalogs list_catalogs reads before it pages them, system aside, 1 at least; above it the tool answers an error, never a cut list10000
MCP_DESCRIBE_SCHEMA_TABLESthe most tables in one page of describe_schema, from 1 to 100: a page reads each of its tables by its own branch, so its cost grows with the page20
MCP_DESCRIBE_SCHEMA_COLUMNSthe most columns one page of describe_schema holds in all, 1 at least; the first table of a page comes whole however wide, and a page shortened for this budget says so1000
MCP_SEARCH_MIN_LENGTHthe shortest word search_columns searches, 1 at least; shorter words are ignored, and a text with none left is refused3
MCP_SEARCH_MAX_WORDSthe most words a text of search_columns may hold, 1 at least; above it the search is refused before Trino8
MCP_SEARCH_MAX_WORD_LENGTHthe longest word search_columns takes, not below MCP_SEARCH_MIN_LENGTH; a longer word is refused before Trino64
MCP_SEARCH_RESERVE_SECONDSthe seconds of the call's budget search_columns keeps in reserve, so that it answers what it searched before the call runs out; 0 or more, below MCP_CALL_TIMEOUT_SECONDS when that is set; 0 is warned of2
TRINO_QUERY_TIMEOUT_SECONDStime budget of one query: past it, the server cancels the query in Trino; 0 leaves the limit to Trino60
MCP_CALL_TIMEOUT_SECONDStime 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 none120
TRINO_SOURCEthe source Trino records for every query (X-Trino-Source)the server name
TRINO_HTTP_SCHEMEhttp or https; a password is never sent over httphttp
TRINO_VERIFYTLS verification: true, false, or a path to the CA bundle Trino's TLS chain is checked againsttrue
TRINO_REQUEST_TIMEOUT_SECONDSper-request timeout on the Trino client30
TRINO_IDENTITY_MODEwho the server is for Trino: jwt (caller, by token exchange), impersonation (caller, impersonated by TRINO_USER), oauth2 (native browser login, stdio only, checked by the first request that is not discovery), or service (TRINO_USER, announced as a warning); all except service require https; jwt and impersonation also require a valid MCP token; an extension may add others, and an unknown mode refuses to startjwt
TRINO_TOKEN_EXCHANGE_URLjwt: the provider's token endpoint (https only)none (required in jwt)
TRINO_TOKEN_EXCHANGE_CLIENT_ID, TRINO_TOKEN_EXCHANGE_CLIENT_SECRETjwt: this server's confidential client at the provider, sent by Basic authentication; --check never prints the secretnone (required in jwt)
TRINO_TOKEN_EXCHANGE_AUDIENCEjwt: the audience of the token requested for Trino, the one Trino's required-audience names; never an audience this server acceptsnone (required in jwt)
TRINO_TOKEN_EXCHANGE_SCOPEjwt: scopes to request with it, space-separatednone
TRINO_TOKEN_EXCHANGE_VERIFYTLS verification of the token endpoint: true, false, or a CA bundle pathtrue

Identity

VariableMeaningDefault
MCP_AUTH_ENABLEDverify the callers' bearer tokens (the door)true
MCP_AUTH_REQUIREDrefuse with 401 a request without a valid token; jwt and impersonation refuse to start without it; in oauth2, an additional MCP token gate, never a switch to disable Trino logintrue
MCP_JWKS_URLthe provider's JWKS endpointnone (required when auth is enabled)
MCP_OIDC_ISSUERthe issuer a token must carrynone
MCP_OIDC_AUDIENCEthe audience a token must carry: the one the provider gives tokens issued for this server (RFC 8707); never Trino'snone (this or MCP_RESOURCE_URL is required when auth is enabled)
MCP_RESOURCE_URLthis server's canonical URL, such as https://mcp.example.com/mcp: accepted as an audience, named in the protected resource metadata the server then publishes (RFC 9728) and in the challenge of a 401none
MCP_JWT_LEEWAY_SECONDStolerance on exp/nbf/iat for clock drift between the issuer and this server30
MCP_JWT_USER_CLAIMthe claim of a caller's token that names the user, as the access rules know them. sub is the OpenID Connect standard: the stable, unique identifier the issuer gives the user. An issuer whose sub is an opaque id the rules do not know (Keycloak gives a UUID; Entra ID, Okta and Dex an opaque id too) names another claim: preferred_username, upn, uid... A token without it is refused, never read from another claimsub
MCP_JWT_ALGORITHMSthe signature algorithms a caller's token may use, comma-separated, among RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512 (for example RS256,RS512 for an identity provider that signs with either); a shared-secret algorithm (HS256...) or none refuses to startRS256
MCP_ALLOWED_HOSTSHost 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 Hostnone (off)

Auth enabled without a JWKS URL, or without an audience of this server's own, refuses to start: an authentication layer that cannot verify anything must not pretend to. So does a door that requires a token it cannot verify (MCP_AUTH_REQUIRED=true with MCP_AUTH_ENABLED=false).

Plugins and extensions

VariableMeaningDefault
MCP_TOOL_PLUGINScomma-separated names of registrars installed under the entry-point group akko_mcp_trino.toolsnone
MCP_EXTENSIONScomma-separated names of extensions installed under the entry-point group akko_mcp_trino.extensionsnone

Serving

VariableMeaningDefault
MCP_TRANSPORTstreamable-http, sse or stdiostreamable-http
MCP_PORT, MCP_HEALTH_PORTlistening ports3000, 3001
MCP_SERVER_NAMEname announced to hoststrino-mcp
MCP_USER_TOKENstdio only: the user's access token, verified like a bearer headernone (required in strict mode)

Operations

Refusals

Every refusal carries X-Reason and the request id, never the token. The core refuses with 401 unauthenticated a request without a valid token (none, forged, expired, or issued for another audience, Trino's included), and says in the body what to send:

json
{"error": "unauthenticated", "message": "this server requires a bearer token issued for it by the identity provider: send Authorization: Bearer <access token>"}

When MCP_RESOURCE_URL is set, the server publishes its protected resource metadata at /.well-known/oauth-protected-resource (and under the path of the URL, such as /.well-known/oauth-protected-resource/mcp), naming the resource and MCP_OIDC_ISSUER as its authorization server, and the 401 carries WWW-Authenticate: Bearer resource_metadata="…", as the MCP authorization specification asks. An extension's guards add their own reasons; its documents and challenges take precedence over the core's.

Warnings

A configuration that leaves something open is said three times: as a WARNING line at start, in --check, and to every MCP host in the instructions of the initialization. The core warns of service mode and of a door that does not require a valid token.

Audit

Every response of the MCP endpoint carries X-Request-Id. 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:

json
{"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.

FieldMeaning
request_id, tool, subjectwhich call, which tool, for whom; the line has no field that could hold the bearer or the SQL text
ok, errorthe 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
duration_mshow long the call took

What a call did (the SHA-256 of its SQL, the tables, the rows, whether they were cut) is kept in its record while it runs (akko_mcp_trino.audit.note); the core's line does not write it, an extension may (wrap_audit).

Health and metrics

/health on the health port never touches Trino, so a liveness probe cannot kill the server because Trino is slow. /ready asks the identity mode: in jwt mode Trino's public /v1/info; in impersonation and service modes, 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.

Development

bash
pip install -e ".[dev]"ruff check akko_mcp_trino tests examples && ruff format akko_mcp_trino tests examplesruff check akko_mcp_trino --select D100,D101,D102,D103,D105,D107   # every public name documentedmypy akko_mcp_trino           # the package ships py.typed and type-checks cleanpip-audit                     # no known vulnerability in the dependency treebash lint-vendor-neutral.sh   # fails if the package imports anything product-specificpytest                        # 826 tests; line or branch coverage below 100 % fails the runintegration/run.sh            # live: Keycloak, Trino and three servers in Docker, then removedpython -m build && twine check dist/*akko-mcp-trino --check        # effective configuration, no secrets; exit 2 if it would not start

tests/test_core_alone.py lists what the core holds (tools, modules, settings, audit fields) and fails the day something is added unlisted.

Every module in akko_mcp_trino/ has one responsibility and its tests, in tests/:

ModuleResponsibility
config.pyread the environment, neutral defaults
auth.pyJWT verification against JWKS, Principal
identity.pythe verified caller in a ContextVar
middleware.pythe guard: token, request id, the extensions' guards and documents
extensions.pythe extension points and their loading
transport_security.pythe Host header filter (MCP_ALLOWED_HOSTS)
audit.pythe audit line, the request id, the record and the time budget of a call
sql_guard.pyidentifier validation, read-only decision on the AST
tools.py, plugins.pythe eight tools and the ToolContext, loading tool plugins
trino_client.pythe Trino connection, the identity modes (jwt, impersonation, oauth2, service in the core) and the time budgets
token_exchange.pyOAuth 2.0 Token Exchange (RFC 8693): a token for Trino in exchange for the caller's
health.py, metrics.py/health, /ready and /metrics; the Prometheus counters
server.py, app.py, __main__.pyassembly, 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 verified caller would never reach the tools.

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 goes through the identity mode, not only execute_query. Listing catalogs, schemas and tables is reading metadata; a test walks every tool to check it reaches Trino the same way.

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). The engine policy must refuse them as well, system.jdbc and system.metadata aside; the guard is a second line, not the only one.

No token passthrough. Up to 0.5, jwt mode sent Trino the caller's own token, once verified. The MCP authorization specification forbids it: a token issued for one service must not be sent on to another, or that service may trust a token that was never meant for it (the "confused deputy"). Since 0.6, the server accepts only tokens whose audience is its own, refuses a token issued for Trino, and obtains for Trino a token of its own by OAuth 2.0 Token Exchange (RFC 8693). Where the provider cannot exchange tokens, impersonation is the explicit fallback; it is never chosen in silence.

The verified identity, never one the caller names. The caller's name always comes from their verified token. A caller sending a header that names a Trino user changes nothing: the server builds its own request to Trino. A password, a token or a client certificate is never sent over plain http, and an identity mode the core does not know refuses to start rather than fall back.

Safe by default. Up to 0.5, the defaults ran every query as TRINO_USER and served a request without a token. Since 0.6, the default runs every query as the caller and refuses a request without a valid token; service mode and an open door are still possible, chosen by name, and announced as warnings. The row and time budgets have defaults too: unbounded was a choice nobody had made.

An extension can only refuse. Its guards run around the token verification, never instead of it, and the read-only guard applies whatever they answer.

The server does not decide access. Trino does, for each caller or for the configured 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 project working on governed access for AI agents to enterprise data, in place, on the engines and identity providers organisations already run.

Maintained by: AKKO ([email protected]) · Issues: github.com/AKKO-p/akko-mcp-trino/issues

License

Apache 2.0. See LICENSE.

Source: README.md at commit 28a0b6f

Tools

0
SourceWeft started this package once in an isolated sandbox, without credentials, to list its tools. That failed: the package did not install.

Version history

3
  1. v0.7.2LatestNov 4, 2026
  2. v0.7.0Nov 3, 2026
  3. v0.5.3Oct 11, 2026

Score

50% of the applicable checks met

How complete this listing is, computed from the registry entry, the endpoint and public sources. Not a rating of the server and not a statement that it is safe or working today: reachability is a dated check, and no installs or reviews go into the score.

  • Reached in the last 30 days25 pts

    Keep the endpoint answering the MCP handshake; SourceWeft probes it every 30 days.

  • Auth declared10 pts

    Declare the credential headers in the registry entry, or keep the endpoint answering the probe so its auth can be classified.

  • Tools listed15 pts

    Answer tools/list without a credential, or ship a package that starts in a sandbox without one.

  • DescriptionRequired15 pts
  • Logo10 pts
  • Documentation15 pts
  • License10 pts