MCP tools¶
The user portal serves a Model Context Protocol endpoint at
/mcp, so an AI agent works with your databases, semantic models and data shares as you. This page
describes every tool, then the choices behind them and why they were made. To register the
endpoint in a coding agent, see AI agents.
Platform administrators have a separate endpoint on the administration portal, described in the
administration guide. Extensions serve their
own: Conversational BI at http://localhost:3007/mcp (client ext-conversationalbi-mcp), dbt at
http://localhost:3004/mcp (client ext-dbt-mcp) and Ingestion (dlt) at http://localhost:3008/mcp
(client ext-ingestion-mcp). On Kubernetes each is /mcp on its own HTTPS host; see
addresses and sign-in.
How every tool works¶
- As you. The agent signs in through Keycloak with the public
iceberg-mcpclient. Each call carries your token. The portal re-reads your teams and roles from Polaris on every call, and Polaris applies your grants. A tool can never see or do more than you can in the portal. - By id. Databases are addressed by their opaque id (
db-…), never by name, because names repeat across environments. Namespaces are lists of parts, such as["analytics", "nested"]. - No free-form SQL. Agents browse the catalog, preview rows and ask governed questions of a semantic model. They cannot send their own SQL.
- Errors are answers. A refused call returns a tool error with a reason the agent can act on, such
as the metrics that do exist or the
entityVersionit must read first.
Tool reference¶
Catalog¶
| Tool | What it does |
|---|---|
list_databases |
Your teams with your role in each, the databases they own, and sharedDatabases: databases of other teams shared with yours, read-only. Start here. |
list_namespaces |
Child namespaces of a database or of one namespace. |
list_tables |
Tables and views in one namespace. |
describe_table |
Schema with column comments, partitioning, sort order, branches and tags, and the latest snapshots. |
describe_view |
Schema and the SQL of the latest view versions. |
preview_rows |
Up to 100 rows rendered as text, at the current or a given snapshot. Uses a query slot. |
Semantic models¶
| Tool | What it does |
|---|---|
list_semantic_models |
Names of the Apache Ossie models stored in one namespace. |
describe_semantic_model |
Datasets, fields, relationships, metrics and AI instructions, and queryable: the dataset each metric counts, what it can be split by, and problems. It returns a summary by default. search and dataset narrow it, detail="full" returns every description and expression, and include_definition adds the stored document. |
query_semantic_model |
Answers a question with up to 5 metrics, 5 dimensions (DATASET.field, optionally with a day, week, month, quarter or year grain and the relationships to follow with via), 10 filters and 200 rows. It returns the rows, the metric definitions with their unit, the compiled SQL and parameters, and dataAsOf. Uses a query slot. |
list_dimension_values |
Distinct values of one dimension with their row counts. search keeps only values that contain it, ignoring case. Use it to find the exact value to filter on. Uses a query slot. |
publish_semantic_model |
Creates a model or replaces one (Writer role). Replacing it needs the entityVersion you read. It returns the warnings from validation. |
delete_semantic_model |
Deletes a model (Writer role). confirm_name must equal its name. |
Databases¶
| Tool | What it does |
|---|---|
create_database |
Creates a catalog and its own bucket for a team you administer, in an environment. |
rename_database |
Changes a database's display name. Its id stays the same. |
delete_database |
Destroys the tables, views, files, bucket and shares of a database. confirm_name must equal its current name. |
Data shares¶
| Tool | What it does |
|---|---|
list_share_teams |
Teams that can receive a share of a database. |
list_shares / list_received_shares |
Outgoing shares of a database, and the shares your teams received. |
create_share / update_share |
Share tables, views and semantic models with a team, an external party, the data catalog (listed, with a contact) or a combination. A listing publishes metadata only and needs a data catalog extension. By default, selecting a semantic model also selects the tables it reads (addedTables). |
rotate_share_credential |
Returns a new client secret for an external share, once. The old one stops working. |
delete_share |
Revokes all access immediately. confirm_name must equal the share's name. |
Only team administrators create, change and delete databases and shares. The tools say so when you are not one.
Design choices and why¶
Agents pick names; the model holds the SQL¶
query_semantic_model takes metric names, DATASET.field dimensions and typed filters, never SQL.
The portal builds the query from the model's own expressions:
- Filter values become parameters, never part of the SQL text.
- Metric and field expressions must pass an allow-list of scalar and aggregate functions.
- The query runs in a sandboxed DuckDB process that can read only the bound tables, in UTC.
Why: A metric means the same thing in every answer, whoever asks. An agent cannot read other tables, change data, or answer from a definition the model owners did not agree.
The trade-offs:
- Functions outside the allow-list are refused, among them timezone() and AT TIME ZONE.
Hours and dates are UTC; a model's instructions should say so.
- Relative windows such as "last 7 days" are filters at question time, not subqueries in a metric.
Joins go from many to one only¶
A query starts at the dataset its metrics count (the metric's home). It joins only along declared
relationships whose to_columns are the target's primary key.
Why: Every join is then many-to-one, so it can never repeat rows and inflate a sum or a count. The price is that one query counts one dataset. Comparing numbers from two datasets, such as seven sector tables each with its own metrics, takes one query per dataset. The model's instructions should say so, and a single long table with a sector column is easier to query than one table per sector.
describe_semantic_model summarises by default¶
The full answer lists every field and metric with its whole description and expression. For a model the size of CBS Regionale kerncijfers (about 300 fields and 250 metrics), that is 150 to 300 KB, or 50,000 to 80,000 tokens. That fills much of an agent's context before it asks anything.
The summary keeps what an agent needs to pick names:
- the model description and its AI instructions, in full
- per dataset: its fields with datatype, dimension flags and the first sentence of the description
- per metric: the dataset it counts, its unit, its expression, its synonyms and the first sentence of
its description
- relationships, and queryable.problems, in full
- queryable.dimensions as plain DATASET.field names. Only an entry that needs a join path, or
that is ambiguous and needs via, keeps its details.
search keeps the fields and metrics whose name, synonyms or description contain the text. dataset
keeps one dataset and the metrics that count it. Both report how many items they kept (matched).
detail="full" returns everything.
Why summary is the default: Agents call this tool with default arguments first. The default should work for the largest models. An agent that needs every word asks for it.
list_dimension_values finds exact spellings¶
Filters compare exact values, and real data has spellings an agent cannot guess:
- CBS names regions with a level suffix, such as Utrecht (PV) for the province.
- Municipalities with the same name get a province suffix, such as Laren (NH.).
Without this tool, an agent either guesses and gets an empty answer, or abuses a metric query to list values.
The tool's limits, and why:
- Only fields the model marks as dimensions. That is all a query may filter on anyway, and it
respects what the model owners chose to expose.
- One dataset, no joins. Values are listed from the field's own dataset.
- search is a parameter. It is matched case-insensitively with contains, and never becomes
SQL.
- Your permissions and a query slot. It runs as you, in the same sandbox and slots as
query_semantic_model.
- Row counts with each value. They show at once which spelling is the common one.
Query slots¶
Previews and governed queries run in separate DuckDB processes, capped at 4 GB of address space and
20 seconds of CPU for a preview, and 6 GB and 60 seconds for a query. The portal runs at most
USER_QUERY_SLOTS of them at once, 2 by default, for all users together.
When every slot is taken, an MCP preview or query waits up to USER_QUERY_WAIT_SECONDS (15 by
default) for one, and only then reports "Query slots are busy" or "Preview slots are busy".
Why the slots exist: A few heavy queries must not starve the gateway that serves everyone.
Why MCP calls wait: Agents often ask several questions at once. When MCP calls failed straight away, four of six parallel questions failed, and agents retried in a loop or gave up.
The portal's own Preview button still fails at once with HTTP 429, because a person can simply
click again. Raise USER_QUERY_SLOTS when the host has the memory for it. Both settings are in
configuration.
Publishing checks fields against the table's columns¶
publish_semantic_model checks the model's structure, warns about metrics a governed query would
refuse, and reads each dataset's table:
- A field whose expression is a plain column name must be a column of that table. A typo blocks the
model with "is not a column of the table".
- A table that is missing or that you cannot read does not block the model. It becomes a warning
instead.
- A dataset without a description gets the table's comment, and a plain-column field without one
gets the column's doc, such as those a dbt build writes. Derived fields never inherit.
Why: A typo in a field otherwise surfaces only later, as a failing query in someone else's chat. A missing table is only a warning because a model may be written before its tables are loaded.
Derived fields, whose expression is more than a column name, are not executed at publish time. The
semantic-model skill's check command runs every field, metric and interview question against the
real data before it publishes.
Units and additivity come from the model¶
Each metric in a query result has:
- unit, read from Unit: … in the metric's description. End every metric description with it,
such as Unit: percent. or Unit: 1 000 euro., because agents show it with the number.
- additive, which is true only when the whole expression is a plain sum or count. Such a
metric may be added up across rows.
Ossie has no way to say that a metric adds up over regions but not over time, such as a population on 1 January. The portal does not invent one. Write it in the metric's description and in the model's instructions, such as: "Add up over regions of one level within one year; never over years."
Not built, on purpose¶
- Uploading data through MCP. Files can be large, a URL fetched by the server opens the door to
server-side request forgery, and inferring a schema needs decisions only the owner can make. Load
data with
iceberg_connect.pyand PyIceberg or DuckDB, which write with your own grants. Then describe the tables with a semantic model. - Time zones in governed queries. Local time needs
timezone()orAT TIME ZONE, which the allow-list refuses, and every session runs in UTC. A model can still offer local time as a stored column. - Semi-additive metrics. See units and additivity.
Writing models that agents answer well¶
These lessons come from building models with the tools above:
- Units. End each metric description with Unit: ….
- Dataset names. Write metric expressions with the dataset name, such as sum(SALES.amount) or
count(SALES.id). A metric that names no dataset, such as a bare count(*), cannot be queried on
its own, because the portal cannot tell which dataset it counts.
- Fields. Describe each field, and give it synonyms and examples in its ai_context. A label
groups fields by what they hold across models, such as Delay; a data catalog shows it as a tag.
- Dimensions. Mark every field people group or filter by as a dimension, identifiers included.
Once a dataset marks any field, only marked fields can be used.
- Data gaps. Say in each metric's description which years it covers, and in the instructions what
NULL means. An agent then picks the newest year that has data instead of reporting NULL.
- Spellings and levels. Explain suffixes and levels in the instructions, such as (PV) for
provinces, and the filter on the region level that keeps sums from counting a region twice.
- Ratios. Build ratios from sums, such as 100.0 * sum(a) / nullif(sum(b), 0). They then weigh
correctly over several rows. avg of a stored percentage does not.