Compare commits

...

1 Commits

Author SHA1 Message Date
Ngoc Khuat
5a6307e1b3 workspaces mcp 2026-02-05 23:59:48 +07:00
21 changed files with 983 additions and 1 deletions

View File

@@ -460,7 +460,7 @@
;;; -------------------------------------------------- Middleware ----------------------------------------------------
(defn- enforce-authentication
(defn enforce-authentication
"Middleware that ensures requests are authenticated.
Supports two authentication modes:

View File

@@ -28,6 +28,7 @@
[metabase-enterprise.gsheets.api :as gsheets.api]
[metabase-enterprise.library.api]
[metabase-enterprise.llm.api]
[metabase-enterprise.mcp.api]
[metabase-enterprise.metabot-v3.api]
[metabase-enterprise.metabot-v3.tools.api]
[metabase-enterprise.permission-debug.api]
@@ -127,6 +128,7 @@
(premium-handler :etl-connections))
"/library" (premium-handler metabase-enterprise.library.api/routes :data-studio)
"/logs" (premium-handler 'metabase-enterprise.advanced-config.api.logs :audit-app)
"/mcp" (premium-handler metabase-enterprise.mcp.api/routes :data-studio)
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)

View File

@@ -0,0 +1,68 @@
(ns metabase-enterprise.mcp.api
"MCP HTTP endpoint. Accepts JSON-RPC 2.0 over Streamable HTTP with JWT or session auth."
(:require
[metabase-enterprise.agent-api.api :as agent-api]
[metabase-enterprise.mcp.server :as mcp.server]
[metabase-enterprise.mcp.tools :as mcp.tools]
;; Tool definition namespaces — loaded for side effects (tool registration)
[metabase-enterprise.mcp.tools.execution]
[metabase-enterprise.mcp.tools.query]
[metabase-enterprise.mcp.tools.schema-discovery]
[metabase-enterprise.mcp.tools.transforms]
[metabase-enterprise.mcp.tools.urls]
[metabase-enterprise.mcp.tools.workspace]
[metabase.api.routes.common :as api.routes.common]
[metabase.util.json :as json]
[metabase.util.log :as log]))
(def ^:private +auth
(api.routes.common/wrap-middleware-for-open-api-spec-generation
agent-api/enforce-authentication))
(defn- normalize-body
"The body may arrive as a keyword-keyed map (from wrap-json-body middleware)
or as an InputStream (raw). Normalize to string-keyed map for parse-request.
Recursively converts all nested maps (including inside arrays) to string keys."
[body]
(cond
(and (map? body) (keyword? (first (keys body))))
(into {} (map (fn [[k v]] [(name k) (normalize-body v)])) body)
(map? body)
body
(sequential? body)
(mapv normalize-body body)
(instance? java.io.InputStream body)
(json/decode (slurp body))
:else
body))
(defn- handle-mcp-request
"Ring handler for MCP Streamable HTTP endpoint."
[request respond _raise]
(try
(let [body (normalize-body (:body request))
parsed (mcp.server/parse-request body)
response (if (:error parsed)
(mcp.server/error-response nil
(get-in parsed [:error :code])
(get-in parsed [:error :message]))
(mcp.server/handle-request mcp.tools/global-registry parsed))]
(respond (if (nil? response)
{:status 204 :body nil}
{:status 200
:headers {"Content-Type" "application/json"}
:body (json/encode response)})))
(catch Exception e
(log/error e "MCP request error")
(respond {:status 200
:headers {"Content-Type" "application/json"}
:body (json/encode (mcp.server/error-response nil -32603
(str "Internal error: " (ex-message e))))}))))
(def routes
"MCP endpoint: POST /api/ee/mcp"
(+auth handle-mcp-request))

View File

@@ -0,0 +1,76 @@
(ns metabase-enterprise.mcp.server
"MCP (Model Context Protocol) server implementation.
Handles JSON-RPC 2.0 dispatch for MCP Streamable HTTP transport."
(:require
[metabase-enterprise.mcp.tools :as mcp.tools]))
;;; ------------------------------------------------- JSON-RPC 2.0 -------------------------------------------------
(defn parse-request
"Parse and validate a JSON-RPC 2.0 request map (already deserialized from JSON).
Returns the request map with keyword keys on success, or {:error {...}} on failure."
[request-map]
(cond
(not= (get request-map "jsonrpc") "2.0")
{:error {:code -32600 :message "Invalid Request: missing or invalid jsonrpc version"}}
(nil? (get request-map "method"))
{:error {:code -32600 :message "Invalid Request: missing method"}}
:else
{:jsonrpc "2.0"
:id (get request-map "id")
:method (get request-map "method")
:params (or (get request-map "params") {})}))
(defn success-response
"Build a JSON-RPC 2.0 success response."
[id result]
{"jsonrpc" "2.0"
"id" id
"result" result})
(defn error-response
"Build a JSON-RPC 2.0 error response."
([id code message]
{"jsonrpc" "2.0"
"id" id
"error" {"code" code "message" message}})
([id code message data]
{"jsonrpc" "2.0"
"id" id
"error" {"code" code "message" message "data" data}}))
;;; --------------------------------------------- MCP Method Dispatch ----------------------------------------------
(def ^:private server-info
{"name" "metabase-mcp"
"version" "0.1.0"})
(def ^:private server-capabilities
{"tools" {}})
(defn handle-request
"Dispatch a parsed JSON-RPC request to the appropriate MCP method handler.
Returns a JSON-RPC response map."
[tool-registry {:keys [id method params]}]
(case method
"initialize"
(success-response id {"protocolVersion" "2025-03-26"
"serverInfo" server-info
"capabilities" server-capabilities})
"notifications/initialized"
nil ;; notification, no response
"tools/list"
(success-response id {"tools" (mcp.tools/list-tools tool-registry)})
"tools/call"
(let [tool-name (get params "name")
arguments (get params "arguments" {})
result (mcp.tools/call-tool tool-registry tool-name arguments)]
(success-response id result))
;; Default: method not found
(error-response id -32601 (str "Method not found: " method))))

View File

@@ -0,0 +1,37 @@
(ns metabase-enterprise.mcp.tools
"MCP tool registry. Tools are registered with name, description, JSON Schema, and handler fn.")
(defonce ^{:doc "Global tool registry atom. Tool namespaces register themselves at load time."}
global-registry
(atom {}))
(defn register-tool!
"Register a tool in the given registry atom.
Tool map must have :name, :description, :input-schema, and :handler."
[registry {:keys [name] :as tool}]
(swap! registry assoc name tool))
(defn list-tools
"Return all registered tools in MCP tools/list response format.
Includes annotations when present (readOnlyHint, destructiveHint, etc.)."
[registry]
(mapv (fn [{:keys [name description input-schema annotations]}]
(cond-> {"name" name
"description" description
"inputSchema" input-schema}
annotations (assoc "annotations" annotations)))
(vals @registry)))
(defn call-tool
"Execute a registered tool by name with the given arguments.
Returns {\"content\" [...]} on success or {\"content\" [...] \"isError\" true} on failure."
[registry tool-name arguments]
(if-let [{:keys [handler]} (get @registry tool-name)]
(try
(let [result (handler arguments)]
{"content" (mapv #(update-keys % clojure.core/name) (:content result))})
(catch Exception e
{"content" [{"type" "text" "text" (str "Error: " (ex-message e))}]
"isError" true}))
{"content" [{"type" "text" "text" (str "Unknown tool: " tool-name)}]
"isError" true}))

View File

@@ -0,0 +1,91 @@
(ns metabase-enterprise.mcp.tools.execution
"MCP tools for running transforms, validating, and merging workspaces."
(:require
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.workspaces.impl :as ws.impl]
[metabase-enterprise.workspaces.merge :as ws.merge]
[metabase-enterprise.workspaces.validation :as ws.validation]
[metabase.api.common :as api]
[metabase.util.json :as json]
[toucan2.core :as t2]))
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "run_workspace_transforms"
:description "Run transforms in a workspace. By default runs all transforms in dependency order.
Optionally specify ref_ids to run specific transforms.
Returns per-transform status (succeeded/failed) and error messages."
:annotations {"readOnlyHint" false}
:input-schema {:type "object"
:properties {"workspace_id" {:type "integer" :description "Workspace ID"}
"ref_ids" {:type "array"
:items {:type "string"}
:description "Optional: specific transform ref_ids to run. Omit to run all."}
"stale_only" {:type "boolean" :description "Only run stale transforms (default false)"}}
:required ["workspace_id"]}
:handler
(fn [{:strs [workspace_id ref_ids stale_only]}]
(let [workspace (t2/select-one :model/Workspace workspace_id)
_ (api/check-404 workspace)
_ (api/check-400 (= :ready (:db_status workspace))
"Workspace database not ready")
graph (ws.impl/get-or-calculate-graph! workspace)
result (if (seq ref_ids)
(let [transforms (t2/select :model/WorkspaceTransform
:workspace_id workspace_id
:ref_id [:in ref_ids])]
(reduce (fn [acc tx]
(let [r (ws.impl/run-transform! workspace graph tx)]
(update acc (if (= :succeeded (:status r)) :succeeded :failed)
conj (:ref_id tx))))
{:succeeded [] :failed []}
transforms))
(ws.impl/execute-workspace! workspace graph {:stale-only? (boolean stale_only)}))]
{:content [{:type "text" :text (json/encode result)}]}))})
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "get_problems"
:description "Check for validation problems that would block merging.
Returns problems with severity, whether they block merge, and affected transforms.
Run this before merge_workspace to see if there are issues."
:annotations {"readOnlyHint" true}
:input-schema {:type "object"
:properties {"workspace_id" {:type "integer" :description "Workspace ID"}}
:required ["workspace_id"]}
:handler
(fn [{:strs [workspace_id]}]
(let [workspace (t2/select-one :model/Workspace workspace_id)
_ (api/check-404 workspace)
graph (ws.impl/get-or-calculate-graph! workspace)
problems (ws.validation/find-downstream-problems workspace_id graph)
blockers (filter :block_merge problems)]
{:content [{:type "text"
:text (json/encode {:problems problems
:merge_blocked (boolean (seq blockers))
:blocker_count (count blockers)})}]}))})
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "merge_workspace"
:description "Merge all transforms in the workspace to production.
Creates/updates/deletes global transforms based on workspace state.
Requires a commit_message describing the changes.
Will fail if there are merge-blocking problems — use get_problems first."
:annotations {"readOnlyHint" false "destructiveHint" true}
:input-schema {:type "object"
:properties {"workspace_id" {:type "integer" :description "Workspace ID"}
"commit_message" {:type "string" :description "Description of changes being merged"}}
:required ["workspace_id" "commit_message"]}
:handler
(fn [{:strs [workspace_id commit_message]}]
(let [workspace (t2/select-one :model/Workspace workspace_id)
_ (api/check-404 workspace)
graph (ws.impl/get-or-calculate-graph! workspace)
problems (ws.validation/find-downstream-problems workspace_id graph)
blockers (filter :block_merge problems)]
(when (seq blockers)
(api/check-400 false (str "Cannot merge: " (count blockers) " blocking problems. Use get_problems to see details.")))
(let [result (ws.merge/merge-workspace! workspace api/*current-user-id* commit_message)]
{:content [{:type "text"
:text (json/encode {:merged true
:workspace_id workspace_id
:transforms (get-in result [:merged :transforms])
:errors (:errors result)})}]})))})

View File

@@ -0,0 +1,77 @@
(ns metabase-enterprise.mcp.tools.query
"MCP tools for running queries and previewing table data.
Reuses the same QP execution pattern as the Agent API /v1/execute endpoint."
(:require
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase.api.common :as api]
[metabase.query-processor :as qp]
[metabase.util.json :as json]
[toucan2.core :as t2]))
(def ^:private max-rows
"Hard cap on rows returned by query tools, matching Agent API userspace constraints."
2000)
(defn- format-query-result
"Extract columns and rows from a QP result into a compact JSON-friendly format."
[result]
(if (= :completed (:status result))
(let [cols (mapv (fn [c] {:name (:name c)
:display_name (:display_name c)
:base_type (some-> (:base_type c) name)})
(get-in result [:data :cols]))
rows (get-in result [:data :rows])]
{:columns cols
:rows rows
:row_count (count rows)})
{:error (or (:error result) "Query execution failed")}))
(defn- execute-query
"Run a query through the QP with agent context and row constraints.
Same execution pattern as Agent API /v1/execute."
[query row-limit]
(qp/process-query
(-> query
(assoc :constraints {:max-results row-limit
:max-results-bare-rows row-limit})
(update-in [:middleware :js-int-to-string?] (fnil identity true))
(assoc-in [:info :context] :agent))))
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "run_query"
:description "Run a native SQL query against a database and return results (max 2000 rows).
Use this to inspect data, verify transform logic, or explore tables."
:annotations {"readOnlyHint" true}
:input-schema {:type "object"
:properties {"database_id" {:type "integer" :description "Database ID to run the query against"}
"query" {:type "string" :description "SQL query string"}}
:required ["database_id" "query"]}
:handler
(fn [{:strs [database_id query]}]
(let [result (execute-query {:database database_id
:type :native
:native {:query query}}
max-rows)]
{:content [{:type "text"
:text (json/encode (format-query-result result))}]}))})
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "query_table"
:description "Get rows from a table. Returns column metadata and rows (max 2000).
For complex queries with filters/aggregations, use run_query with SQL instead."
:annotations {"readOnlyHint" true}
:input-schema {:type "object"
:properties {"table_id" {:type "integer" :description "Metabase table ID"}
"limit" {:type "integer" :description "Max rows to return (default 100, max 2000)"}}
:required ["table_id"]}
:handler
(fn [{:strs [table_id limit]}]
(let [table (t2/select-one [:model/Table :id :db_id] table_id)]
(api/check-404 table)
(let [row-limit (min (or limit 100) max-rows)
result (execute-query {:database (:db_id table)
:type :query
:query {:source-table table_id}}
row-limit)]
{:content [{:type "text"
:text (json/encode (format-query-result result))}]})))})

View File

@@ -0,0 +1,51 @@
(ns metabase-enterprise.mcp.tools.schema-discovery
"MCP tools for discovering database schema: search, table details."
(:require
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.metabot-v3.tools.entity-details :as entity-details]
[metabase-enterprise.metabot-v3.tools.search :as metabot-search]
[metabase.util.json :as json]))
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "search"
:description "Search for tables and metrics in the Metabase semantic layer. Returns names, IDs, and descriptions."
:annotations {"readOnlyHint" true}
:input-schema {:type "object"
:properties {"query" {:type "string" :description "Search term"}}
:required ["query"]}
:handler (fn [{:strs [query]}]
(let [result (metabot-search/search-tool {:term-queries [query]})]
{:content [{:type "text"
:text (json/encode (:structured_output result))}]}))})
(defn- slim-field
"Keep only the fields an agent needs to write SQL."
[field]
(select-keys field [:name :display_name :type :database_type :semantic_type :description]))
(defn- slim-table-details
"Strip field_values, trim related_tables to just names, and drop metrics to save tokens."
[details]
(-> details
(update :fields #(mapv slim-field %))
(update :related_tables
(fn [tables]
(mapv #(-> (select-keys % [:id :name :display_name :related_by])
(update :fields (fnil (fn [fs] (mapv slim-field fs)) [])))
(or tables []))))
(dissoc :metrics :measures :segments)))
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "get_table_details"
:description "Get columns, types, and metadata for a specific table. Use the table ID from search results."
:annotations {"readOnlyHint" true}
:input-schema {:type "object"
:properties {"table_id" {:type "integer" :description "Metabase table ID"}}
:required ["table_id"]}
:handler (fn [{:strs [table_id]}]
(let [result (entity-details/get-table-details
{:table-id table_id
:with-field-values? false
:with-metrics? false})]
{:content [{:type "text"
:text (json/encode (slim-table-details (:structured-output result)))}]}))})

View File

@@ -0,0 +1,183 @@
(ns metabase-enterprise.mcp.tools.transforms
"MCP tools for creating and managing transforms within a workspace."
(:require
[clojure.walk :as walk]
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.workspaces.common :as ws.common]
[metabase-enterprise.workspaces.impl :as ws.impl]
[metabase.api.common :as api]
[metabase.util.json :as json]
[toucan2.core :as t2]))
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "create_transforms"
:description "Create one or more SQL transforms in a workspace. Each transform needs a name, SQL definition, and target table.
The workspace will be initialized on first transform if needed.
Example transform:
{\"name\": \"customer_lifetime_value\",
\"source\": {\"type\": \"sql\", \"query\": {\"database\": 1, \"native\": {\"query\": \"SELECT ...\"}}},
\"target\": {\"database\": 1, \"schema\": \"public\", \"name\": \"customer_lifetime_value\"}}"
:annotations {"readOnlyHint" false}
:input-schema {:type "object"
:properties {"workspace_id" {:type "integer" :description "Workspace ID"}
"transforms" {:type "array"
:items {:type "object"
:properties {"name" {:type "string"}
"source" {:type "object" :description "Transform source (type, query)"}
"target" {:type "object" :description "Transform target (database, schema, name)"}}
:required ["name" "source" "target"]}}}
:required ["workspace_id" "transforms"]}
:handler
(fn [{:strs [workspace_id transforms]}]
(let [workspace (t2/select-one :model/Workspace workspace_id)
_ (api/check-404 workspace)
results (mapv (fn [tx-def]
(try
(let [kw-def (walk/keywordize-keys tx-def)
tx (ws.common/add-to-changeset!
api/*current-user-id*
workspace
:transform
nil ;; no global_id for new transforms
kw-def)]
{:ref_id (:ref_id tx) :name (:name tx) :status "created"})
(catch Exception e
{:name (or (get tx-def "name") (:name tx-def))
:status "error" :error (ex-message e)})))
transforms)]
{:content [{:type "text" :text (json/encode results)}]}))})
(defn- create-transform-in-workspace!
"Helper to create a single transform in a workspace. Returns the result map."
[workspace tx-def]
(try
(let [tx (ws.common/add-to-changeset!
api/*current-user-id*
workspace
:transform
nil
tx-def)]
{:ref_id (:ref_id tx) :name (:name tx) :status "created"})
(catch Exception e
{:name (:name tx-def) :status "error" :error (ex-message e)})))
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "create_workspace_sql_transform"
:description "Create a SQL transform in a workspace (draft). This creates a workspace-local transform
that can be tested and iterated on before merging to production.
The database is inferred from the workspace — no need to specify it."
:annotations {"readOnlyHint" false}
:input-schema {:type "object"
:properties {"workspace_id" {:type "integer" :description "Workspace ID"}
"name" {:type "string" :description "Transform name (e.g. customer_lifetime_value)"}
"query" {:type "string" :description "SQL query string (e.g. SELECT customer_id, SUM(total) ...)"}
"target_table" {:type "string" :description "Target table name for the output"}
"target_schema" {:type "string" :description "Target schema (default: public)"}}
:required ["workspace_id" "name" "query" "target_table"]}
:handler
(fn [{:strs [workspace_id name query target_table target_schema]}]
(let [workspace (t2/select-one :model/Workspace workspace_id)
_ (api/check-404 workspace)
db-id (:database_id workspace)
tx-def {:name name
:source {:type "query"
:query {:database db-id
:type "native"
:native {:query query}}}
:target {:database db-id
:schema (or target_schema "public")
:name target_table}}
result (create-transform-in-workspace! workspace tx-def)]
{:content [{:type "text" :text (json/encode result)}]}))})
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "create_workspace_python_transform"
:description "Create a Python transform in a workspace (draft). This creates a workspace-local transform
that can be tested and iterated on before merging to production.
Provide the Python code, source tables it reads from, and a target table name.
The database is inferred from the workspace."
:annotations {"readOnlyHint" false}
:input-schema {:type "object"
:properties {"workspace_id" {:type "integer" :description "Workspace ID"}
"name" {:type "string" :description "Transform name (e.g. customer_segments)"}
"code" {:type "string" :description "Python code string"}
"source_tables" {:type "object" :description "Map of logical name to Metabase table ID, e.g. {\"orders\": 42, \"customers\": 43}"}
"target_table" {:type "string" :description "Target table name for the output"}
"target_schema" {:type "string" :description "Target schema (default: public)"}}
:required ["workspace_id" "name" "code" "source_tables" "target_table"]}
:handler
(fn [{:strs [workspace_id name code source_tables target_table target_schema]}]
(let [workspace (t2/select-one :model/Workspace workspace_id)
_ (api/check-404 workspace)
db-id (:database_id workspace)
tx-def {:name name
:source {:type "python"
:body code
:source-database db-id
:source-tables source_tables}
:target {:database db-id
:schema (or target_schema "public")
:name target_table}}
result (create-transform-in-workspace! workspace tx-def)]
{:content [{:type "text" :text (json/encode result)}]}))})
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "list_transforms"
:description "List transforms in a workspace with their status, last run info, and staleness."
:annotations {"readOnlyHint" true}
:input-schema {:type "object"
:properties {"workspace_id" {:type "integer" :description "Workspace ID"}}
:required ["workspace_id"]}
:handler
(fn [{:strs [workspace_id]}]
(let [transforms (t2/select [:model/WorkspaceTransform
:ref_id :name :source :target
:last_run_status :last_run_at :last_run_message
:definition_changed :input_data_changed :archived_at]
:workspace_id workspace_id
{:order-by [[:created_at :asc]]})]
{:content [{:type "text"
:text (json/encode (mapv #(select-keys % [:ref_id :name :last_run_status :last_run_at
:last_run_message :definition_changed
:input_data_changed :archived_at])
transforms))}]}))})
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "get_transform"
:description "Get full details of a single transform including its SQL source."
:annotations {"readOnlyHint" true}
:input-schema {:type "object"
:properties {"workspace_id" {:type "integer" :description "Workspace ID"}
"ref_id" {:type "string" :description "Transform ref_id"}}
:required ["workspace_id" "ref_id"]}
:handler
(fn [{:strs [workspace_id ref_id]}]
(let [transform (t2/select-one :model/WorkspaceTransform
:workspace_id workspace_id
:ref_id ref_id)]
(api/check-404 transform)
{:content [{:type "text"
:text (json/encode (select-keys transform [:ref_id :name :source :target
:last_run_status :last_run_at :last_run_message
:definition_changed :input_data_changed]))}]}))})
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "delete_transform"
:description "Archive a transform in the workspace (soft delete). If the transform was checked out from a global transform, merging will delete the global version."
:annotations {"readOnlyHint" false "destructiveHint" true}
:input-schema {:type "object"
:properties {"workspace_id" {:type "integer" :description "Workspace ID"}
"ref_id" {:type "string" :description "Transform ref_id"}}
:required ["workspace_id" "ref_id"]}
:handler
(fn [{:strs [workspace_id ref_id]}]
(let [transform (t2/select-one :model/WorkspaceTransform
:workspace_id workspace_id
:ref_id ref_id)]
(api/check-404 transform)
(t2/update! :model/WorkspaceTransform
{:workspace_id workspace_id :ref_id ref_id}
{:archived_at :%now})
(ws.impl/increment-graph-version! workspace_id)
{:content [{:type "text" :text (json/encode {:deleted true :ref_id ref_id})}]}))})

View File

@@ -0,0 +1,39 @@
(ns metabase-enterprise.mcp.tools.urls
"MCP tool for generating Metabase UI URLs for entities."
(:require
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase.system.settings :as system.settings]
[metabase.util.json :as json]))
(defn- base-url []
(or (system.settings/site-url) "http://localhost:3000"))
(defn- entity-url
"Build a full Metabase URL for a given entity type and ID."
[entity-type id & {:keys [extra]}]
(let [base (base-url)
path (case entity-type
"workspace" (str "/data-studio/workspaces/" id)
"transform" (str "/data-studio/transforms/" id)
"table" (str "/question#" id)
"database" (str "/browse/databases/" id)
"question" (str "/question/" id)
"dashboard" (str "/dashboard/" id)
"collection" (str "/collection/" id)
(str "/" entity-type "/" id))]
(str base path (when extra extra))))
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "get_entity_url"
:description "Get the Metabase UI URL for an entity. Use this to give users clickable links to workspaces, transforms, tables, databases, questions, or dashboards."
:annotations {"readOnlyHint" true}
:input-schema {:type "object"
:properties {"entity_type" {:type "string"
:enum ["workspace" "transform" "table" "database" "question" "dashboard" "collection"]
:description "Type of entity"}
"id" {:type ["integer" "string"] :description "Entity ID"}}
:required ["entity_type" "id"]}
:handler
(fn [{:strs [entity_type id]}]
{:content [{:type "text"
:text (json/encode {:url (entity-url entity_type id)})}]})})

View File

@@ -0,0 +1,79 @@
(ns metabase-enterprise.mcp.tools.workspace
"MCP tools for workspace lifecycle: create, list, inspect, archive."
(:require
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.workspaces.common :as ws.common]
[metabase-enterprise.workspaces.impl :as ws.impl]
[metabase-enterprise.workspaces.models.workspace :as ws.models]
[metabase-enterprise.workspaces.validation :as ws.validation]
[metabase.api.common :as api]
[metabase.util.json :as json]
[toucan2.core :as t2]))
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "list_workspaces"
:description "List all workspaces. Returns workspace ID, name, status, database, and transform count."
:annotations {"readOnlyHint" true}
:input-schema {:type "object"
:properties {"database_id" {:type "integer" :description "Filter by database ID (optional)"}}
:required []}
:handler (fn [{:strs [database_id]}]
(let [workspaces (cond->> (t2/select [:model/Workspace :id :name :base_status :db_status :database_id :created_at]
{:where [:not= :base_status "archived"]})
database_id (filter #(= database_id (:database_id %))))]
{:content [{:type "text"
:text (json/encode (mapv #(select-keys % [:id :name :base_status :db_status :database_id])
workspaces))}]}))})
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "create_workspace"
:description "Create a new isolated workspace for editing transforms. Returns the workspace ID."
:annotations {"readOnlyHint" false}
:input-schema {:type "object"
:properties {"name" {:type "string" :description "Workspace name"}
"database_id" {:type "integer" :description "Target database ID"}}
:required ["database_id"]}
:handler (fn [{:strs [name database_id]}]
(let [ws (ws.common/create-workspace! api/*current-user-id*
{:name name :database_id database_id})]
{:content [{:type "text"
:text (json/encode (select-keys ws [:id :name :base_status :db_status :database_id]))}]}))})
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "get_workspace_state"
:description "Get compact workspace state: status, transforms with run status, and validation problems. Use this to understand the current state before making changes."
:annotations {"readOnlyHint" true}
:input-schema {:type "object"
:properties {"workspace_id" {:type "integer" :description "Workspace ID"}}
:required ["workspace_id"]}
:handler (fn [{:strs [workspace_id]}]
(let [workspace (t2/select-one :model/Workspace workspace_id)
_ (api/check-404 workspace)
transforms (t2/select [:model/WorkspaceTransform
:ref_id :name :last_run_status :last_run_at
:definition_changed :archived_at]
:workspace_id workspace_id)
graph (when (= :ready (:db_status workspace))
(ws.impl/get-or-calculate-graph! workspace))
problems (when graph
(ws.validation/find-downstream-problems workspace_id graph))]
{:content [{:type "text"
:text (json/encode {:workspace (select-keys workspace [:id :name :base_status :db_status :database_id])
:transforms (mapv #(select-keys % [:ref_id :name :last_run_status :last_run_at
:definition_changed :archived_at])
transforms)
:problems (or problems [])})}]}))})
(mcp.tools/register-tool! mcp.tools/global-registry
{:name "archive_workspace"
:description "Archive a workspace. Cleans up database isolation resources. The workspace can be unarchived later."
:annotations {"readOnlyHint" false "destructiveHint" true}
:input-schema {:type "object"
:properties {"workspace_id" {:type "integer" :description "Workspace ID"}}
:required ["workspace_id"]}
:handler (fn [{:strs [workspace_id]}]
(let [workspace (t2/select-one :model/Workspace workspace_id)]
(api/check-404 workspace)
(ws.models/archive! workspace)
{:content [{:type "text"
:text (json/encode {:archived true :workspace_id workspace_id})}]}))})

View File

@@ -0,0 +1,60 @@
(ns metabase-enterprise.mcp.api-test
(:require
[clojure.test :refer [deftest is testing use-fixtures]]
[metabase-enterprise.mcp.api :as mcp.api]
[metabase.request.core :as request]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.util.json :as json])
(:import
(java.io ByteArrayInputStream)))
(use-fixtures :once (fixtures/initialize :db :test-users))
(defn- make-ring-request
"Create a minimal Ring request map for the MCP endpoint."
[body-map]
{:body (ByteArrayInputStream. (.getBytes (json/encode body-map) "UTF-8"))
:headers {"content-type" "application/json"}
:request-method :post
:uri "/api/ee/mcp"})
(defn- call-mcp-handler
"Call the MCP Ring handler directly (bypassing auth) with the given user and body."
[user-kw body-map]
(let [result (promise)
user-id (mt/user->id user-kw)]
(request/with-current-user user-id
(mcp.api/routes
(assoc (make-ring-request body-map) :metabase-user-id user-id)
(fn [response] (deliver result response))
(fn [error] (deliver result {:error error}))))
(let [resp (deref result 5000 {:error :timeout})]
(when (:body resp)
(json/decode (:body resp))))))
(deftest mcp-handler-initialize-test
(mt/with-premium-features #{:agent-api}
(testing "authenticated initialize returns server info"
(let [resp (call-mcp-handler :crowberto {"jsonrpc" "2.0" "id" 1 "method" "initialize" "params" {}})]
(is (contains? resp "result"))
(is (= "metabase-mcp" (get-in resp ["result" "serverInfo" "name"])))))))
(deftest mcp-handler-tools-list-test
(mt/with-premium-features #{:agent-api}
(testing "tools/list returns available tools"
(let [resp (call-mcp-handler :crowberto {"jsonrpc" "2.0" "id" 2 "method" "tools/list" "params" {}})]
(is (vector? (get-in resp ["result" "tools"])))))))
(deftest mcp-handler-invalid-request-test
(mt/with-premium-features #{:agent-api}
(testing "invalid JSON-RPC returns error"
(let [resp (call-mcp-handler :crowberto {"id" 1 "method" "initialize"})]
(is (contains? resp "error"))
(is (= -32600 (get-in resp ["error" "code"])))))))
(deftest mcp-handler-unknown-method-test
(mt/with-premium-features #{:agent-api}
(testing "unknown method returns -32601"
(let [resp (call-mcp-handler :crowberto {"jsonrpc" "2.0" "id" 1 "method" "foo/bar" "params" {}})]
(is (= -32601 (get-in resp ["error" "code"])))))))

View File

@@ -0,0 +1,35 @@
(ns metabase-enterprise.mcp.integration-test
"End-to-end MCP workflow test."
(:require
[clojure.test :refer [deftest is testing use-fixtures]]
[metabase-enterprise.mcp.server :as mcp.server]
[metabase-enterprise.mcp.tools :as mcp.tools]
;; Load tool namespaces
[metabase-enterprise.mcp.tools.execution]
[metabase-enterprise.mcp.tools.schema-discovery]
[metabase-enterprise.mcp.tools.transforms]
[metabase-enterprise.mcp.tools.workspace]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]))
(use-fixtures :once (fixtures/initialize :db :test-users))
(defn- mcp-call [method params]
(mcp.server/handle-request
mcp.tools/global-registry
{:jsonrpc "2.0" :id 1 :method method :params params}))
(deftest full-lifecycle-test
(mt/with-premium-features #{:agent-api :workspaces}
(testing "1. Initialize"
(let [resp (mcp-call "initialize" {})]
(is (= "metabase-mcp" (get-in resp ["result" "serverInfo" "name"])))))
(testing "2. List tools"
(let [resp (mcp-call "tools/list" {})
tools (get-in resp ["result" "tools"])]
(is (>= (count tools) 10))
(is (some #(= "create_workspace" (get % "name")) tools))
(is (some #(= "search" (get % "name")) tools))
(is (some #(= "run_transforms" (get % "name")) tools))
(is (some #(= "merge_workspace" (get % "name")) tools))))))

View File

@@ -0,0 +1,57 @@
(ns metabase-enterprise.mcp.server-test
(:require
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.mcp.server :as mcp.server]
[metabase-enterprise.mcp.tools :as mcp.tools]))
(deftest parse-jsonrpc-request-test
(testing "valid request"
(is (= {:jsonrpc "2.0" :id 1 :method "initialize" :params {}}
(mcp.server/parse-request {"jsonrpc" "2.0" "id" 1 "method" "initialize" "params" {}}))))
(testing "missing jsonrpc version"
(is (= {:error {:code -32600 :message "Invalid Request: missing or invalid jsonrpc version"}}
(mcp.server/parse-request {"id" 1 "method" "initialize"}))))
(testing "missing method"
(is (= {:error {:code -32600 :message "Invalid Request: missing method"}}
(mcp.server/parse-request {"jsonrpc" "2.0" "id" 1})))))
(deftest format-jsonrpc-response-test
(testing "success response"
(is (= {"jsonrpc" "2.0" "id" 1 "result" {"key" "value"}}
(mcp.server/success-response 1 {"key" "value"}))))
(testing "error response"
(is (= {"jsonrpc" "2.0" "id" 1 "error" {"code" -32600 "message" "bad request"}}
(mcp.server/error-response 1 -32600 "bad request")))))
(deftest handle-request-test
(let [registry (atom {})]
(mcp.tools/register-tool! registry
{:name "ping"
:description "Ping"
:input-schema {:type "object" :properties {}}
:handler (fn [_] {:content [{:type "text" :text "pong"}]})})
(testing "initialize returns server info and capabilities"
(let [resp (mcp.server/handle-request registry
{:jsonrpc "2.0" :id 1 :method "initialize" :params {}})]
(is (= "2.0" (get resp "jsonrpc")))
(is (= 1 (get resp "id")))
(is (contains? (get resp "result") "serverInfo"))
(is (contains? (get resp "result") "capabilities"))))
(testing "tools/list returns registered tools"
(let [resp (mcp.server/handle-request registry
{:jsonrpc "2.0" :id 2 :method "tools/list" :params {}})]
(is (= 1 (count (get-in resp ["result" "tools"]))))))
(testing "tools/call executes tool"
(let [resp (mcp.server/handle-request registry
{:jsonrpc "2.0" :id 3 :method "tools/call"
:params {"name" "ping" "arguments" {}}})]
(is (= [{"type" "text" "text" "pong"}]
(get-in resp ["result" "content"])))))
(testing "unknown method returns error"
(let [resp (mcp.server/handle-request registry
{:jsonrpc "2.0" :id 4 :method "unknown/method" :params {}})]
(is (= -32601 (get-in resp ["error" "code"])))))))

View File

@@ -0,0 +1,11 @@
(ns metabase-enterprise.mcp.tools.execution-test
(:require
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.mcp.tools.execution]))
(deftest execution-tools-registered-test
(testing "execution tools are in the global registry"
(is (contains? @mcp.tools/global-registry "run_transforms"))
(is (contains? @mcp.tools/global-registry "get_problems"))
(is (contains? @mcp.tools/global-registry "merge_workspace"))))

View File

@@ -0,0 +1,30 @@
(ns metabase-enterprise.mcp.tools.query-test
(:require
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.mcp.tools.query]))
(deftest run-query-tool-registered-test
(testing "run_query tool is in the global registry"
(is (contains? @mcp.tools/global-registry "run_query"))))
(deftest query-table-tool-registered-test
(testing "query_table tool is in the global registry"
(is (contains? @mcp.tools/global-registry "query_table"))))
(deftest format-query-result-test
(testing "format-query-result handles completed results"
(let [fmt #'metabase-enterprise.mcp.tools.query/format-query-result
result {:status :completed
:data {:cols [{:name "id" :display_name "ID" :base_type :type/Integer}
{:name "name" :display_name "Name" :base_type :type/Text}]
:rows [[1 "Alice"] [2 "Bob"]]}}
formatted (fmt result)]
(is (= 2 (:row_count formatted)))
(is (= 2 (count (:columns formatted))))
(is (= "Integer" (get-in formatted [:columns 0 :base_type])))))
(testing "format-query-result handles failed results"
(let [fmt #'metabase-enterprise.mcp.tools.query/format-query-result
result {:status :failed :error "bad query"}
formatted (fmt result)]
(is (= "bad query" (:error formatted))))))

View File

@@ -0,0 +1,13 @@
(ns metabase-enterprise.mcp.tools.schema-discovery-test
(:require
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.mcp.tools.schema-discovery]))
(deftest search-tool-registered-test
(testing "search tool is in the global registry"
(is (contains? @mcp.tools/global-registry "search"))))
(deftest get-table-details-tool-registered-test
(testing "get_table_details tool is in the global registry"
(is (contains? @mcp.tools/global-registry "get_table_details"))))

View File

@@ -0,0 +1,12 @@
(ns metabase-enterprise.mcp.tools.transforms-test
(:require
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.mcp.tools.transforms]))
(deftest transform-tools-registered-test
(testing "transform tools are in the global registry"
(is (contains? @mcp.tools/global-registry "create_transforms"))
(is (contains? @mcp.tools/global-registry "list_transforms"))
(is (contains? @mcp.tools/global-registry "get_transform"))
(is (contains? @mcp.tools/global-registry "delete_transform"))))

View File

@@ -0,0 +1,9 @@
(ns metabase-enterprise.mcp.tools.urls-test
(:require
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.mcp.tools.urls]))
(deftest get-entity-url-tool-registered-test
(testing "get_entity_url tool is in the global registry"
(is (contains? @mcp.tools/global-registry "get_entity_url"))))

View File

@@ -0,0 +1,12 @@
(ns metabase-enterprise.mcp.tools.workspace-test
(:require
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.mcp.tools :as mcp.tools]
[metabase-enterprise.mcp.tools.workspace]))
(deftest workspace-tools-registered-test
(testing "workspace tools are in the global registry"
(is (contains? @mcp.tools/global-registry "list_workspaces"))
(is (contains? @mcp.tools/global-registry "create_workspace"))
(is (contains? @mcp.tools/global-registry "get_workspace_state"))
(is (contains? @mcp.tools/global-registry "archive_workspace"))))

View File

@@ -0,0 +1,40 @@
(ns metabase-enterprise.mcp.tools-test
(:require
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.mcp.tools :as mcp.tools]))
(deftest tool-registry-test
(testing "register and list tools"
(let [registry (atom {})]
(mcp.tools/register-tool! registry
{:name "test_tool"
:description "A test tool"
:input-schema {:type "object"
:properties {"name" {:type "string"}}
:required ["name"]}
:handler (fn [params] {:content [{:type "text" :text (str "Hello " (get params "name"))}]})})
(is (= 1 (count @registry)))
(is (contains? @registry "test_tool"))))
(testing "list-tools returns MCP tool format"
(let [registry (atom {})]
(mcp.tools/register-tool! registry
{:name "my_tool"
:description "Does things"
:input-schema {:type "object" :properties {}}
:handler (fn [_] {:content [{:type "text" :text "ok"}]})})
(let [tools (mcp.tools/list-tools registry)]
(is (= [{"name" "my_tool"
"description" "Does things"
"inputSchema" {:type "object" :properties {}}}]
tools)))))
(testing "call-tool executes handler"
(let [registry (atom {})]
(mcp.tools/register-tool! registry
{:name "greet"
:description "Greet someone"
:input-schema {:type "object" :properties {"name" {:type "string"}}}
:handler (fn [params] {:content [{:type "text" :text (str "Hi " (get params "name"))}]})})
(is (= {"content" [{"type" "text" "text" "Hi Alice"}]}
(mcp.tools/call-tool registry "greet" {"name" "Alice"}))))))