Skip to content
mcp-data-platform composable mcp data platform
v1.x part of txn2 ↗

Configuration

mcp-data-platform uses YAML configuration with environment variable expansion. Variables in the format ${VAR_NAME} are replaced with their environment values at load time.

How Configuration Works

File mode (default): Configuration is loaded from a YAML file at startup. This is the simplest deployment — no database required.

File + database: Adding database.dsn unlocks persistent platform features (audit logging, knowledge capture, session externalization). When a database is available, individual config entries stored in the config_entries table override file defaults for whitelisted keys. Changes made via the admin API take effect immediately without restart. File defaults are preserved and used as fallback when database entries are deleted.

What you configure What it unlocks
YAML file only Read-only config, in-memory sessions, no audit
database.dsn Audit logging, knowledge capture, OAuth persistence, database-backed sessions, per-key config overrides via admin API
database.dsn + admin.enabled: true REST endpoints for system health, config entries CRUD, personas, auth keys, audit

See Operating Modes for the full comparison and Admin API for the REST endpoints.

Unknown keys and strict parsing

By default the loader accepts a config file that contains keys it does not recognize: each unknown key is logged as a prominent WARN at startup and then ignored. This applies at every level that maps to a defined config field: a stray key under server:, auth.oidc:, or inside a persona definition is flagged just like a stray top-level key. This keeps older configs loading, but it also means a typo or a renamed key silently does nothing.

Set config.strict: true to reject unknown keys with a hard error at startup instead. This is recommended, since it turns typos and stale keys into an immediate, actionable failure rather than a silent no-op:

config:
  strict: true

Free-form maps are exempt because they accept arbitrary keys by design: the toolkits tree, each toolkit's config: map, and the persona names under personas: (the fields within a persona definition, such as display_name and tools, are still validated). A future release will make strict rejection the default; you will be able to opt back out with config.strict: false.

Startup validation

After parsing, the server validates the configuration and refuses to start when it finds a setting that cannot work as written. The error names every problem it found at once, so a config with several is fixed in one pass rather than one restart at a time. Validation covers:

  • personas.default_persona, which was removed and is refused rather than ignored
  • auth.oidc.issuer when OIDC is enabled
  • auth.browser_session: OIDC must also be enabled, signing_key is required, same_site must be one of lax, strict, none, and same_site: none requires secure: true (browsers drop a SameSite=None cookie that is not Secure, which would break portal login)
  • oauth.issuer when the OAuth server is enabled, its upstream.issuer, upstream.client_id and upstream.redirect_uri when an upstream is set, and oauth.signing_key on an HTTP transport unless oauth.allow_ephemeral_signing_key: true is set (a per-process key makes each replica reject tokens minted by its peers)
  • database.dsn when sessions.store: database, and on that store a sessions.broadcast_channel that fits PostgreSQL's 63-byte LISTEN identifier limit and unquoted-identifier grammar (a longer name would be truncated by LISTEN but not by NOTIFY, so replicas would never hear each other)
  • audit.delivery

These are separate from the unknown-key handling above: a key here is one the schema recognizes, so config.strict has no bearing on it. A value that is merely unusual is not refused; validation covers settings whose effect would otherwise be silent.

Newly enforced

These checks were written alongside the settings they guard, but nothing ran them: the loader parsed and applied defaults and the server started. A deployment could therefore be running today on a config that will be refused after upgrading. Two cases are worth checking before you roll out, because each previously produced a partial failure rather than an error:

  • auth.browser_session.enabled: true with auth.oidc.enabled: false started with portal login silently switched off. It is now refused.
  • oauth.enabled: true on an HTTP transport with no oauth.signing_key minted a per-process key, which peers reject; multi-replica deployments saw intermittent auth failures. It is now refused unless oauth.allow_ephemeral_signing_key: true says the ephemeral key is intended.

Run the server against your config once before rolling out. It reports every problem in a single error.

Configuration File

Create a platform.yaml file:

apiVersion: v1

server:
  name: mcp-data-platform
  transport: stdio

toolkits:
  trino:
    enabled: true
    instances:
      primary:
        host: trino.example.com
        port: 443
        user: ${TRINO_USER}
        password: ${TRINO_PASSWORD}
        ssl: true
        catalog: hive
        schema: default
    default: primary

  s3:
    enabled: true
    instances:
      primary:
        region: us-east-1
        access_key_id: ${AWS_ACCESS_KEY_ID}
        secret_access_key: ${AWS_SECRET_ACCESS_KEY}
    default: primary

enrichment:
  trino_semantic_enrichment: true
  column_context_filtering: true     # Only include SQL-referenced columns (default: true)

Naming note: the config-block key is enrichment:. The legacy injection: key still loads as a deprecated alias (with a warning) so existing configs keep working, but new configs should use enrichment:. The feature has always been about enriching tool responses with context.

Config Versioning

Every configuration file should include an apiVersion field as the first key. This enables safe schema evolution with deprecation warnings and migration tooling.

apiVersion: v1

server:
  name: mcp-data-platform
Field Type Default Description
apiVersion string v1 Config schema version. Omitting defaults to v1 for backward compatibility.

Supported versions: v1 (current)

Version Lifecycle

  • current: Actively supported, no warnings
  • deprecated: Still works, emits a warning at startup with migration guidance
  • removed: Rejected at startup with an error pointing to the migration tool

Migration Tool

Migrate config files to the latest version:

# From file to stdout
mcp-data-platform migrate-config --config platform.yaml

# From stdin to file
cat platform.yaml | mcp-data-platform migrate-config --output migrated.yaml

# Specify target version
mcp-data-platform migrate-config --config platform.yaml --target-version v1

The migration tool preserves ${VAR} environment variable references.

Server Configuration

server:
  name: mcp-data-platform      # Server name reported to clients
  transport: stdio             # stdio or http
  address: ":8080"             # Listen address for HTTP transports
  trusted_proxies: []          # e.g. ["10.42.0.0/16"]: the ingress's range
  tls:
    enabled: false
    cert_file: ""
    key_file: ""
