docs - mcp tool documentation gen (#82592)
This commit is contained in:
@@ -728,6 +728,7 @@
|
||||
:uses #{api
|
||||
api-routes
|
||||
app-db
|
||||
mcp
|
||||
metabot
|
||||
metabot.llm
|
||||
models
|
||||
@@ -1331,7 +1332,8 @@
|
||||
metabase.mcp.http-handler
|
||||
metabase.mcp.init
|
||||
metabase.mcp.usage
|
||||
metabase.mcp.v2.api}
|
||||
metabase.mcp.v2.api
|
||||
metabase.mcp.v2.registry}
|
||||
:model-exports #{:model/McpSessionLog :model/McpToolCallLog}
|
||||
:uses #{activity-feed
|
||||
ai-tracing
|
||||
|
||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -43,6 +43,7 @@ docs/configuring-metabase/environment-variables.md # auto-generated
|
||||
docs/configuring-metabase/config-template.md # auto-generated
|
||||
docs/installation-and-operation/commands.md # auto-generated
|
||||
docs/ai/providers.md # auto-generated
|
||||
docs/ai/mcp-tools.md # auto-generated
|
||||
docs/embedding/sdk/api # auto-generated
|
||||
docs/embedding/eajs/snippets # auto-generated
|
||||
|
||||
|
||||
11
.github/workflows/docs-generate-base.yml
vendored
11
.github/workflows/docs-generate-base.yml
vendored
@@ -86,6 +86,16 @@ jobs:
|
||||
clojure -M:ee:doc ai-providers-documentation
|
||||
continue-on-error: false
|
||||
|
||||
- name: Generate MCP tool documentation
|
||||
id: generate_mcp_tool_docs
|
||||
# Same reason as the AI provider step: release branches without the generator would fail the command.
|
||||
if: hashFiles('src/metabase/cmd/mcp_tools_dox.clj') != ''
|
||||
run: |
|
||||
set -e # Exit on any error
|
||||
echo "Generating MCP tool documentation..."
|
||||
clojure -M:ee:doc mcp-tools-documentation
|
||||
continue-on-error: false
|
||||
|
||||
- name: Generate usage analytics documentation
|
||||
run: ./bin/generate-usage-analytics-docs.bb
|
||||
|
||||
@@ -176,6 +186,7 @@ jobs:
|
||||
- \`clojure -M:ee:doc api-documentation\`
|
||||
- \`clojure -M:ee:doc command-documentation\`
|
||||
- \`clojure -M:ee:doc ai-providers-documentation\`
|
||||
- \`clojure -M:ee:doc mcp-tools-documentation\`
|
||||
- \`./bin/generate-usage-analytics-docs.bb\`
|
||||
- \`bun run embedding-sdk:docs:generate\`
|
||||
- \`bun run embedding-eajs:docs:generate\`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
frontend/src/cljs
|
||||
docs/configuring-metabase/environment-variables.md
|
||||
docs/ai/providers.md
|
||||
docs/ai/mcp-tools.md
|
||||
docs/api/*
|
||||
docs/api-documentation.md
|
||||
frontend/src/embedding-sdk-shared/.storybook/msw-public/mockServiceWorker.js
|
||||
|
||||
@@ -159,6 +159,7 @@ Metabase's reference documentation.
|
||||
- [AI system prompts](./ai/system-prompts.md)
|
||||
- [Agent API](./ai/agent-api.md)
|
||||
- [MCP server](./ai/mcp.md)
|
||||
- [MCP server tools](./ai/mcp-tools.md)
|
||||
- [Metabot in Slack](./ai/metabot-slack.md)
|
||||
- [AI privacy](./ai/privacy.md)
|
||||
|
||||
|
||||
469
docs/ai/mcp-tools.md
Normal file
469
docs/ai/mcp-tools.md
Normal file
@@ -0,0 +1,469 @@
|
||||
---
|
||||
title: MCP server tools
|
||||
summary: The tools Metabase's MCP server exposes to AI clients, the permission each one needs, and the arguments each one takes.
|
||||
---
|
||||
|
||||
# MCP server tools
|
||||
|
||||
_This documentation was generated from source by running:_
|
||||
|
||||
```
|
||||
clojure -M:ee:doc mcp-tools-documentation
|
||||
```
|
||||
|
||||
These are the tools an AI client can call once you've [connected it to your Metabase's MCP server](./mcp.md). Every tool runs as you, scoped to your permissions, so a tool can never reach data you couldn't see in Metabase yourself.
|
||||
|
||||
Some clients (like Claude Desktop) ask you to approve or block each tool the first time it's used.
|
||||
|
||||
The argument notes here are exactly what your agent sees (which is why they sound robotic). Several tools take or return a `query_handle`. A handle stands for a query that already ran (or was validated), so your agent can visualize or save exactly that query without sending it again. By default, handles expire after 24 hours.
|
||||
|
||||
Your agent will use `execute_query` for anything Metabase's query language can express (counts, sums, grouping, filtering, joins) and `execute_sql` for the rest (window functions, CTEs, engine-specific functions), or when you ask for SQL outright.
|
||||
|
||||
## Alert write
|
||||
|
||||
- Tool name: `alert_write`
|
||||
- Permission scope: `agent:delivery:write` — Set up scheduled delivery of your data to email addresses and Slack channels it chooses
|
||||
- Creates or changes content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `active` | boolean | false pauses the alert, true resumes it (resuming needs the agent:query:run scope). Defaults to true on create. Alerts have no archived state and cannot be deleted here. |
|
||||
| `card_id` | integer or string | Numeric id of the saved question the alert runs. Fixed at creation. 21-character entity_id of the saved question the alert runs. Fixed at creation. |
|
||||
| `channel` | string | One of: `email`, `slack`. Where to deliver: "email" (default) with `recipients`, or "slack" with `slack_channel`. Passing any of channel, slack_channel, or recipients on update replaces the alert's delivery; omit all three to leave it alone. |
|
||||
| `condition` | object | When the alert sends. Defaults to sending whenever the question returns rows; the goal conditions need a goal line on the question's chart. |
|
||||
| `id` | integer or string | Numeric id of the alert to update. The same numeric id as a string. Alerts have no entity_id. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new alert (requires `card_id` and `schedule`); "update" edits the one named by `id`, changing only the fields you pass. |
|
||||
| `recipients` | array | Who gets the email: numeric user ids, or email addresses. Defaults to you. On update this replaces the current list. Not used for channel "slack". |
|
||||
| `schedule` | object | When the question runs, in the instance's report time zone. Required on create; never a cron string. |
|
||||
| `slack_channel` | string | Slack channel name to post to, e.g. "#data-team". Required for channel "slack". |
|
||||
|
||||
## Bookmark content
|
||||
|
||||
- Tool name: `bookmark_content`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Creates or changes content.
|
||||
- Running it again with the same arguments has the same effect as running it once.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `bookmarked` | boolean | true bookmarks the item, false removes the bookmark. |
|
||||
| `id` | integer or string | Numeric id. A 21-character entity_id. |
|
||||
| `type` | string | One of: `collection`, `dashboard`, `document`, `metric`, `model`, `question`. The content type, as returned by search/browse_collection. |
|
||||
|
||||
## Browse collection
|
||||
|
||||
- Tool name: `browse_collection`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ----------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `created_by` | string | One of: `me`. items mode: me restricts results to items the current user created (questions, models, metrics, dashboards, documents — other types return nothing under this filter). Composes with type. |
|
||||
| `depth` | integer | tree mode: subcollection levels to expand (default 2). Deeper or trimmed nodes carry a truncation marker naming the re-rooting call. |
|
||||
| `fields` | array of string | items mode: dot-paths picked from the detailed row shape, item-relative (e.g. "last-edit-info.email"). Mutually exclusive with response_format. |
|
||||
| `id` | integer or string | Numeric collection id. A 21-character entity_id, "root" (the per-namespace root), or "trash" (items mode only — archived content, making restore discoverable). |
|
||||
| `limit` | integer | items mode: maximum rows to return (default 50). |
|
||||
| `mode` | string | One of: `items`, `tree`. items (default) lists the collection's contents; tree returns the nested subcollection structure (collections only, no items, no pagination). |
|
||||
| `namespace` | string | One of: `content`, `snippets`, `transforms`, `analytics`. Which collection partition to browse; only meaningful with id: "root" (a real collection id already carries its namespace). content (default) holds questions/dashboards/etc.; snippets holds snippet folders and snippets; transforms holds transform folders and transforms; analytics is the read-only usage-analytics tree. |
|
||||
| `offset` | integer | items mode: rows to skip, for paging. |
|
||||
| `pinned_state` | string | One of: `all`, `is_pinned`, `is_not_pinned`. items mode: all (default) interleaves pinned and unpinned rows. Pinned-first as in the product is two calls — is_pinned, then is_not_pinned — each paging independently. |
|
||||
| `response_format` | string | One of: `concise`, `detailed`. items mode: concise (default) returns {id, name, model, description, collection_position} rows; detailed adds entity_id, collection_id, archived, location, and last-edit info. |
|
||||
| `sort_column` | string | One of: `name`, `last_edited_at`, `model`. items mode: sort key (default name). |
|
||||
| `sort_direction` | string | One of: `asc`, `desc`. items mode: sort direction (default asc). |
|
||||
| `type` | array of string | One of: `question`, `model`, `metric`, `dashboard`, `collection`, `document`. items mode, content namespace only: return only these item types. |
|
||||
|
||||
## Browse data
|
||||
|
||||
- Tool name: `browse_data`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ----------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `action` | string | One of: `list_databases`, `list_schemas`, `list_tables`, `list_models`, `get_fields`. What to browse. list_databases → list_schemas → list_tables → get_fields walks the hierarchy; list_models lists the models built on a database. |
|
||||
| `database_id` | integer | Numeric database id (databases have no entity_id). Required for list_schemas, list_tables, and list_models. Ignored by get_fields. |
|
||||
| `fields` | array of string | Dot-paths picked from the detailed row shape, item-relative (e.g. "fields.name"). Mutually exclusive with response_format. Not supported for list_schemas. |
|
||||
| `include_hidden` | boolean | Include hidden schemas/tables (list_schemas, list_tables) or hidden fields (get_fields). Sensitive fields are always excluded. Default false. |
|
||||
| `limit` | integer | list_* actions: maximum rows to return (default 50). |
|
||||
| `offset` | integer | list_* actions: rows to skip, for paging. For get_fields it pages the fields of a single oversized table, as directed by the continuation message. |
|
||||
| `response_format` | string | One of: `concise`, `detailed`. concise (default) returns the essential columns; detailed adds the full projection (for get_fields: effective_type, coercion_strategy, database_type, fingerprint, has_field_values, and inline values for list-type fields). Values and fingerprints are permission-dependent, and absent rather than empty when withheld: values need permission to query the table, not just to read its metadata, and fingerprints are withheld entirely when your row access to the table is narrowed. |
|
||||
| `schema` | string | list_tables only: the schema to list. Omit (or pass "") for databases without schemas. |
|
||||
| `search` | string | list_tables only: case-insensitive substring filter on table name or display name, applied before paging. |
|
||||
| `table_ids` | array of integer | get_fields only: numeric table ids (tables have no entity_id), at most 20 per call. |
|
||||
|
||||
## Collection write
|
||||
|
||||
- Tool name: `collection_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ----------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves the collection and its contents to the trash, false restores them. Archiving is the only removal path — there is no hard delete. Omit to leave the collection's trashed state alone. |
|
||||
| `authority_level` | string | One of: `official`. Marks the collection Official. Requires an admin on an instance with the Official Collections feature. To make a collection unofficial again, name it in `clear` — `clear: ["authority_level"]`. |
|
||||
| `clear` | array of string | One of: `description`, `authority_level`. Update only: property names to unset (description, authority_level). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `description` | string | Optional human-readable description. To remove one, name it in `clear` — `clear: ["description"]`. |
|
||||
| `id` | integer or string | Numeric id of the collection to update. 21-character entity_id of the collection to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new collection (requires `name`); "update" edits the one named by `id`. |
|
||||
| `name` | string | Required on create; editable on update: display name of the collection. |
|
||||
| `namespace` | string | One of: `snippets`, `transforms`. Create only: puts the collection in a separate hierarchy instead of the normal one. "snippets" holds SQL snippet folders; "transforms" holds transform folders, the ones transform_write's `collection_id` names. Omit for a normal collection. |
|
||||
| `parent_id` | integer or string | The collection to nest under (create) or move into (update). Numeric id, 21-character entity_id, or "root" for the top level. Omitted on create means your personal collection. You need write access to the parent. |
|
||||
|
||||
## Dashboard write
|
||||
|
||||
- Tool name: `dashboard_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves it to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
|
||||
| `auto_apply_filters` | boolean | When false, filter changes wait for the viewer to press Apply. Default true. |
|
||||
| `cache_ttl` | integer | Cache lifetime for this dashboard's results, in hours. |
|
||||
| `clear` | array of string | One of: `description`, `collection_position`, `cache_ttl`. Update only: property names to unset (description, collection_position, cache_ttl). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `collection_id` | integer or string | Numeric id of the collection to put it in. Omit on create for your personal collection. Collection entity_id, or "root" for the top-level collection. |
|
||||
| `collection_position` | integer | Pin position within the collection; omit to leave it unpinned. |
|
||||
| `description` | string | One or two sentences on what the dashboard answers. |
|
||||
| `id` | integer or string | Numeric id of the dashboard to update. 21-character entity_id of the dashboard to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new dashboard (requires `name`); "update" edits the one named by `id`. |
|
||||
| `name` | string | Dashboard title. |
|
||||
| `ops` | array of object | Editor operations, applied in order. One atomic save on update; on create the dashboard row is written before the ops are applied. |
|
||||
| `validate_only` | boolean | Dry run: returns the layout the ops would produce, writing nothing. |
|
||||
| `width` | string | One of: `fixed`, `full`. "fixed" (default) centers the grid; "full" stretches it to the browser width. |
|
||||
|
||||
## Document write
|
||||
|
||||
- Tool name: `document_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Creates or changes content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves it to the trash, false restores it. |
|
||||
| `clear` | array of string | One of: `collection_position`. Update only: property names to unset (collection_position). A null cannot say this — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `collection_id` | integer or string | Numeric id of the collection to put it in. Omit on create for your personal collection. Collection entity_id, or "root" for the top-level collection. |
|
||||
| `collection_position` | integer | Pin position within the collection; omit to leave it unpinned. |
|
||||
| `content_markdown` | string | The full body in Metabase-flavored Markdown: CommonMark plus card embeds, entity links, and ::: layout containers (learn("documents")). Required on create. On update it is a deliberate full-body rewrite that orphans every comment thread anchored to the body; pass `edits` to change text in place instead. |
|
||||
| `edits` | array of object | Update only: surgical text edits, each {old_str, new_str, replace_all?}, applied in order against the current server-side Markdown. Exactly one of `edits` or `content_markdown`. An empty list changes only name, collection_id, collection_position, or archived without touching the body. |
|
||||
| `id` | integer or string | Numeric id of the document to update. 21-character entity_id of the document to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new document (requires `name` and `content_markdown`); "update" edits the one named by `id`. |
|
||||
| `name` | string | Document title. Required on create; on update, renames it. |
|
||||
|
||||
## Duplicate content
|
||||
|
||||
- Tool name: `duplicate_content`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Creates or changes content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `collection_id` | integer or string | Collection to copy into. Omit to copy into your personal collection; pass "root" for the root collection. A 21-character collection entity_id, or "root". |
|
||||
| `id` | integer or string | Numeric id of the item to copy. A 21-character entity_id of the item to copy. |
|
||||
| `is_deep_copy` | boolean | Dashboards only: also copy the dashboard's questions into the destination collection, instead of pointing the copy at the originals. |
|
||||
| `new_name` | string | Name for the copy. Defaults to "Copy of <source name>". |
|
||||
| `type` | string | One of: `question`, `dashboard`, `document`. The kind of content to copy. Card flavors other than question (model, metric) aren't supported yet. |
|
||||
|
||||
## Execute query
|
||||
|
||||
- Tool name: `execute_query`
|
||||
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `cursor` | string | The next_cursor from a previous truncated response — fetches the next page. Exactly one of query \| query_handle \| cursor, but row_limit may accompany it and sets this page's size. |
|
||||
| `prompt` | string | The user's original request, stored with the minted query_handle and carried along its cursor pages. |
|
||||
| `query` | object | A fresh structured query (shape and examples in the tool description): numeric table/field ids from browse_data, never base64, never SQL. Exactly one of query \| query_handle \| cursor. |
|
||||
| `query_handle` | string | A query_handle from a previous call — re-validates and re-runs the exact stored query. Exactly one of query \| query_handle \| cursor. |
|
||||
| `row_limit` | integer | Maximum rows to return in this call (default 100, max 2000) — the page size, not a bound on the result; the bound is limit: N in the query's stage. |
|
||||
| `validate_only` | boolean | true validates against schema + database metadata and mints a query_handle without executing (default false). |
|
||||
|
||||
## Execute SQL
|
||||
|
||||
- Tool name: `execute_sql`
|
||||
- Permission scope: `agent:sql:run` — Write and run its own raw SQL on your connected databases
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `database_id` | integer | Numeric id of the database to run the SQL against. Requires native-query permission on it. |
|
||||
| `prompt` | string | The user's original request, stored with the minted query_handle and carried along its cursor pages. |
|
||||
| `row_limit` | integer | Maximum rows to return in this call (default 100, max 2000). |
|
||||
| `sql` | string | The raw SQL text, run verbatim against the database. Put caller-supplied values behind {% raw %}{{tag}}{% endraw %} placeholders bound via template_tag_values — never splice them into this string. |
|
||||
| `template_tag_values` | object | Values for the {% raw %}{{tag}}{% endraw %} placeholders in sql, keyed by tag name. Each value binds as a driver-level prepared-statement parameter (injection-safe): strings bind as text, numbers as numbers. Snippet ({% raw %}{{snippet: …}}{% endraw %}) and card-reference ({% raw %}{{#123}}{% endraw %}) tags cannot be populated here. |
|
||||
| `validate_only` | boolean | true mints a query_handle without executing — template tags and permissions are checked, the SQL text itself is not (default false). |
|
||||
|
||||
## Get content
|
||||
|
||||
- Tool name: `get_content`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ----------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `include` | array of string | One of: `definition`, `fields`, `visualization_settings`, `parameters`, `layout`, `dimensions`, `comments`. Extra sections, each applied to every item whose type supports it and ignored for the rest — so a mixed-type batch can ask for several at once: definition (query-bearing types, returned as the stored query — numeric ids, the shape execute_query and question_write accept back verbatim), fields (question/model column metadata), visualization_settings (question/model stored chart settings, the shape question_write takes back; {} when nothing is stored), parameters (dashboard's full parameter array), layout (dashboard grid + tabs, document block outline), dimensions (metric/measure), comments (document comment threads, each anchored into the returned content_markdown by {start, end, text} character offsets — the exact slice of the block the thread is attached to; comments attach to whole blocks, a block nested inside a list/blockquote anchors to the span of the nearest enclosing block that has one, and an empty block gives start == end; threads whose block no longer exists come back under orphaned_comments so they can be re-anchored by editing the right block, and if the document read fell back to flattened text no thread carries an anchor). A section no item in the batch supports is an error. |
|
||||
| `items` | array of object | The content to fetch — up to 10 items, mixed types allowed (e.g. a dashboard and its questions in one call). |
|
||||
| `response_format` | string | One of: `concise`, `detailed`. concise (default) returns each type's essential shape; detailed adds entity_id, creator, timestamps, and other secondary columns. |
|
||||
|
||||
## Get parameter values
|
||||
|
||||
- Tool name: `get_parameter_values`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| -------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `constraints` | object | Chain filtering: the current selections of the dashboard's OTHER filters, keyed by their parameter ids, narrowing this filter to the values still valid alongside them. Dashboards only. |
|
||||
| `id` | integer or string | Numeric id. A 21-character entity_id. |
|
||||
| `limit` | integer | Maximum values to return in this call (default 100, max 1000). |
|
||||
| `offset` | integer | Index of the first value to return (default 0) — continue a truncated response. Each page refetches from the source, which returns at most 1000 values, so narrow with `query` rather than paging to reach anything past that. |
|
||||
| `parameter_id` | string | The parameter's id, as returned by get_content under `parameters` — not its name or slug. |
|
||||
| `query` | string | Return only values matching this search string. Use it to narrow a large value list. |
|
||||
| `target` | string | One of: `dashboard`, `question`. Whether id names a dashboard or a card. "question" covers any card — question, model, or metric. |
|
||||
|
||||
## Glossary
|
||||
|
||||
- Tool name: `glossary`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| -------- | ------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `limit` | integer | Maximum entries to return (default 50, max 500). Ignored with "term", which is a lookup, not a page. |
|
||||
| `offset` | integer | Number of entries to skip, for paging (default 0). Ignored with "term". |
|
||||
| `term` | string | The term to define, matched case-insensitively. Omit to list terms with their definitions. |
|
||||
|
||||
## Learn
|
||||
|
||||
- Tool name: `learn`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ----------- | ------ | -------------------------------------------------------------------------- |
|
||||
| `reference` | string | A reference file of `topic`, by the name the skill (or the catalog) lists. |
|
||||
| `topic` | string | A topic from the catalog. Omit to list all topics. |
|
||||
|
||||
## Measure write
|
||||
|
||||
- Tool name: `measure_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves it to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
|
||||
| `clear` | array of string | One of: `description`. Update only: property names to unset (description). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `definition` | object or array | Either (a) the aggregation clause — what get_content's "definition" include returns for a measure (one-element array or bare clause) and what execute_query takes in stages[0].aggregation — reassembled onto `table_id`; or (b) a full single-stage query ("lib/type": "mbql/query") holding exactly one aggregation. No filters, breakouts, joins, expressions, or limits. May reference other measures but not metrics; cycles are rejected. |
|
||||
| `description` | string | Optional human-readable description. |
|
||||
| `id` | integer or string | Numeric id of the measure to update. 21-character entity_id of the measure to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new measure (requires `table_id`, `name`, `definition`); "update" edits the one named by `id` (requires `revision_message`). |
|
||||
| `name` | string | Create only (editable on update): display name of the measure. |
|
||||
| `revision_message` | string | Update only, required: a short sentence describing the change, recorded in the revision history. |
|
||||
| `table_id` | integer | Create only: numeric table id (tables have no entity_ids). A bare-clause `definition` is reassembled into a query on this table; a full-query one must name this same source table — a mismatch is a teaching error, not silently reconciled. |
|
||||
|
||||
## Metric write
|
||||
|
||||
- Tool name: `metric_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `archived` | boolean | Update only: true moves the metric to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
|
||||
| `clear` | array of string | One of: `description`, `collection_position`. Update only: property names to unset (description, collection_position). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `collection_id` | integer or string | Numeric id of the collection to save the metric in. 21-character entity_id of the collection, or "root" for the root collection. |
|
||||
| `collection_position` | integer | Pins the metric at this position in its collection. |
|
||||
| `definition` | object | The metric's query: a full single-stage query holding exactly one aggregation and at most one breakout. Accepts the same numeric-id shape get_content's "definition" include returns for a metric and execute_query takes. Pass this or query_handle, not both. |
|
||||
| `description` | string | Optional human-readable description. |
|
||||
| `display` | string | One of: `table`, `bar`, `line`, `pie`, `scatter`, `area`, `row`, `combo`, `pivot`, `scalar`, `smartscalar`, `gauge`, `progress`, `funnel`, `map`, `waterfall`, `sankey`. How the metric's result is visualized. Defaults to "scalar" — right for a single number, wrong for a metric with a grouping, which usually wants "line" (a grouping over time) or "bar" (a grouping over categories). Editable on update. |
|
||||
| `id` | integer or string | Numeric id of the metric to update. 21-character entity_id of the metric to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new metric (requires `name` and one query source); "update" edits the one named by `id`. |
|
||||
| `name` | string | Create only (editable on update): display name of the metric. |
|
||||
| `query_handle` | string | A query_handle from execute_query — saves exactly the query that ran. Pass this or definition, not both. |
|
||||
|
||||
## Question write
|
||||
|
||||
- Tool name: `question_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves it to the trash, false restores it. |
|
||||
| `cache_ttl` | integer | Legacy per-question cache TTL. Stored and echoed back, but no longer read: caching is configured through cache policies in admin settings. |
|
||||
| `card_type` | string | One of: `question`, `model`. "question" (default) or "model". |
|
||||
| `clear` | array of string | One of: `description`, `collection_position`, `cache_ttl`. Update only: property names to unset (description, collection_position, cache_ttl). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `collection_id` | integer or string | Numeric id of the collection to save it in. Omit on create for your personal collection. Exclusive with `dashboard_id`. Collection entity_id, or "root" for the top-level collection. |
|
||||
| `collection_position` | integer | Pin position within the collection; omit to leave it unpinned. |
|
||||
| `column_metadata` | array of object | Per-column result metadata to set, matched to the query's result columns by name. Typically used with card_type "model". |
|
||||
| `dashboard_id` | integer or string | Numeric id of a dashboard to save the question inside; it inherits that dashboard's collection. On update, moves the card into that dashboard. Exclusive with `collection_id`. 21-character entity_id of the dashboard to save the question inside. |
|
||||
| `description` | string | One or two sentences on what the question answers. |
|
||||
| `display` | string | One of: `table`, `list`, `bar`, `line`, `pie`, `scatter`, `area`, `row`, `combo`, `pivot`, `scalar`, `smartscalar`, `gauge`, `progress`, `funnel`, `map`, `waterfall`, `sankey`. Visualization type. learn("visualization-settings") covers the choice. |
|
||||
| `id` | integer or string | Numeric id of the question to update. 21-character entity_id of the question to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new question or model (requires `name` and exactly one of `query_handle`, `query`, or `native`); "update" edits the one named by `id`. |
|
||||
| `name` | string | Question title. Required on create. |
|
||||
| `native` | object | A native SQL query to save: {database_id, sql, template_tags?}. Requires the agent:sql:run scope and the mcp-execute-sql-enabled setting. Call learn("native-parameters") before first passing template_tags. |
|
||||
| `query` | object | An inline query with numeric ids and a top-level database id (learn("query-dialect")). Prefer `query_handle`. |
|
||||
| `query_handle` | string | A handle returned by execute_query, execute_sql, or visualize_query. The preferred query source on create: it saves exactly the query that tool validated. |
|
||||
| `visualization_settings` | object | Display settings for the chosen `display`; learn("visualization-settings") lists the keys. |
|
||||
|
||||
## Render drill through
|
||||
|
||||
- Tool name: `render_drill_through`
|
||||
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
|
||||
- Read-only.
|
||||
- Interactive: renders a chart inline in your AI client. Only available in clients that support inline visualizations.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| -------------- | ------ | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `query_handle` | string | The handle UUID from the user's drill-through message. Pass it through verbatim — do not run the query yourself. |
|
||||
|
||||
## Run saved question
|
||||
|
||||
- Tool name: `run_saved_question`
|
||||
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | integer or string | Numeric card id. A 21-character entity_id. |
|
||||
| `parameters` | array of object | Parameter values to apply, each {id, value} where id is the parameter's id or slug. The card's stored target and type always apply. Discover a card's parameters with get_content. |
|
||||
| `row_limit` | integer | Maximum rows to return in this call (default 100, max 2000). |
|
||||
|
||||
## Search
|
||||
|
||||
- Tool name: `search`
|
||||
- Permission scope: `agent:content:read` — See your Metabase content and data structure
|
||||
- Read-only.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | true searches the trash instead of active content. |
|
||||
| `collection_id` | integer or string | Numeric collection id. A 21-character entity_id, or "root" for no scoping. |
|
||||
| `created_by` | string | One of: `me`. "me" restricts results to items you created. Only question, model, metric, dashboard, document, measure, and action index a creator. |
|
||||
| `fields` | array of string | Dot-paths picked from the detailed row shape, item-relative (e.g. "collection.name"). Mutually exclusive with response_format. |
|
||||
| `limit` | integer | Maximum results to return (default 20, max 50). |
|
||||
| `offset` | integer | Number of results to skip, for paging (default 0). |
|
||||
| `recent` | boolean | true returns your recently viewed items instead of searching. Combines with type (only question, model, metric, dashboard, document, collection, table are tracked) but not with queries or other filters. Reflects Metabase UI views only — content you read through these tools is not recorded as a view, so a fresh token's recents can be empty even after get_content calls in the same session. |
|
||||
| `response_format` | string | One of: `concise`, `detailed`. concise (default) returns {type, id, name, collection_path, description} rows; detailed returns the full search-result rows. |
|
||||
| `semantic_queries` | array of string | A natural-language query matched by semantic similarity when a semantic engine is active (keyword-ranked otherwise). Each query runs separately; results are merged by rank. |
|
||||
| `term_queries` | array of string | A keyword query matched against names and descriptions via full-text search. Each query runs separately; results are merged by rank. |
|
||||
| `type` | array of string | One of: `question`, `model`, `metric`, `measure`, `segment`, `dashboard`, `document`, `collection`, `table`, `database`, `snippet`, `transform`, `action`. Restrict results to these entity types. "snippet" is served by a separate listing (snippets aren't in the search index), requires the agent:content:read scope, and must be requested on its own — combining it with other types is an error. Omit to search every type this tool supports except snippets. |
|
||||
|
||||
## Segment write
|
||||
|
||||
- Tool name: `segment_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Can overwrite or delete existing data or content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| ------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true moves it to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
|
||||
| `clear` | array of string | One of: `description`. Update only: property names to unset (description). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `definition` | object or array | Either (a) an array of filter clauses — what get_content's "definition" include returns for a segment and what execute_query takes in stages[0].filters — reassembled onto `table_id`; or (b) a full single-stage query. Filters only: no aggregations, breakouts, joins, expressions, or limits. May reference other segments; cycles are rejected. |
|
||||
| `description` | string | Optional human-readable description. |
|
||||
| `id` | integer or string | Numeric id of the segment to update. 21-character entity_id of the segment to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new segment (requires `table_id`, `name`, `definition`); "update" edits the one named by `id` (requires `revision_message`). |
|
||||
| `name` | string | Create only (editable on update): display name of the segment. |
|
||||
| `revision_message` | string | Update only, required: a short sentence describing the change, recorded in the revision history. |
|
||||
| `table_id` | integer | Create only: numeric table id (tables have no entity_ids). A bare-clause `definition` is reassembled into a query on this table; a full-query one must name this same source table — a mismatch is a teaching error, not silently reconciled. |
|
||||
|
||||
## Subscription write
|
||||
|
||||
- Tool name: `subscription_write`
|
||||
- Permission scope: `agent:delivery:write` — Set up scheduled delivery of your data to email addresses and Slack channels it chooses
|
||||
- Creates or changes content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `archived` | boolean | Update only: true pauses the subscription and moves it to the trash, false restores it. This is the only removal path. |
|
||||
| `channel` | string | One of: `email`, `slack`. Where to deliver. Defaults to "email" on create; on update, to the subscription's channel when it has exactly one. |
|
||||
| `dashboard_id` | integer or string | Numeric id of the dashboard to deliver. 21-character entity_id of the dashboard to deliver. |
|
||||
| `id` | integer or string | Numeric id of the subscription to update. 21-character entity_id of the subscription to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" subscribes to the dashboard named by `dashboard_id`; "update" edits the subscription named by `id`. |
|
||||
| `parameters` | array of object | Filter values applied to the dashboard before it is sent, making a filtered subscription. Only ids the dashboard actually has are accepted. |
|
||||
| `recipients` | array | Who gets the email: numeric user ids, or raw email addresses for people without a Metabase account. Defaults to you. On update this replaces the current list. Numeric id of a Metabase user. An email address. |
|
||||
| `schedule` | object | When to deliver. Metabase sends at the top of the hour, in the instance's report time zone. |
|
||||
| `skip_if_empty` | boolean | When true, no email is sent if every card comes back empty. Default false. |
|
||||
| `slack_channel` | string | Slack channel or username to post to, e.g. "data-team". Required for channel "slack". |
|
||||
|
||||
## Transform write
|
||||
|
||||
- Tool name: `transform_write`
|
||||
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
|
||||
- Creates or changes content.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| --------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `clear` | array of string | One of: `description`. Update only: property names to unset (description). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
|
||||
| `collection_id` | integer or string | Numeric id of the transform folder to file the transform in. 21-character entity_id of the transform folder, or "root" for the top level of the transforms tree. |
|
||||
| `definition` | object | The transform's source: {"type": "query", "query": …}, the shape get_content's "definition" include returns for a transform. The query inside is the same numeric-id dialect execute_query takes, and may be native SQL. Pass this or query_handle, not both. |
|
||||
| `description` | string | Optional human-readable description. |
|
||||
| `id` | integer or string | Numeric id of the transform to update. 21-character entity_id of the transform to update. |
|
||||
| `method` | string | One of: `create`, `update`. "create" makes a new transform (requires `name`, `target`, and one query source); "update" edits the one named by `id`. |
|
||||
| `name` | string | Create only (editable on update): display name of the transform. |
|
||||
| `query_handle` | string | A query_handle from execute_query or execute_sql — saves exactly the query that ran. Pass this or definition, not both. |
|
||||
| `tag_ids` | array of integer | Numeric ids of transform tags to label the transform with; jobs select transforms by tag. Replaces the current list — pass [] to clear it. |
|
||||
| `target` | object | The table the transform writes, recreated on every run. On update this patches the current target, so passing only `name` renames the table and keeps its schema. A target read back from get_content can be passed back unchanged. |
|
||||
|
||||
## Visualize query
|
||||
|
||||
- Tool name: `visualize_query`
|
||||
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
|
||||
- Read-only.
|
||||
- Interactive: renders a chart inline in your AI client. Only available in clients that support inline visualizations.
|
||||
|
||||
Arguments:
|
||||
|
||||
| Argument | Type | Description |
|
||||
| -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `prompt` | string | The user's original request, recorded with a freshly minted handle for the iframe's feedback flow. Ignored alongside `query_handle`: the stored prompt is fixed at mint time and there is no update path, so pass `prompt` on the call that mints the handle. |
|
||||
| `query` | object | A fresh query in the same dialect execute_query takes: numeric table/field ids from browse_data, never base64. Exactly one of query \| query_handle. |
|
||||
| `query_handle` | string | A query_handle from a previous execute_query / execute_sql call — visualizes the exact stored query, MBQL or native SQL. Preferred over query. Exactly one of query \| query_handle. |
|
||||
121
docs/ai/mcp.md
121
docs/ai/mcp.md
@@ -13,17 +13,19 @@ Metabase includes an [MCP (Model Context Protocol)](https://modelcontextprotocol
|
||||
|
||||
Your Metabase's MCP server is served from the `/api/metabase-mcp` endpoint.
|
||||
|
||||
If your admin has turned on [your Metabase's MCP server](#enable-mcp-server), all you need to do is point your MCP client at your Metabase's MCP server's URL.
|
||||
Once an admin has [turned on the MCP server](#turn-on-the-mcp-server), point your MCP client at:
|
||||
|
||||
```
|
||||
https://{your-metabase.example.com}/api/metabase-mcp
|
||||
```
|
||||
|
||||
Replace `{your-metabase.example.com}` with your Metabase's URL. Admins can also find your instance's MCP URL in **Admin > AI > MCP > Settings > MCP server URL**.
|
||||
Replace `{your-metabase.example.com}` with your Metabase's URL. Admins can also copy the URL from **Admin > AI > MCP > Settings > MCP server URL**.
|
||||
|
||||
Your client will direct you to an authentication page for your Metabase.
|
||||
|
||||
Once authenticated, you can approve or block the tools you want your Agent to have access to in your client (not in your Metabase).
|
||||
Once authenticated, you can approve or block the tools in your client (not in your Metabase).
|
||||
|
||||
Connected a client before Metabase 0.64? It'll ask you to sign in again after you upgrade.
|
||||
|
||||
### Connecting Claude Code to your Metabase's MCP server
|
||||
|
||||
@@ -45,6 +47,8 @@ Replace `{your-metabase-url}` with your Metabase address.
|
||||
|
||||
5. Click **Authenticate**, and authenticate with your Metabase. (You may first need to **Enable** the Metabase MCP server).
|
||||
|
||||
If Claude asks you to choose authentication options, see [Choose authentication options in Claude](#choose-authentication-options-in-claude).
|
||||
|
||||
Once authenticated, ask your agent about your Metabase. You should see your agent use Metabase tools to interact with your Metabase.
|
||||
|
||||
See [Claude Code MCP docs](https://code.claude.com/docs/en/mcp).
|
||||
@@ -53,97 +57,94 @@ See [Claude Code MCP docs](https://code.claude.com/docs/en/mcp).
|
||||
|
||||
If you use Claude on the web or Claude Desktop, go to [Claude's connector directory](https://claude.ai/directory/connectors/metabase) and enter your [Metabase's MCP URL](#connect-a-client-to-your-metabase-mcp-servers-url).
|
||||
|
||||
One of your Metabase admins will still need to have [turned on your Metabase's MCP server](#enable-mcp-server). You'll authenticate against your own Metabase during setup.
|
||||
One of your Metabase admins will still need to have [turned on your Metabase's MCP server](#turn-on-the-mcp-server). You'll authenticate against your own Metabase during setup. If Claude asks you to choose authentication options, see [Choose authentication options in Claude](#choose-authentication-options-in-claude).
|
||||
|
||||
## Available tools
|
||||
## MCP tools
|
||||
|
||||
Some clients (like Claude Desktop) will ask you to approve each tool the first time it's used. We've grouped the tools below, but your client may simply list the available tools.
|
||||
The MCP server gives your agent tools for searching, browsing, querying, and creating content in your Metabase. For the full list and the permission each one needs, see [MCP server tools](./mcp-tools.md).
|
||||
|
||||
### Interactive tools
|
||||
## Using the MCP server
|
||||
|
||||
These render inline visualizations in your AI client, and only work in clients that support inline charts.
|
||||
The MCP server will return results as either text or an inline chart, depending on the question you asked.
|
||||
|
||||
- **Render drill through** (`render_drill_through`): Render the chart a person drilled into from an inline visualization.
|
||||
- **Visualize query** (`visualize_query`): Render a query's results as an inline chart in your AI client.
|
||||
If you want the MCP server to return an inline chart, ask it to "show" or "visualize" the data:
|
||||
|
||||
### Read-only tools
|
||||

|
||||
|
||||
- **Construct query** (`construct_query`): Construct a query against a table or metric. Returns an opaque query handle that can be passed to `execute_query`.
|
||||
- **Execute query** (`execute_query`): Execute a previously constructed query and return the results with column metadata, row count, and execution time.
|
||||
- **Query tables and metrics** (`query`): Run a query and return results, paging through large result sets with a continuation token. Accepts a query handle from `construct_query`, a fresh query, or a continuation token. Paging returns up to 200 rows per page and 2,000 rows total.
|
||||
- **Read resource** (`read_resource`): Read up to five Metabase entities (or lists) per call by `metabase://` URI. List URIs (like a collection's items or a table's fields) return up to 25 items each. Entities available to read include:
|
||||
- collection
|
||||
- dashboard
|
||||
- database
|
||||
- metric
|
||||
- model
|
||||
- question
|
||||
- schema
|
||||
- table
|
||||
- transform
|
||||
- **Search tables and metrics** (`search`): Find tables and metrics using keyword or natural language search.
|
||||
Metabase picks a chart type based on the shape of the result. You can switch between bar, line, and table (when the data fits), and if the chart has a date axis, change the time range and granularity. You can also drill through the chart: zooming and sorting update the chart in place, and other drills either re-render in place or, in Claude, open the question in your Metabase. Every chart has an **Explore in Metabase** button that opens the question in Metabase.
|
||||
|
||||
### Write/delete tools
|
||||
|
||||
- **Create collection** (`create_collection`): Create a new collection, optionally nested under a parent collection.
|
||||
- **Create dashboard** (`create_dashboard`): Create a new dashboard, optionally populated with saved questions.
|
||||
- **Create question** (`create_question`): Save a query as a named question.
|
||||
- **Execute SQL** (`execute_sql`): Execute a raw SQL query against a database. Requires native-query permission on the target database. An admin can disable this tool instance-wide via the `mcp-execute-sql-enabled` setting (enabled by default).
|
||||
- **Update dashboard** (`update_dashboard`): Update a dashboard's metadata (name, description, collection, archived).
|
||||
- **Update question** (`update_question`): Update a saved question. Setting `collection_id` moves the question to another collection.
|
||||
Clients that can't render inline charts (like Claude Code) won't list the `visualize_query` tool at all. That's expected, not a sign of a broken connection.
|
||||
|
||||
## MCP server settings
|
||||
|
||||
_Admin > AI > MCP_
|
||||
|
||||
### Enable MCP server
|
||||
### Turn on the MCP server
|
||||
|
||||
From **Admin > AI**, open the **MCP** tab in the left sidebar, and use the **MCP server** toggle to turn external access to the MCP server on or off.
|
||||
From **Admin > AI**, open the **MCP** tab in the left sidebar, and use the **MCP server** toggle to turn the MCP server on or off.
|
||||
|
||||
The MCP server also requires that [AI features](./overview.md) are enabled for your instance. You don't need to configure an AI provider to use the MCP server, but if **AI features** are turned off in **Admin > AI**, the MCP server stays off too.
|
||||
The MCP server also needs [AI features](./overview.md) on. If **Disable all AI features** is on in **Admin > AI**, the MCP server stays off and the **MCP** settings are grayed out.
|
||||
|
||||
You don't need an [AI provider](settings.md#choose-ai-provider) set up in Metabase to use the MCP server. Your client brings the model; Metabase just brings the tools. So MCP calls don't count against your Metabase's AI connection, even if you've set up a provider for Metabot. Results from the MCP server go to your client, which may forward them to an AI provider depending on how the client is set up. See [AI privacy](./privacy.md).
|
||||
|
||||
### Show inline charts in these MCP clients
|
||||
|
||||
These toggles control which browser-based clients can display [inline charts](#using-the-mcp-server) generated by Metabase. Switch on any of the supported clients:
|
||||
|
||||
- **Claude** (Claude Desktop and Claude on the web)
|
||||
- **Claude** (Claude Desktop and Claude on the web). In Claude Desktop, use Chat, _not_ Cowork: inline charts don't work in Cowork.
|
||||
- **Cursor and VS Code**
|
||||
- **ChatGPT**
|
||||
|
||||
Toggling on a client automatically adds that client's sandbox domains to Metabase's CORS allowlist, which is what lets the client render Metabase's charts in its browser sandbox.
|
||||
Turning on a client adds its domains to Metabase's CORS allowlist, so the client's browser sandbox can load Metabase's charts.
|
||||
|
||||
These toggles only control inline charts; they don't gate whether a client can connect. Any MCP client can connect to your MCP server (subject to [authentication](#authentication)), and clients that run outside the browser (like Claude Code on your own machine) don't need a CORS allowlist entry at all.
|
||||
These toggles only control inline charts; they don't gate whether a client can connect. Any MCP client can connect (subject to [authentication](#authentication)), and clients that run outside the browser (like Claude Code on your own machine) don't need an allowlist entry at all.
|
||||
|
||||
### Custom inline chart origins
|
||||
### Allowed origins for custom MCP clients
|
||||
|
||||
The **Custom inline chart origins** field is for browser-based MCP clients that render [inline charts](#using-the-mcp-server) but aren't in the supported list (like a self-hosted client).
|
||||
Use the **Allowed origins for custom MCP clients** field for browser-based clients that render [inline charts](#using-the-mcp-server) but aren't in the list above (like a self-hosted client). It adds the origin to the same allowlist as the toggles.
|
||||
|
||||

|
||||
|
||||
Adding an origin here puts it on Metabase's CORS allowlist, just like toggling on one of the supported clients. Clients that run outside the browser (like Claude Code on your own machine) don't need an entry here.
|
||||
|
||||
Add the client's origin to the field. Separate values with a space, for example:
|
||||
Add the client's origin, separating multiple origins with spaces:
|
||||
|
||||
```
|
||||
https://mcp.internal.example.com https://*.staging.example.com
|
||||
```
|
||||
|
||||
The field accepts wildcards (`*`) for subdomains. Changes take effect in about a minute. Might be a good time to get up and pour yourself a glass of water.
|
||||
Wildcards (`*`) work for subdomains. Origins can't include a path. Changes take effect within a minute.
|
||||
|
||||
## Authentication
|
||||
|
||||
MCP clients authenticate with Metabase using OAuth 2.0. Metabase runs its own embedded OAuth server, so you don't need to set up an external OAuth provider.
|
||||
|
||||
Your MCP client should try to connect to your Metabase. You'll see a Metabase-branded consent page asking you to approve the connection to your Metabase.
|
||||
|
||||
A first-time connection will go something like this:
|
||||
|
||||
1. The client discovers Metabase's OAuth endpoints.
|
||||
2. The client registers itself with Metabase.
|
||||
3. You're redirected to Metabase to log in (if you aren't already) and approve the connection.
|
||||
3. You're redirected to a Metabase consent page to log in (if you aren't already) and approve the connection.
|
||||
4. The client receives an access token scoped to the permissions you have in Metabase.
|
||||
|
||||
Results returned by the MCP server are sent to your MCP client, which may forward them to an AI provider depending on how the client is configured. See [AI privacy](./privacy.md).
|
||||
### Choose authentication options in Claude
|
||||
|
||||
If Claude asks you to choose authentication options when you add the Metabase connector, keep the detected defaults:
|
||||
|
||||
- **Authentication**: **Sign in now**. None of the tools work without an account, so **Sign in when needed** just delays the same sign-in, and **No sign-in** won't connect.
|
||||
- **OAuth client**: **Register automatically**. Metabase supports dynamic client registration, but not **Use Claude's published identity** or **Use your own OAuth client**.
|
||||
- **Request headers**: leave empty. The OAuth token is all Metabase needs.
|
||||
|
||||
### Permission scopes
|
||||
|
||||
The consent page lists the permission scopes the client is asking for. Each [tool](./mcp-tools.md) needs one of these:
|
||||
|
||||
- `agent:content:read`: see your Metabase content and data structure.
|
||||
- `agent:content:write`: create, edit, and trash Metabase content.
|
||||
- `agent:query:run`: run queries against your connected databases and see the results.
|
||||
- `agent:sql:run`: write and run raw SQL on your connected databases.
|
||||
- `agent:delivery:write`: create alerts and subscriptions that email or Slack your data. Creating one also needs `agent:query:run`, since it runs a query when it fires.
|
||||
|
||||
The consent page may also list `agent:resource:read` (shown as **Read MCP resources**). No tool needs it; it covers the server's own reference material: the list of fields your agent can ask `get_content` to return for each content type.
|
||||
|
||||
Most clients ask for all of them. A scope never grants more than your Metabase permissions allow: `agent:sql:run` won't let a client run SQL against a database you can't write native queries on.
|
||||
|
||||
## Authorization logs
|
||||
|
||||
@@ -159,27 +160,13 @@ Each record includes:
|
||||
|
||||
Use the event filter to narrow the list to a single event type.
|
||||
|
||||
## With the MCP server, your client provides the AI
|
||||
## MCP analytics
|
||||
|
||||
MCP server requests are handled by whatever AI client you're using (like a desktop AI app or editor plugin). The MCP server just provides tools (like searching for an entity or running the query) for your AI.
|
||||
On [Pro and Enterprise plans](https://www.metabase.com/pricing), admins can see how people use the MCP server. Go to **Monitor > AI Auditing > MCP analytics**.
|
||||
|
||||
For example, if you ask your AI client to use your Metabase's MCP server "what's our q3 revenue," your client will interact with the MCP server to figure out which tools it needs to field your request. Your AI can decide that it needs to use the tools **construct_query** and **execute_query**, and what those queries might be. Then your client will call those tools for Metabase to run.
|
||||
The **Usage** tab charts calls over time by client, tool, and user, plus errors by type when there are any. The **Tool calls** tab lists each call with its tool, client, user, status, duration, and error type. Error messages only show up if you've turned on PII retention.
|
||||
|
||||
You don't need to have an [AI provider](settings.md#choose-ai-provider) configured in Metabase to use your Metabase's MCP server. If you _do_ have an AI provider configured in Metabase to power Metabot, that provider will _not_ be used for MCP server requests. MCP calls by your local client have no effect on token usage for your Metabase's AI connection.
|
||||
|
||||
## Using the MCP server
|
||||
|
||||
The MCP server will return results as either text or an inline chart, depending on the question you asked.
|
||||
|
||||
If you want the MCP server to return an inline chart, ask it to "show" or "visualize" the data:
|
||||
|
||||

|
||||
|
||||
Inline charts are limited to bar, line, or table charts (which you can toggle between). You can also drill through the charts or change their time granularity. Depending on which client you're using, drilling through will either let you keep exploring the chart right there in your client, or give you a link to continue your exploration in your Metabase.
|
||||
|
||||
If your client is connected to other MCP servers, you can ask questions that combine data from multiple sources. For example, you can ask a question about your customers that combines data from Metabase, your CRM, and your support ticket platform (Though maybe you should put all that data into your Metabase).
|
||||
|
||||
See [Available tools](#available-tools) for the list of functionality supported by the MCP server.
|
||||
MCP calls don't appear in [AI usage auditing](./usage-auditing.md), since they don't go through Metabot.
|
||||
|
||||
## Use the MCP server with agent-driven development
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ See [full docs for Metabot](metabot.md) and [embedded AI chat](../embedding/ai-c
|
||||
|
||||
Connect your favorite third-party AI tool - like Claude or Codex - to the Metabase MCP server. MCP servers are designed for answering in-the-moment questions like "hey btw what's our q3 revenue?" Metabase's MCP server is also useful when combined with other MCP servers. For example, you can ask Claude a question about your customers that combines data from Metabase, your CRM, and your support ticket platform.
|
||||
|
||||
**Controls**: Metabase MCP server requires people to authenticate into Metabase, and all the responses it provides will be scoped to their permissions. However, unlike built-in Metabot, MCP server doesn't come with granular control over which _tools_ people can use, or disable MCP server altogether.
|
||||
Compared to the built-in Metabot, the MCP server has a narrower set of tools. See [MCP server tools](mcp-tools.md) for what it can do.
|
||||
|
||||
**Controls**: People log in to Metabase to use the MCP server, and everything it returns is scoped to their permissions. Admins can [turn the MCP server off](mcp.md#turn-on-the-mcp-server) for everyone.
|
||||
|
||||
**Provider**: Requests to Metabase's MCP server are handled by the provider you choose to connect to the MCP server (e.g. Claude, Cursor, etc).
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ You pick which AI providers Metabase can use:
|
||||
- If you're **self-hosting Metabase** and want to use Metabot, you'll need to [connect to an AI provider](#connect-to-an-ai-provider) with your own credentials.
|
||||
- On **Metabase Cloud**, you can [connect to an AI provider](#connect-to-an-ai-provider) with your own credentials, [use the Metabase AI service](#metabase-ai-service), or both.
|
||||
|
||||
The providers you set up in AI settings power Metabase's built-in AI functionality, not the MCP server. With the MCP server, [your client provides the AI](mcp.md#with-the-mcp-server-your-client-provides-the-ai).
|
||||
The providers you set up here power Metabase's built-in AI features. The [MCP server](mcp.md#turn-on-the-mcp-server) doesn't use them; your MCP client brings its own model.
|
||||
|
||||
### Metabase AI service
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ A REST API for building headless, agentic BI applications on top of Metabase's s
|
||||
|
||||
Connect MCP-compatible AI clients to your Metabase.
|
||||
|
||||
## [MCP server tools](./mcp-tools.md)
|
||||
|
||||
The tools an AI client can call through the MCP server, and the permission each one needs.
|
||||
|
||||
## [Metabot in Slack](./metabot-slack.md)
|
||||
|
||||
Chat with Metabot directly in Slack — ask questions, get charts, and manage subscriptions without leaving Slack.
|
||||
|
||||
@@ -86,7 +86,7 @@ Once you're done here simply follow the link to **Take me to Metabase**. And if
|
||||
|
||||
If you click **Set up AI**, Metabase walks you through [connecting an AI provider](../ai/settings.md).
|
||||
|
||||
The providers you set up in AI settings power Metabase's built-in AI functionality, not the MCP server. With the MCP server, [your client provides the AI](../ai/mcp.md#with-the-mcp-server-your-client-provides-the-ai).
|
||||
The providers you set up here power Metabase's built-in AI features. The [MCP server](../ai/mcp.md#turn-on-the-mcp-server) doesn't use them; your MCP client brings its own model.
|
||||
|
||||
## Getting started with Metabase
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ Options:
|
||||
|
||||
Transfer data from existing H2 database to the newly created MySQL or Postgres DB specified by env vars.
|
||||
|
||||
## `mcp-tools-documentation`
|
||||
|
||||
Generates `docs/ai/mcp-tools.md`, the reference for the tools Metabase's MCP server exposes to AI clients.
|
||||
|
||||
## `migrate direction`
|
||||
|
||||
Run database migrations. Valid options for `direction` are `up`, `force`, `down`, `down-force`, `print`, or `release-locks`.
|
||||
|
||||
@@ -138,6 +138,8 @@ categories:
|
||||
url: "ai/settings"
|
||||
- name: "MCP server"
|
||||
url: "ai/mcp"
|
||||
- name: "MCP tools"
|
||||
url: "ai/mcp-tools"
|
||||
- name: "Agent API"
|
||||
url: "ai/agent-api"
|
||||
- name: "Agent-driven development"
|
||||
|
||||
@@ -174,6 +174,12 @@
|
||||
(classloader/require 'metabase.cmd.ai-provider-dox)
|
||||
((resolve 'metabase.cmd.ai-provider-dox/generate-dox!)))
|
||||
|
||||
(defn ^:command mcp-tools-documentation
|
||||
"Generates `docs/ai/mcp-tools.md`, the reference for the tools Metabase's MCP server exposes to AI clients."
|
||||
[]
|
||||
(classloader/require 'metabase.cmd.mcp-tools-dox)
|
||||
((resolve 'metabase.cmd.mcp-tools-dox/generate-dox!)))
|
||||
|
||||
(defn ^:command command-documentation
|
||||
"Generates a markdown file containing documentation for all CLI commands. This is written to a file called
|
||||
`docs/installation-and-operation/commands.md`."
|
||||
|
||||
@@ -41,6 +41,46 @@
|
||||
;; not `format`, whose grouping separator follows the default locale and so would differ between a laptop and CI
|
||||
(str/replace (str n) #"(\d)(?=(\d{3})+$)" "$1,"))
|
||||
|
||||
(defn flatten-prose
|
||||
"Collapse a run of whitespace in `s` into single spaces. Prose written as one line arrives ready to use, but a
|
||||
schema description written as a multi-line string literal carries the newlines and indentation it was written
|
||||
with, which would break out of a bullet or a table cell. Nil when nothing is left, so a blank description
|
||||
contributes nothing rather than an empty line."
|
||||
[s]
|
||||
(some-> s str str/trim not-empty (str/replace #"\s+" " ")))
|
||||
|
||||
(defn escape-liquid
|
||||
"`s` with every span the docs site's Liquid pass would swallow — `{{ … }}` and `{% … %}`, or a lone opener —
|
||||
wrapped in `{% raw %}…{% endraw %}`. Argument prose describes Metabase's own template syntax (`{{tag}}`) in
|
||||
exactly Liquid's spelling; unescaped, the site rejects unknown tags and renders the known-looking ones as blank. Balanced spans are tried first, then a bare opener, so an unclosed `{{` can't
|
||||
still break the page."
|
||||
[s]
|
||||
(str/replace (str s) #"\{\{.*?\}\}|\{%.*?%\}|\{\{|\{%" "{% raw %}$0{% endraw %}"))
|
||||
|
||||
(def ^:private fixed-case-words
|
||||
"Words that keep their capitalization in a sentence-cased title, keyed by lowercase form."
|
||||
{"sql" "SQL"
|
||||
"url" "URL"
|
||||
"uri" "URI"
|
||||
"api" "API"
|
||||
"ui" "UI"
|
||||
"id" "ID"
|
||||
"mbql" "MBQL"
|
||||
"metabase" "Metabase"})
|
||||
|
||||
(defn sentence-case
|
||||
"`title` with the first word capitalized and the rest lowered, except for the acronyms and names in
|
||||
[[fixed-case-words]], which keep their spelling wherever they fall."
|
||||
[title]
|
||||
(->> (str/split title #" ")
|
||||
(map-indexed (fn [i word]
|
||||
(let [lowered (str/lower-case word)]
|
||||
(or (get fixed-case-words lowered)
|
||||
(if (zero? i)
|
||||
(str/capitalize lowered)
|
||||
lowered)))))
|
||||
(str/join " ")))
|
||||
|
||||
(defn sentence
|
||||
"`s` as a sentence: forced out of i18n and terminated with a period. Nil when there is nothing to say, so a field
|
||||
whose text is blank contributes no stray `.` to its bullet.
|
||||
@@ -87,12 +127,22 @@
|
||||
[label body]
|
||||
(paragraphs [label body]))
|
||||
|
||||
(defn- escape-cell
|
||||
"`s` with its pipes escaped. The table owns this rather than its callers: `|` is the one character the row syntax
|
||||
reserves, and a description that contains one would otherwise split its row into extra columns."
|
||||
[s]
|
||||
(str/replace (str s) "|" "\\|"))
|
||||
|
||||
(defn table
|
||||
"A Markdown table. `rows` is a sequence of already-rendered cell vectors."
|
||||
"A Markdown table. `rows` is a sequence of already-rendered cell vectors; a cell's pipes are escaped for it, so
|
||||
callers pass their text as it reads."
|
||||
[headers rows]
|
||||
;; columns are padded to a common width: the rendered page is what reviewers read in a PR diff, and a ragged table
|
||||
;; is hard to scan there even though it renders identically
|
||||
(let [widths (apply mapv (fn [& cells] (apply max (map count cells))) headers rows)
|
||||
(let [headers (mapv escape-cell headers)
|
||||
rows (mapv #(mapv escape-cell %) rows)
|
||||
;; measured after escaping, so a cell that grew an escape still lines up with its column
|
||||
widths (apply mapv (fn [& cells] (apply max (map count cells))) headers rows)
|
||||
row->line (fn [cells]
|
||||
(str "| " (str/join " | " (map #(format (str "%-" %2 "s") %1) cells widths)) " |"))]
|
||||
(str/join "\n"
|
||||
|
||||
239
src/metabase/cmd/mcp_tools_dox.clj
Normal file
239
src/metabase/cmd/mcp_tools_dox.clj
Normal file
@@ -0,0 +1,239 @@
|
||||
(ns metabase.cmd.mcp-tools-dox
|
||||
"Generate the reference page for the tools Metabase's MCP server exposes, by running
|
||||
|
||||
clojure -M:ee:doc mcp-tools-documentation
|
||||
|
||||
The page is written from the same tool entries a client receives from `tools/list`, with full extension support so
|
||||
no tool is hidden. Each tool is first read into a plain map by [[tool->entry]]; everything after that
|
||||
renders those maps. A tool's own description is written for the model and stays off the page; the argument notes
|
||||
are the prose readers get."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase.cmd.common :as cmd.common]
|
||||
[metabase.cmd.markdown :as md]
|
||||
;; Required for its side effect: loading it loads every tool namespace, which registers itself.
|
||||
[metabase.mcp.v2.api]
|
||||
[metabase.mcp.v2.registry :as v2.registry]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private output-path "docs/ai/mcp-tools.md")
|
||||
|
||||
(def ^:private intro-resource "metabase/cmd/resources/mcp-tools-intro.md")
|
||||
|
||||
;;;; Which tools belong on the page
|
||||
|
||||
(defn- app-only?
|
||||
"Is `tool` one the MCP App calls for itself rather than one a model chooses? Such tools stay off the page."
|
||||
[tool]
|
||||
(boolean (some #{"app"} (get-in tool [:_meta :ui :visibility]))))
|
||||
|
||||
;;;; Reading a property's JSON Schema
|
||||
;;;;
|
||||
;;;; Malli puts an argument's description and enum on the wrapped schema, not the property:
|
||||
;;;; `[:maybe [:int {:description ...}]]` publishes `{:oneOf [{:type "integer" :description ...} {:type "null"}]}`.
|
||||
;;;; Everything below reads through those alternatives.
|
||||
|
||||
(defn- union-branches
|
||||
"The `oneOf`/`anyOf` alternatives of `schema`."
|
||||
[{:keys [oneOf anyOf]}]
|
||||
(concat oneOf anyOf))
|
||||
|
||||
(defn- alternatives
|
||||
"`schema` and every schema its unions reach, preorder. Does not descend into `:items`."
|
||||
[schema]
|
||||
(tree-seq map? union-branches schema))
|
||||
|
||||
(defn- own-types
|
||||
"A schema's own `:type` members, minus `null`, which marks an optional argument rather than a value."
|
||||
[{:keys [type]}]
|
||||
;; a set used as a predicate can't reject nil
|
||||
(when type
|
||||
(remove #{"null"} (if (coll? type) type [type]))))
|
||||
|
||||
(defn- object-typed?
|
||||
"Does the schema's own `:type` include `object`?"
|
||||
[schema]
|
||||
(boolean (some #{"object"} (own-types schema))))
|
||||
|
||||
(defn- property-types
|
||||
"The JSON Schema types a property accepts, distinct and in order."
|
||||
[property]
|
||||
(into [] (comp (mapcat own-types) (distinct)) (alternatives property)))
|
||||
|
||||
(defn- element-types
|
||||
"The types an array's elements accept. A nullable array keeps `:items` in its non-null branch."
|
||||
[property]
|
||||
(into [] (comp (keep :items) (mapcat property-types) (distinct)) (alternatives property)))
|
||||
|
||||
(defn- documented-elements
|
||||
"The element schemas of an array `schema` whose prose and enums belong in the property's table cell. Elements that
|
||||
can be objects contribute nothing: the cell can't show their shape."
|
||||
[schema]
|
||||
(->> (alternatives schema)
|
||||
(keep :items)
|
||||
(remove #(some object-typed? (alternatives %)))))
|
||||
|
||||
(defn- documented-schemas
|
||||
"Every schema a property's cell reads: the property's own alternatives, then those of its [[documented-elements]],
|
||||
however deeply nested."
|
||||
[property]
|
||||
(mapcat alternatives (tree-seq map? documented-elements property)))
|
||||
|
||||
(defn- property-descriptions
|
||||
"Every non-blank description among a property's [[documented-schemas]], in order and deduped."
|
||||
[property]
|
||||
(into [] (comp (keep :description) (remove str/blank?) (distinct)) (documented-schemas property)))
|
||||
|
||||
(defn- enum-values
|
||||
"The enum members among a property's [[documented-schemas]], deduped. For an action hub like `browse_data`, the
|
||||
enum is the argument's real documentation."
|
||||
[property]
|
||||
(into [] (comp (mapcat :enum) (distinct)) (documented-schemas property)))
|
||||
|
||||
;;;; From the registry to page data
|
||||
|
||||
(defn- page-prose
|
||||
"Registry prose flattened to one line and Liquid-escaped, or nil when blank. Applied where registry text enters an
|
||||
entry, so the table is padded after escaping and its columns stay aligned. Not for the intro resource, which may
|
||||
use Liquid for real."
|
||||
[s]
|
||||
(some-> (md/flatten-prose s) md/escape-liquid))
|
||||
|
||||
(defn- property->argument
|
||||
"One argument as data, from its `[name property]` entry."
|
||||
[[k property]]
|
||||
{:name (name k)
|
||||
:types (property-types property)
|
||||
:element-types (element-types property)
|
||||
:enum (enum-values property)
|
||||
:descriptions (mapv page-prose (property-descriptions property))})
|
||||
|
||||
(defn- tool-title
|
||||
"The section heading: the tool's `:title`, else its name with underscores spaced out; sentence-cased either way."
|
||||
[{:keys [title] :as tool}]
|
||||
(md/sentence-case (or title (str/replace (:name tool) "_" " "))))
|
||||
|
||||
(defn- tool-effect
|
||||
"What the tool's annotations promise about its effects: `:read-only`, `:destructive`, or `:writes`."
|
||||
[{{:keys [readOnlyHint destructiveHint]} :annotations}]
|
||||
(cond
|
||||
readOnlyHint :read-only
|
||||
destructiveHint :destructive
|
||||
:else :writes))
|
||||
|
||||
(defn- tool-scope
|
||||
"The OAuth scope `tool` requires, read from the `securitySchemes` it publishes."
|
||||
[tool]
|
||||
(-> tool :securitySchemes first :scopes first))
|
||||
|
||||
(defn- tool->entry
|
||||
"Everything the page says about `tool`, as data. `:scope` carries the consent screen's English wording under
|
||||
`:description`, the same label the manifest uses, or nil when no `defscope` registered the scope."
|
||||
[{tool-name :name :keys [inputSchema annotations] :as tool}]
|
||||
(let [{:keys [readOnlyHint idempotentHint]} annotations
|
||||
scope (tool-scope tool)]
|
||||
{:name tool-name
|
||||
:title (tool-title tool)
|
||||
:scope {:id scope :description (page-prose (v2.registry/english-scope-label scope))}
|
||||
:effect (tool-effect tool)
|
||||
;; a read is trivially repeatable, so idempotence is only worth saying about a writer
|
||||
:idempotent? (boolean (and idempotentHint (not readOnlyHint)))
|
||||
:inline-ui? (some? (get-in tool [:_meta :ui]))
|
||||
;; alphabetical, so the table doesn't churn with map order
|
||||
:arguments (mapv property->argument (sort-by (comp name key) (:properties inputSchema)))}))
|
||||
|
||||
;;;; Rendering an entry
|
||||
|
||||
(def ^:private effect-sentences
|
||||
"The bullet for each [[tool-effect]]."
|
||||
{:read-only "Read-only."
|
||||
:destructive "Can overwrite or delete existing data or content."
|
||||
;; a hint, not a contract: `document_write` carries it and rewrites whole bodies
|
||||
:writes "Creates or changes content."})
|
||||
|
||||
(defn- facts-bullets
|
||||
"The bullets under a tool's heading: how to call it, what it needs, what it does to your data."
|
||||
[{entry-name :name :keys [scope effect idempotent? inline-ui?]}]
|
||||
(md/bullets
|
||||
[(str "Tool name: " (md/code entry-name))
|
||||
(when-let [scope-description (:description scope)]
|
||||
(str "Permission scope: " (md/code (:id scope)) " — " scope-description))
|
||||
(effect-sentences effect)
|
||||
(when idempotent?
|
||||
"Running it again with the same arguments has the same effect as running it once.")
|
||||
;; clients that can't render an iframe never list such a tool
|
||||
(when inline-ui?
|
||||
(str "Interactive: renders a chart inline in your AI client. Only available in clients that support inline "
|
||||
"visualizations."))]))
|
||||
|
||||
(defn- array-type-cell
|
||||
"`array of X` for a homogeneous array, else `array`."
|
||||
[{:keys [element-types]}]
|
||||
(if (= 1 (count element-types))
|
||||
(str "array of " (first element-types))
|
||||
"array"))
|
||||
|
||||
(defn- type-cell
|
||||
"The Type column: `any` when unconstrained, [[array-type-cell]] for an array, else the types joined by `or`."
|
||||
[{:keys [types] :as argument}]
|
||||
(cond
|
||||
(empty? types) "any"
|
||||
(= ["array"] types) (array-type-cell argument)
|
||||
:else (str/join " or " types)))
|
||||
|
||||
(defn- enum-sentence
|
||||
"\"One of: `a`, `b`.\" — or nil for an argument with no enum."
|
||||
[{:keys [enum]}]
|
||||
(when (seq enum)
|
||||
(str "One of: " (str/join ", " (map md/code enum)) ".")))
|
||||
|
||||
(defn- description-cell
|
||||
"The Description column: enum, then prose, each description an [[md/sentence]] so several don't run together. An
|
||||
em dash when the schema says nothing."
|
||||
[{:keys [descriptions] :as argument}]
|
||||
(or (not-empty (md/sentences (cons (enum-sentence argument) (map md/sentence descriptions))))
|
||||
"—"))
|
||||
|
||||
(defn- argument-row
|
||||
"One table row."
|
||||
[{:keys [name] :as argument}]
|
||||
[(md/code name) (type-cell argument) (description-cell argument)])
|
||||
|
||||
(defn- arguments-markdown
|
||||
"The arguments as a table. No `Required` column: the strict schema lists every property as required so clients can
|
||||
send explicit nulls."
|
||||
[{:keys [arguments]}]
|
||||
(if (empty? arguments)
|
||||
"This tool takes no arguments."
|
||||
(md/table ["Argument" "Type" "Description"] (map argument-row arguments))))
|
||||
|
||||
(defn- tool-section
|
||||
"One tool's section: heading, facts, arguments."
|
||||
[{:keys [title] :as entry}]
|
||||
(md/paragraphs
|
||||
[(md/heading 2 title)
|
||||
(facts-bullets entry)
|
||||
(md/labeled-block "Arguments:" (arguments-markdown entry))]))
|
||||
|
||||
(defn- document-markdown
|
||||
"The intro, then a section per entry."
|
||||
[intro entries]
|
||||
(md/document (cons intro (map tool-section entries))))
|
||||
|
||||
;;;; Entry point
|
||||
|
||||
(defn generate-dox!
|
||||
"Write the MCP tool reference to `path`, defaulting to `docs/ai/mcp-tools.md`. Returns `{:path ... :tools n}`.
|
||||
Throws when the registry is empty."
|
||||
([]
|
||||
(generate-dox! output-path))
|
||||
([path]
|
||||
(printf "Generating MCP tool documentation in %s\n" path)
|
||||
(let [entries (into [] (comp (remove app-only?) (map tool->entry)) (v2.registry/list-tools))]
|
||||
(when (empty? entries)
|
||||
(throw (ex-info "No MCP tools found; metabase.mcp.v2.api no longer loads the tool namespaces" {})))
|
||||
(cmd.common/write-doc-file! path (document-markdown (cmd.common/load-resource! intro-resource) entries))
|
||||
(printf "Wrote %s (%d tools)\n" path (count entries))
|
||||
(println "Done.")
|
||||
{:path path :tools (count entries)})))
|
||||
20
src/metabase/cmd/resources/mcp-tools-intro.md
Normal file
20
src/metabase/cmd/resources/mcp-tools-intro.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: MCP server tools
|
||||
summary: The tools Metabase's MCP server exposes to AI clients, the permission each one needs, and the arguments each one takes.
|
||||
---
|
||||
|
||||
# MCP server tools
|
||||
|
||||
_This documentation was generated from source by running:_
|
||||
|
||||
```
|
||||
clojure -M:ee:doc mcp-tools-documentation
|
||||
```
|
||||
|
||||
These are the tools an AI client can call once you've [connected it to your Metabase's MCP server](./mcp.md). Every tool runs as you, scoped to your permissions, so a tool can never reach data you couldn't see in Metabase yourself.
|
||||
|
||||
Some clients (like Claude Desktop) ask you to approve or block each tool the first time it's used.
|
||||
|
||||
The argument notes here are exactly what your agent sees (which is why they sound robotic). Several tools take or return a `query_handle`. A handle stands for a query that already ran (or was validated), so your agent can visualize or save exactly that query without sending it again. By default, handles expire after 24 hours.
|
||||
|
||||
Your agent will use `execute_query` for anything Metabase's query language can express (counts, sums, grouping, filtering, joins) and `execute_sql` for the rest (window functions, CTEs, engine-specific functions), or when you ask for SQL outright.
|
||||
@@ -418,24 +418,50 @@
|
||||
|
||||
(def ^:private alert-write-args-schema
|
||||
[:map {:closed true}
|
||||
[:method [:enum "create" "update"]]
|
||||
[:id {:optional true} [:maybe [:or :int :string]]]
|
||||
[:card_id {:optional true} [:maybe [:or :int :string]]]
|
||||
[:method
|
||||
[:enum {:description (str "\"create\" makes a new alert (requires `card_id` and `schedule`); "
|
||||
"\"update\" edits the one named by `id`, changing only the fields you pass.")}
|
||||
"create" "update"]]
|
||||
[:id {:optional true}
|
||||
[:maybe [:or
|
||||
[:int {:description "Numeric id of the alert to update."}]
|
||||
[:string {:description "The same numeric id as a string. Alerts have no entity_id."}]]]]
|
||||
[:card_id {:optional true}
|
||||
[:maybe [:or
|
||||
[:int {:description "Numeric id of the saved question the alert runs. Fixed at creation."}]
|
||||
[:string {:description "21-character entity_id of the saved question the alert runs. Fixed at creation."}]]]]
|
||||
[:condition {:optional true}
|
||||
[:maybe [:map {:closed true}
|
||||
[:maybe [:map {:closed true
|
||||
:description (str "When the alert sends. Defaults to sending whenever the question returns "
|
||||
"rows; the goal conditions need a goal line on the question's chart.")}
|
||||
[:type {:optional true} [:maybe [:enum "has_result" "goal_above" "goal_below"]]]
|
||||
[:send_once {:optional true} [:maybe :boolean]]]]]
|
||||
[:schedule {:optional true}
|
||||
[:maybe [:map {:closed true}
|
||||
[:maybe [:map {:closed true
|
||||
:description (str "When the question runs, in the instance's report time zone. Required on "
|
||||
"create; never a cron string.")}
|
||||
[:schedule_type [:enum "hourly" "daily" "weekly" "monthly"]]
|
||||
[:schedule_hour {:optional true} [:maybe [:int {:min 0 :max 23}]]]
|
||||
[:schedule_minute {:optional true} [:maybe [:int {:min 0 :max 59}]]]
|
||||
[:schedule_day {:optional true} [:maybe [:enum "mon" "tue" "wed" "thu" "fri" "sat" "sun"]]]
|
||||
[:schedule_frame {:optional true} [:maybe [:enum "first" "mid" "last"]]]]]]
|
||||
[:channel {:optional true} [:maybe [:enum "email" "slack"]]]
|
||||
[:slack_channel {:optional true} [:maybe [:string {:min 1}]]]
|
||||
[:recipients {:optional true} [:maybe [:sequential [:or :int [:string {:min 1}]]]]]
|
||||
[:active {:optional true} [:maybe :boolean]]])
|
||||
[:channel {:optional true}
|
||||
[:maybe [:enum {:description (str "Where to deliver: \"email\" (default) with `recipients`, or \"slack\" with "
|
||||
"`slack_channel`. Passing any of channel, slack_channel, or recipients on "
|
||||
"update replaces the alert's delivery; omit all three to leave it alone.")}
|
||||
"email" "slack"]]]
|
||||
[:slack_channel {:optional true}
|
||||
[:maybe [:string {:min 1
|
||||
:description "Slack channel name to post to, e.g. \"#data-team\". Required for channel \"slack\"."}]]]
|
||||
[:recipients {:optional true}
|
||||
[:maybe [:sequential {:description (str "Who gets the email: numeric user ids, or email addresses. Defaults "
|
||||
"to you. On update this replaces the current list. Not used for "
|
||||
"channel \"slack\".")}
|
||||
[:or :int [:string {:min 1}]]]]]
|
||||
[:active {:optional true}
|
||||
[:maybe [:boolean {:description (str "false pauses the alert, true resumes it (resuming needs the "
|
||||
"agent:query:run scope). Defaults to true on create. Alerts have no "
|
||||
"archived state and cannot be deleted here.")}]]]])
|
||||
|
||||
(registry/deftool alert-write
|
||||
"Create or update an alert: a notification sent on a schedule when a saved question's results meet a condition.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
[metabase.users.models.user :as user]
|
||||
[metabase.users.settings :as users.settings]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -473,12 +474,31 @@
|
||||
|
||||
(def ^:private document-write-args-schema
|
||||
[:map {:closed true}
|
||||
[:method [:enum "create" "update"]]
|
||||
[:id {:optional true} [:maybe [:or :int :string]]]
|
||||
[:name {:optional true} [:maybe documents/DocumentName]]
|
||||
[:content_markdown {:optional true} [:maybe :string]]
|
||||
[:method
|
||||
[:enum {:description (str "\"create\" makes a new document (requires `name` and `content_markdown`); "
|
||||
"\"update\" edits the one named by `id`.")}
|
||||
"create" "update"]]
|
||||
[:id {:optional true}
|
||||
[:maybe [:or
|
||||
[:int {:description "Numeric id of the document to update."}]
|
||||
[:string {:description "21-character entity_id of the document to update."}]]]]
|
||||
;; `DocumentName`'s `:json-schema` override is published verbatim and carries its length bounds, so the
|
||||
;; prose goes on the `:maybe` rather than inside the override, which would drop them.
|
||||
[:name {:optional true}
|
||||
[:maybe {:description "Document title. Required on create; on update, renames it."}
|
||||
documents/DocumentName]]
|
||||
[:content_markdown {:optional true}
|
||||
[:maybe [:string {:description (str "The full body in Metabase-flavored Markdown: CommonMark plus card "
|
||||
"embeds, entity links, and ::: layout containers (learn(\"documents\")). "
|
||||
"Required on create. On update it is a deliberate full-body rewrite "
|
||||
"that orphans every comment thread anchored to the body; pass `edits` "
|
||||
"to change text in place instead.")}]]]
|
||||
[:edits {:optional true}
|
||||
[:maybe [:sequential
|
||||
[:maybe [:sequential {:description (str "Update only: surgical text edits, each {old_str, new_str, "
|
||||
"replace_all?}, applied in order against the current server-side "
|
||||
"Markdown. Exactly one of `edits` or `content_markdown`. An empty "
|
||||
"list changes only name, collection_id, collection_position, or "
|
||||
"archived without touching the body.")}
|
||||
[:map
|
||||
[:old_str :string]
|
||||
[:new_str :string]
|
||||
@@ -490,9 +510,18 @@
|
||||
;; Kept as an `:or` so the generated JSON schema still shows both accepted shapes, which is what
|
||||
;; the agent reads. The humanized message lists each branch rather than one sentence; an
|
||||
;; `:error/message` on the `:or` itself is ignored by Malli's humanizer.
|
||||
[:collection_id {:optional true} [:maybe [:or ms/PositiveInt :string]]]
|
||||
[:collection_position {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:archived {:optional true} [:maybe :boolean]]
|
||||
;; `PositiveInt`'s own `:description` is its humanized error message, so it is replaced here
|
||||
;; with prose while its `:error/fn` is kept.
|
||||
[:collection_id {:optional true}
|
||||
[:maybe [:or
|
||||
(mu/with ms/PositiveInt
|
||||
{:description "Numeric id of the collection to put it in. Omit on create for your personal collection."})
|
||||
[:string {:description "Collection entity_id, or \"root\" for the top-level collection."}]]]]
|
||||
[:collection_position {:optional true}
|
||||
[:maybe (mu/with ms/PositiveInt
|
||||
{:description "Pin position within the collection; omit to leave it unpinned."})]]
|
||||
[:archived {:optional true}
|
||||
[:maybe [:boolean {:description "Update only: true moves it to the trash, false restores it."}]]]
|
||||
[:clear {:optional true}
|
||||
[:maybe [:sequential [:enum {:description (str "Update only: property names to unset "
|
||||
"(collection_position). A null cannot say this — "
|
||||
|
||||
@@ -595,7 +595,9 @@ Dialect (JSON): tables and columns go by NUMERIC ID (from browse_data list_table
|
||||
[:int {:min 1 :description "Numeric card id."}]
|
||||
[:string {:min 1 :description "A 21-character entity_id."}]]]
|
||||
[:parameters {:optional true}
|
||||
[:maybe [:sequential
|
||||
[:maybe [:sequential {:description (str "Parameter values to apply, each {id, value} where id is the "
|
||||
"parameter's id or slug. The card's stored target and type "
|
||||
"always apply. Discover a card's parameters with get_content.")}
|
||||
[:map
|
||||
[:id {:optional true}
|
||||
[:maybe [:string {:min 1 :description "The parameter's id (or slug) from the card's parameter list."}]]]
|
||||
|
||||
@@ -486,13 +486,27 @@
|
||||
|
||||
(def ^:private question-write-args-schema
|
||||
[:map {:closed true}
|
||||
[:method [:enum "create" "update"]]
|
||||
[:id {:optional true} [:maybe [:or :int :string]]]
|
||||
[:card_type {:optional true} [:maybe [:enum "question" "model"]]]
|
||||
[:query_handle {:optional true} [:maybe :string]]
|
||||
[:query {:optional true} [:maybe :map]]
|
||||
[:method
|
||||
[:enum {:description (str "\"create\" makes a new question or model (requires `name` and exactly one of "
|
||||
"`query_handle`, `query`, or `native`); \"update\" edits the one named by `id`.")}
|
||||
"create" "update"]]
|
||||
[:id {:optional true}
|
||||
[:maybe [:or
|
||||
[:int {:description "Numeric id of the question to update."}]
|
||||
[:string {:description "21-character entity_id of the question to update."}]]]]
|
||||
[:card_type {:optional true}
|
||||
[:maybe [:enum {:description "\"question\" (default) or \"model\"."} "question" "model"]]]
|
||||
[:query_handle {:optional true}
|
||||
[:maybe [:string {:description (str "A handle returned by execute_query, execute_sql, or visualize_query. The "
|
||||
"preferred query source on create: it saves exactly the query that tool "
|
||||
"validated.")}]]]
|
||||
[:query {:optional true}
|
||||
[:maybe [:map {:description (str "An inline query with numeric ids and a top-level database id "
|
||||
"(learn(\"query-dialect\")). Prefer `query_handle`.")}]]]
|
||||
[:native {:optional true}
|
||||
[:maybe [:map
|
||||
[:maybe [:map {:description (str "A native SQL query to save: {database_id, sql, template_tags?}. Requires "
|
||||
"the agent:sql:run scope and the mcp-execute-sql-enabled setting. Call "
|
||||
"learn(\"native-parameters\") before first passing template_tags.")}
|
||||
[:database_id [:or :int :string]]
|
||||
[:sql [:string {:min 1}]]
|
||||
[:template_tags {:optional true}
|
||||
@@ -522,20 +536,39 @@
|
||||
"\"id\".")}]]]
|
||||
[:required {:optional true} [:maybe :boolean]]
|
||||
[:default {:optional true} [:maybe :any]]]]]]]]]
|
||||
[:name {:optional true} [:maybe [:string {:min 1}]]]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:collection_id {:optional true} [:maybe [:or :int :string]]]
|
||||
[:dashboard_id {:optional true} [:maybe [:or :int :string]]]
|
||||
[:collection_position {:optional true} [:maybe :int]]
|
||||
[:display {:optional true} [:maybe common/card-display-enum]]
|
||||
[:visualization_settings {:optional true} [:maybe :map]]
|
||||
[:cache_ttl {:optional true} [:maybe :int]]
|
||||
[:archived {:optional true} [:maybe :boolean]]
|
||||
[:name {:optional true} [:maybe [:string {:min 1 :description "Question title. Required on create."}]]]
|
||||
[:description {:optional true}
|
||||
[:maybe [:string {:description "One or two sentences on what the question answers."}]]]
|
||||
[:collection_id {:optional true}
|
||||
[:maybe [:or
|
||||
[:int {:description (str "Numeric id of the collection to save it in. Omit on create for your "
|
||||
"personal collection. Exclusive with `dashboard_id`.")}]
|
||||
[:string {:description "Collection entity_id, or \"root\" for the top-level collection."}]]]]
|
||||
[:dashboard_id {:optional true}
|
||||
[:maybe [:or
|
||||
[:int {:description (str "Numeric id of a dashboard to save the question inside; it inherits that "
|
||||
"dashboard's collection. On update, moves the card into that dashboard. "
|
||||
"Exclusive with `collection_id`.")}]
|
||||
[:string {:description "21-character entity_id of the dashboard to save the question inside."}]]]]
|
||||
[:collection_position {:optional true}
|
||||
[:maybe [:int {:description "Pin position within the collection; omit to leave it unpinned."}]]]
|
||||
[:display {:optional true}
|
||||
[:maybe {:description "Visualization type. learn(\"visualization-settings\") covers the choice."}
|
||||
common/card-display-enum]]
|
||||
[:visualization_settings {:optional true}
|
||||
[:maybe [:map {:description (str "Display settings for the chosen `display`; learn(\"visualization-settings\") "
|
||||
"lists the keys.")}]]]
|
||||
[:cache_ttl {:optional true}
|
||||
[:maybe [:int {:description (str "Legacy per-question cache TTL. Stored and echoed back, but no longer "
|
||||
"read: caching is configured through cache policies in admin settings.")}]]]
|
||||
[:archived {:optional true}
|
||||
[:maybe [:boolean {:description "Update only: true moves it to the trash, false restores it."}]]]
|
||||
[:clear {:optional true}
|
||||
[:maybe [:sequential [:enum {:description "Update only: property names to unset (description, collection_position, cache_ttl). Needed because a null cannot say \"clear this\" — strict clients fill every unset property with null, so nulls are stripped at the boundary."}
|
||||
"description" "collection_position" "cache_ttl"]]]]
|
||||
[:column_metadata {:optional true}
|
||||
[:maybe [:sequential
|
||||
[:maybe [:sequential {:description (str "Per-column result metadata to set, matched to the query's result "
|
||||
"columns by name. Typically used with card_type \"model\".")}
|
||||
[:map
|
||||
[:name [:string {:min 1}]]
|
||||
[:display_name {:optional true} [:maybe :string]]
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
(thunk)))
|
||||
|
||||
(deftest ^:parallel error-message-test
|
||||
(is (=? ["Unrecognized command: 'a-command-that-does-not-exist'"
|
||||
#"\QValid commands: version, help, import,\E.*"]
|
||||
(#'cmd/validate "a-command-that-does-not-exist" [])))
|
||||
;; the command list comes from `ns-interns`, so its order is the map's, not the source's — assert that
|
||||
;; commands are listed, not where they happen to fall
|
||||
(let [[message commands] (#'cmd/validate "a-command-that-does-not-exist" [])]
|
||||
(is (= "Unrecognized command: 'a-command-that-does-not-exist'" message))
|
||||
(is (re-find #"^Valid commands: " commands))
|
||||
(doseq [command ["version" "help" "import" "mcp-tools-documentation"]]
|
||||
(is (re-find (re-pattern (str "\\b" command "\\b")) commands)
|
||||
(str "no " command " in the command list"))))
|
||||
(is (= ["The 'rotate-encryption-key' command requires the following arguments: [new-key], but received: []."]
|
||||
(#'cmd/validate "rotate-encryption-key" [])))
|
||||
(is (nil? (#'cmd/validate "rotate-encryption-key" [:some-arg]))))
|
||||
|
||||
@@ -45,6 +45,32 @@
|
||||
(is (= "## Anthropic" (md/heading 2 "Anthropic")))
|
||||
(is (= "### `MB_ADMIN_EMAIL`" (md/heading 3 (md/code "MB_ADMIN_EMAIL"))))))
|
||||
|
||||
(deftest ^:parallel sentence-case-test
|
||||
(are [expected title] (= expected (md/sentence-case title))
|
||||
"Search Metabase content" "Search Metabase Content"
|
||||
;; acronyms keep their spelling wherever they fall, whichever convention the title came from
|
||||
"Execute SQL" "execute sql"
|
||||
"Execute SQL" "Execute Sql"
|
||||
"Refresh UI credential" "refresh ui credential"
|
||||
;; a word that merely contains an acronym is untouched
|
||||
"Validate idea" "validate idea"))
|
||||
|
||||
(deftest ^:parallel escape-liquid-test
|
||||
;; the docs site runs Liquid over every page before Markdown, so prose that quotes Metabase's template syntax
|
||||
;; has to be fenced or the site either drops it or refuses the page
|
||||
(are [expected s] (= expected (md/escape-liquid s))
|
||||
"Bind {% raw %}{{tag}}{% endraw %} first." "Bind {{tag}} first."
|
||||
"{% raw %}{% card id=1 %}{% endraw %} embeds" "{% card id=1 %} embeds"
|
||||
"{% raw %}{{snippet: …}}{% endraw %} and {% raw %}{{#123}}{% endraw %}" "{{snippet: …}} and {{#123}}"
|
||||
;; two spans on one line are fenced separately, not swallowed into one greedy match
|
||||
"{% raw %}{{a}}{% endraw %} then {% raw %}{{b}}{% endraw %}" "{{a}} then {{b}}"
|
||||
;; an opener with no closer would still break the page, so it is fenced on its own
|
||||
"stray {% raw %}{{{% endraw %} brace" "stray {{ brace"
|
||||
;; single braces are Markdown-safe and mean something in document layout syntax
|
||||
"::: flex {columns=[60,40]}" "::: flex {columns=[60,40]}"
|
||||
"plain" "plain"
|
||||
"" nil))
|
||||
|
||||
(deftest ^:parallel thousands-test
|
||||
(testing "large numbers are grouped without leaning on the default locale"
|
||||
(is (= "1,000,000" (md/thousands 1000000)))
|
||||
|
||||
320
test/metabase/cmd/mcp_tools_dox_test.clj
Normal file
320
test/metabase/cmd/mcp_tools_dox_test.clj
Normal file
@@ -0,0 +1,320 @@
|
||||
(ns metabase.cmd.mcp-tools-dox-test
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase.cmd.mcp-tools-dox :as mcp-tools-dox]
|
||||
[metabase.mcp.v2.registry :as v2.registry]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- documented-tools
|
||||
"The registry entries the page documents."
|
||||
[]
|
||||
(remove #'mcp-tools-dox/app-only? (v2.registry/list-tools)))
|
||||
|
||||
(defn- argument
|
||||
"`property` read as the page reads an argument."
|
||||
[property]
|
||||
(#'mcp-tools-dox/property->argument [:arg property]))
|
||||
|
||||
(defn- with-scope
|
||||
"`tool` requiring the OAuth `scope`, published the way the registry publishes it."
|
||||
[tool scope]
|
||||
(assoc tool :securitySchemes [{:type "oauth2" :scopes [scope]}]))
|
||||
|
||||
(def ^:private search-tool
|
||||
"A minimal documentable registry entry."
|
||||
(with-scope {:name "search" :description "Search." :annotations {:readOnlyHint true} :inputSchema {}}
|
||||
"agent:content:read"))
|
||||
|
||||
;;;; Titles
|
||||
|
||||
(deftest ^:parallel tool-title-test
|
||||
(testing "a tool's own title wins, lowered to the sentence case a heading wants"
|
||||
(is (= "Search Metabase content" (#'mcp-tools-dox/tool-title {:name "search" :title "Search Metabase Content"}))))
|
||||
(testing "without a title, the snake_case name is spaced out and sentence-cased"
|
||||
;; heading a section `render_drill_through` would read as a slug rather than a name
|
||||
(is (= "Render drill through" (#'mcp-tools-dox/tool-title {:name "render_drill_through"})))
|
||||
(is (= "Get parameter values" (#'mcp-tools-dox/tool-title {:name "get_parameter_values"}))))
|
||||
(testing "acronyms survive the sentence-casing, whichever convention the title came from"
|
||||
(is (= "Execute SQL" (#'mcp-tools-dox/tool-title {:name "execute_sql"})))
|
||||
(is (= "Execute SQL" (#'mcp-tools-dox/tool-title {:name "execute_sql" :title "Execute Sql"})))))
|
||||
|
||||
;;;; From the registry to page data
|
||||
|
||||
(deftest ^:parallel tool->entry-test
|
||||
(testing "a registry entry becomes a flat map of what the page says about it"
|
||||
(is (= {:name "search"
|
||||
:title "Search"
|
||||
:scope {:id "agent:content:read" :description "See your Metabase content and data structure"}
|
||||
:effect :read-only
|
||||
:idempotent? false
|
||||
:inline-ui? false
|
||||
:arguments []}
|
||||
(#'mcp-tools-dox/tool->entry search-tool))))
|
||||
(testing "the tool's own description is written for the model and is not carried onto the page"
|
||||
(is (not (contains? (#'mcp-tools-dox/tool->entry search-tool) :description))))
|
||||
(testing "a scope no `defscope` registered has no wording"
|
||||
(is (= {:id "agent:nope" :description nil}
|
||||
(:scope (#'mcp-tools-dox/tool->entry (with-scope search-tool "agent:nope"))))))
|
||||
(testing "a tool publishing a :_meta :ui block is interactive"
|
||||
(is (true? (:inline-ui? (#'mcp-tools-dox/tool->entry
|
||||
(assoc search-tool :_meta {:ui {:resourceUri "ui://metabase/visualize-query.html"}}))))))
|
||||
(testing "arguments come out alphabetically, whatever order the schema's map has"
|
||||
(is (= ["a" "b" "c"]
|
||||
(map :name (:arguments (#'mcp-tools-dox/tool->entry
|
||||
(assoc search-tool :inputSchema {:properties {:c {} :a {} :b {}}}))))))))
|
||||
|
||||
(deftest ^:parallel tool-effect-test
|
||||
(are [expected annotations] (= expected (#'mcp-tools-dox/tool-effect {:annotations annotations}))
|
||||
:read-only {:readOnlyHint true}
|
||||
;; `destructiveHint false` is a hint: `document_write` carries it and rewrites whole bodies, so the page
|
||||
;; promises nothing about what a writer won't touch
|
||||
:writes {:destructiveHint false}
|
||||
:destructive {:destructiveHint true}
|
||||
;; the registry always merges `destructiveHint false` in, so a tool with neither hint is a writer
|
||||
:writes {}))
|
||||
|
||||
(deftest ^:parallel idempotent-test
|
||||
(are [expected annotations] (= expected (:idempotent? (#'mcp-tools-dox/tool->entry
|
||||
(assoc search-tool :annotations annotations))))
|
||||
;; idempotence is worth saying about a writer
|
||||
true {:destructiveHint true :idempotentHint true}
|
||||
;; but a read-only tool doesn't stutter about being repeatable
|
||||
false {:readOnlyHint true :idempotentHint true}
|
||||
;; and a writer that doesn't claim it gets no line
|
||||
false {:destructiveHint false}))
|
||||
|
||||
;;;; Facts
|
||||
|
||||
(deftest ^:parallel facts-bullets-test
|
||||
(let [bullets (fn [tool] (#'mcp-tools-dox/facts-bullets (#'mcp-tools-dox/tool->entry tool)))]
|
||||
(testing "the scope is published with the wording the consent screen uses"
|
||||
(is (= (str "- Tool name: `search`\n"
|
||||
"- Permission scope: `agent:content:read` — See your Metabase content and data structure\n"
|
||||
"- Read-only.")
|
||||
(bullets search-tool))))
|
||||
(testing "a scope with no wording contributes no bullet"
|
||||
(is (not (str/includes? (bullets (with-scope search-tool "agent:nope")) "Permission scope"))))
|
||||
(testing "a writer says what it does to data, and whether repeating it is safe"
|
||||
(is (= (str "- Tool name: `search`\n"
|
||||
"- Can overwrite or delete existing data or content.\n"
|
||||
"- Running it again with the same arguments has the same effect as running it once.")
|
||||
(bullets (assoc (with-scope search-tool "agent:nope")
|
||||
:annotations {:destructiveHint true :idempotentHint true})))))
|
||||
(testing "an interactive tool says so; the page has no section grouping such tools, so each one says it for itself"
|
||||
(is (str/includes? (bullets (assoc search-tool :_meta {:ui {:resourceUri "ui://x"}})) "- Interactive:"))
|
||||
(is (not (str/includes? (bullets search-tool) "Interactive:"))))))
|
||||
|
||||
;;;; Argument types
|
||||
|
||||
(deftest ^:parallel type-cell-test
|
||||
(are [expected property] (= expected (#'mcp-tools-dox/type-cell (argument property)))
|
||||
"string" {:type "string"}
|
||||
;; the strict-tool transform spells "optional" as nullable; `null` isn't a value a caller passes
|
||||
"string" {:type ["string" "null"]}
|
||||
"array of string" {:type "array" :items {:type "string"}}
|
||||
"array" {:type "array"}
|
||||
;; a nullable array keeps its `:items` inside the non-null branch
|
||||
"array of integer" {:oneOf [{:type "array" :items {:type "integer"}} {:type "null"}]}
|
||||
"string or array" {:oneOf [{:type "string"} {:type "array" :items {:type "string"}}]}
|
||||
"object" {:type "object" :properties {:a {:type "string"}}}
|
||||
"any" {}))
|
||||
|
||||
;;;; Argument descriptions
|
||||
;;;;
|
||||
;;;; A `deftool` writes its prose on the schema it wraps, not on the property, and Malli leaves it there. These
|
||||
;;;; pin the three shapes that puts it in, because reading `(:description property)` alone finds none of them.
|
||||
|
||||
(deftest ^:parallel property-descriptions-test
|
||||
(are [expected property] (= expected (#'mcp-tools-dox/property-descriptions property))
|
||||
["Plain."] {:description "Plain."}
|
||||
;; `[:maybe [:int {:description ...}]]`
|
||||
["Nullable."] {:oneOf [{:type "integer" :description "Nullable."} {:type "null"}]}
|
||||
;; `[:sequential [:string {:description ...}]]`
|
||||
["Per item."] {:type "array" :items {:type "string" :description "Per item."}}
|
||||
;; `[:maybe [:sequential [:string {:description ...}]]]` — both wrappers at once
|
||||
["Both."] {:oneOf [{:type "array" :items {:type "string" :description "Both."}} {:type "null"}]}
|
||||
;; `[:maybe [:or [:int {...}] [:string {...}]]]` — one per branch, both worth keeping
|
||||
["An id." "An entity_id."]
|
||||
{:oneOf [{:oneOf [{:type "integer" :description "An id."}
|
||||
{:type "string" :description "An entity_id."}]}
|
||||
{:type "null"}]}
|
||||
;; the same prose reached twice is said once
|
||||
["Once."] {:oneOf [{:type "string" :description "Once."} {:type "string" :description "Once."}]}
|
||||
;; an array of objects keeps the array's own prose and drops the elements': the table can't show the
|
||||
;; nested shape, and `dashboard_write`'s `ops` is a union of two dozen such objects
|
||||
["The list."] {:type "array" :description "The list." :items {:type "object" :description "One item."}}
|
||||
["The list."] {:type "array" :description "The list."
|
||||
:items {:oneOf [{:type "object" :description "Op A."} {:type "object" :description "Op B."}]}}
|
||||
;; and a `:multi` of objects drops the wrapper's prose along with its branches'
|
||||
["The list."] {:type "array" :description "The list."
|
||||
:items {:description "One op." :oneOf [{:type "object" :description "Op A."}]}}
|
||||
;; nested arrays are read all the way down
|
||||
["Row." "Cell."] {:type "array" :description "Row." :items {:type "array" :items {:type "string" :description "Cell."}}}
|
||||
;; but a nullable object argument keeps its prose — that's the property's own shape, not a nested one
|
||||
["Chain filtering."] {:oneOf [{:type "object" :description "Chain filtering."} {:type "null"}]}
|
||||
[] {:type "string"}))
|
||||
|
||||
(deftest ^:parallel enum-values-test
|
||||
(are [expected property] (= expected (#'mcp-tools-dox/enum-values property))
|
||||
["create" "update"] {:type "string" :enum ["create" "update"]}
|
||||
["create" "update"] {:oneOf [{:type "string" :enum ["create" "update"]} {:type "null"}]}
|
||||
["a" "b"] {:type "array" :items {:type "string" :enum ["a" "b"]}}
|
||||
;; object elements are opaque to the cell, enums included
|
||||
[] {:type "array" :items {:type "object" :enum ["a"]}}
|
||||
[] {:type "string"}))
|
||||
|
||||
(deftest ^:parallel description-cell-test
|
||||
(are [expected property] (= expected (#'mcp-tools-dox/description-cell (argument property)))
|
||||
;; an enum publishes what it accepts ahead of its prose: the v2 surface dispatches on these, so the
|
||||
;; members are the argument's real documentation
|
||||
"One of: `create`, `update`. What to do." {:type "string" :enum ["create" "update"] :description "What to do."}
|
||||
;; a description buried in a nullable branch is found rather than dashed out: the shape of every optional
|
||||
;; v2 argument
|
||||
"Max rows." {:oneOf [{:type "integer" :description "Max rows."} {:type "null"}]}
|
||||
;; and one on an array's items
|
||||
"A keyword query." {:type "array" :items {:type "string" :description "A keyword query."}}
|
||||
;; prose is flattened and Liquid-fenced
|
||||
"Use {% raw %}{{tag}}{% endraw %}." {:type "string" :description "Use\n {{tag}}."}
|
||||
;; each description is terminated, so a property's prose and its items' prose don't run together
|
||||
"Max rows." {:type "string" :description "Max rows"}
|
||||
"Keywords. One per term." {:type "array" :description "Keywords" :items {:type "string" :description "One per term"}}
|
||||
;; a dangling lead-in reads as a period once it stands alone
|
||||
"Pass one of." {:type "string" :description "Pass one of:"}
|
||||
;; nothing at all is an em dash, not an empty cell
|
||||
"—" {:type "string"}))
|
||||
|
||||
(deftest ^:parallel arguments-markdown-test
|
||||
(let [markdown (fn [input-schema]
|
||||
(#'mcp-tools-dox/arguments-markdown (#'mcp-tools-dox/tool->entry (assoc search-tool :inputSchema input-schema))))]
|
||||
(testing "a tool with no properties says so rather than rendering an empty table"
|
||||
(is (= "This tool takes no arguments." (markdown {}))))
|
||||
(testing "each argument is a row of name, type, description, with a dash where the schema carries none"
|
||||
(is (= [["`id`" "integer" "The ID."]
|
||||
["`prompt`" "string" "—"]]
|
||||
(map #'mcp-tools-dox/argument-row
|
||||
(:arguments (#'mcp-tools-dox/tool->entry
|
||||
(assoc search-tool :inputSchema {:properties {:id {:type "integer" :description "The ID."}
|
||||
:prompt {:type ["string" "null"]}}})))))))
|
||||
(testing "the schema's `:required` is not published"
|
||||
;; `strict-tool-input-schema` lists every property there so strict clients can send an explicit null, so it
|
||||
;; can't tell a required argument from an optional one
|
||||
(is (not (str/includes? (markdown {:properties {:id {:type "integer"}} :required [:id]}) "Required"))))
|
||||
(testing "a pipe in a description doesn't split the row"
|
||||
(is (str/includes? (markdown {:properties {:q {:type "string" :description "a | b"}}}) "a \\| b")))
|
||||
(testing "template syntax in a description is Liquid-fenced before the column is padded"
|
||||
(let [table (markdown {:properties {:sql {:type "string" :description "Use {{tag}}."}
|
||||
:x {:type "string" :description "Short."}}})]
|
||||
(is (str/includes? table "| Use {% raw %}{{tag}}{% endraw %}. |"))
|
||||
(testing "so the shorter cell is padded to the fenced width"
|
||||
(is (str/includes? table "| Short. |")))))))
|
||||
|
||||
;;;; Which tools land on the page
|
||||
|
||||
(deftest documented-tools-test
|
||||
(let [tools (documented-tools)]
|
||||
(is (seq tools))
|
||||
(testing "a tool the MCP App calls for itself is left off the page"
|
||||
;; the model never calls `refresh_ui_credential`; it takes no arguments and returns a credential
|
||||
(is (not (some #(= "refresh_ui_credential" (:name %)) tools)))
|
||||
(is (some #(= "refresh_ui_credential" (:name %)) (v2.registry/list-tools))
|
||||
"no registered tool is named refresh_ui_credential; pick another app-only tool to guard against"))
|
||||
(testing "the MCP Apps tools are the ones carrying a :_meta :ui block, which is how the page flags them"
|
||||
;; `:inline-ui?` keys off `:_meta`; keep it in step with the tools a client without MCP Apps never sees
|
||||
(let [listed-without-ui (set (map :name (v2.registry/list-tools {:supports-mcp-ui? false})))]
|
||||
(doseq [{:keys [name _meta]} tools]
|
||||
(is (= (not (contains? listed-without-ui name)) (some? (:ui _meta)))
|
||||
(str name " disagrees about being an MCP Apps tool")))))))
|
||||
|
||||
(deftest all-arguments-described-test
|
||||
(testing "every top-level argument of every tool carries a description the page can show"
|
||||
;; `description-cell` renders an em dash when a schema says nothing. Prose on the keys of a nested object is
|
||||
;; unreachable by design (see `documented-elements`), so an array of objects needs it on the `:sequential`
|
||||
;; wrapper and a nested map on the `:map` itself; anything else wants it on the innermost schema.
|
||||
(doseq [{tool-name :name :keys [arguments]} (->> (documented-tools)
|
||||
;; test-only, and still registered when a shared test JVM
|
||||
;; has loaded `metabase.mcp.v2.test-util`
|
||||
(remove #(= "test_echo" (:name %)))
|
||||
(map #'mcp-tools-dox/tool->entry))
|
||||
{:keys [name] :as argument} arguments]
|
||||
(is (not= "—" (#'mcp-tools-dox/description-cell argument))
|
||||
(str tool-name " argument " name " has no description")))))
|
||||
|
||||
(deftest ^:parallel document-markdown-test
|
||||
(testing "the page is the intro, then a section per tool, ending in one newline"
|
||||
(let [markdown (#'mcp-tools-dox/document-markdown "intro" [(#'mcp-tools-dox/tool->entry search-tool)])]
|
||||
(is (str/starts-with? markdown "intro\n\n## Search\n"))
|
||||
(is (str/ends-with? markdown "This tool takes no arguments.\n")))))
|
||||
|
||||
;;;; End to end
|
||||
|
||||
(deftest generate-dox-test
|
||||
(mt/with-temp-file [path]
|
||||
(let [{:keys [tools]} (mcp-tools-dox/generate-dox! path)
|
||||
markdown (slurp path)
|
||||
documented (documented-tools)]
|
||||
(testing "reports what it wrote"
|
||||
(is (= (count documented) tools))
|
||||
(is (pos? tools)))
|
||||
(testing "the intro template is included"
|
||||
(is (str/starts-with? markdown "---\ntitle: MCP server tools")))
|
||||
(testing "every registered tool gets a section"
|
||||
;; the point of the page: a `deftool` can't quietly go undocumented
|
||||
(doseq [t documented]
|
||||
(is (str/includes? markdown (str "\n## " (#'mcp-tools-dox/tool-title t) "\n"))
|
||||
(str "no section for " (:name t)))
|
||||
(is (str/includes? markdown (str "Tool name: `" (:name t) "`"))
|
||||
(str "no tool name for " (:name t)))))
|
||||
(testing "tools are not grouped: every heading is a tool's own, and nothing nests beneath one"
|
||||
(let [titles (set (map #'mcp-tools-dox/tool-title documented))]
|
||||
(doseq [heading (map second (re-seq #"(?m)^## (.*)$" markdown))]
|
||||
(is (contains? titles heading) (str "heading is not a tool: " heading))))
|
||||
(is (not (str/includes? markdown "\n### "))))
|
||||
(testing "tools run in name order"
|
||||
;; `list-tools` name-sorts; the page keeps that rather than imposing an order of its own
|
||||
(let [positions (map #(str/index-of markdown (str "\n## " (#'mcp-tools-dox/tool-title %) "\n")) documented)]
|
||||
(is (apply < positions))))
|
||||
(testing "an interactive tool says so, and a plain one doesn't"
|
||||
(let [section-of (fn [title]
|
||||
(-> (str/split markdown (re-pattern (str "\n## " title "\n")))
|
||||
second
|
||||
(str/split #"\n## ")
|
||||
first))]
|
||||
(is (str/includes? (section-of "Visualize query") "Interactive:"))
|
||||
(is (not (str/includes? (section-of "Search") "Interactive:")))))
|
||||
(testing "each section runs facts, then arguments, with no description paragraph between them"
|
||||
;; the tool's description is written for the model and is wordy for a reader; the argument notes carry the prose
|
||||
(let [section (second (str/split markdown #"\n## Search\n"))]
|
||||
(is (< (str/index-of section "Tool name:")
|
||||
(str/index-of section "Permission scope:")
|
||||
(str/index-of section "Arguments:")))
|
||||
(is (re-find #"(?m)^- [^\n]*\n\nArguments:\n" section) "something other than the facts list precedes Arguments:")))
|
||||
(testing "scopes are published with the consent screen's wording"
|
||||
(is (str/includes? markdown "Permission scope: `agent:content:read`")))
|
||||
(testing "argument prose survives the nullable wrapper every v2 argument has"
|
||||
;; an optional v2 argument carries its prose on the nullable branch, not on the property
|
||||
(let [section (second (str/split markdown #"\n## Search\n"))]
|
||||
(is (str/includes? section "| `limit`"))
|
||||
(is (not (re-find #"\| `limit`\s+\| integer\s+\| —" section)))))
|
||||
(testing "an action hub publishes the actions it dispatches on"
|
||||
(let [section (second (str/split markdown #"\n## Browse data\n"))]
|
||||
(is (str/includes? section "`list_databases`"))))
|
||||
(testing "an array-of-objects argument carries only its own prose, not every element's"
|
||||
;; `ops` is a union of two dozen op objects, each with a sentence; only the array's own belongs in the cell
|
||||
(let [section (second (str/split markdown #"\n## Dashboard write\n"))
|
||||
ops-row (re-find #"(?m)^\| `ops` .*$" section)]
|
||||
(is (some? ops-row))
|
||||
(is (< (count ops-row) 400) ops-row)
|
||||
(is (not (str/includes? ops-row "Add a tab.")))
|
||||
(is (not (str/includes? ops-row "One editor operation")))))
|
||||
(testing "no unfenced Liquid reaches the page"
|
||||
;; the docs site rejects `{% card %}` outright and renders `{{tag}}` as nothing; the intro is hand-written
|
||||
;; and may carry a real `{% include %}`, so only the generated sections are held to this
|
||||
(let [generated (subs markdown (str/index-of markdown "\n## "))
|
||||
unfenced (str/replace generated #"\{% raw %\}.*?\{% endraw %\}" "")]
|
||||
(is (not (re-find #"\{\{|\{%" unfenced)))
|
||||
(testing "and the fencing was exercised, not vacuous"
|
||||
;; `execute_sql` quotes `{{tag}}`; if that tool stops doing so, pick another that quotes template syntax
|
||||
(is (str/includes? generated "{% raw %}{{tag}}{% endraw %}"))))))))
|
||||
Reference in New Issue
Block a user