Field Type Default Description
name string mcp-data-platform Server name in MCP handshake
version string build-injected (dev in unlinked builds) Server version reported to clients
description string - Explains when to use this MCP server - which business, products, or domains it covers. Agents use this to route questions to the right MCP server; also shown in platform_info
tags array [] Discovery keywords (company names, product names, business domains) that agents match against user questions
transport string stdio Transport protocol: stdio or http (sse accepted for backward compatibility)
address string :8080 Listen address for HTTP transports
tls.enabled bool false Enable TLS for HTTP transport
tls.cert_file string - Path to TLS certificate
tls.key_file string - Path to TLS private key
slow_request_threshold duration 5s An inbound HTTP request slower than this is logged at WARN with its route template, method, status and duration (slow HTTP request); see Observability
state_probe_interval duration 1m How often dependency_up re-pings each dependency, at most once per interval and only when /metrics is scraped; a signal only, /readyz never reads it. mcp_platform_connections sends nothing: it reads the outcome of the last call through each connection. See Observability
trusted_proxies array [] CIDRs of the proxies in front of the platform whose X-Forwarded-For it reads a client's address from: an endpoint's ip_rate_limit and req["client_ip"] (#2556), and the portal and OAuth rate limits when their own trusted_proxies are empty. Empty trusts none, and the peer's address is used. A range wider than /8 (IPv4) or /32 (IPv6) is refused at startup. See The client's address

The client's address

Behind a load balancer and an ingress, the address a request arrives from is the ingress's. To limit and report the real client, the chain has to carry the address and the platform has to trust only the hops it knows:

  1. The load balancer preserves the client's address (proxy protocol, or a load balancer that does not rewrite the source), or the ingress sets X-Forwarded-For and trusts only the load balancer's own range.
  2. server.trusted_proxies holds the ingress's range and nothing broader. The platform reads X-Forwarded-For only when the peer is in that range, right to left past the trusted hops, so a client cannot name its own address by writing the header.
  3. A one-line endpoint confirms it: bind def handle(req): return {"json": {"client_ip": req["client_ip"]}} and call it from outside; it answers your own address, not the ingress's.

A hostname the built-in ACME listener serves has no proxy in front and needs no setting. Endpoint limits count each replica on its own, so the effective limit across three replicas is three times the setting.

HTTP Transport Security

When using HTTP transport without TLS, a warning is logged. For production deployments, always enable TLS to encrypt credentials in transit.

Prompts

The platform registers MCP prompts at three levels:

  1. Auto-registered platform-overview — Built dynamically from server.description and enabled toolkits. Lists what the platform can do based on which toolkits (Trino, S3, Search, Portal, Knowledge) are configured.

  2. Operator-configured prompts — Defined in server.prompts. Support typed arguments with {placeholder} substitution in content.

  3. Workflow prompts — Registered automatically when required toolkits are present. Provide guided multi-step workflows (e.g., explore-available-data, create-interactive-dashboard, create-a-report, trace-data-lineage).

Operator-configured prompts override any auto-registered prompt with the same name. Toolkits (Portal, Knowledge) may also register their own prompts via the PromptDescriber interface.

server:
  description: "ACME Corp analytics platform"
  prompts:
    - name: routing_rules
      description: "How to route queries between systems"
      content: |
        Before querying, determine if you need ENTITY STATE or ANALYTICS...
    - name: explore-topic
      display_name: "Explore a Topic"
      description: "Explore data about a specific topic"
      content: "Find all datasets related to {topic} and summarize key metrics."
      arguments:
        - name: topic
          description: "The topic to explore"
          required: true
Field Type Default Description
server.prompts[].name string required Prompt name
server.prompts[].display_name string - Human-readable title served as the MCP prompt title (falls back to the name)
server.prompts[].description string - Prompt description
server.prompts[].content string required Prompt content (supports {arg_name} placeholders)
server.prompts[].arguments array [] Typed arguments for the prompt
server.prompts[].arguments[].name string required Argument name (maps to {name} in content)
server.prompts[].arguments[].description string - Argument description shown to clients
server.prompts[].arguments[].required bool false Whether the argument is required

Built-in workflow prompts:

Prompt Required Toolkits Description
explore-available-data Search Discover datasets about a topic
create-interactive-dashboard Search, Trino, Portal Full workflow: discover, query, visualize, save
create-a-report Search, Trino Discover data, query it, produce a Markdown report
trace-data-lineage Search Trace upstream/downstream lineage for a dataset

Each built-in workflow prompt registers automatically when its required toolkits are present. Turn one off by name with server.builtin_prompts:

server:
  builtin_prompts:
    trace-data-lineage: false      # do not register this workflow prompt
Field Type Default Description
server.builtin_prompts map[string]bool {} Per-prompt switch for the built-in workflow prompts above, keyed by prompt name. A name absent from the map (or set to true) registers as usual; false suppresses it. Operator prompts in server.prompts override a built-in of the same name regardless of this setting

platform_info does not list the prompt library. It once did, carrying every prompt with its full body, which made the mandatory first call of a session grow with the library and spend the agent's context on prompts the session would not run (#1586). A prompt is reached by handle instead: manage_prompt command use resolves a stored name, display name, mcp:prompt:<id> reference or free text, command list browses, show_prompts opens the library for the human, and fetch on mcp:prompt:<id> returns one in full.

Prompt titles and resolution. Database prompts are served on the native MCP prompts surface under per-viewer scope-prefixed names (global-<name>, <persona>-<name>, personal-<name>, shared-<name>), which keeps the surface collision-free by construction; every descriptor carries a title from display_name so clients show the human name regardless. Users never need to know any machine name: agents resolve a prompt from whatever handle the user says (stored name, display name, mcp:prompt:<id>, or free text) with the manage_prompt use command.

Versioning and approval provenance. Every database prompt is versioned: each mutation of its content, display name, description, arguments, or tags snapshots an immutable version row with the author, and approval stamps bind to the specific version that was approved. Editing the content or arguments of an approved global or persona prompt does not change what is served: the edit is saved as a pending draft version, and the approved snapshot keeps serving until an admin approves the draft (admin API POST /api/v1/admin/prompts/{id}/versions/{version}/approve, or reject with .../reject; history via GET .../versions). Metadata-only edits (tags, category, description, display name) apply directly. Personal prompts version silently without review. Served prompts carry their provenance: prompts/get responses stamp prompt_version, prompt_approved_by, prompt_approved_at, and prompt_reference into _meta, and manage_prompt use reports the same in its provenance block, so an agent can state "running Daily Sales Report v4, approved by jane@example.com" before executing. Run counts and last-run timestamps are aggregated from prompt-serve audit events (within the audit retention window) and exposed on manage_prompt get and the GET /api/v1/admin/prompts/usage and GET /api/v1/portal/prompts/usage endpoints.

Streamable HTTP Configuration

The HTTP transport serves both legacy SSE (/sse, /message) and Streamable HTTP (/) endpoints. Streamable HTTP session behavior is configured under server.streamable:

server:
  streamable:
    session_timeout: 30m
    stateless: false
Field Type Default Description
session_timeout duration 30m How long an idle session persists before cleanup
stateless bool false Disable session tracking (no Mcp-Session-Id validation)

The MCP SDK caps each Streamable HTTP request body at 4 MiB and rejects a larger one with 413 Request Entity Too Large. The limit applies to inbound JSON-RPC bodies, so it bounds tool-call arguments; it does not bound tool results, managed-resource uploads, or asset exports, which travel other paths with their own limits.

Authentication Configuration

auth:
  allow_anonymous: false       # Require authentication (default)
  oidc:
    enabled: true
    issuer: "https://auth.example.com/realms/platform"
    client_id: "mcp-data-platform"
    audience: "mcp-data-platform"
    role_claim_path: "realm_access.roles"
    role_prefix: "dp_"
  api_keys:
    enabled: true
    keys:
      - key: ${API_KEY_ADMIN}
        name: "admin"
        roles: ["admin"]
Field Type Default Description
allow_anonymous bool false Allow unauthenticated requests
oidc.enabled bool false Enable OIDC authentication
oidc.issuer string - OIDC issuer URL
oidc.client_id string - OAuth client ID
oidc.audience string - Expected token audience
oidc.role_claim_path string roles Path to roles in token claims
oidc.role_prefix string - Filter roles to those with this prefix
api_keys.enabled bool false Enable API key authentication
api_keys.keys array - List of API key configurations

Token clock skew is a fixed 30 seconds and is not exposed as a YAML setting.

The JWKS signing keys are fetched from the issuer at startup and cached for one hour. The cache self-heals on demand: a token whose key is missing because the cache has expired, or because the IdP rotated its keys, triggers a single refresh from the issuer, performed during that request's validation and honoring the request's own deadline. Concurrent requests collapse into one fetch, which runs independently so a slow issuer cannot pin request goroutines past their deadlines. Refreshes are throttled by the outcome of the last fetch: after a success the next on-demand refresh is at most once per minute, so a flood of unknown key IDs cannot hammer the issuer; after a failure a short recovery window applies so a brief issuer outage heals within seconds rather than being held down for the full minute. No restart is required after key rotation, and none of this is exposed as a YAML setting.

Fail-Closed Security

Authentication follows a fail-closed model. Missing tokens, invalid signatures, expired tokens, or missing required claims (sub, exp) all result in denied access. If a JWKS refresh fails while the cache is expired, tokens are rejected rather than accepted unverified.

Browser Sessions (OIDC Login for Portal UI)

When both auth.oidc and auth.browser_session are enabled, the portal UI offers SSO login via the configured OIDC provider. The flow uses authorization code with PKCE and stores the session in an HMAC-SHA256 signed JWT cookie.

auth:
  oidc:
    enabled: true
    issuer: "https://auth.example.com/realms/platform"
    client_id: "mcp-data-platform"
    client_secret: "${OIDC_CLIENT_SECRET}"
    audience: "mcp-data-platform"
    role_claim_path: "realm_access.roles"
    role_prefix: "dp_"
    scopes: [openid, profile, email]
  browser_session:
    enabled: true
    signing_key: "${SESSION_SIGNING_KEY}"  # openssl rand -base64 32
    ttl: 8h
    secure: true
    same_site: lax
Field Type Default Description
browser_session.enabled bool false Enable cookie-based browser sessions
browser_session.signing_key string - Base64-encoded HMAC key (32+ bytes)
browser_session.ttl duration 8h Session lifetime
browser_session.secure bool true HTTPS-only cookies (set false for local dev)
browser_session.cookie_name string mcp_session Cookie name. On https with no domain the platform prefixes it __Host-, so a sibling hostname cannot set it
browser_session.domain string - Cookie domain restriction
browser_session.same_site string lax Cookie SameSite mode: lax, strict, or none. none requires secure: true and disables the browser's built-in CSRF defense (see below)

The portal UI automatically detects OIDC availability and shows an SSO button. API key authentication remains as a fallback. MCP protocol clients are unaffected — browser sessions only apply to the portal HTTP endpoints.

Sending sign-in straight to the identity provider

The portal login screen shows the SSO button, an "or use an API key" divider and an API key form. On a deployment whose users only ever sign in through the identity provider, portal.login_redirect: true skips that screen:

portal:
  login_redirect: true        # default false
  allow_api_key_login: true   # default true
  • Where the portal would draw the login screen (a fresh visit, or a session that expired), it navigates to /portal/auth/login with the page that was opened as return_to, so after signing in the user is on that page.
  • When the identity provider sends the user back with an error (/portal/?error=access_denied and the other codes the callback writes), the portal does not redirect again. It shows the error and a Try again button, so a refused user does not loop between the portal and the provider.
  • /portal/?login=api_key shows the full login screen, API key form included, for administrators and service accounts. portal.allow_api_key_login: false turns that off: the address is sent to the identity provider like any other.

The public branding endpoint (GET /api/v1/admin/public/branding) reports login_redirect (true only when the setting and OIDC browser sign-in are both on) and allow_api_key_login beside oidc_enabled; the login screen reads them there.

Session Limitations

Sessions are stateless (no server-side store). Individual sessions cannot be revoked. Rotating signing_key invalidates all active sessions. Users must re-authenticate after TTL expires.

CSRF Protection

Because portal, admin, and managed-resources mutations can be authenticated by the session cookie, which the browser attaches automatically, the platform enforces token-based CSRF protection on cookie-authenticated, state-changing requests (POST, PUT, PATCH, DELETE):

  • On login, GET /api/v1/portal/me returns a csrf_token bound to the session (an HMAC over the session subject under the signing key; stateless, no server store).
  • The SPA echoes it in the X-CSRF-Token header on every non-GET request. Requests missing or presenting an invalid token are rejected with 403.
  • Read-only requests (GET, HEAD, OPTIONS) are exempt, as are API-key / Bearer-authenticated requests, because those credentials are not attached automatically by the browser and so are not vulnerable to CSRF.

SameSite=Lax (the default) is retained as defense-in-depth. Setting same_site: none removes that browser-level defense and makes the X-CSRF-Token check the sole protection; the platform logs a startup warning in that case.

Cross-origin reads

The platform answers a cross-origin request from any origin, so a browser-based MCP client on another site can call it with a bearer token or an API key. It allows credentials (Access-Control-Allow-Credentials: true) only for its own origin: the host the request arrived on, or the origin of portal.public_base_url. A page on any other origin, a sibling host under the same domain included, can send a request that carries the session cookie, but the browser withholds the answer, so the page cannot read what the cookie unlocks. Behind a proxy that does not pass the original Host through, set portal.public_base_url to the address people open the portal at.

OAuth 2.1 Server (Inbound)

The built-in oauth: block turns the platform itself into an OAuth 2.1 authorization server, for clients like Claude Desktop that expect to sign in directly to the MCP server rather than through an existing OIDC provider. For most deployments, auth.oidc or auth.api_keys above are simpler and sufficient. See OAuth 2.1 Server for the full config reference, Dynamic Client Registration guidance, and setup walkthrough.

Signing key

When oauth.enabled is set on server.transport: http, oauth.signing_key (base64, 32+ bytes) is required: startup fails without it, because an auto-generated per-process key makes each replica reject tokens minted by its peers. Set oauth.allow_ephemeral_signing_key: true to override this for a single-replica dev setup (unsafe for replicas). On stdio the key is auto-generated when omitted (single-process by construction). To rotate the key without logging users out, see Rotating the signing key.

Rate limiting

The unauthenticated /token and /register endpoints are rate limited by default. /token runs a bcrypt compare per attempt and /register runs a bcrypt hash plus a database insert per request, so both are CPU (and, for /register, storage) amplification levers. Each endpoint has a per-client-IP limit plus an internal global backstop that bounds total throughput regardless of how requests attribute to IPs.

oauth:
  rate_limit:
    enabled: true                 # default: true; set false to disable limiting
    trusted_proxies:              # CIDRs whose X-Forwarded-For is trusted
      - "10.0.0.0/8"
    token:
      requests_per_minute: 60     # default: 60
      burst: 10                   # default: 10
    register:
      requests_per_minute: 10     # default: 10
      burst: 3                    # default: 3
Field Type Default Description
rate_limit.enabled bool true Enable rate limiting for /token and /register
rate_limit.trusted_proxies list [] CIDRs whose X-Forwarded-For is trusted for client attribution. Empty trusts none: the direct peer address is used and forwarding headers are ignored. Set this to your ingress/load-balancer CIDRs so per-client limiting works behind a proxy without being spoofable
rate_limit.token.requests_per_minute int 60 Per-IP /token limit
rate_limit.token.burst int 10 Per-IP /token burst allowance
rate_limit.register.requests_per_minute int 10 Per-IP /register limit
rate_limit.register.burst int 3 Per-IP /register burst allowance

On limit, the endpoint returns HTTP 429 with a Retry-After header and an {"error":"slow_down"} JSON body. The global backstop for each endpoint is sized at ten times its per-IP rate and burst.

Dynamically-registered (DCR) clients that are never issued a token are reaped 24 hours after registration by the OAuth store's cleanup routine, bounding oauth_clients growth from the unauthenticated /register endpoint. Pre-registered (config-file) clients are never eligible.

Database Configuration

The database block configures the PostgreSQL connection used by audit logging, knowledge capture, session externalization, OAuth persistence, and (optionally) the config store.

database:
  dsn: ${DATABASE_URL}
  max_open_conns: 25
Field Type Default Description
dsn string - PostgreSQL connection string
max_open_conns int 25 Maximum open database connections. At least 4: one for the session every background lock shares, one for a locked pass, and two for requests; a smaller value is refused at startup

What the database unlocks

Setting dsn enables audit logging, knowledge capture, session externalization, and OAuth persistence. Without it, these features degrade to in-memory or noop implementations.

Config Store

When a database is available (database.dsn is set), the platform uses a granular key/value config store. Individual config entries in the config_entries table override file defaults for whitelisted keys.

The store is the authority for these keys: nothing is copied into memory at startup and nothing is patched in place on a write. Every read resolves the key from the store and falls back to the file value when no row exists. A change made through the admin API is therefore in force on every replica as soon as it commits, with no restart and no cross-replica notification, and deleting a row restores the file default everywhere on the next read.

If the store cannot be read, the file-config value is used. A database outage degrades to the YAML the operator shipped rather than to an empty value, so agent instructions and deny patterns survive it.

Whitelisted keys (phase 1):

Key Description
server.description Platform description shown in platform-overview prompt and platform_info tool
server.agent_instructions Business/deployment context layered beneath the platform-owned instruction baseline (see below)

Only whitelisted keys can be set via the admin API. Attempting to set a non-whitelisted key returns 400 Bad Request.

Agent instruction composition

The instructions an agent receives via platform_info are composed in layers:

[platform baseline]          platform-owned, versioned with the release, always present:
                             how to operate (search-first / topology discovery, capture
                             proactively). Names only tools the caller's persona can reach.
  + server.agent_instructions   admin: business/deployment context (which backends hold what,
                                 data origins, domain rules)
  + persona suffix/override      persona tuning (override replaces the admin layer only)
  + runtime notes                e.g. the uploaded-resources hint

The platform baseline is an index, not a manual (#1586): each line names one capability, the tool that enters it, and the judgment made before reaching for it, and a second section indexes the built-in knowledge pages that carry the depth. Guidance an agent needs only sometimes is fetched when it is needed rather than carried in every session's first response. The baseline is non-overridable and updates automatically when the platform is upgraded, so the operating model never has to be re-authored per deployment. A persona's agent_instructions_override replaces the admin layer only; the baseline is always present. Because the baseline names a tool (search, memory_capture, manage_script) only when that tool is registered and the persona is allowed to call it, it never points an agent at a tool it cannot use. "Registered" covers the tools the platform registers itself (platform_info, list_connections, platform_find_tools, and the prompt and script tools where their store exists) as well as the toolkits'; before #1586 the gate read the toolkit registry alone, so the baseline's prompt and script guidance never rendered on any deployment. The page index names each page by slug (mcp:knowledge_page:platform-writing-managed-scripts), which fetch resolves: a built-in page's row id is generated per deployment at reconcile time, so the slug is the only handle the shipped text can name. Each entry is gated on the capability it documents, so a persona that cannot write scripts is not handed the scripts pages, and the index is omitted entirely for a caller with neither fetch nor search, since nothing else returns a knowledge page. The agent receives the baseline as part of the composed agent_instructions in the platform_info response; admins can see the baseline on its own read-only in the portal's Agent Instructions screen and via GET /api/v1/admin/config/agent-instructions-baseline.

The customized layer is byte-bounded

server.agent_instructions is composed into the first response of every session on the deployment, so its size is paid for by every caller before any work happens. Nothing bounded it before #1607: config_entries.value_text is unbounded TEXT, and neither the config store nor its REST writer measured a value. A deployment measured in that ticket carried 9,923 characters of it, most of them a data dictionary and a set of query examples -- material that belongs on a knowledge page rather than in a field every session reads.

The bound belongs to the layer rather than to whichever writer produced it:

Bound Bytes Behavior
Advisory 12,288 The write succeeds and the response carries a notice saying the layer has grown from a set of rules into a document, and what belongs on a knowledge page instead.
Limit 32,768 The write is refused, naming the size, the limit, the overage, and the knowledge-page alternative.

Both writers enforce it: PUT /api/v1/admin/config/entries/server.agent_instructions (400 on refusal) and the apply_knowledge agent_instructions sink (see Knowledge). GET /api/v1/admin/config/agent-instructions-baseline reports both numbers as limit_bytes and advisory_bytes, and the portal's Agent Instructions screen draws its size meter from them, so the size an operator is shipping is visible while editing rather than at the moment of a refusal.

The limit is a ceiling on runaway growth, not a target. Keep this layer to the rules a session must know before it does anything, and index longer guidance from it: a one-line mcp:knowledge_page:<slug> entry, in the same form the platform baseline's own page index uses.

The config store is selected automatically: setting database.dsn makes config database-backed (mutations to personas, auth keys, and config entries persist to PostgreSQL and survive restarts); without a database the config is read-only from the YAML file and mutations are blocked. There is no separate mode switch.

See Operating Modes for the full comparison of deployment configurations.

Tool Visibility Configuration

The tools block controls which tools appear in tools/list responses. This is a visibility filter for reducing LLM token usage — it hides tools from discovery but does not affect authorization. Persona-level tool filtering (see Tool Filtering) remains the security boundary for tools/call.

tools:
  allow:
    - "trino_*"
    - "search"
    - "fetch"
  deny:
    - "*_delete_*"
Field Type Default Description
tools.allow array [] Tool name patterns to include in tools/list
tools.deny array [] Tool name patterns to exclude from tools/list
tools.description_overrides map {} Override tool descriptions in tools/list (key: tool name, value: description text). Config values take precedence over built-in defaults, e.g. the built-in trino_query/trino_execute overrides that guide agents to call search first
tools.result_budget.max_bytes int 32768 The context budget: the most of a rendered tool result a model client over MCP is handed. See Tool result context budget
tools.result_budget.per_tool map {} Per-tool overrides of max_bytes (key: tool name, value: bytes, positive)

Tool result context budget

A model reads a tool result into its context, and a client refuses or spills a result past what it accepts (a 64,213-character result was measured refused). tools.result_budget is that budget, set once for the platform:

tools:
  result_budget:
    max_bytes: 32768        # default
    per_tool:
      trino_query: 65536

It is enforced in one place, on the MCP response to a model, and only on a result the model can recover the rest of. Three tools qualify, because each has an export equivalent that returns the whole result without a context cost; each cuts in its own shape and says so. What is measured is the text the client receives, the platform's own additions (the call reference, enrichment) included. A result within the budget is untouched.

Tool Past the budget
api_invoke_endpoint Re-encoded compactly first; if that is not enough a list body is cut on whole items, body_truncated is set, body_items reports how many are shown of how many, next_arguments is the call that reads on when the operation declares paging parameters, and export_arguments carries the api_export call that streams the whole response into an asset. A body with no list to cut is cut to a prefix, flagged, and steered to api_export
graphql_query Re-encoded compactly first; if that is not enough the list in data is cut on whole items, data_truncated is set, data_items reports how many are shown of how many, and export_arguments carries the graphql_export call. An answer with no list to cut has its data withheld whole (a JSON document cut in half cannot be parsed)
trino_query The most whole rows that fit are kept, in the format asked for; result_truncated and rows_shown say how many of row_count, and export_arguments carries the trino_export call

Every other result reaches the model whole, whatever its size: a knowledge page, manage_script help, a prompt, platform_info, an uploaded document read through fetch, an s3_object read, a proxied MCP tool's output. None of these has a way back to the rest, and a model reasoning from half a manual does worse than one whose client spills an oversized result to a file it can still read. The same holds when one of the three tools cannot cut a result recoverably (an answer whose errors alone are past the budget): it is returned whole.

A fitted result's structured content is the fitted value too, so the message a client receives carries the cut result twice rather than the whole one.

Only a model's call over MCP is held to the budget. A REST gateway call (POST /api/v1/gateway/{connection}/invoke), a managed script's run and an admin call are never fitted: a program that parses a response cannot read a cut one. Those callers meet only the real resource limits, a connection's max_response_bytes and the gateway's in-flight memory budget, and a response past max_response_bytes fails with an explicit error (413 on the REST route) instead of arriving cut.

The budget replaced max_inline_bytes, which was set per api and graphql connection. A connection that still carries it loads normally, the value has no effect, and a warning naming tools.result_budget is logged when the connection is read.

Semantics:

  • No patterns configured: all tools visible (default)
  • Allow only: only matching tools appear
  • Deny only: all tools appear except denied
  • Both: allow patterns are evaluated first, then deny removes from that set

Patterns use filepath.Match syntax — * matches any sequence of non-separator characters. For example, trino_* matches trino_query, trino_execute, and trino_describe_table.

When to use this

Deployments that only use a subset of toolkits (e.g., only Trino) can hide unused tools to save tokens. A full tool list is 26-33 tools; filtering to trino_* reduces it to 8.

Not a security boundary

Tool visibility filtering only affects tools/list responses. A user who knows a tool name can still call it via tools/call if their persona allows it. Use persona tool filtering for access control.

Admin API Configuration

The admin block enables and configures the REST API for system health, configuration management, persona CRUD, auth key management, and audit queries.

admin:
  enabled: true
  persona: admin
  path_prefix: /api/v1/admin
Field Type Default Description
enabled bool true Enable admin REST API. Set false to disable. The routes always require admin-role auth to use, so enabling exposes the route, not open access.
persona string admin Persona required for admin access
path_prefix string /api/v1/admin URL prefix for admin endpoints

HTTP transport required

The admin API is served over HTTP. It is not available when running in stdio transport mode.

The admin portal provides a web-based dashboard for audit log exploration, tool execution testing, and system monitoring. Enable with portal.enabled: true. When enabled, it is served at /portal/. See Admin API for the full endpoint reference and Admin Portal for the visual guide.

Portal Configuration

The portal block enables the asset portal - the web UI plus REST API that persists AI-generated assets (JSX dashboards, HTML reports, SVG charts, exports) to S3 with PostgreSQL metadata tracking. See Admin Portal for branding and public-viewer walkthroughs and User Portal for the end-user feature tour.

portal:
  enabled: true
  brand_name: "ACME"                              # Deployment brand; the title becomes "ACME Portal"
  brand_url: "https://acme.example.com"           # Brand site the portal's brand mark links to
  version_url: "https://acme.example.com/changelog"  # Optional link target for the header version number
  title: "ACME Data Platform"                     # Overrides the brand-composed title
  tagline: "Sign in to access your data."         # Login-screen subtitle
  oidc_button_label: "Sign in with ACME Keycloak" # Login-screen SSO button text
  login_redirect: false         # Send signed-out visitors straight to the identity provider
  allow_api_key_login: true     # Keep /portal/?login=api_key offering the API key form
  logo: https://example.com/logo.svg              # Logo URL (fallback for both themes)
  logo_light: https://example.com/logo-light.svg  # Logo for light theme
  logo_dark: https://example.com/logo-dark.svg    # Logo for dark theme
  s3_connection: primary        # S3 toolkit instance for asset storage
  s3_bucket: portal-assets      # Bucket for asset content
  s3_prefix: "artifacts/"       # Key prefix within the bucket (storage key, unchanged)
  public_base_url: "https://portal.example.com"   # Base URL for portal links
  max_content_size: 10485760    # Max asset size in bytes (default: 10MB)
  max_versions: 100             # Versions an asset keeps by default (0 = unlimited)
  asset_refs:
    max: 20                     # References one asset may declare (1-100, default 20)
  embeds:
    hosts:                      # https hosts an HTML asset may frame and play media from (#2069)
      - fast.wistia.net
      - cdn.example.com
  deleted_retention_days: 30    # Days a deleted asset/collection/thread/page is kept before it is purged
  orphaned_producer_retention_days: 90  # Days a producer record outlives its file
  implementor:                                    # Optional implementor brand (left zone of public viewer header)
    name: "ACME Corp"
    logo: "https://acme.com/logo.png"
    url: "https://acme.com"
  terms_url: "https://example.com/terms"          # Optional terms-of-service link (notification email footers)
  privacy_url: "https://example.com/privacy"      # Optional privacy-policy link (notification email footers)
  about_text: "The ACME data portal delivers curated datasets and reports."  # Optional footer block on all outgoing email
  support_contact: "[email protected]"             # Optional help contact (email or URL) rendered with about_text
  reply_to: "[email protected]"                 # Optional Reply-To header on all outgoing email
  rate_limit:                                     # Public portal viewer rate limiting
    requests_per_minute: 60
    burst_size: 10
    trusted_proxies:                              # CIDRs whose X-Forwarded-For is trusted
      - "10.0.0.0/8"
  export:                                         # trino_export configuration
    enabled: true                                 # auto-enabled when portal + trino are configured
    max_rows: 100000                              # hard row cap per export
    max_bytes: 104857600                          # hard byte cap (100 MB)
    default_timeout: "5m"                         # default query timeout
    max_timeout: "10m"                             # maximum allowed timeout
Field Type Default Description
enabled bool false Enable the portal SPA frontend and asset API
brand_name string mcpapps brand_name Deployment brand. Names the brand once and the portal title, the public-viewer header, the branded denial pages, and the built-in MCP Apps all follow it. Falls back to brand_name in the mcpapps.apps.platform-info.config block when that subsystem is enabled
brand_url string mcpapps brand_url Brand home page. The portal's brand mark (sidebar logo and name) links to it in a new tab; unset leaves the mark inert. Falls back to brand_url in the mcpapps.apps.platform-info.config block when that subsystem is enabled
version_url string - Link target for the version number in the portal header (release notes or a changelog). Unset leaves the version as plain text. Served on the unauthenticated branding endpoint, so point it at a URL you are willing to disclose publicly
title string <brand_name> Portal, else MCP Data Platform Sidebar/branding title text. Composed from brand_name when unset, so a branded deployment needs no second string to keep in sync. A brand already ending in "Portal" is not doubled. Only a brand_name set in the portal block composes the title: a brand inherited from the mcpapps block leaves an existing deployment's title unchanged
tagline string Sign in to access the platform. Login-screen subtitle text
oidc_button_label string Sign in with OIDC Login-screen SSO button text
feedback.standalone_recipients list [] Who hears about a General feedback post until an administrator saves the list (#2603): each entry an email address, persona:<name> or channel:<name>. Empty seeds the administrator persona. See Feedback
login_redirect bool false Send a signed-out visitor straight to the identity provider instead of the login screen. In force only when auth.oidc.enabled and auth.browser_session.enabled are both on; set without them it is logged as a configuration warning (login_redirect_ignored) and ignored. See Sending sign-in straight to the identity provider
allow_api_key_login bool true With login_redirect on, whether /portal/?login=api_key still shows the login screen with the API key form. Set false and that address is sent to the identity provider like any other. It changes only the portal login screen: API keys keep working on the MCP endpoint and the REST API
logo string - URL to the logo image, in any format (used for both themes if no theme-specific logo is set). Also the platform brand mark on the public viewer and the share pages, which link it. The built-in MCP Apps cannot link it — a sandboxed iframe blocks external loads — so it is fetched once at startup and written into their config inline, and a fetch that fails logs one warning
logo_light string - URL to logo for light theme (overrides logo)
logo_dark string - URL to logo for dark theme (overrides logo)
s3_connection string - Name of the S3 toolkit instance to use for asset storage
s3_bucket string portal-assets S3 bucket for storing asset content
s3_prefix string artifacts/ Key prefix every asset object is written under: content and versions from the portal, the admin console, the asset tools, the exports and script outputs, and collection mosaics (under <prefix>collections/). A bucket policy or lifecycle rule scoped to the prefix covers all of them. An object written before a change of prefix keeps the key its row recorded and stays where it is; releases before #1903 wrote portal and admin edits and collection mosaics under portal/, and those objects are still served from there
public_base_url string - Base URL for portal links returned in save_asset responses
max_content_size int 10485760 Maximum asset size in bytes (10 MB)
deleted_retention_days int 30 How long a deleted asset, collection, feedback thread or knowledge page is kept before it is removed for good, with every version object, tile and collection mosaic it names. A delete only hides the item until then. 0 uses the default; a negative value keeps deleted items forever. A hidden built-in knowledge page is never removed. See Data Retention
orphaned_producer_retention_days int 90 How long the record of what produced an asset or managed resource outlives the file itself. 0 uses the default; a negative value keeps them
max_versions int 100 Versions an asset keeps when it carries no override of its own. A version pushed past the cap is deleted along with its stored content and thumbnails; the current version is never pruned. 0 keeps every version, and a negative value is refused at startup. Applied at the write, so an asset already over the cap is trimmed the next time it is written, not when this setting changes. An asset's owner can override it — see Asset version retention
asset_refs.max int 20 How many files one asset may reference (managed resources and other assets together). A declaration above it is refused, and the refusal states the number. save_asset and manage_asset advertise it in references.maxItems, so an agent plans its document around this deployment's number. The reference route's per-IP rate limit is rate_limit multiplied by this value, so a page declaring the cap's worth loads in one burst. Must be between 1 and 100; anything else is refused at startup. See Asset references
embeds.hosts list Wistia (fast.wistia.net, fast.wistia.com), YouTube (www.youtube-nocookie.com, www.youtube.com), Vimeo (player.vimeo.com) The https hosts an HTML asset or slide deck may frame (frame-src) and play media from (media-src), in the portal's asset frame and on share links alike (#2069). An entry is a host such as player.example.com, with https://, a port or a leading *. if it needs one; anything else (another scheme, a path, a bare *, punctuation) is refused at startup naming the entry. An empty list turns embeds off. An administrator's list saved under Admin > Settings > Embed hosts replaces this one. The Wistia player and a <video> from a listed CDN play inside the asset frame; a YouTube or Vimeo embed draws as a link that opens the video in a new tab (#2396). See Content Types and Viewers
implementor.name string - Implementor display name shown in the left zone of the public viewer, the public collection viewer, the guest share landing page, and the access-denied page. Independent of implementor.logo: either one alone renders the implementor block
implementor.logo string - URL to the implementor logo, in any image format. The public viewer and the share pages link it with an <img> element; its origin is added to the img-src of the pages whose policy would otherwise block it. Renders with or without implementor.name
implementor.url string - Clickable link wrapping the implementor name and logo
terms_url string - Terms-of-service URL rendered as a small footer link in notification emails. Omitted when unset
privacy_url string - Privacy-policy URL rendered as a small footer link in notification emails. Omitted when unset
about_text string - A sentence or two about the platform, rendered as a help/about footer block on all outgoing email (HTML and text parts). Gives first-contact recipients sender context and adds body text content filters look for. Omitted when unset
support_contact string - Help contact rendered with about_text: an email address (linked as mailto:) or an http(s) URL. Omitted when unset
reply_to string - Reply-To address applied to every outgoing email so recipient replies reach a monitored mailbox. Validated at startup; unset leaves the header off
rate_limit.requests_per_minute int 60 Public portal viewer per-IP rate limit
rate_limit.burst_size int 10 Public portal viewer per-IP burst allowance
rate_limit.trusted_proxies list [] CIDRs whose X-Forwarded-For is trusted for client attribution. Empty trusts none: the direct peer address is used and forwarding headers are ignored. Set this to your ingress/load-balancer CIDRs so per-client limiting works behind a proxy without being spoofable. A global backstop bounds total throughput regardless of attribution
export.enabled bool auto Enable trino_export tool. Auto-enabled when portal and Trino are both configured. Set false to disable
export.max_rows int 100000 Hard row cap for trino_export. A result it cuts is refused unless the call sets on_truncation: "warn"; see Exports cut at a limit. platform_info reports it under limits.export
export.max_bytes int64 104857600 Hard byte cap for formatted output (100 MB)
export.default_timeout string 5m Default query timeout for exports
export.max_timeout string 10m Maximum allowed query timeout for exports

The route an artifact's declared files are served from has a limiter of its own, sized at twenty times the rate_limit values (an asset declares at most twenty files, and one page view fetches them all at once). With no rate_limit block that is 1,200 requests a minute and a burst of 200 per client. The platform's own requests to that route, the ones the thumbnail renderer makes while it draws a document, are not counted.

Prerequisites

Portal requires database.dsn to be configured for metadata storage, and at least one S3 toolkit instance for asset content storage.

Audit Configuration

The audit block controls audit logging of MCP tool calls. By default audit events are written asynchronously to PostgreSQL.

audit:
  enabled: true
  log_tool_calls: true
  log_parameters: true
  redact_keys: ["password", "token"]
  delivery: async          # async (default) | sync
  retention_days: 90
Field Type Default Description
enabled bool true (when a database is available) Enable audit logging. Set false to disable.
log_tool_calls bool true Log MCP tool call events. Set false to keep audit on but skip per-tool-call rows.
log_parameters bool true Capture tool-call arguments on each event. Set false to store a null parameters field when arguments may carry sensitive data that redaction cannot make safe to retain.
redact_keys list of strings [] Top-level argument keys whose values are replaced with [REDACTED] before the event leaves the request path. Matching is case-insensitive; nested keys are not matched (top-level only).
delivery string async Store-write path: async (best-effort, never blocks the tool call) or sync (writes on the request goroutine for backpressure and zero queue drops). See below.
retention_days int 90 Days to retain audit events

Requires database

Audit logging requires database.dsn to be configured. With a database available and no audit: block, both audit and per-tool-call logging are on by default. Setting enabled: false disables audit entirely; log_tool_calls: false keeps audit on but stops recording per-tool-call events.

Delivery semantics and data captured

What is captured per event. Each audit event records: identifiers (id, request_id, session_id, user_id, user_email, persona), the call target (tool_name, toolkit_kind, toolkit_name, connection, event_kind), the raw tool-call arguments (parameters), the outcome (success, error_message, authorized), timing and size (timestamp, duration_ms, request_chars, response_chars, content_blocks), transport metadata (transport, source), and enrichment accounting (enrichment_applied, enrichment_tokens_full, enrichment_tokens_dedup, enrichment_mode, enrichment_match_kind).

Sensitive data in parameters. The parameters field stores tool-call arguments verbatim, including complete SQL text and anything embedded in it. Unless you set redact_keys (to mask named top-level values) or log_parameters: false (to drop the field entirely), sensitive values pasted into a query or argument are retained in the audit table. A built-in baseline additionally masks the well-known keys password, secret, token, api_key, authorization, and credentials, but this is a safety net, not a substitute for configuring redact_keys for your own sensitive argument names.

Async delivery (default). Events are enqueued on a bounded in-memory writer and persisted by a single background goroutine, so a tool call is never blocked by store latency. This is best-effort: under a sustained store outage or a crash, events in the queue are dropped rather than retained. Every lost event increments the audit_events_dropped_total metric, which also covers writes that fail or exceed the per-write timeout.

Sync delivery. Set delivery: sync when a compliance posture requires durability over latency. Each event is written on the request goroutine with a per-write timeout (5s), so a slow store applies backpressure to the tool call (it waits) rather than shedding events: there are no queue-overflow drops. A store write that still fails or times out is logged and counted (audit_events_dropped_total) but, as in async mode, never fails the tool call: audit must not break tools. Two tradeoffs to weigh: under a stalled store every tool call blocks for up to the timeout before returning, and sync writes draw from the same database connection pool as OAuth, sessions, and portal queries, so under load against a slow store they can contend with those subsystems (the async writer's single drain goroutine caps audit at one connection and avoids both). Graceful shutdown cancels in-flight sync writes.

See Audit Logging for query examples and retention details.

Call Catalog Configuration

The calls block controls how long the call catalog keeps a recorded query or API invocation, and whose calls it keeps at all. Nothing here turns the catalog on: it is written from the audit pipeline, so it exists wherever audit does and nowhere else.

calls:
  retention_days: 90
  exclude_personas:
    - ingest-service
Field Type Default Description
retention_days int 90 How long a recorded call is kept when nothing came of it. Zero or negative takes the default.
exclude_personas list [] Personas whose calls are machinery: audited, not cataloged. The file-config form of a persona's own service_account setting; either one excludes. Empty catalogs every call not made under a service-account persona.

What retention does not touch. The sweep is by what a record came to, not by its age alone. A record an asset, an export, or a capture cites; a record someone promoted; a record someone declined; and a record another session found and re-ran are all evidence, and none of them is ever swept, whatever their age. What ages out is the draft nobody used: a query that ran, answered nothing anybody kept, and was never run again.

The sweep runs once when the platform starts and then once a day per deployment, under a PostgreSQL advisory lock so that several replicas sharing one database delete once rather than each.

Excluding an automated caller

The catalog exists to answer one question about a recorded call: is this worth running again. An automated system driving ingestion through the same tools people use never produces that answer. Each of its calls fetches a distinct upstream resource once, and each is recorded, embedded, and ranked in search against the handful of records that did answer something. On one deployment a single service principal wrote 472,156 of 476,749 records in ten days, against 43 records that had been used for anything.

Mark the persona the automated caller signs in under as a service account. In the portal, open the persona under Admin, then Personas, and in its Identity section choose the Automated caller account type ("Its calls are audited but not added to Calls."). A persona defined in the config file takes the same setting:

personas:
  integration:
    display_name: "CRM integration"
    roles: ["crm-sync"]
    service_account: true
    tools:
      allow: ["*"]
    connections:
      allow: ["crm"]

A call made under a service-account persona is audited exactly as before and no call record is written. Persona is the discriminator because it is the layer you already assign per API key to say what a caller is for: an API key reaches a persona through its roles, so give the integration its own persona and every key that holds it is covered, with no list to maintain. A person's own persona is left unmarked, and their calls are cataloged as before.

The setting is read from the live persona on every call. Saving a persona in the portal takes effect on the next call, on every replica, with no restart.

calls.exclude_personas is the file-config form of the same rule, and both apply: a persona named there is treated as a service account whether or not its own setting is on.

calls:
  exclude_personas:
    - ingest-service

Finding the caller. The admin Indexing page lists the Top callers of the call catalog under the calls card: the callers and personas holding the largest share of its records, each with its count and share, whether the persona is already a service account (or named in calls.exclude_personas), and a link to that persona's editor. An automated caller shows as one principal under one persona holding most of the catalog. The counts are taken at most every five minutes, since grouping a catalog of a million records on every refresh would be wasted work; the service-account marks are read on each request (GET /api/v1/admin/calls/top-callers).

What this does not change. The audit row, its retention, and the API gateway metrics are untouched, so what an automated system did stays fully visible in the Activity view and in the gateway charts. The one dimension the gateway charts lack is the principal, and the audit log carries it.

What else it withholds. A data call normally comes back with its own mcp:call:<id> reference, which an agent is told to cite when it saves an asset or captures an insight. A call the catalog declines is handed none: that id would resolve to nothing, so citing it would store a citation that can never be satisfied.

Records already written. Marking a persona also removes the records it wrote before it was marked. They are swept on the next sweep, with the same evidence clauses standing: a record something was built from, or that was promoted, declined, or re-run, is kept whoever produced it. Saving a persona as a service account through the portal or the admin API starts that sweep at once; for a name added to calls.exclude_personas it is the sweep at startup, so the restart that applies the setting is the restart that clears the backlog. A record's vector is on its row and goes with it, and the sweep deletes the record's indexing units (pending, running or finished) in the same statement, so the calls index total on the Indexing page falls by the number of records removed.

A large backlog. The sweep deletes in batches of 5,000 records, each its own statement and transaction, until a batch comes back short. A backlog of a million records is removed by a couple of hundred short statements rather than one long transaction; a batch that committed stays committed if the process stops, and the next sweep resumes from what is left.

A name that matches nothing. An entry naming no persona the deployment knows excludes nothing, and the platform logs a warning at startup naming it. Personas come from both this file and the database, so a name added to the database later is matched at the next start.

The one caller you do not name

A managed script run's calls are not cataloged, and there is no setting for it. The platform's own scheduler is the automated system here, and a run is by construction the re-run: its statement is the script's source, its outputs are on the run record and in the provenance of the assets it wrote, and nobody fetches one of its calls to run it again. On one deployment 76% of the catalog was the six calls a single hourly schedule made, every one embedded, every one with a reuse count of zero.

It cannot be named either, because a run presents the persona of the person who wrote the script: the calls a schedule makes every hour are indistinguishable by persona from that author's own. So the rule is keyed on how the call arrived rather than on who made it, and the same persona in an ordinary session is cataloged exactly as before.

Everything above applies to a run's calls too: they are audited in full, they are handed no mcp:call:<id> reference, and the rows written before this existed are swept at the next startup sweep with the evidence clauses standing.

Notifications Configuration

The notifications block controls the email-notification substrate: the delivery queue, send worker, and daily-digest scheduling. See Email Notifications for the full feature (admin SMTP settings, per-user preferences, delivery semantics).

notifications:
  enabled: true         # default: on when a database is available
  digest_hour_utc: 13   # UTC hour daily digests are sent
Field Type Default Description
enabled bool true Enable email notifications. Set false to disable enqueue and delivery entirely.
digest_hour_utc int 13 UTC hour of day (0-23) at which daily-digest emails are scheduled. Out-of-range values fall back to the default.

Requires database

Email notifications require database.dsn. The SMTP connection itself is not configured here: admins set host, credentials, and TLS mode at runtime in the portal (Admin > Settings > Email) or via /api/v1/admin/settings/smtp, with the password encrypted at rest. The knowledge review-queue alert threshold is admin-configured the same way, under /api/v1/admin/settings/review-queue-alert; see Review queue alerts.

Session Configuration

The sessions block controls how MCP session state is stored. In-memory sessions are lost on restart; database-backed sessions survive restarts and support multi-replica deployments.

sessions:
  store: database
  ttl: 30m
  cleanup_interval: 1m
Field Type Default Description
store string memory Backend: memory or database
ttl duration streamable session_timeout Session lifetime
cleanup_interval duration 1m Cleanup routine interval

Requires database

The database store requires database.dsn to be configured.

See Session Externalization for architecture details and multi-replica considerations.

Explicit Session Handles

The sessions.handles block controls explicit session handles (issue #792). When enabled, platform_info mints a session_id that the model passes back as an ordinary argument on every subsequent tool call. This is the pattern the MCP 2026-07-28 release candidate recommends after removing the protocol-level session and the Mcp-Session-Id header (SEP-2567).

This makes platform_info structurally unskippable (no handle exists until it is called, and gated tools require one), gives audit and provenance a deliberate session key, and keeps the platform working unchanged when clients move to the sessionless protocol.

sessions:
  handles:
    enabled: true      # mint, advertise, and validate handles (default on)
    ttl: 8h            # handle lifetime, refreshed on use
    require: true      # a gated caller must have an established session
Field Type Default Description
enabled bool true Mint a session_id from platform_info, advertise it on every tool's input schema, validate and strip it on each call. Set false for byte-identical legacy transport-session behavior.
ttl duration 8h Handle lifetime, refreshed on use.
require bool true Require a gated caller to have an established session, not that a handle is threaded on every call. A call carrying a valid handle uses it; a call without one adopts the caller's own most-recently-active session, resolved from their authenticated identity, so an MCP App's sandboxed calls (which cannot thread the handle) are scoped rather than refused. Only a caller with no session at all is refused with SESSION_REQUIRED, which keeps platform_info structurally required for a genuinely fresh agent. The model still threads and validates the handle exactly as before, so nothing about a compliant agent's behavior changes; the fallback only affects calls that arrive without a handle, which in practice are an app's. Set false to drop the requirement entirely, where a handle-less call falls back to the transport session.

Every tool advertises the injected session_id argument except platform_info (which mints it). Upstream toolkits never see the argument: the platform strips it before the handler runs. A handle presented by a different authenticated identity, or an unknown/expired handle, is refused with SESSION_EXPIRED. With require: true, platform_info mints and threads a handle on every transport (stdio, SSE, Streamable HTTP); there is no stdio carve-out. The mcp_session_resolution_total{source} metric (explicit, transport, stdio, none) shows how much traffic still relies on a transport session; with require: true only explicit and none occur on gated tools.

Toolkit Configuration

Trino

toolkits:
  trino:
    enabled: true
    instances:
      primary:                   # Instance name (can be any identifier)
        host: trino.example.com
        port: 443
        user: analyst
        password: ${TRINO_PASSWORD}
        catalog: hive
        schema: default
        ssl: true
        ssl_verify: true
        timeout: 120s
        default_limit: 1000
        max_limit: 10000
        read_only: false
        scratch:                 # Where a registered table is created (#1327)
          catalog: scratch
          schema: uploads
          statement_timeout: 10m # Optional; defaults to the connection's timeout
    default: primary
Field Type Default Description
host string required Trino coordinator hostname
port int 8080 (443 if SSL) Trino coordinator port
user string required Trino username
password string - Trino password (if auth enabled). A password is sent only over TLS: a connection with a password needs ssl: true, and startup refuses one without it before any database migration runs (password authentication requires SSL). A connection other than the default that sets no password inherits the default's, so it needs TLS too. The admin API refuses to save such a connection, and one already saved in the database is not loaded at startup: the platform logs it by name and serves the rest, and the connection is fixed or deleted in the admin API
catalog string - The Trino session catalog every query on this connection runs with, so a table named without a catalog resolves in it
schema string - The Trino session schema, used with catalog, so a table named without a schema resolves in it. A CREATE TABLE t on the connection creates <catalog>.<schema>.t. A connection other than the default that sets neither takes the default connection's, as it does every key it leaves unset; a fully qualified name is unaffected
ssl bool false on the default connection, auto-detected on the others Enable SSL/TLS. Omitting it on a non-default connection leaves the choice to the host: any host that is not localhost or 127.0.0.1 is assumed to be HTTPS on port 443. Write ssl: false to say plain HTTP explicitly
ssl_verify bool true on the default connection, inherited on the others Verify SSL certificates. A non-default connection that omits it takes the default connection's setting
timeout duration 120s Query timeout
default_limit int 1000 Default row limit for queries
max_limit int 10000 Maximum allowed row limit
read_only bool false Reject write SQL on this connection. Set per instance: the other instances of the same toolkit are unaffected, and a call that omits connection is judged by the default instance's setting
scratch.catalog string - Catalog a registered table is created in on this connection. Unset (or set without schema) means registration is unavailable here
scratch.schema string - Schema a registered table is created in. Required alongside catalog; a block naming only one is ignored with a warning
scratch.statement_timeout duration the connection's timeout How long each statement that creates, drops or fills a table in the scratch target may run: a table registration, a follow, a webhook source's tables and partitions. A CREATE TABLE on a Hive catalog with a file metastore over slow object storage can take longer than the query that wrote the file. A value that does not parse refuses the connection
connection_name string - No effect; accepted for compatibility and warned about at startup. Trino routes by the instances: key, so that key is the name list_connections advertises, a connection parameter carries, an audit row records and a persona rule matches. See Connection Names
descriptions map {} Override tool descriptions for this instance (key: s3_list or s3_object, value: description text)

The default timeout is not a fixed 30 s for every caller. The s3_* tools bound a call at 30 s when the instance sets nothing. The platform's own clients — the portal's asset client and the managed-resource client — read a whole object in one call, because a table registration takes the header row and the line-break scan from the full bytes. A fixed deadline on a read that size is a throughput budget rather than a timeout: the same 126 MB CSV registered in 2 s on a healthy path and failed at exactly 30.0 s the day one deployment's DNS sent the pod across a WAN link (#1773). With no timeout set, those two clients take the deployment's own object ceiling — the larger of resources.managed.max_upload_bytes and portal.max_content_size — at a floor of 1 MB/s, never below 30 s. A 250 MB ceiling is a 250 s deadline; a deployment storing small objects keeps the 30 s it had.

A non-default connection could not be plain HTTP before #1436: ssl: false was indistinguishable from an absent ssl, so it reached the client as "auto-detect", and auto-detect turns HTTPS on for every host that is not localhost. Both keys are now forwarded as written, and only a connection that never mentions ssl gets auto-detect. A deployment whose non-default connections rely on that auto-detect is unaffected.

read_only became per connection in #1269. Before that it was read from the default instance alone and applied to the whole Trino toolkit, which cut both ways: read_only: true on the default instance refused write SQL on every Trino connection, and read_only: true on any other instance did nothing. A deployment that was relying on the default instance's read_only to cover its other Trino connections must now set read_only: true on each connection it wants refused.

Connections stored in the database (Admin > Connections) carry the same key, with a Read Only toggle in the Trino connection form. A connection the toolkit holds no setting for — one just added, or a name that is not configured — refuses write SQL until its setting is recorded.

scratch: names a target, not a boundary. Nothing in the toolkit restricts a catalog or a schema, and catalog/schema on a connection are session defaults; what keeps a registration off the warehouse is the Trino identity the connection authenticates as. Every requirement a scratch connection and its catalog have is on Scratch Catalog.

S3

toolkits:
  s3:
    enabled: true
    instances:
      primary:
        region: us-east-1
        endpoint: ""                    # Custom endpoint for MinIO, etc.
        public_endpoint: ""             # Public endpoint for presigned URLs (see below)
        access_key_id: ${AWS_ACCESS_KEY_ID}
        secret_access_key: ${AWS_SECRET_ACCESS_KEY}
        session_token: ""
        profile: ""                     # AWS profile name
        use_path_style: false           # Use path-style URLs
        timeout: 30s
        disable_ssl: false
        read_only: true                 # Restrict to read operations
        max_get_size: 10485760          # 10MB
        max_put_size: 104857600         # 100MB
        connection_name: primary
        bucket_prefix: ""               # Limit the connection to buckets with this prefix
    default: primary
Field Type Default Description
region string us-east-1 AWS region
endpoint string - Custom S3 endpoint for data operations (for MinIO, SeaweedFS, etc.)
public_endpoint string - Public-facing endpoint used only to sign presigned URLs (s3_object presign). When set to an externally resolvable address, presigned URLs are signed against it instead of endpoint, while data traffic keeps using endpoint. Empty falls back to endpoint.
access_key_id string - AWS access key ID
secret_access_key string - AWS secret access key
session_token string - AWS session token (for temporary creds)
profile string - AWS credentials profile name
use_path_style bool false Use path-style S3 URLs
timeout duration see below Bounds a whole S3 call: the request and the read of the response body. It applies to the s3_* tools and, when this instance is named by portal.s3_connection or resources.managed.s3_connection, to the platform's own reads of assets and managed resources. Setting it is the operator's final word.
disable_ssl bool false Disable SSL (for local testing)
read_only bool false Refuse the writing actions of s3_object (put, copy, delete, and presign with method: PUT) on this connection; the refusal names the connection. Per connection, so a read-only connection added at run time is bound by its own flag. The toolkit is the only enforcement: give a read-only connection credentials that can only read and list.
max_get_size int64 10485760 Max bytes to read from objects
max_put_size int64 104857600 Max bytes to write to objects
connection_name string instance name The name a call binds this connection by: what an audit row records, what a persona's connections.allow / connections.deny rules match, and what the semantic layer resolves its platform and catalog mapping through. See Connection Names
bucket_prefix string - Limits this connection to buckets whose name starts with this prefix: s3_list lists only those buckets, and s3_list with a bucket and every s3_object action (a copy's dest_bucket included) are refused for any other bucket, naming the connection. Compared exactly, case included. A script destination whose bucket lies outside it is reported at startup as script_destination_outside_prefix. The toolkit is the enforcement here, as with read_only: give the connection credentials limited to the same buckets for defence in depth. Whatever the prefix, the platform's own portal and managed-resource buckets are refused on every connection (#2616, see S3 Tools).
descriptions map {} Override tool descriptions for this instance (key: s3_list or s3_object, value: description text)

MCP Gateway

The mcp toolkit kind proxies upstream MCP servers and re-exposes their tools as <connection_name>__<remote_tool>. Connections are managed exclusively through the admin portal — no per-instance config goes in platform.yaml. The only YAML knob is enabled, which turns the kind on.

toolkits:
  mcp:
    enabled: true
Field Type Default Description
enabled bool false Register the gateway toolkit kind. When false, mcp connections in connection_instances are ignored.

Required environment for the OAuth + at-rest encryption path:

Variable Required for Notes
ENCRYPTION_KEY Encrypted credentials in connection_instances, gateway_oauth_tokens, oauth_pkce_states, and the SMTP password in platform_settings 32 bytes of key material, accepted in three forms: 64 hex characters, 44-character base64, or 32 raw bytes (set via printf / file). Without it, sensitive fields are stored in plaintext and the platform logs a warning. Required for any production gateway deployment.
DATABASE_URL OAuth authorization_code grant (refresh-token persistence) and multi-replica deployments Without a database, OAuth tokens live in process memory only and don't survive restarts. Multi-replica deployments additionally need this so PKCE state is shared across pods.

See Gateway Toolkit for the connection-config reference, auth modes (none/bearer/api_key/oauth), OAuth grant types (client_credentials and authorization_code + PKCE), and the cross-enrichment rule schema.

GraphQL

The graphql toolkit kind reaches a GraphQL endpoint: one URL every document is POSTed to, a schema the platform reads by introspection and keeps, and three tools (graphql_discover, graphql_query, graphql_export) serving every connection. Connections are normally managed through the admin portal, like the other gateway kinds; the YAML form below exists for a file-configured deployment.

toolkits:
  graphql:
    enabled: true
    instances:
      metadata:
        endpoint_url: "https://metadata.example.com/api/graphql"
        auth_mode: bearer
        credential: ${METADATA_API_TOKEN}
        description: "The metadata service's GraphQL API."
        static_headers:
          x-tenant-id: acme
        connect_timeout: 10s
        call_timeout: 60s
        max_response_bytes: 10485760
        schema_validation: strict
        max_query_depth: 15
        namespace_depth: 3
        read_only: false
    default: metadata
Field Type Default Description
endpoint_url string - Required. The full URL documents are POSTed to. Unlike an HTTP API's base_url this is the whole address: a GraphQL endpoint has exactly one
description string endpoint URL Human-readable description, surfaced by list_connections and the admin UI
auth_mode string none none, bearer, api_key, basic, signed_jwt, oauth, mtls. Same keys, defaults and at-rest encryption as an api connection's — both kinds read them through one shared implementation. signed_jwt mints a short-lived assertion per call from an identifier and a signing key; see Signed JWT upstreams. oauth with oauth_grant: jwt_bearer exchanges such an assertion for an access token; see OAuth JWT bearer grant
static_headers map {} Headers attached to every outbound request, in addition to whatever auth_mode contributes. This is where an upstream's tenant or folder routing goes, and where a User-Agent other than the platform's default mcp-data-platform/<version> is pinned for an endpoint whose firewall refuses it. Operator-owned; a model can neither set nor override them. Encrypted at rest
connect_timeout duration 10s Dial timeout
call_timeout duration 60s Per-call timeout. A caller's timeout_seconds may lower it, never raise it; a higher one is reported as capped (timeout_applied_seconds, timeout_capped_by)
max_concurrent_calls int 0 How many calls the upstream takes at once across every replica; a call over it waits for a slot under its own timeout. 0 is unlimited, at most 32
max_response_bytes int64 10485760 Upstream read cap: the most the platform reads of one response. An answer past it is refused, naming the cap and graphql_export. What reaches a model is the platform's context budget, not a per-connection setting
schema_validation string strict strict refuses a document the stored schema does not admit, naming the connection and when its schema was read. warn sends it and reports the violations beside the answer
max_query_depth int 15 Deepest selection a document may have. A deeply nested document is how one small request makes an endpoint do unbounded work
namespace_depth int 3 How many segments a dotted operation id may have when the schema is walked into operations. A flat schema indexes its root fields whatever this is; a namespaced one (package, entity, verb) needs 3
read_only bool false Refuse every mutation document on this connection, for every persona
jwt_algorithm, jwt_client_secret, jwt_private_key_pem, jwt_key_id, jwt_issuer, jwt_subject, jwt_audience, jwt_token_lifetime, jwt_issued_at_skew - see page The signed_jwt assertion, as on an api connection. jwt_audience defaults to endpoint_url, the lifetime to 300s and the skew to 30s; the secret and the private key are encrypted at rest. Under oauth_grant: jwt_bearer the same keys sign the exchanged assertion, with jwt_audience defaulting to oauth_token_url and the algorithm to RS256. See Signed JWT upstreams
mtls_client_cert_pem, mtls_client_key_pem, tls_ca_bundle_pem string - The connection's TLS material, as on an api connection
identity_passthrough bool false Forward the acting caller's inbound bearer token as the outbound Authorization header instead of this connection's credential

See GraphQL Toolkit for the schema lifecycle, namespace descent, the skeleton documents graphql_discover renders, paging, and how a persona's api_routes rules govern an operation.

Cross-Enrichment Configuration

enrichment:
  trino_semantic_enrichment: true    # Add catalog context to Trino results
  unwrap_json: true               # Auto-unwrap single-row VARCHAR-of-JSON (default: true)
  column_context_filtering: true     # Only include SQL-referenced columns (default: true)
  lineage_inheritance: true          # Columns borrow upstream curation along lineage (default: true)
  estimate_row_counts: false         # Run COUNT(*) for availability enrichment (default: false)
  semantic_fallback: false           # Suggest similar tables on a URN miss (default: false)
  semantic_fallback_top_k: 1         # Suggestions per miss, 1-10 (default: 1)

  # Memory-enrichment payload budget (issue #761): keeps recalled memories a
  # supporting note rather than crowding out the analyzed data.
  memory_limit: 5                    # Max memory records recalled per tool call (default: 5)
  memory_context_budget_bytes: 1500  # Byte budget for rendered summaries; over-budget records become fetchable stubs; 0 disables (default: 1500)
  memory_summary_bytes: 280          # Per-record summary excerpt cap; 0 = full content (default: 280)

  # Session metadata deduplication (avoids repeating metadata for same table)
  session_dedup:
    enabled: true             # Default: true
    mode: reference           # reference (default), summary, none
    entry_ttl: 5m             # Defaults to semantic.cache.ttl
    session_timeout: 30m      # Defaults to server.streamable.session_timeout
Field Type Default Description
trino_semantic_enrichment bool true Enrich Trino results with the platform catalog's curation. Default on; read-only and no-ops without a semantic provider. Set false to disable.
unwrap_json bool true Auto-unwrap single-row VARCHAR-of-JSON results
column_context_filtering bool true Limit column enrichment to SQL-referenced columns
lineage_inheritance bool true A column with no description or glossary terms of its own shows the same-named upstream column's, from the nearest dataset up to two hops up the catalog's lineage that the caller reaches, marked inherited_from (Inherited column curation). Set false to disable
estimate_row_counts bool false Run SELECT COUNT(*) when reporting table availability, so a fetched catalog dataset's query_availability carries an estimated row count. Also what lets the insight review path state a row count beside a pending claim, and what the advisory claim-conflict marker compares against (Knowledge governance). Off by default: COUNT(*) can trigger a full table scan and make search enrichment very slow
semantic_fallback bool false When a URN-equality lookup misses, fall back to similarity search and surface the top hit as a suggested match, annotated match_kind=semantic so the model knows it was inferred rather than resolved. Audit rows record enrichment_match_kind so operators can measure the false-positive rate. The platform catalog's dataset search ranks the suggestions
semantic_fallback_top_k int 1 Suggestions surfaced per miss when semantic_fallback is on. Clamped to 1-10 to keep suggested-match output bounded
memory_limit int 5 Max memory records recalled and rendered into memory_context per tool call
memory_context_budget_bytes int 1500 Byte budget for the rendered memory summaries; records beyond it are listed as compact id+reference stubs in memory_context_omitted (still fetchable, at least one always rendered). 0 disables the budget
memory_summary_bytes int 280 Per-record summary-first excerpt cap; the full record is fetchable via its mcp:memory:<id> reference. 0 renders full content
session_dedup.enabled bool true Whether session dedup is active
session_dedup.mode string reference Repeat query content: reference, summary, none
session_dedup.entry_ttl duration semantic cache TTL How long a table stays "already sent"
session_dedup.session_timeout duration streamable session timeout Idle session cleanup interval

Tuning Configuration

Static operational rules that shape agent behavior via platform_info guidance and tool descriptions.

tuning:
  rules:
    quality_threshold: 0.7
  prompts_dir: "/etc/mcp/prompts"
Field Type Default Description
rules.quality_threshold float 0.7 Minimum quality score below which a warning is surfaced
prompts_dir string - Directory of additional prompt resource files

Search-First Gate Configuration

A hard gate that refuses query tools until the agent calls search in the session. When a query tool (trino_query, trino_execute) is called before any discovery tool, the tool handler does not run; a SEARCH_REQUIRED error result is returned instructing the agent to call search first. Once search has been called, every subsequent query tool call proceeds normally with no further check for as long as the platform session lasts: the search is remembered for the lifetime of the session handle platform_info mints (sessions.handles.ttl, 8 hours by default, extended while the session is in use), or the Streamable HTTP session timeout when that is longer. The gate is enabled by default; set require_search: false to disable gating (and hinting) entirely.

workflow:
  require_search: false             # Default: true (gate on). false disables it entirely.
  # discovery_tools: []             # Tools that satisfy the gate (defaults to search and fetch)
  # query_tools: []                 # Tools that are gated (defaults to trino_query, trino_execute)
Field Type Default Description
require_search bool true Enable the search-first hard gate. false disables gating with no block and no hint.
discovery_tools array search, fetch Tool names that satisfy the gate. search is the front door agents are steered toward, and fetch also counts: reading a dataset with its curation and lineage, or browsing a connection's catalog, discovers what a query needs.
query_tools array trino_query, trino_execute Tool names gated by discovery

Behavior change

require_search replaces the former workflow.require_discovery_before_query and its warn-after-execution behavior. It is a breaking rename (not aliased) and a hard gate: a deployment that never touched workflow gating will begin refusing trino_query/trino_execute until search is called once per session.

Schema Gate Configuration

A hard gate on the SQL tools (trino_query, trino_execute, trino_export) that refuses a statement against a table whose columns the agent has not read. A search hit names a table and describes it but does not list its columns, so an agent that goes from a search hit straight to SQL writes the column names it expects. A wrong guess fails; a guess that happens to name a real column (filtering on id when the record id is p_id) returns rows that look right and are wrong, and nothing reports it.

  • The first statement against an unread table is refused with a SCHEMA_REQUIRED error before it runs. The refusal lists every such table's columns and types, with the column comments and the catalog's column descriptions and glossary terms, so the agent can correct the statement without another call. Delivering the schema counts as reading it: the same statement sent again runs.
  • A table's schema has been read after trino_describe_table of the table, fetch of its mcp:dataset reference, a DESCRIBE or SHOW COLUMNS statement, or a refusal or failure that delivered its columns.
  • A statement that fails because a column or a type is wrong (Trino's COLUMN_NOT_FOUND, TYPE_MISMATCH, AMBIGUOUS_NAME, MISSING_COLUMN_NAME, INVALID_COLUMN_REFERENCE or FUNCTION_NOT_FOUND) comes back with the columns of each table it names. A timeout, an access denial or an outage comes back unchanged. This holds with require_schema: false too.
  • Never refused: a statement naming no table the platform can pick out, information_schema and system tables, a table whose schema cannot be read (Trino's own error for it stands), a managed script's run, a portal or REST shim run, and every tool other than the three SQL tools.
  • Reads are kept per caller under the same scope as the search-first gate, in the same store (shared across replicas when there is a database), for as long as a search is remembered.
workflow:
  require_schema: false             # Default: true (gate on). false turns the refusal off.
Field Type Default Description
require_schema bool true Refuse a SQL statement against a table whose columns the session has not read, returning the table's schema. Independent of require_search.

Session Gate Configuration

A hard gate that refuses every non-exempt tool until the agent calls the session-initialization tool (platform_info by default) once in the session. Before the init tool has run, any other tool call is short-circuited before its handler executes and a SETUP_REQUIRED error result is returned (error category setup_required) telling the agent to call the init tool first, then retry. Once the init tool has been called in a session, subsequent tool calls proceed normally until the session's TTL expires.

Unlike most platform sections, this gate is off by default: enabled is a plain bool, so an absent session_gate block means the gate is disabled (it does not follow the default-on *bool convention used by sections like progress or audit).

session_gate:
  enabled: true                     # Default: false. true activates the gate.
  init_tool: platform_info          # Tool that initializes the session (default: platform_info)
  exempt_tools:                     # Tools that bypass the gate entirely
    - list_connections
Field Type Default Description
enabled bool false Activate the session-initialization gate.
init_tool string platform_info The tool that initializes a session. Calling it records the session as initialized; it is always exempt from the gate.
exempt_tools array (empty) Tool names that bypass the gate and may be called before the init tool.

The gate's memory of an initialized session expires after the session TTL, which is derived from Session Configuration (sessions.ttl, falling back to the Streamable HTTP session timeout) rather than from a field on this block.

Distinct from the search-first gate

The session gate and the search-first gate are independent. The session gate requires an init tool (platform_info) before any tool; the search-first gate requires a discovery tool (search) before query tools. Both can be enabled at once.

Superseded by explicit session handles

When explicit session handles (sessions.handles) are enabled, the session gate is skipped: handle resolution enforces initialization instead, and the gate's exempt_tools are carried into the handle resolver. Enabling both does not double-gate.

Purpose Configuration

The purpose block controls the purpose argument (issue #1317): one sentence, stated by the agent, naming the wider task a data-access call serves. Audit records what a call did and, without this, never why. The platform advertises purpose on the input schema of each gated tool, takes it off the request before the tool sees it, and stores it in the audit row's purpose column.

It is enabled and required by default. The name is purpose, not intent, because search already takes an intent argument that is the query text and keeps that meaning.

purpose:
  enabled: true      # advertise, strip, and record (default on)
  require: true      # refuse a gated call that states none (default on)
  tools:             # override the gated set; empty means the default below
    - search
    - trino_query
    - "s3_*"
    - "kind:mcp"
Field Type Default Description
enabled bool true Advertise purpose on the gated tools, strip it before the handler, and record it on the audit event. Set false to remove the argument entirely.
require bool true Refuse a gated call that states no purpose with PURPOSE_REQUIRED (error category purpose_required). Set false to record a purpose whenever one is stated but never refuse a call for omitting it.
tools array see below The gated set: where the argument is advertised, and where a missing one is refused. Entries are tool-name globs (filepath.Match semantics, e.g. s3_*) plus kind:<toolkit-kind> entries that gate every tool a toolkit of that kind serves.

The default set is the data-access surface plus the two tools that write an asset: search, fetch, trino_query, trino_execute, trino_export, trino_describe_table, api_invoke_endpoint, api_export, s3_object, s3_list, save_asset, manage_asset, and kind:mcp — the last covering every tool an MCP gateway connection proxies, whose names are chosen upstream and change when the upstream does. The rest of the orientation and platform-management surface (platform_info, list_connections, platform_find_tools, memory_*, the other manage_* tools) is deliberately outside it: their purpose is their name, and gating them would tax every call an agent makes to set itself up.

save_asset and manage_asset are in the set because an asset is the one output a person opens weeks later, often someone who was not there when it was made, and the first question they ask is what it was for. manage_asset is named whole, so its reads state a purpose alongside its writes: the gate decides from a tool name, and one name covers both halves. apply_knowledge stays outside on its own reasoning — what an apply_knowledge call applies is itself the explanation, while an asset is the output and not the reason for it.

The gated set is also what the agent instructions name, so a model is told which of the tools in front of it take the argument rather than being given a category to guess at. The two halves of the set are named differently, because they are different in kind: an entry that names tools (including a glob) reaches the model as the tool names it resolves to on this deployment, while a kind: entry reaches it as the kind — "every tool served by a connection of kind mcp". One MCP gateway connection can proxy more tools than the platform's own data-access surface has, under names the platform did not choose and that change when the upstream does, so naming the kind says the same thing in one clause and stays true.

A purpose stated off the gate

Not being gated means a missing purpose never refuses the call and the argument is not advertised there. A purpose that is stated on an ungated tool is taken off the request and recorded on the audit row like any other (issue #1640). A tool's input schema is closed to unknown properties, so leaving the argument in place would fail the call with invalid_arguments for stating something the platform's own instructions asked the model to state, and a volunteered sentence on manage_table is worth no less to the operator reading the row than a required one on trino_query.

The one exception is a tool proxied from an upstream MCP server that the deployment has taken out of the gated set. There the platform does not own the parameter name, so the value is recorded and still delivered to the upstream server.

Who is refused

require: true refuses only a caller that threaded an explicit session_id handle on the same call, which is the platform's proof that the caller can thread a platform-injected argument at all. That single condition covers every exemption the feature needs:

  • An MCP App's sandboxed call is session-adopted from its authenticated identity and threads nothing (see Explicit Session Handles).
  • The gateway REST shim, the admin tool runner, and a managed script drive a fresh in-memory session per request and thread nothing.
  • An isolated dpp_/dpx_ run has its session minted server-side rather than passed in.

None of them can state a purpose, so none is refused for not stating one. A real MCP agent, which the platform already requires to thread a handle, is. Because the condition is the handle, setting sessions.handles.enabled: false also stops purpose from ever being required, though it is still advertised and recorded.

The platform owns the argument name on every tool it defines

On a tool the platform defines, purpose is the platform's argument: it is taken off the request before the handler runs, gated or not. A tool proxied from an upstream MCP server is the one place the platform did not choose the parameter names, so a deployment whose upstream server defines a purpose parameter of its own drops kind:mcp from purpose.tools (or lists the tools it wants gated by name); those tools then keep their own argument, and the platform records the value without consuming it.

Tool-Call Rate Limiting

A per-identity safety net on authenticated tools/call requests. It bounds a runaway agent loop or a compromised account before it can saturate the audit pipeline, the shared database pool, or an upstream (Trino, S3, a proxied MCP server). It is not a throughput throttle: the default limit is generous enough that ordinary interactive and agent use never touches it. When a user exceeds the limit, the offending call is short-circuited before its handler runs and a RATE_LIMITED error result is returned (error code and category rate_limited) with a retry hint, so an agent backs off and retries rather than seeing a transport failure. The structured envelope carries the interval as retry_after_seconds beside code, category, message and hint, so a consumer reads the wait as a number rather than out of the message. platform_info is always exempt so a throttled agent can re-read platform guidance.

A platform run of a managed script is queued, not refused. Its calls cross the limiter as the script principal (AuthType script), and when the bucket is empty the limiter holds the call until the sustained rate refills a token and then admits it. A run's calls are serial, so the wait for one call after the burst is spent is one refill interval (250 ms at the defaults), and the wait ends early with the run if the run is canceled or reaches its deadline. A queued call is not a refusal: it does not count in mcp_rate_limited_total and logs no warning; it counts in mcp_rate_limit_queued_total and logs at Info under the run's session id, so an operator can see that scripts are running against the limit. A draft run carries its author's own identity and is refused as any interactive call is; there the script host waits retry_after_seconds, bounded by the run's deadline, and issues the call again, so the script sees only the admitted call's result and the wait is recorded in the run's log.

The limit is keyed on the authenticated user, not the client IP. A multi-user connector delivers every user's traffic from one egress address, so per-IP limiting would be both useless (one bucket for everyone) and harmful (one busy user starves the rest); identity keying matches the abuse shape the limiter exists to catch (a single runaway authenticated principal), regardless of source address. Callers with a shared/anonymous identity (auth disabled) fall back to a per-session key; a call with no attributable identity is not limited (fail-open, since the call has already passed auth).

Enabled by default; set rate_limit.enabled: false to remove the limiter from the chain entirely.

rate_limit:
  enabled: true                     # Default: true. false removes the limiter.
  requests_per_minute: 240          # Default: 240. Sustained per-user tools/call rate.
  burst: 60                         # Default: 60. Largest instantaneous per-user burst.
  exempt_tools:                     # Tools never limited (platform_info is always exempt)
    - search
Field Type Default Description
enabled bool true Enable the per-user tool-call limiter. false removes the middleware from the chain.
requests_per_minute int 240 Sustained per-user tools/call rate (token refill). 240 is 4 calls/second per user.
burst int 60 Token-bucket depth: the largest burst a single user may issue before the sustained rate governs.
exempt_tools array (empty) Tool names never rate limited, in addition to platform_info (which is always exempt).

Each refusal increments the mcp_rate_limited_total metric and logs a warning naming the throttled identity and tool. Each call a script principal was held for increments mcp_rate_limit_queued_total and logs at Info naming the tool, the principal, the run and the time waited.

Per-replica limit

The token bucket is in-memory per replica: behind a load balancer the effective ceiling is (replica count x the configured limit). This is intentional for a backstop: distributed coordination (Redis/DB round-trips on the hot tool path) is not warranted to bound abuse that per-replica limiting already bounds. Size the limit with the per-replica semantics in mind; see Tuning and Scaling.

Distinct from the OAuth endpoint limiter

This top-level rate_limit: block governs authenticated MCP tools/call requests and is keyed on identity. It is unrelated to the oauth.rate_limit block, which is a per-IP limiter on the unauthenticated /token and /register OAuth endpoints.

Semantic and Query Provider Configuration

With a database, the platform catalog is the semantic provider; no semantic: block is needed. The query provider names the Trino connection that answers whether a catalog dataset is queryable:

semantic:
  provider: noop              # Optional: turn the semantic layer off
  cache:
    ttl: 5m                   # The default for enrichment.session_dedup.entry_ttl

query:
  provider: trino             # Provider type: trino or noop
  instance: primary           # Which Trino instance to use
Field Type Default Description
semantic.provider string - Empty (the default) makes the platform catalog the semantic provider wherever there is a database; noop turns the semantic layer off. datahub is read as the platform catalog for this release, while the DataHub toolkits.datahub names is imported (Moving from DataHub); any other value is refused at startup.
semantic.cache.enabled bool false No effect: the platform catalog is read from the platform's own database on every lookup.
semantic.cache.ttl duration 5m The default for enrichment.session_dedup.entry_ttl
query.provider string - Provider type: trino or noop
query.instance string - Toolkit instance name

Persona Configuration

Personas define tool access based on user roles. The security model follows a default-deny approach.

Persona names are keyed directly under personas: (the config's Definitions field is an inline map, so there is no definitions: wrapper key).

personas:
  analyst:
    display_name: "Data Analyst"
    roles: ["analyst", "data_engineer"]
    tools:
      allow: ["*"]
      deny: ["*_delete_*", "*_drop_*"]
    connections:
      allow: ["*"]
  admin:
    display_name: "Administrator"
    roles: ["admin"]
    tools:
      allow: ["*"]
    connections:
      allow: ["*"]
Field Type Default Description
<name> map - Named persona configuration, keyed directly under personas:
<name>.display_name string - Human-readable name
<name>.roles array - Roles that map to this persona
<name>.tools.allow array [] Allowed tool patterns
<name>.tools.deny array [] Denied tool patterns
<name>.context.description_prefix string - Prepended to platform description
<name>.context.description_override string - Replaces platform description entirely
<name>.context.agent_instructions_suffix string - Appended to the admin agent_instructions layer
<name>.context.agent_instructions_override string - Replaces the admin agent_instructions layer only; the platform baseline is always present
<name>.portal object resolved What the persona's people may do in the portal, one setting per area: assets, resources, knowledge, catalog, apis, scratch_tables (none, list, register, #2257), external_files (none, view, manage, #2290), and the switches feedback, prompts, automations, search, memory, download (PDF export) and history (earlier versions, #2290). An unset area resolves to what the persona reached before the setting existed. Administrators have every area. See Portal permissions (#2225)
<name>.portal.scratch_tables string register The scratch-table routes and page: none, list (list the tables on the persona's connections), register (list, register and unregister). External sets it to none (#2257)
<name>.portal.uploads bool false An external persona only: its people may upload files to their own folder, which External files reaches. Set true on an internal persona it is refused (#2258, #2290)
<name>.portal.history bool false Earlier versions: the persona's people read the earlier versions of an asset shared with them as viewer. Off, a viewer reads the current version only and the version routes answer 403; the owner, editors and administrators always read them (#2290)
<name>.portal.external_files string none External files: view lists and opens every external person's own folder, manage also renames, versions, moves, deletes and shares their files. An external persona has none; administrators have manage (#2290)
<name>.external bool false A persona for people outside the organization, who sign in through the identity provider under a role that resolves to it: no MCP tool, only what is shared with its people and their own folder, no Global or persona folder, scratch tables or other portal area. The administrator persona is never external, and a file persona that marks it so is refused at startup (#2257, #2290)
<name>.service_account bool false The persona an automated caller signs in under. Never set together with external (#1980, #2257)
<name>.hide_automations bool false Replaced by portal.automations; still read as portal.automations: false when the block does not set automations (#2109, #2225)
<name>.allow_sharing bool true false withholds sharing from this persona: no Share action in the portal, and every share-creating route and tool action refuses. Administrators can always share (#2241)

No persona means no access

A caller whose roles match no persona has no access at all: tool calls resolve to the built-in deny-all persona and are refused, the portal answers 403 with a branded page, and the managed-resources API refuses the request. There is no fallback persona, so every user who should reach anything needs a role one of your personas lists.

personas.default_persona was removed. It assigned its persona to every caller whose roles matched nothing, including accounts carrying no claims. A config that still sets it is refused at startup with an error naming the key.

Some tools must be granted together

search returns references and fetch is the only tool that dereferences one; memory_capture and apply_knowledge both write into a body of knowledge that search is the only way back into. Granting one half of a pair without the other leaves the persona able to start something it can never finish, so the server logs a warning naming the persona, the missing tool, and the fix — at startup and on every persona write. Prefer allow: ["*"] with a targeted deny, as analyst does above: an enumerated allow-list silently loses each tool a later upgrade adds. See Personas: some tools are a unit.

Knowledge Capture Configuration

Knowledge capture records domain knowledge shared during AI sessions and provides a workflow for applying approved insights to the platform catalog. See Knowledge Capture for the full feature documentation.

knowledge:
  enabled: true
  apply:
    enabled: true
    require_confirmation: true
  reflexive_capture:
    enabled: true
  pages:
    dedup_threshold: 0.85
    dedup_disabled: false
    oversize_bytes: 16384
    oversize_sections: 12
  catalog_index:
    enabled: true
    sync_interval: 30m
    max_entries: 5000
  verifiable_insights: true
  index_reconcile_interval: 5m
  search_provider_timeout: 5s
  search_embed_timeout: 5s
Field Type Default Description
enabled bool true (when a database is available) Enable the knowledge review and write-back toolkit (apply_knowledge). Knowledge capture lives in the memory toolkit (memory_capture) and is enabled with the memory layer, not this flag
apply.enabled bool true (when a database is available) Enable the apply_knowledge tool for admin review and catalog write-back. Set false to disable. Still gated behind database availability.
apply.require_confirmation bool false Require explicit confirm: true on apply actions
pages.dedup_threshold float 0.85 Cosine similarity, in [0,1], at or above which creating a knowledge page is blocked as a near-duplicate of an existing one. The gate acts only when a real embedding provider is configured, since cosine similarity is undefined without one. A non-positive value selects the default; disable the gate with dedup_disabled rather than by zeroing this
pages.dedup_disabled bool false Turn the duplicate gate off entirely. Explicit, so "no gate" is never confused with "left at default"
pages.oversize_bytes int 16384 Body size, in bytes, at or above which a page write returns a non-blocking suggestion to split it. A negative value disables this arm. This is an editorial nudge toward focused, cross-linked pages, not a bound on what search can reach: a page's content is embedded as chunks sized to the provider's input budget, so a page of any size is semantically searchable end to end
pages.oversize_sections int 12 Markdown heading count at or above which the same split suggestion fires. A negative value disables this arm
reflexive_capture.enabled bool true Auto-capture a "misconception + fix" correction when a Trino query errors and a later related same-session query on the same connection succeeds (#635). Source automation, reviewed sink-class (enters review, never live), gated by the persona's memory_capture grant. Default-on when the memory subsystem is available; set false to disable
catalog_index.enabled bool true Index the platform catalog's dataset descriptions into the platform's own semantic search, so a fact applied to a description is reachable from a topical query that names no entity. Requires a database and an embedding provider; without either it is inert. Set false to opt out
catalog_index.sync_interval duration 30m The longest the index goes without re-reading the catalog. A catalog that changed since the last read is re-read on the index queue's next check (index_reconcile_interval) whatever this says
index_reconcile_interval duration 5m How often the index queue checks every index for work it owes: a catalog changed since its last read, a vector from an old embedding model. Lowering it makes a catalog change searchable sooner, at the cost of checking every index that often
catalog_index.max_entries int 5000 Cap on how many datasets are mirrored. The cap bounds both the table and one sweep's working set. When it binds, the index keeps every Trino table first, then GraphQL operations, then API operations, each in reference order, and logs the truncation; the admin Indexing page holds the index to the catalog's own count and reads degraded while datasets are left out. A lowered cap applies on the next start: a mirror holding more datasets than the cap is swept down to it then, rather than when the sync interval next runs out (#2429). The built-in platform-admin connection's operations are never indexed (they are the platform's own admin API, found through api_discover); they stay in the catalog and are fetched as any other dataset
verifiable_insights bool true Mark a delivered insight as checkable: when the catalog entity its claim is about resolves to a queryable table, the delivered record carries a verifiable block naming that table and connection, on every delivery surface (search insight hits, fetch of mcp:insight:<id>, and the memory_context enrichment block). Additive and absent whenever nothing resolves, so a deployment with no query provider is unaffected; it honors the persona connection boundary, and resolves without running the COUNT(*) that enrichment.estimate_row_counts enables. Set false to deliver insights with no marker
search_provider_timeout duration 5s Per-provider deadline for the search fan-out arms. Each knowledge source (catalog, memory, insights, endpoints, …) is bounded by this, so one slow source drops out as a collected error while the rest still return, instead of stalling the whole search. Set a negative duration to disable the bound (a search then waits for its slowest provider).
search_embed_timeout duration 5s Deadline for the serial intent-embedding step in search, independent of search_provider_timeout. A slow or unreachable embedder degrades to lexical ranking rather than stalling the search; because that silently loses semantic relevance, this knob lets you give a slow (cold or CPU-only) embedder more headroom to preserve hybrid ranking without loosening the fan-out bound. Set a negative duration to disable the bound.

Prerequisites

Knowledge capture requires database.dsn to be configured. The apply_knowledge tool requires the admin persona.

Memory Layer Configuration

The memory layer provides persistent memory for agent and analyst sessions with vector search, cross-enrichment, and staleness detection. See Memory Layer for the full feature documentation.

memory:
  enabled: true
  embedding:
    provider: ollama
    ollama:
      url: "http://localhost:11434"
      model: "nomic-embed-text"
      timeout: 30s
      max_input_bytes: 6000
  staleness:
    enabled: true
    interval: 15m
    batch_size: 50
  archived_retention_days: 90
Field Type Default Description
enabled bool true (when database available) Enable memory layer. Set false to explicitly disable.
embedding.provider string noop Embedding provider: ollama or noop
embedding.ollama.url string http://localhost:11434 Ollama API base URL
embedding.ollama.model string nomic-embed-text Ollama embedding model (768-dim)
embedding.ollama.timeout duration 30s Embedding API timeout
embedding.ollama.max_input_bytes int 6000 Per-text input cap (bytes) applied before embedding. The platform truncates input itself on a UTF-8 boundary because Ollama's truncate flag is unreliable for content over the model's context. The default sits below nomic-embed-text's ~2048-token boundary; raise it only for a larger-context model. Only the embedded text is trimmed; stored content is unaffected. Knowledge pages are not trimmed at all: this value sizes the chunks a page's content is embedded as, so raising it for a larger-context model widens those chunks. If the model refuses an input anyway -- token density varies by close to an order of magnitude between prose and dense content, so no fixed byte count is an exact token budget -- the provider halves what it sends and retries, down to a 256-byte floor, so a dense document converges on a bound the model accepts instead of failing identically on every attempt. The vector then covers a prefix of the text rather than all of it, and the shrink is logged with the model and the size refused; the lexical arm still matches the whole text, so the content stays findable while the operator lowers the cap.
staleness.enabled bool false Enable background staleness watcher
staleness.interval duration 15m Staleness check interval
staleness.batch_size int 50 Records per check cycle
archived_retention_days int 90 How long a deleted (archived) memory record is kept before it is removed. Recall never reads an archived record. 0 uses the default; a negative value keeps archived records forever. See Data Retention

Prerequisites

Memory requires database.dsn to be configured and the pgvector PostgreSQL extension installed. Memory tools are opt-in per persona (memory_* in tools.allow).

Ollama batch endpoint

Batch embedding calls (API gateway spec indexing) issue a single POST /api/embed request per batch against modern Ollama servers. Servers that lack the batch endpoint (response: HTTP 404) are detected on the first call; the platform logs a WARN and transparently falls back to one POST /api/embeddings request per text. Upgrading the Ollama server is recommended for substantially faster batch indexing on multi-spec catalogs. Memory writes embed one record at a time and always use /api/embeddings.

API Gateway Configuration

Cluster-wide tuning for the API gateway toolkit's background work. Connection-level configuration (base_url, auth_mode, credentials) lives in the connection store; this section is for knobs that apply to every API connection.

apigateway:
  embed_jobs:
    workers: 1
    embed_timeout: 5m
    lease_duration: 10m
    batch_size: 32
    retention_days: 14
    failed_retention_days: 90
Field Type Default Description
embed_jobs.workers int 1 Number of embedding-worker goroutines per pod. Each worker embed yields to interactive ones (#1988). Before each call it waits, up to 30 seconds, while a search, discovery ranking or other request-path embed is in flight on any replica, so a backlog of index jobs does not put a person's search behind it. A replica marks its interactive embeds in the embed_interactive table (migration 000173), and every replica's worker reads it before sending the embedding server background work. A background call already at the embedding server is not stopped. Each goroutine independently claims and processes jobs; the lease + SKIP LOCKED predicate in the queue's claim path prevents two goroutines (in the same pod or across pods) from picking the same job. Increase to 2-4 for deployments with many specs and a fast embedder; CPU-only embedders typically saturate at 1 because the bottleneck is the embedding model.
embed_jobs.embed_timeout duration 5m HTTP timeout the worker applies to its batched /api/embed POSTs against Ollama. Scoped to the worker only so the shared 30s memory.embedding.ollama.timeout continues to govern request-path callers (memory recall, memory_capture, etc.); a wedged Ollama therefore fails MCP tool calls in 30s while the worker tolerates the longer batched-inference floor. Lower this on GPU embedders to tighten the failure floor.
embed_jobs.lease_duration duration 10m Time a claim stamps on a job; the worker heartbeat re-stamps it at lease_duration / 3 cadence so a long embed pass is not reaped mid-flight. Must be greater than embed_timeout. Caps "pod went silent", not "embed batch is slow".
embed_jobs.batch_size int 32 Texts per upstream EmbedBatch call. Sets the starting chunk size only: when a chunk exceeds embed_timeout, the worker automatically halves it and retries the sub-chunks down to a floor of one text, so a batch too large for a slow (e.g. CPU-only) embedder converges to a size that completes and persists partial progress instead of failing the whole unit at a fixed size. Non-timeout provider errors (5xx, malformed response) still fail fast without subdividing. Lower this to skip the initial shrink cycles on a known-slow embedder; raise it on GPU embedders where per-call overhead dominates.
embed_jobs.retention_days int 14 Age past which finished index_jobs history is purged by the background retainer: succeeded rows and failed rows that were resolved (superseded by a later success or operator-dismissed). The reconciler records one row per unit per sweep, so this keeps the table bounded while preserving a recent window for the admin Indexing dashboard's throughput, latency, and job-log views. Open failures (failed with no resolved_at) and in-flight jobs (pending / running) are never purged regardless of age. 0 uses the default (14); a negative value disables retention (history grows unbounded, for externally-managed cleanup).
embed_jobs.failed_retention_days int 90 Age past which a failed job nobody resolved is purged. retention_days leaves those for the failure-triage surface; past this age one has been superseded by a newer job for its unit, or belongs to a source that no longer exists. 0 uses the default; a negative value keeps them until they are resolved.
catalog_fetch.allow_private_cidrs list [] Private address prefixes a spec fetched for an API catalog may reach (#2483): adding a spec by URL, refresh and the scheduled check. A host whose every address is inside one may also be fetched over http. Read from the configuration file only; an entry that does not parse is logged and the list ignored.

MCP Apps Configuration

MCP Apps provide interactive UI components that enhance tool results. The platform provides the infrastructure; you provide the HTML/JS/CSS apps.

mcpapps:
  enabled: true
  apps:
    query_results:
      enabled: true
      assets_path: "/etc/mcp-apps/query-results"
      tools:
        - trino_query
        - trino_execute
      csp:
        resource_domains:
          - "https://cdn.jsdelivr.net"
Field Type Default Description
enabled bool false Enable MCP Apps infrastructure
apps map - Named app configurations
apps.<name>.enabled bool true Enable this app
apps.<name>.assets_path string required Absolute path to app directory
apps.<name>.tools array required Tools this app enhances
apps.<name>.csp.resource_domains array - Allowed CDN origins

See MCP Apps Configuration for complete options.

Resource Templates Configuration

Resource templates expose platform data as browseable, parameterized MCP resources using RFC 6570 URI templates.

resources:
  enabled: true
Field Type Default Description
enabled bool true Serve the resource templates below. Read-only, so it is on by default; set false to disable

When enabled, the platform registers these resource templates:

  • schema://{catalog}.{schema}/{table} — Table schema with column types and descriptions
  • glossary://{term} — Glossary term definitions
  • availability://{catalog}.{schema}/{table} — Query availability and row counts

Clients that support resource browsing (e.g., Claude Desktop) will show these as navigable resources alongside tools.

Managed Resources

Managed resources are the human-uploaded files people attach through the portal (reference material, specifications, images), stored as rows in PostgreSQL with their bytes in S3 and served back over MCP and the REST API. They are a separate subsystem from the read-only templates above, configured under resources.managed. See Content Model for how they relate to knowledge pages and assets.

resources:
  managed:
    enabled: true             # auto-enabled when a database is available
    uri_scheme: "mcp"         # URI prefix for resource URIs
    s3_connection: "primary"  # name of the S3 toolkit instance holding the blobs
    s3_bucket: "managed-resources"
    max_versions: 10          # content revisions kept per resource
    max_upload_bytes: 1073741824  # largest file accepted (default 1 GiB)
    table_registration_max_bytes: 104857600  # largest CSV/JSON-lines file a registration reads (see below)
    extract:                      # manage_resource extract limits (#1879)
      max_member_bytes: 2147483648  # largest member, uncompressed (default 2 GiB)
      max_total_bytes: 4294967296   # selected members added up (default 4 GiB)
      max_members: 100000           # entries an archive may hold (default 100000)
      max_ratio: 500                # uncompressed / compressed, per member (default 500)
Field Type Default Description
enabled bool auto Enable managed resources. Unset means enabled whenever a database is configured; set false to disable the subsystem outright
uri_scheme string mcp Scheme of the URIs minted for managed resources (<scheme>://global/<path>/<filename>, where <path> is the folder path the resource is filed under, and the persona/user equivalents). Changing it after resources exist changes the URIs the platform serves for them
s3_connection string the S3 kind's default: Name of the S3 toolkit instance used for blob storage
s3_bucket string managed-resources Bucket the uploaded bytes are written to
max_versions int 10 Content revisions a resource keeps, counting the current one. A revision past the cap prunes the oldest stored file; live content is never pruned. A non-positive value selects the default, and anything below 2 is raised to 2, since a cap of 1 would keep no history at all
max_upload_bytes int 1073741824 (1 GiB) Largest file POST /api/v1/resources and POST /api/v1/resources/{id}/content accept. Absent, zero, or negative selects the default, so a deployment that sets nothing stores a video without configuration (#2069). The refusal message and the portal's file chooser both state this deployment's number — the browser reads it from GET /api/v1/portal/me rather than holding a copy. It bounds bytes streamed, not bytes held: see below
table_registration_max_bytes int this deployment's max_upload_bytes when that is set, else 104857600 (100 MB) Largest CSV or JSON-lines file a table registration reads, which it reads whole to learn its columns (#2069). Its size is read first, so a larger file is refused naming the number before it is read. A Parquet file is read by its footer and registers at any size. Absent, zero or negative selects the default, which keeps #1634 for a deployment that raised its upload ceiling

| extract.max_member_bytes | int | 2147483648 (2 GiB) | Largest a single archive member may be uncompressed for manage_resource extract | | extract.max_total_bytes | int | 4294967296 (4 GiB) | Most the selected members of one extraction may add up to, uncompressed | | extract.max_members | int | 100000 | Entries an archive may hold, directories included. A zip's directory is read whole to open it, so this also bounds the memory opening one takes (about 20 MB at the default, which admits a site's media folder of tens of thousands of images in one archive, #2593) | | extract.max_ratio | int | 500 | Furthest a member may expand past its compressed size. Checked once a member passes 1 MiB, so a small file of repeated bytes is not refused |

Managed resources require a database. With none configured the block has no effect, and the platform runs the read-only templates alone.

Search indexing

Every managed resource is indexed for search under one of three policies (#2111). The portal's upload form, file details and webhook source editor show them under a field named Search:

Policy Portal label What search reads
full Contents The name, description, folder, filename, tags and a prefix of the extracted contents
metadata Details only The name, description, folder, filename and tags. No content is read or kept
none Not indexed Nothing: no index job, no vector, no kept text. The file is still listed in its folder and readable by its URI, and its folder is found by search as one entry

The writer decides first: manage_resource (index), the upload form and the REST API (index form field or JSON key), platform.export(..., index=...), a managed-resource destination's index, and a webhook source's config.index. A writer that says nothing gets the default resources.index gives the file:

resources:
  index:
    default: full              # the policy no rule decides (default full)
    rules:
      - path: "pipelines/**"   # a bulk backfill: found through its folder
        index: none
      - path: "brand/**"
        mime: ["image/*"]
        index: metadata
      - origin: [script, webhook]
        size_over: 5MiB
        max: metadata          # a ceiling: a writer may ask for less, never more
Field Type Default Description
index.default string full The policy a resource no rule decides gets: full, metadata or none
index.rules list [] Rules matched in order. Every matcher a rule sets must hold. The first matching rule that sets index decides the default; the first matching rule that sets max caps what a writer may ask for
index.rules.path string - A slash glob over the folder path, where ** matches any number of folders: pipelines/** is everything filed under pipelines
index.rules.mime list - Media types, each exact (text/csv) or a family (image/*)
index.rules.size_over string - Matches a file larger than this: 1MiB, 500KB, or a number of bytes
index.rules.scope string - The library: global, persona or user
index.rules.origin list - Where the write came from: upload (the portal or REST API), agent (a session's manage_resource), script (any write while a managed script runs), export (an export tool's output), webhook (a webhook source's compacted window). Being written by a script decides nothing on its own; it is a matcher a rule may use
index.rules.index string - The policy a writer that says nothing gets. A rule may set only max
index.rules.max string - The most indexing a writer may ask for where the rule matches. A request above it is lowered to it

After the operator's rules come the built-in ones: a CSV, TSV or JSON-lines file is indexed on its details alone, since its contents are a table of values. An operator rule that matches first decides instead. A value the section does not know is refused at startup.

A file's policy is fixed when it is written. Changing the rules later changes nothing until an administrator reclassifies: Admin > Indexing > Resource search > Reclassify (POST /api/v1/admin/resources/index/reclassify) applies the rules again to every resource no writer chose a policy for, in batches of 500, and a resource whose policy drops to metadata or none loses the text it kept for search. A policy a writer chose is never changed by it. The same section reports how many files are under each policy (GET /api/v1/admin/resources/index).

A folder holding files that are not indexed one by one is found by search as one entry, matched on its path, the number and types of the files it holds, a few of their names, and a description of what it holds, written with manage_resource (action: update with path and description), the file manager's Describe... action, or PUT /api/v1/resources/folders/description.

Archive extraction limits

manage_resource extract writes the members of a stored zip, gzip or gzipped tar out as managed resources, and resources.managed.extract bounds it. Each field takes its default when absent, zero or negative. The limits are apart from max_upload_bytes on purpose: an archive is delivered by an upstream rather than picked at an upload form, and the file inside a monthly delivery is routinely several times the upload ceiling. A member extracted here is not held to max_upload_bytes.

Extraction streams in both directions: the archive is read from storage by range in 8 MiB blocks, and each member goes to storage through the multipart uploader. What one extraction holds is two blocks, the decompressor's window and the uploader's part buffers, whatever the member's size, so these limits bound what an archive may expand to on disk rather than what it costs in memory.

An archive past a limit is refused with the key that stopped it, for example member "feed.csv" is larger than 2 GiB uncompressed (max_member_bytes); raise resources.managed.extract.max_member_bytes to allow it. A zip declares every member's size and compression in its directory, and a tar is read through once before its members are written, so for both a refusal comes before anything is written. A gzip holds one member whose size is only known once it has been read; its limits are enforced as it streams, and a gzip that passes one leaves nothing stored, because its one write is abandoned.

What raising max_upload_bytes costs

The upload path streams. A request walks the multipart form part by part and hands the file part to the multipart uploader as a reader, so the object is never assembled — not in memory, and not in a temporary file. What a request holds is the uploader's part buffers and a few kilobytes for content detection, whatever the ceiling is, so raising it raises what the deployment will store rather than what it has to fit in memory.

Two consequences worth stating, because both were true the other way before. The upload path needs no writable temporary directory, which is what the published image (built FROM scratch) has: nothing is spooled. And the storage backend is written through multipart, so a backend that bounds a single PutObject — MinIO refuses one above 16 MiB under the aws-chunked encoding the AWS SDK uses over HTTPS — takes a file of any size the ceiling allows.

The read path streams too (#2395): every binary content route reads the object by range, a block at a time, with or without a Range header, so serving a large managed resource holds one block, not the file.

Two things a raised ceiling does not change. Content indexing still stops at MaxContentReadBytes (8 MiB), so a file above that is indexed on its metadata alone whatever the ceiling is. And an ingress or proxy in front of the platform enforces its own body limit: raise that too, or a request the platform would accept never reaches it.

What a registration reads

A table registration over a CSV or JSON-lines file reads the object it is pointed at — the header row, and whether a line-based reader can read the file at all, which only the rest of the bytes settle — and that read holds the object. It reads by table_registration_max_bytes: unset, the deployment's own max_upload_bytes when that is set (#1634), else 100 MB, which stayed where it was when the upload default rose to 1 GiB (#2069). The file's size is read before the file, so one over the number is refused naming it without being read. A Parquet file is read by its footer and registers at any size, which is the answer to a large table.

The file part goes last

Both write routes read the multipart form in order and stop at the file part, because that part is handed to the uploader where it is found. Every other field — scope, scope_id, path, display_name, description, tags — has to arrive before it. A form that puts a part behind the file is refused with the file part must be the last part of the form, and nothing is stored: the refusal happens while the file is being read, so the uploader aborts the object it had begun. The portal sends this order; a script posting to these routes has to append its file last.

Argument Autocompletion

The platform answers the MCP completion/complete request so clients that support autocompletion (the MCP Inspector, IDE clients) can suggest valid values as a user types a prompt argument or a resource-template variable. There is nothing to configure: the capability is advertised automatically whenever prompts or resource templates are available, and it uses the catalog the platform already knows.

Completions are served for:

  • Prompt arguments, routed by argument name across built-in and database prompts:
  • dataset: dataset names from the semantic search index (e.g. the trace-data-lineage prompt).
  • topic: domains, data products, and glossary terms (e.g. explore-available-data, create-a-report, create-interactive-dashboard).
  • connection: configured connection names the caller's persona may reach.
  • Resource-template variables:
  • schema://{catalog}.{schema_name}/{table} and availability://...: catalog, schema, and table names from the query engine (schema_name completes once a catalog is chosen; table once both are).
  • glossary://{term}: business glossary terms.

Completions are persona-filtered exactly like tools/list and search: a caller only receives values it could already discover through the corresponding tool (dataset/topic/glossary require search; catalog/schema/table require trino_browse; connection names require list_connections and are further filtered by the persona's connection rules). Unauthenticated sessions receive no completions, and each lookup runs under a short latency budget so an unavailable upstream degrades to an empty list rather than an error.

A response carries at most 100 values, and the two optional fields beside them are reported only when they are provable. hasMore is set when the catalog counted more matches than the response holds — read from the catalog's own match count, not inferred from how many rows a page happened to return, since a catalog is free to return fewer rows than were asked for. total is set only when the returned set is the complete one; when the catalog cannot report a count, both fields are omitted rather than asserting a completeness the platform cannot verify.

Custom Resources Configuration

Custom resources let you expose arbitrary static content as named MCP resources — brand assets, operational limits, environment docs, or any structured blob that agents can read by URI. They are registered whenever resources.custom is non-empty, independent of resources.enabled.

resources:
  custom:
    - uri: "brand://theme"
      name: "Brand Theme"
      description: "Primary brand colors and site URL"
      mime_type: "application/json"
      content: |
        {
          "colors": {"primary": "#FF6B35", "secondary": "#004E89"},
          "url": "https://example.com"
        }

    - uri: "brand://logo"
      name: "Brand Logo SVG"
      mime_type: "image/svg+xml"
      content_file: "/etc/platform/logo.svg"
Field Type Required Description
uri string Yes Unique resource URI (e.g., brand://theme, docs://limits)
name string Yes Human-readable name shown in resources/list
description string No Optional description for MCP clients
mime_type string Yes MIME type (e.g., application/json, image/svg+xml, text/plain)
content string One of Inline content (text, JSON, SVG, etc.)
content_file string One of Absolute path to a file; read on every request (supports hot-reload)

content and content_file are mutually exclusive. Invalid entries (missing required fields, both or neither content fields set) are skipped with a warning at startup; valid entries in the same list are still registered.

Thumbnails

The platform draws a thumbnail of every portal asset, managed resource and collection itself, in a headless Chrome that runs beside it (#1787). A new or rewritten file gets its tile within seconds, whether or not anybody opens it. The renderer is a second container in the platform's pod; see Thumbnail renderer for the manifest and for how the platform keeps a document from reaching the network through it. Enabled by default; nothing here needs to be set when the renderer runs beside the platform.

thumbnails:
  enabled: false                        # only needed to opt out; defaults to true
  renderer_url: "http://127.0.0.1:9222" # the renderer's DevTools address; this is the default
  concurrency: 1                        # documents one replica draws at once
  render_timeout: 45s                   # one variant of one document
  batch: 1                              # rows of each kind one pass claims; defaults to concurrency
  lease: 4m10s                          # default worked out from batch, concurrency and render_timeout
  poll: 5s                              # first idle wait; doubles to 1m while idle, woken by new work
  max_attempts: 5                       # tries before a document that never finishes is recorded
  retry_backoff: 1m                     # hold after the first unfinished try; x4 each try, at most 1h
  max_source_bytes: 1048576             # largest document a tile is drawn from (1 MB)
  large_source_bytes: 33554432          # the bound for PDF, CSV and TSV (32 MB)
  allow_private_cidrs: []               # private prefixes a document may load files from (#2122)
Field Type Default Description
enabled *bool true (nil = enabled) Draw thumbnails. Off, stored tiles keep serving and nothing new is drawn.
renderer_url string http://127.0.0.1:9222 The renderer's DevTools address, http:// or ws://. The platform dials it; the renderer is never pointed at the platform.
concurrency int 1 How many documents one replica draws at once. The renderer is one browser beside the replica, so raise it only with the renderer's CPU.
render_timeout duration 45s How long one variant of one document may take. A document that has not reported itself drawn by then is recorded as not drawable.
batch int concurrency How many assets, resources and collections one pass claims of each kind. A claimed row is held from every other replica while it waits for the rows ahead of it, so the default claims only what is drawn next.
lease duration worked out How long a claimed row is held. The default outlasts drawing the batch (per round of concurrency rows: the file's read, then for each of two variants render_timeout, the page's teardown and the tile's write) plus a minute; 4m10s with every other default. A lease shorter than the batch can take is refused at startup.
poll duration 5s How long an idle worker first waits before asking for work again. The wait doubles while nothing is found, up to a minute (or poll when longer), and a write that makes a document owed wakes every replica at once over PostgreSQL LISTEN, so a new upload is drawn without waiting for the poll.
max_attempts int 5 How many times a document is tried before one whose attempt never finishes is recorded as not drawable.
retry_backoff duration 1m How long a document is held back after its first unfinished attempt; four times longer after each one after, at most an hour.
max_source_bytes int 1048576 (1 MB) The largest document a tile is drawn from, for every family but the three below. The renderer loads the whole document to draw it.
large_source_bytes int 33554432 (32 MB) The bound for the families whose tile is drawn from part of the file: page one of a PDF, the header and first rows of a CSV or TSV.
allow_private_cidrs list of CIDR empty Private address prefixes a document drawn by the renderer (a tile, or a PDF export) may load files from, such as an internal CDN a report takes its stylesheet from (#2122). Every other private, loopback, link-local and cloud-metadata address stays refused; a prefix that does not parse stops the platform at start.

A negative value for any of these is refused at startup.

A file larger than its bound is never offered to the renderer and keeps its content-type icon. Every asset and resource read says so (#2072): the portal API, manage_asset get/list and manage_resource report thumbnail_skipped: "over_source_limit" and thumbnail_source_limit (the bound, in bytes), and the file's Thumbnail panel says the file is too large for a thumbnail, with both sizes. Without them a skipped file reads the same as one not drawn yet. Raising max_source_bytes is the fix for a scripted dashboard that carries its data inline; the renderer holds the whole document while it draws, so give it memory to match.

Tiles are stored at 800×600. HTML, JSX, markdown, CSV, JSON and the text families get a light and a dark tile; SVG and raster images get one. A release that changes how tiles are drawn raises the renderer generation, and on upgrade the worker redraws every stored tile once in the background, newest first; each old tile keeps serving until its replacement lands.

With no renderer answering, the platform starts and serves normally: files keep their content-type icons, and the platform logs once that no renderer answers and once when one does. A binary built without the portal UI embeds no tile page and draws nothing. A document the renderer cannot draw -- one that does not settle within render_timeout, an image no browser decodes, an artifact whose linked files did not load -- is recorded with the reason, shown on the file's Thumbnail panel, and not tried again until the file changes or its owner asks.

Every claim is charged to the row as an attempt (#1868). An attempt that does not finish -- the renderer stops answering or closes the page while the document is loaded, the stored file cannot be read (a file whose object is gone), a file the document references is answered by the platform with an error or not in time (a font, image or script storage was slow to read, #2218), or the tile cannot be written -- records nothing and holds the document back for retry_backoff, then four times longer each time. At max_attempts the document is recorded as not drawable with the last reason, and leaves the queue until it changes or its tile is asked for again, so one document cannot hold the renderer or the rest of the queue. Before each document the worker asks the renderer whether it answers; a renderer that does not is not the next document's doing, so the rows not yet tried are handed back with their attempt returned. The warning that says the renderer stopped answering names the last document whose attempt did not finish.

A document whose public stylesheet or script could not be fetched for a reason that may pass -- the network failed, or its server answered 429 or 5xx -- is an attempt that did not finish too, held back the same way (#2122): a tile drawn without its styles is a picture of unstyled text that would otherwise be stored as a good tile. A file its server says does not exist (404) is the document's own, and the page is drawn without it; a font is never waited for (#2062). An HTML or JSX frame reports itself ready only once every stylesheet it names has loaded or failed, its fonts have settled, and nothing about its styles or layout has changed for a short quiet period, and a JSX frame waits for its module to run rather than a fixed delay. The renderer keeps each public answer it fetched for ten minutes, so a library of documents on one CDN fetches each file once per pass.

A collection's mosaic is held to the same rule, and one that cannot be composed from its members' tiles is not tried again until a member is added, removed or redrawn.

Asking for work costs the same in a library of any size (#2084). Each asset, resource and collection row carries thumbnail_due_at: a trigger sets it on every write the renderer did not make, and on a collection when a section, an item or a member's tile changes. The worker reads only due rows, through a partial index that holds nothing else, takes the ones owed nothing out of the due set a thousand at a time, and claims from the rest; a claim or a hold writes its end time there, so a row comes due again when it passes. With 500,000 resources and 500,000 assets and nothing owed, an idle claim runs in about 2 ms and the backlog count in under 1 ms, reading one index page and no table row. When the rule for what is owed changes (a new renderer generation, a changed max_source_bytes or large_source_bytes, a newly drawable type), the first replica to start marks every row due once, 2,000 rows per statement, under an advisory lock, and records the rule in thumbnail_sweeps, so a restart does not do it again; 500,000 rows took 17 seconds.

Animations are stopped for the picture. Every page is loaded with prefers-reduced-motion: reduce and its animation timeline stopped, in every frame; just before the capture an animation that ends is shown ended and one that never ends is removed. A document with an infinite CSS animation -- which, painted in software, never lets the renderer go idle -- draws in the time any other does.

Inbound Webhooks

Webhook sources are administrator-managed records (Admin > Webhooks, or /api/v1/admin/webhooks/sources); see Inbound Webhooks. This section decides which replicas receive and which compact, and paces the compactor. Both halves are on by default, so a deployment sets nothing.

webhooks:
  receiver:
    enabled: true        # only needed to opt out; defaults to true
    address: ""          # also serve /hooks/ on a listener of its own
    write_timeout: 30s
    spool:
      dir: ""            # off until set: answer from local disk, drain behind the answer
      max_bytes: 1073741824
      max_age: 1h
  compactor:
    enabled: true        # only needed to opt out; defaults to true
    grace: 2m
    poll: 30s
    lease: 10m
    batch: 4
    retry_backoff: 1m
    retention_every: 10m
    expired_window_retention_days: 90
    telemetry_retention_days: 14
Field Type Default Description
receiver.enabled *bool true Serve /hooks/ on this replica. A deployment that keeps bursts off the replicas serving MCP and the portal turns it off there and runs a receiver-only deployment behind the webhook Ingress.
receiver.address string empty Also serve /hooks/ on this address, so the receiver can sit behind its own Service. The main listener serves it either way.
receiver.write_timeout duration 30s How long writing one segment may take. A request whose segment is not written by then is answered 503. With a spool, it bounds one drain of a spool file, and how long a stopping replica drains.
receiver.spool.dir string empty The receiver's local write-ahead spool (#2121), one directory per replica. When set, a request is answered once its events are synced here, and they are drained to object storage behind the answer; empty answers once they are in object storage. See Inbound Webhooks.
receiver.spool.max_bytes int 1073741824 (1 GiB) Past this many bytes waiting, a request is answered 503 with Retry-After.
receiver.spool.max_age duration 1h Once the oldest event waiting is older than this, a request is answered 503 with Retry-After.
compactor.enabled *bool true Compact windows and apply retention on this replica.
compactor.grace duration 2m How long after a window ends the compactor waits for segments still being written into it.
compactor.poll duration 30s How long an idle compactor waits before looking for work.
compactor.lease duration 10m How long a claimed window is held from other replicas.
compactor.batch int 4 Windows one pass claims.
compactor.retry_backoff duration 1m How long a failed window is held back, times its attempts, at most an hour.
compactor.retention_every duration 10m How often retention runs.
compactor.rollup_check_every, compactor.rollup_delete_delay duration none Not read (#2140). They paced the v1.146.1 merge of the open window, which is gone; a configuration that sets them still starts and logs deprecated_key. See Inbound Webhooks.
compactor.expired_window_retention_days int 90 How long the record of an expired window is kept once its partition, file and raw segments are gone. 0 uses the default; a negative value keeps them for as long as their source exists.
compactor.telemetry_retention_days int 14 How long the Webhooks page's history is kept (#2150): request counts by outcome and HTTP status, acknowledgement latency, each receiver's minutes and the compaction runs, per minute for 48 hours and per hour after. 0 uses the default.

A negative value for any of the durations, batch and spool.max_bytes is refused at startup, and so is a spool directory that cannot be opened. A source's table needs a scratch connection whose catalog meets the requirements on Scratch Catalog.

Progress Notifications Configuration

Progress notifications send granular updates to MCP clients during long-running Trino queries. The client must include _meta.progressToken in the request to receive updates. Enabled by default; set enabled: false to opt out.

progress:
  enabled: false   # only needed to opt out; defaults to true
Field Type Default Description
enabled *bool true (nil = enabled) Enable progress notifications

When enabled, Trino query execution sends progress updates including rows scanned, bytes processed, and query stage information. Clients that don't send a progressToken receive no notifications (zero overhead).

MCP Tasks Configuration

The MCP Tasks extension (#2009) answers a client that declares io.modelcontextprotocol/tasks on its tools/call with a task for run_script and the export tools (trino_export, api_export, graphql_export), followed with tasks/get and tasks/cancel. Enabled by default wherever a database is configured; see MCP Tasks.

tasks:
  enabled: false     # only needed to opt out; defaults to true
  retention_days: 7  # how long an export task is kept
Field Type Default Description
enabled *bool true (nil = enabled) Offer the extension. Off, every call is answered directly, as for a client that does not declare it.
retention_days int 7 How long an export run as a task is kept for its caller to read the result. A run_script task is the run, kept by scripts.run_retention_days.

Client Logging Configuration

Client logging sends server-to-client log messages via the MCP logging/setLevel protocol. Messages include enrichment decisions, timing data, and platform diagnostics. Enabled by default; set enabled: false to opt out.

client_logging:
  enabled: false   # only needed to opt out; defaults to true
Field Type Default Description
enabled *bool true (nil = enabled) Enable client logging

Zero overhead if the client hasn't subscribed via logging/setLevel. When active, log messages report semantic cache hits/misses, enrichment timing, and cross-enrichment decisions.

Elicitation Configuration

Elicitation requests user confirmation before potentially expensive or sensitive operations. Requires client-side elicitation support (e.g., Claude Desktop). Gracefully degrades to a no-op if the client doesn't support elicitation. Elicitation and pii_consent are on by default; cost_estimation is off by default (#2586), since trino_query's row limit and tools.result_budget already bound what a query returns. Set enabled: false at any level to opt out, and cost_estimation.enabled: true to turn the cost prompt on.

elicitation:
  enabled: false        # only needed to opt out; defaults to true
  cost_estimation:
    enabled: true       # only needed to opt in; defaults to false
    row_threshold: 1000000
  pii_consent:
    enabled: false      # only needed to opt out; defaults to true
Field Type Default Description
enabled *bool true (nil = enabled) Enable elicitation
cost_estimation.enabled *bool false (nil = off) Prompt before a query that returns many rows
cost_estimation.row_threshold int 1000000 Prompt when the rows a query is estimated to return, capped by the row limit the call runs with, exceed this
pii_consent.enabled *bool true (nil = enabled) Prompt when a query reads a column tagged PII, or a table tagged PII

Client support required

Elicitation uses the MCP elicitation/create capability. Clients that don't support elicitation will not receive prompts — queries proceed without confirmation.

How a prompt reaches the client depends on the protocol revision it speaks (#2052). A client on 2026-07-28 is answered with the prompts as input requests on the trino_query result (resultType: "input_required") and retries the call with its answers; one cost prompt and one PII prompt owed by the same statement come in one round. An older client is asked in band while the call waits, which needs a deployment that keeps sessions in process: with a database session store the platform serves MCP stateless, a stateless server can send no request to the client, and an older client is not prompted (the query runs as it would for a client without elicitation). Either way a prompt that does not come back accepted ends the call, and nothing is sent to Trino past the estimate. A declined prompt is a user_declined error. A prompt the client comes back from with no answer (it canceled, or gave up waiting) is a prompt_unanswered error saying the person was not reached and that sending the same call again only asks again (#2586). The call that issued the prompt is recorded on its audit row with the input_required category, not as a success: it ran nothing, so it is never a source in an asset's provenance and the call catalog keeps no record of it. The prompts are a layer of the platform's tools/call chain, inside authorization, the session and search-first gates and the rate limiter, so a call that may not run is never estimated or prompted for. Each Trino connection carries its own settings: the platform's apply to every connection in toolkits.trino.instances that sets no elicitation block of its own, and a connection added through the admin API with none takes the default connection's. A connection's own elicitation block is file configuration: the admin API drops the key from a stored connection, so a connection added there always takes the default connection's settings. The estimate's EXPLAIN (TYPE IO) runs on the connection the query names and is measured as one: trino_queries_total{query_kind="explain"} and a trino.explain span under the tool call's. The cost prompt is about what comes back: the plan's own output estimate (the top-level estimate.outputRowCount of EXPLAIN (TYPE IO)), capped by the row limit the call runs with, never the rows a table scan reads, which the query timeout bounds (#2586). An estimate Trino could not make asks nothing. The PII prompt fires for a column the catalog tags PII that the statement reads (its name appears in the statement outside strings and comments, or the statement selects * or t.*), and for a table it tags PII (a tag whose name contains "pii"), since a catalog commonly tags the dataset holding personal data rather than each column. A PII column the statement never names is not counted (#2586). Before #2052 neither prompt ran on any deployment: the middleware was built only by a single-connection constructor the platform never used.

Behavior change for existing deployments

PII-consent prompts fire out of the box. The cost prompt does not since #2586: a deployment that wants it sets elicitation.cost_estimation.enabled: true, and its row_threshold is now compared with the rows a query returns (capped by its row limit), so a threshold above the connection's max_limit never prompts.

Icons Configuration

Icons add visual metadata to tools, resources, and prompts in MCP list responses. Upstream toolkits (Trino, S3) provide default icons; this configuration overrides or extends them. Enabled by default; set enabled: false to opt out.

icons:
  enabled: false   # only needed to opt out; defaults to true
  tools:
    trino_query:
      src: "https://example.com/custom-trino.svg"
      mime_type: "image/svg+xml"
  resources:
    "schema://{catalog}.{schema}/{table}":
      src: "https://example.com/schema.svg"
  prompts:
    knowledge_capture:
      src: "https://example.com/knowledge.svg"
Field Type Default Description
enabled *bool true (nil = enabled) Enable icon injection middleware
tools map - Icon overrides keyed by tool name
resources map - Icon overrides keyed by resource URI
prompts map - Icon overrides keyed by prompt name
*.src string - Icon source URL
*.mime_type string - Icon MIME type (e.g., image/svg+xml)

Default icons

Each upstream toolkit provides a default icon for all its tools. You only need this configuration if you want to customize or override those defaults.

Environment Variables

Common environment variables:

Variable Description
TRINO_USER Trino username
TRINO_PASSWORD Trino password
AWS_ACCESS_KEY_ID AWS access key
AWS_SECRET_ACCESS_KEY AWS secret key
AWS_SESSION_TOKEN AWS session token
DATABASE_URL PostgreSQL connection string (for audit/OAuth)

Complete Example

apiVersion: v1

server:
  name: mcp-data-platform
  transport: http
  address: ":8080"

database:
  dsn: ${DATABASE_URL}

portal:
  enabled: true

admin:
  enabled: true
  persona: admin

# Hide unused tools from tools/list to save LLM tokens
tools:
  allow:
    - "trino_*"
    - "search"
    - "fetch"
    - "memory_capture"
  deny:
    - "*_delete_*"

audit:
  enabled: true
  log_tool_calls: true
  retention_days: 90

sessions:
  store: database
  ttl: 30m
  cleanup_interval: 1m

auth:
  api_keys:
    enabled: true
    keys:
      - key: ${API_KEY_ADMIN}
        name: "admin"
        roles: ["admin"]

toolkits:
  trino:
    enabled: true
    instances:
      primary:
        host: trino.example.com
        port: 443
        user: ${TRINO_USER}
        password: ${TRINO_PASSWORD}
        ssl: true
        catalog: hive
        schema: default
        default_limit: 1000
        max_limit: 10000
    default: primary

  s3:
    enabled: true
    instances:
      primary:
        region: us-east-1
        read_only: true
    default: primary

query:
  provider: trino
  instance: primary

enrichment:
  trino_semantic_enrichment: true
  unwrap_json: true
  column_context_filtering: true

resources:
  enabled: true

# progress, client_logging, and elicitation (with pii_consent) are enabled by
# default, so no block is needed here unless you want to opt out; the cost
# prompt (elicitation.cost_estimation) is off unless enabled: true.

personas:
  analyst:
    display_name: "Data Analyst"
    roles: ["analyst"]
    tools:
      allow: ["*"]
      deny: ["*_delete_*"]
    connections:
      allow: ["*"]
  admin:
    display_name: "Administrator"
    roles: ["admin"]
    tools:
      allow: ["*"]
    connections:
      allow: ["*"]

Next Steps