Compare commits

...

35 Commits

Author SHA1 Message Date
Edward Paget
07f72e1070 Handle less string OAuth clients like cursor 2026-03-20 15:32:09 -05:00
Edward Paget
20b03e72ed Upgrade oidc-provider to 0.5.0 with application_type support
Bump net.carcdr/oidc-provider from 0.3.0 to 0.5.0 which adds
application_type (web/native) for client registration. Web clients
(the default) no longer accept HTTP loopback redirect URIs, and
confidential clients must use Basic auth instead of body credentials.

- Add application_type column to oauth_client table (migration)
- Thread application_type through store, admin API, and schemas
- Update tests to use Basic auth for token/revocation requests
- Fix localhost redirect test to use application_type=native
2026-03-20 13:33:29 -05:00
Chris Truter
33a560203f Test fixes 2026-03-20 11:47:38 -05:00
Edward Paget
d2008d9468 Add embedded OAuth/OIDC provider for MCP authentication
Implement a complete OAuth 2.0 / OpenID Connect provider embedded in
Metabase to support secure MCP client authentication. Includes:

- OIDC discovery and JWKS endpoints
- Database-backed OAuth/OIDC provider storage
- Dynamic client registration (with admin toggle)
- Authorization endpoint with server-rendered consent page
- Token and revocation endpoints
- Admin CRUD API for OAuth client management
- Bearer token auth for MCP endpoints with scope filtering
- CSRF protection on consent form
- Malli response schemas for all API endpoints
2026-03-20 11:47:38 -05:00
Chris Truter
081886e36e Add MCP server with agent API integration
Implements an MCP (Model Context Protocol) server for Metabase that exposes
agent API endpoints as MCP tools. Includes SSE transport, session management,
scope-based auth, and tool dispatch via defendpoint metadata.

Co-Authored-By: Edward Paget <edpaget@users.noreply.github.com>
Co-Authored-By: Noah Moss <32746338+noahmoss@users.noreply.github.com>
2026-03-20 11:44:06 -05:00
Oleksandr Solovyov
3379bb98fa dashboardddddrrrr 2026-03-20 18:17:24 +02:00
Oleksandr Solovyov
3c7720cdb9 move metabot models to oss 2026-03-20 17:31:16 +02:00
Oleksandr Solovyov
9a46737f72 is this a fix for dashboard tests 2026-03-20 17:14:59 +02:00
Oleksandr Solovyov
4ce7bd5ed6 cljfmt 2026-03-20 16:47:33 +02:00
Oleksandr Solovyov
1287d0aec3 maybe fix dashboard tests? 2026-03-20 16:33:12 +02:00
Oleksandr Solovyov
e7dd964675 fix slack test 2026-03-20 15:57:15 +02:00
Noah Moss
61a2d35aa6 cljfmt 2026-03-20 09:33:49 -04:00
Oleksandr Solovyov
dbe5a90c20 fix token check tests 2026-03-20 15:25:19 +02:00
Oleksandr Solovyov
0e1d9f6854 fix test 2026-03-20 15:25:19 +02:00
Oleksandr Solovyov
65634a9197 fix clj-kondo 2026-03-20 15:25:18 +02:00
Noah Moss
f2c3234298 lint 2026-03-20 09:22:19 -04:00
Oleksandr Solovyov
f6b1a2c80e fix module config 2026-03-20 14:02:25 +02:00
Oleksandr Solovyov
70ebef6bd7 fix openapi 2026-03-20 14:02:25 +02:00
Oleksandr Solovyov
6df7eb1939 sort routes 2026-03-20 14:02:25 +02:00
Oleksandr Solovyov
42fce08268 fix tool calls 2026-03-20 14:02:25 +02:00
Noah Moss
21abe0d10f fix tests 2026-03-20 14:02:25 +02:00
Noah Moss
dee17b76ef move around some code to break drivers dependency 2026-03-20 14:02:25 +02:00
Noah Moss
a491176b68 lint fixes 2026-03-20 14:02:24 +02:00
Noah Moss
36c92cc1a2 compilation fix 2026-03-20 14:02:24 +02:00
Noah Moss
1ba4a32d0b checkpoint: switch tools to use mu/defn or defenterprise directly 2026-03-20 14:02:24 +02:00
Noah Moss
51b1b45526 whitespace fix 2026-03-20 14:02:24 +02:00
Noah Moss
d68e2a286d fix namespaces 2026-03-20 14:02:24 +02:00
Noah Moss
b964fce112 move llm transforms code to oss 2026-03-20 14:02:24 +02:00
Noah Moss
8b601bd84f start using deprecated-name, more ns path fixes 2026-03-20 14:02:24 +02:00
Noah Moss
2e27352337 Fix stale metabot-v3 aliases, dangling module refs, disabled linting, broken test paths, and typo 2026-03-20 14:02:24 +02:00
Oleksandr Solovyov
fcdcfb2fff move tests 2026-03-20 14:02:24 +02:00
Oleksandr Solovyov
c9042c3491 ability to have enterprise-only tools 2026-03-20 14:02:24 +02:00
Oleksandr Solovyov
74d6646379 move slack connect to opensource 2026-03-20 14:02:24 +02:00
Oleksandr Solovyov
0a28fe6605 lots of file movement 2026-03-20 14:02:24 +02:00
Oleksandr Solovyov
66611ad720 move ee/llm into oss 2026-03-20 14:02:23 +02:00
312 changed files with 7877 additions and 3865 deletions

View File

@@ -178,6 +178,7 @@
metabase.channel.core/send!
metabase.driver.sql-jdbc.execute/execute-prepared-statement!
metabase.notification.models/create-notification!
metabase.oauth-server.core/reset-provider!
metabase.pulse.send/send-pulse!
metabase-enterprise.transforms-python.python-runner-test/execute!
;; Workspace isolation functions are safe in parallel tests because they operate on
@@ -552,6 +553,7 @@
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
metabase-enterprise.metabot.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp

View File

@@ -129,6 +129,18 @@
models
util}}
agent-api
{:team "Metabot"
:api #{metabase.agent-api.api}
:friends #{enterprise/agent-api}
:uses #{api
auth-identity
metabot
query-processor
request
server
util}}
analytics
{:team "UX West"
:api #{metabase.analytics.api
@@ -175,6 +187,7 @@
metabase.api.docs
metabase.api.init
metabase.api.macros
metabase.api.macros.defendpoint.tools-manifest
metabase.api.macros.scope
metabase.api.open-api
metabase.api.response
@@ -310,7 +323,6 @@
channel
{:team "Gadget"
:api #{metabase.channel.api
metabase.channel.api.slack
metabase.channel.core
metabase.channel.email
metabase.channel.email.messages
@@ -340,6 +352,7 @@
premium-features
query-processor
settings
slackbot
system
task
timeline
@@ -751,7 +764,8 @@
internal-stats
{:team "UX West"
:api #{metabase.internal-stats.core}
:api #{metabase.internal-stats.core
metabase.internal-stats.metabot}
:uses #{app-db
models}}
@@ -830,13 +844,17 @@
llm
{:team "Metabot"
:api #{metabase.llm.api
metabase.llm.init}
metabase.llm.init
metabase.llm.settings}
:uses #{analytics
analyze
api
driver
lib
lib-be
models
parameters
query-processor
request
settings
sql-tools
@@ -868,6 +886,18 @@
settings
util}}
mcp
{:team "Metabot"
:api #{metabase.mcp.api}
:uses #{api
config
oauth-server
premium-features
request
server
system
util}}
measures
{:team "Querying"
:api #{metabase.measures.api}
@@ -882,6 +912,52 @@
search
util}}
metabot
{:team "Metabot"
:api #{metabase.metabot.api
metabase.metabot.core
metabase.metabot.init}
:friends #{agent-api
enterprise/metabot
slackbot}
:uses #{activity-feed
analytics
api
app-db
audit-app
channel
collections
config
documents
driver
internal-stats
legacy-mbql
lib
lib-be
llm
models
native-query-snippets
notification
parameters
permissions
premium-features
pulse
query-processor
request
search
server
settings
slackbot
sql-tools
store-api
sync
system
task
transforms
transforms-base
util
warehouses}}
metrics
{:team "UX West"
:api #{metabase.metrics.api
@@ -939,6 +1015,7 @@
legacy-mbql
lib
lib-be
oauth-server
permissions
query-processor
remote-sync
@@ -963,6 +1040,19 @@
remote-sync
util}}
oauth-server
{:team "UX West"
:api #{metabase.oauth-server.api
metabase.oauth-server.core
metabase.oauth-server.init}
:uses #{api
appearance
models
premium-features
settings
system
util}}
notification
{;; TODO: re-enable when :model/NotificationDashboard is no longer a problem
:model-imports :skip-test
@@ -1419,11 +1509,13 @@
embedding
initialization-status
lib-be
oauth-server
premium-features
query-processor
request
session
settings
sso
system
tracing
users
@@ -1494,6 +1586,29 @@
system
util}}
slackbot
{:team "Metabot"
:api #{metabase.slackbot.api
metabase.slackbot.config
metabase.slackbot.init}
:friends #{metabot}
:uses #{analytics
api
channel
config
formatter
lib
metabot
permissions
query-processor
request
server
settings
sso
system
upload
util}}
source-swap
{:team "Graphy"
:api #{metabase.source-swap.core
@@ -1522,17 +1637,21 @@
sso
{:team "UX West"
:api #{metabase.sso.api
metabase.sso.core
metabase.sso.init}
:uses #{api
auth-identity
config
permissions
premium-features
settings
system
util}}
:api #{metabase.sso.api
metabase.sso.api.slack-connect
metabase.sso.core
metabase.sso.init
metabase.sso.settings}
:friends #{enterprise/sso}
:uses #{api
auth-identity
config
permissions
premium-features
request
settings
system
util}}
startup
{:team "DevEx"
@@ -2026,23 +2145,17 @@
enterprise/agent-api
{:team "Metabot"
:api #{metabase-enterprise.agent-api.api}
:api #{metabase-enterprise.agent-api.workspace}
:uses #{api
auth-identity
query-processor
request
server
util
enterprise/api
enterprise/metabot-v3
enterprise/workspaces}}
enterprise/ai-entity-analysis
{:team "Metabot"
:api #{metabase-enterprise.ai-entity-analysis.api}
:uses #{api
premium-features
enterprise/metabot-v3}}
metabot
premium-features}}
enterprise/ai-sql-fixer
{:team "Metabot"
@@ -2051,23 +2164,24 @@
driver
lib
lib-be
metabot
query-processor
util
enterprise/metabot-v3}}
util}}
enterprise/ai-sql-generation
{:team "Metabot"
:api #{metabase-enterprise.ai-sql-generation.api}
:uses #{api
driver
util
enterprise/metabot-v3}}
metabot
util}}
enterprise/analytics
{:team "UX West"
:api #{}
:uses #{driver
premium-features
sso
enterprise/advanced-config
enterprise/scim
enterprise/semantic-search
@@ -2075,8 +2189,7 @@
enterprise/api
{:team "UX West"
:api #{metabase-enterprise.api.core
metabase-enterprise.api.routes.common}
:api #{metabase-enterprise.api.core}
:uses #{api
premium-features
util}}
@@ -2322,67 +2435,38 @@
collections
premium-features}}
enterprise/llm
{:team "Metabot"
:api #{metabase-enterprise.llm.api
metabase-enterprise.llm.init
metabase-enterprise.llm.settings}
:uses #{analytics
analyze
api
parameters
query-processor
settings
util}}
enterprise/metabot-v3
enterprise/mcp
{:team "Metabot"
:api #{metabase-enterprise.metabot-v3.api
metabase-enterprise.metabot-v3.core
metabase-enterprise.metabot-v3.init
metabase-enterprise.metabot-v3.tools.api}
:friends #{enterprise/agent-api
enterprise/slackbot}
:uses #{activity-feed
analytics
api
app-db
audit-app
channel
collections
config
documents
driver
legacy-mbql
lib
lib-be
models
native-query-snippets
notification
parameters
permissions
:friends #{enterprise/oauth-server}
:uses #{api
premium-features
enterprise/agent-api}}
enterprise/metabot
{:team "Metabot"
:api #{metabase-enterprise.metabot.tools.api}
:friends #{enterprise/agent-api
slackbot
mcp
metabot}
:uses #{api
lib-be
metabot
models
premium-features
pulse
query-processor
request
search
server
settings
sql-tools
store-api
sync
system
task
transforms
transforms-base
util
warehouses
enterprise/api
enterprise/dependencies
enterprise/llm
enterprise/slackbot
enterprise/transforms-python}}
enterprise/oauth-server
{:team "UX West"
:api #{metabase-enterprise.oauth-server.admin-api
metabase-enterprise.oauth-server.core}
:uses #{api
enterprise/mcp
premium-features
util}}
enterprise/permission-debug
{:team "UX West"
:api #{metabase-enterprise.permission-debug.api}
@@ -2490,6 +2574,7 @@
app-db
config
connection-pool
llm
models
permissions
premium-features
@@ -2497,8 +2582,7 @@
settings
task
tracing
util
enterprise/llm}}
util}}
enterprise/serialization
{:team "Querying"
@@ -2521,27 +2605,6 @@
setup
util}}
enterprise/slackbot
{:team "Metabot"
:api #{metabase-enterprise.slackbot.init}
:friends #{enterprise/metabot-v3}
:uses #{analytics
api
channel
config
formatter
lib
permissions
premium-features
query-processor
request
settings
system
upload
util
enterprise/metabot-v3
enterprise/sso}}
enterprise/snippet-collections
{:team "Querying"
:api #{}

View File

@@ -186,5 +186,5 @@
{:node node})
(defn use-fixtures [{:keys [node config]}]
(warn-about-disallowed-parallel-forms node config)
(warn-about-disallowed-parallel-forms node (get-in config [:linters :metabase/validate-deftest]))
{:node node})

1
.gitignore vendored
View File

@@ -176,3 +176,4 @@ CLAUDE.local.md
/resources/python-sources/sqlglot-*.dist-info/
/resources/python-sources/.lock
__pycache__/
.mcp.json

View File

@@ -6,7 +6,7 @@ For detailed guidance on writing and reviewing code and documentation, see the s
## Clojure
- **[clojure-eval](.claude/skills/clojure-eval/SKILL.md)** - Always use this to evaluate Clojure code, **run tests**, and verify edits/compile. Prefer this over `clojure -X:...` shell commands.
- **[clojure-eval](.claude/skills/clojure-eval/SKILL.md)** - Always use this to evaluate Clojure code, **run tests**, and verify edits/compile. Prefer this over `clojure -X:...` and `/bin/test-agent` shell commands.
- **[clojure-write](.claude/skills/clojure-write/SKILL.md)** - Clojure/ClojureScript development with REPL-driven workflow and coding conventions
- **[clojure-review](.claude/skills/clojure-review/SKILL.md)** - Clojure/ClojureScript code review guidelines and style enforcement

View File

@@ -4,7 +4,8 @@
;; !! PLEASE KEEP THESE ORGANIZED ALPHABETICALLY !!
;; !! AND ADD A COMMENT EXPLAINING THEIR PURPOSE !!
;; !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
{amalloy/ring-gzip-middleware {:mvn/version "0.1.4"} ; Ring middleware to GZIP responses if client can handle it
{integrant/integrant {:mvn/version "1.0.1"}
amalloy/ring-gzip-middleware {:mvn/version "0.1.4"} ; Ring middleware to GZIP responses if client can handle it
babashka/fs {:mvn/version "0.5.27"} ; Portable filesystem operations
bigml/histogram {:mvn/version "5.0.0" ; Histogram data structure
:exclusions [junit/junit]}
@@ -106,6 +107,7 @@
methodical/methodical {:mvn/version "1.0.127"} ; drop-in replacements for Clojure multimethods and adds several advanced features
metosin/malli {:git/url "https://github.com/metabase/malli" ; Data-driven Schemas for Clojure/Script and babashka
:git/sha "b4a2d78647a049870b316753d7aae7fb4ade3ae5"} ; patched to resolve an issue causing OOM
net.carcdr/oidc-provider {:mvn/version "0.5.0"} ; OAuth/OIDC server abstract implementation
net.cgrand/macrovich {:mvn/version "0.2.2"} ; utils for writing macros for both Clojure & ClojureScript
net.clojars.wkok/openai-clojure {:mvn/version "0.23.0"
:exclusions [lambdaisland/uri]} ; OpenAI

View File

@@ -2,14 +2,12 @@
"Workspace endpoints for the Agent API, mounted at `/api/agent/v1/workspace/...`.
Provides workspace access with JWT-based agent authentication."
(:require
[metabase-enterprise.api.core :as ee.api]
[metabase-enterprise.workspaces.api.common :as ws.api.common]
[metabase-enterprise.workspaces.types :as ws.t]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :as api.routes.common]
[metabase.api.util.handlers :as handlers]
[metabase.util.i18n :as i18n :refer [deferred-tru]]
[metabase.util.i18n :as i18n]
[metabase.util.malli.schema :as ms]))
(set! *warn-on-reflection* true)
@@ -335,7 +333,4 @@
"Returns the workspace routes for the agent API, keyed by version prefix.
Currently only v1; new versions can be added here without changing api.clj."
[+auth]
(handlers/route-map-handler
{"/v1" {"/workspace" (ee.api/+require-premium-feature
:workspaces (deferred-tru "Workspaces")
(api.macros/ns-handler 'metabase-enterprise.agent-api.workspace +authorize +auth))}}))
(api.macros/ns-handler 'metabase-enterprise.agent-api.workspace +authorize +auth))

View File

@@ -1,9 +1,9 @@
(ns metabase-enterprise.ai-entity-analysis.api
"`/api/ee/ai-entity-analysis/` routes"
(:require
[metabase-enterprise.metabot-v3.core :as metabot-v3]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.metabot.core :as metabot]
[metabase.premium-features.core :as premium-features]))
(set! *warn-on-reflection* true)
@@ -31,7 +31,7 @@
:chart {:name name
:description description}
:timeline_events timeline_events}
resp (metabot-v3/analyze-chart chart-data)]
resp (metabot/analyze-chart chart-data)]
{:summary (:analysis resp)}))
(def ^{:arglists '([request respond raise])} routes

View File

@@ -1,12 +1,12 @@
(ns metabase-enterprise.ai-sql-fixer.api
"`/api/ee/ai-sql-fixer/` routes"
(:require
[metabase-enterprise.metabot-v3.core :as metabot-v3]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.driver.util :as driver.u]
[metabase.lib-be.core :as lib-be]
[metabase.lib.core :as lib]
[metabase.metabot.core :as metabot]
[metabase.query-processor.middleware.permissions :as qp.perms]
[metabase.util.malli.schema :as ms]))
@@ -27,10 +27,10 @@
(qp.perms/check-current-user-has-adhoc-native-query-perms query)
(let [driver (-> query :database driver.u/database->driver)
normalized-query (lib-be/normalize-query query)]
(-> (metabot-v3/fix-sql {:sql (lib/raw-native-query normalized-query)
:dialect driver
:error_message error_message
:schema_ddl (metabot-v3/schema-sample normalized-query)})
(-> (metabot/fix-sql {:sql (lib/raw-native-query normalized-query)
:dialect driver
:error_message error_message
:schema_ddl (metabot/schema-sample normalized-query)})
(select-keys [:fixes]))))
(def ^{:arglists '([request respond raise])} routes

View File

@@ -1,10 +1,10 @@
(ns metabase-enterprise.ai-sql-generation.api
"`/api/ee/ai-sql-generation/` routes"
(:require
[metabase-enterprise.metabot-v3.core :as metabot-v3]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.driver.util :as driver.u]
[metabase.metabot.core :as metabot]
[metabase.util.malli.schema :as ms]))
(set! *warn-on-reflection* true)
@@ -20,9 +20,9 @@
{:keys [prompt database_id]} :- [:map
[:prompt ms/NonBlankString]
[:database_id ms/PositiveInt]]]
(-> (metabot-v3/generate-sql {:dialect (driver.u/database->driver database_id)
:instructions prompt
:tables (metabot-v3/database-tables database_id)})
(-> (metabot/generate-sql {:dialect (driver.u/database->driver database_id)
:instructions prompt
:tables (metabot/database-tables database_id)})
(select-keys [:generated_sql])))
(def ^{:arglists '([request respond raise])} routes

View File

@@ -6,6 +6,7 @@
[metabase-enterprise.sso.settings :as sso-settings]
[metabase.driver :as driver]
[metabase.premium-features.core :as premium-features :refer [defenterprise]]
[metabase.sso.settings :as oss-sso-settings]
[toucan2.core :as t2]))
(defenterprise ee-snowplow-features-data
@@ -21,7 +22,7 @@
:enabled (sso-settings/saml-enabled)}
{:name :sso-slack
:available (premium-features/enable-sso-slack?)
:enabled (sso-settings/slack-connect-enabled)}
:enabled (oss-sso-settings/slack-connect-enabled)}
{:name :scim
:available (premium-features/enable-scim?)
:enabled (boolean (scim/scim-enabled))}

View File

@@ -8,7 +8,7 @@
[metabase-enterprise.action-v2.api]
[metabase-enterprise.advanced-config.api.logs]
[metabase-enterprise.advanced-permissions.api.routes]
[metabase-enterprise.agent-api.api]
[metabase-enterprise.agent-api.workspace :as agent-api.workspace]
[metabase-enterprise.ai-entity-analysis.api]
[metabase-enterprise.ai-sql-fixer.api]
[metabase-enterprise.ai-sql-generation.api]
@@ -27,9 +27,8 @@
[metabase-enterprise.embedding-hub.api]
[metabase-enterprise.gsheets.api :as gsheets.api]
[metabase-enterprise.library.api]
[metabase-enterprise.llm.api]
[metabase-enterprise.metabot-v3.api]
[metabase-enterprise.metabot-v3.tools.api]
[metabase-enterprise.metabot.tools.api]
[metabase-enterprise.oauth-server.admin-api]
[metabase-enterprise.permission-debug.api]
[metabase-enterprise.remote-sync.api]
[metabase-enterprise.replacement.api]
@@ -44,17 +43,15 @@
[metabase-enterprise.transforms.api]
[metabase-enterprise.upload-management.api]
[metabase-enterprise.workspaces.api]
[metabase.agent-api.api :as agent-api]
[metabase.api.macros :as api.macros]
[metabase.api.util.handlers :as handlers]
[metabase.util.i18n :refer [deferred-tru]]))
(comment metabase-enterprise.advanced-config.api.logs/keep-me
metabase-enterprise.llm.api/keep-me
metabase-enterprise.agent-api.api/keep-me)
(comment metabase-enterprise.advanced-config.api.logs/keep-me)
(def ^:private required-feature->message
{:advanced-permissions (deferred-tru "Advanced Permissions")
:agent-api (deferred-tru "Agent API")
:ai-sql-fixer (deferred-tru "AI SQL Fixer")
:ai-sql-generation (deferred-tru "AI SQL Generation")
:ai-entity-analysis (deferred-tru "AI Entity Analysis")
@@ -68,7 +65,6 @@
:remote-sync (deferred-tru "Remote Sync")
:etl-connections (deferred-tru "ETL Connections")
:etl-connections-pg (deferred-tru "ETL Connections PG replication")
:llm-autodescription (deferred-tru "LLM Auto-description")
:metabot-v3 (deferred-tru "MetaBot")
:scim (deferred-tru "SCIM configuration")
:semantic-search (deferred-tru "Semantic Search")
@@ -93,7 +89,7 @@
`/ee/<feature>/`).
TODO -- Please fix them! See #22687"
{"/agent" (premium-handler metabase-enterprise.agent-api.api/routes :agent-api)
{"/agent" {"/v1" {"/workspace" (premium-handler (agent-api.workspace/workspace-handler agent-api/+auth) :workspaces)}}
"/moderation-review" metabase-enterprise.content-verification.api.routes/routes
"/mt" metabase-enterprise.sandbox.api.routes/sandbox-routes
"/table" metabase-enterprise.sandbox.api.routes/sandbox-table-routes})
@@ -109,7 +105,6 @@
"/ai-sql-fixer" (premium-handler metabase-enterprise.ai-sql-fixer.api/routes :ai-sql-fixer)
"/ai-sql-generation" (premium-handler metabase-enterprise.ai-sql-generation.api/routes :ai-sql-generation)
"/audit-app" (premium-handler metabase-enterprise.audit-app.api.routes/routes :audit-app)
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
"/billing" metabase-enterprise.billing.api.routes/routes
"/content-translation" (premium-handler metabase-enterprise.content-translation.routes/routes :content-translation)
"/cloud-add-ons" metabase-enterprise.cloud-add-ons.api/routes
@@ -130,8 +125,8 @@
(premium-handler :etl-connections))
"/library" (premium-handler metabase-enterprise.library.api/routes :library)
"/logs" (premium-handler 'metabase-enterprise.advanced-config.api.logs :audit-app)
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
"/metabot-v3" metabase-enterprise.metabot-v3.api/routes
"/metabot-tools" metabase-enterprise.metabot.tools.api/routes
"/oauth-server" (premium-handler metabase-enterprise.oauth-server.admin-api/routes :metabot-v3)
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)
;; TODO (Ngoc 2026-03-25) -- use :transforms-advanced feature flag once it exists
"/transforms" (premium-handler metabase-enterprise.transforms.api/routes :transforms-python)

View File

@@ -13,12 +13,9 @@
[metabase-enterprise.database-replication.init]
[metabase-enterprise.dependencies.init]
[metabase-enterprise.gsheets.init]
[metabase-enterprise.llm.init]
[metabase-enterprise.metabot-v3.init]
[metabase-enterprise.remote-sync.init]
[metabase-enterprise.scim.init]
[metabase-enterprise.semantic-search.init]
[metabase-enterprise.slackbot.init]
[metabase-enterprise.sso.init]
[metabase-enterprise.stale.init]
[metabase-enterprise.support-access-grants.init]

View File

@@ -1,42 +0,0 @@
(ns metabase-enterprise.llm.api
(:require
[metabase-enterprise.llm.tasks.describe-dashboard :refer [describe-dashboard]]
[metabase-enterprise.llm.tasks.describe-question :refer [describe-question]]
[metabase.analyze.query-results :as qr]
[metabase.api.macros :as api.macros]
[metabase.parameters.schema :as parameters.schema]
[metabase.util.malli.schema :as ms]))
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
;;
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
(api.macros/defendpoint :post "/card/summarize"
"Summarize a question."
[_route-params
_query-params
body :- [:map
[:dataset {:optional true} [:maybe :boolean]]
[:dataset_query ms/Map]
[:parameters {:optional true} [:maybe [:sequential ::parameters.schema/parameter]]]
[:parameter_mappings {:optional true} [:maybe [:sequential ::parameters.schema/parameter-mapping]]]
[:description {:optional true} [:maybe ms/NonBlankString]]
[:display ms/NonBlankString]
[:visualization_settings ms/Map]
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
[:collection_position {:optional true} [:maybe ms/PositiveInt]]
[:result_metadata {:optional true} [:maybe qr/ResultsMetadata]]
[:cache_ttl {:optional true} [:maybe ms/PositiveInt]]]]
;; check that we have permissions to run the query that we're trying to save
;(check-data-permissions-for-query dataset_query)
{:summary (describe-question body)})
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
;;
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
(api.macros/defendpoint :post "/dashboard/summarize/:id"
"Provide a summary of a dashboard."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
{:summary (describe-dashboard id)})

View File

@@ -1,3 +0,0 @@
(ns metabase-enterprise.llm.init
(:require
[metabase-enterprise.llm.settings]))

View File

@@ -1,126 +0,0 @@
(ns metabase-enterprise.llm.settings
(:require
[clojure.string :as str]
[metabase.settings.core :as setting :refer [defsetting]]
[metabase.util.i18n :refer [deferred-tru tru]]))
(defsetting ee-openai-model
(deferred-tru "The OpenAI Model (e.g. ''gpt-4'', ''gpt-3.5-turbo'')")
:encryption :no
:visibility :settings-manager
:default "gpt-4.1-mini"
:export? false
:doc false)
(defsetting ee-openai-api-base-url
(deferred-tru "The OpenAI embeddings base URL used in Metabase Enterprise.")
:encryption :no
:visibility :settings-manager
:default "https://api.openai.com"
:export? false
:doc false)
(defsetting ee-openai-api-key
(deferred-tru "The OpenAI API Key used in Metabase Enterprise.")
:sensitive? true
:visibility :settings-manager
:export? false
:doc false)
(defsetting ee-anthropic-api-base-url
(deferred-tru "The Anthropic API base URL used in Metabase Enterprise.")
:encryption :no
:visibility :settings-manager
:default "https://api.anthropic.com"
:export? false
:doc false)
(defsetting ee-anthropic-api-key
(deferred-tru "The Anthropic API Key used in Metabase Enterprise.")
:sensitive? true
:visibility :settings-manager
:export? false
:doc false)
(defsetting ee-openrouter-api-base-url
(deferred-tru "The OpenRouter API base URL used for Chat Completions.")
:encryption :no
:visibility :settings-manager
:default "https://openrouter.ai/api"
:export? false
:doc false)
(defsetting ee-openrouter-api-key
(deferred-tru "The OpenRouter API Key used in Metabase Enterprise.")
:sensitive? true
:visibility :settings-manager
:export? false
:doc false)
(def ^:private supported-metabot-providers
"Set of supported LLM provider prefixes for the `ee-ai-metabot-provider` setting."
#{"anthropic" "openai" "openrouter"})
(defn- validate-metabot-provider!
"Validate that `value` has the format `provider/model` with a supported provider prefix.
Throws an exception with `:status-code 400` on invalid input."
[value]
(when-not (string? value)
(throw (ex-info (tru "Metabot provider must be a string, got: {0}" (pr-str value))
{:status-code 400})))
(let [[provider model] (str/split value #"/" 2)]
(when-not (contains? supported-metabot-providers provider)
(throw (ex-info (tru "Unknown provider {0}. Supported providers: {1}"
(pr-str provider) (str/join ", " (sort supported-metabot-providers)))
{:status-code 400
:provider provider
:supported supported-metabot-providers})))
(when (str/blank? model)
(throw (ex-info (tru "Model name is required. Expected format: provider/model, e.g. \"anthropic/claude-haiku-4-5\"")
{:status-code 400
:value value})))))
(defsetting ee-ai-metabot-provider
(deferred-tru "The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-4.1-mini`, `openrouter/anthropic/claude-haiku-4-5`.")
:type :string
:encryption :no
:default "openrouter/anthropic/claude-haiku-4-5"
:visibility :settings-manager
:export? false
:doc false
:setter (fn [new-value]
(when new-value
(validate-metabot-provider! new-value))
(setting/set-value-of-type! :string :ee-ai-metabot-provider new-value)))
(defsetting ee-ai-metabot-provider-lite
(deferred-tru "The AI provider and model for lightweight Metabot tasks (e.g. user intent classification).")
:type :string
:encryption :no
:default "openrouter/openai/gpt-oss-20b"
:visibility :settings-manager
:export? false
:doc false
:setter (fn [new-value]
(when new-value
(validate-metabot-provider! new-value))
(setting/set-value-of-type! :string :ee-ai-metabot-provider-lite new-value)))
(defsetting ee-ai-metabot-internal-tasks-enabled?
(deferred-tru "Controls whether Metabot performs internal tasks that might require background tasks or additional LLM calls (e.g. user intent classification).")
:type :boolean
:visibility :settings-manager
:default true
:export? false
:doc false)
(defsetting ee-ai-features-enabled
(deferred-tru "Enable AI features.")
:type :boolean
:visibility :public
:default false
:export? false
:setter (fn [new-value]
(when (some? (ee-openai-api-key))
(setting/set-value-of-type! :boolean :ee-ai-features-enabled new-value)))
:doc false)

View File

@@ -0,0 +1,66 @@
(ns metabase-enterprise.metabot.agent.tools.transforms
"Enterprise implementations of Python transform tools."
(:require
[metabase-enterprise.metabot.tools.transforms :as transform-tools]
[metabase-enterprise.metabot.tools.transforms-write :as transforms-write-tools]
[metabase.metabot.agent.tools.shared :as shared]
[metabase.metabot.agent.tools.transforms :as agent-transforms]
[metabase.metabot.util :as metabot.u]
[metabase.premium-features.core :refer [defenterprise]]))
(set! *warn-on-reflection* true)
;;; ──────────────────────────────────────────────────────────────────
;;; Formatting helpers
;;; ──────────────────────────────────────────────────────────────────
(defn- format-python-library-output
[{:keys [path] :as lib}]
(metabot.u/xml
[:python-library {:path path}
(when-let [content (:content lib)] [:content content])]))
;;; ──────────────────────────────────────────────────────────────────
;;; Tool definitions
;;; ──────────────────────────────────────────────────────────────────
(defenterprise get-transform-python-library-details-tool
"Get information about a Python library by path."
:feature :none
[{:keys [path]}]
(agent-transforms/add-output (transform-tools/get-transform-python-library-details {:path path})
format-python-library-output))
(defenterprise write-transform-python-tool
"Write new Python code or edit existing code for transforms.
Supports two modes:
- edit: Targeted string replacements with partial edits
- replace: Replace entire code atomically
For edit mode, provide edits as an array of {old_string, new_string, replace_all} objects.
For replace mode, provide new_content with the complete Python code.
Use `get_transform_python_library_details` before writing any Python code to inspect the shared library.
Use the shared library in your code by adding `import common` at the top of the file.
Keep `import common` at the top of the file even if it is currently unused."
:feature :none
[{:keys [transform_id edit_action thinking transform_name transform_description
source_database source_tables]}]
(try
(agent-transforms/add-output
(transforms-write-tools/write-transform-python
{:transform_id transform_id
:edit_action edit_action
:thinking thinking
:transform_name transform_name
:transform_description transform_description
:source_database source_database
:source_tables source_tables
:memory-atom shared/*memory-atom*
:context (shared/current-context)})
agent-transforms/format-transform-write-output)
(catch Exception e
(if (:agent-error? (ex-data e))
{:output (ex-message e)}
{:output (str "Failed to write Python transform: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -0,0 +1,134 @@
(ns metabase-enterprise.metabot.tools.api
"EE-only metabot tool endpoints for transforms and dependencies."
(:require
[clojure.set :as set]
[metabase-enterprise.metabot.tools.dependencies :as metabot.tools.dependencies]
[metabase-enterprise.metabot.tools.transforms :as metabot.tools.transforms.ee]
[metabase.api.macros :as api.macros]
[metabase.metabot.tools.api :as tools.api]
[metabase.metabot.tools.deftool :refer [deftool]]
[metabase.metabot.tools.transforms :as metabot.tools.transforms]
[metabase.metabot.util :as metabot.u]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]))
;;; ------------------------------------------------ Transform Schemas ------------------------------------------------
(mr/def ::basic-transform
[:map
{:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
[:id :int]
[:name :string]
[:type [:enum {:decode/tool-api-response name} "mbql" "native" "python"]]
[:description {:optional true} [:maybe :string]]
[:entity_id {:optional true} [:maybe :string]]
;; :source keys are not snake_cased to match what the FE expects / provides in user_is_viewing context
[:source ::metabot.tools.transforms/transform-source]])
(mr/def ::full-transform
[:merge
::basic-transform
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
[:created_at ms/TemporalString]
[:updated_at ms/TemporalString]
;; :target keys are not snake_cased to match what the FE expects / provides in user_is_viewing context
[:target ::metabot.tools.transforms/transform-target]]])
;;; ------------------------------------------------ Transform Tools --------------------------------------------------
(mr/def ::get-transforms-result
[:or
[:map
{:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
[:structured_output [:sequential ::basic-transform]]]
[:map [:output :string]]])
(deftool "/get-transforms"
"Get a list of all known transforms."
{:result-schema ::get-transforms-result
:handler metabot.tools.transforms/get-transforms})
(mr/def ::get-transform-details-arguments
[:and
[:map
[:transform_id :int]]
[:map {:encode/tool-api-request
#(set/rename-keys % {:transform_id :transform-id})}]])
(mr/def ::get-transform-details-result
[:or
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
[:structured_output [:sequential ::full-transform]]]
[:map [:output :string]]])
(deftool "/get-transform-details"
"Get information about a transform."
{:args-schema ::get-transform-details-arguments
:result-schema ::get-transform-details-result
:handler metabot.tools.transforms/get-transform-details})
(mr/def ::get-transform-python-library-details-arguments
[:and
[:map
[:path :string]]
[:map {:encode/tool-api-request
#(update-keys % metabot.u/safe->kebab-case-en)}]])
(mr/def ::get-transform-python-library-details-result
[:or
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
[:structured_output [:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
[:source :string]
[:path :string]
[:created_at ms/TemporalString]
[:updated_at ms/TemporalString]]]]
[:map [:output :string]]])
(deftool "/get-transform-python-library-details"
"Get information about a Python library by path."
{:args-schema ::get-transform-python-library-details-arguments
:result-schema ::get-transform-python-library-details-result
:handler metabot.tools.transforms.ee/get-transform-python-library-details})
;;; --------------------------------------------- Dependency Tools ----------------------------------------------------
(mr/def ::check-transform-dependencies-arguments
[:and
[:map
[:transform_id :int]
[:source ::metabot.tools.transforms/transform-source]]
[:map {:encode/tool-api-request
#(set/rename-keys % {:transform_id :id})}]])
(mr/def ::broken-question
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
[:id :int]
[:name :string]])
(mr/def ::broken-transform
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
[:id :int]
[:name :string]])
(mr/def ::check-transform-dependencies-result
[:or
[:map {:decode/tool-api-response #(update-keys % metabot.u/safe->snake_case_en)}
[:structured_output [:map
[:success :boolean]
[:bad_transform_count :int]
[:bad_transforms [:sequential ::broken-transform]]
[:bad_question_count :int]
[:bad_questions [:sequential ::broken-question]]]]]
[:map [:output :string]]])
(deftool "/check-transform-dependencies"
"Check a proposed edit to a transform and return details of cards or transforms that would be broken by the change."
{:args-schema ::check-transform-dependencies-arguments
:result-schema ::check-transform-dependencies-result
:handler metabot.tools.dependencies/check-transform-dependencies})
;;; ---------------------------------------------------- Routes -------------------------------------------------------
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/metabot-tools` EE-only tool routes for transforms and dependencies."
(api.macros/ns-handler *ns* tools.api/+tool-session))

View File

@@ -1,10 +1,11 @@
(ns metabase-enterprise.metabot-v3.tools.dependencies
(ns metabase-enterprise.metabot.tools.dependencies
(:require
[metabase-enterprise.dependencies.core :as dependencies]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.api.common :as api]
[metabase.lib-be.core :as lib-be]
[metabase.metabot.tools.util :as metabot.tools.u]
[metabase.models.interface :as mi]
[metabase.premium-features.core :refer [defenterprise]]
[toucan2.core :as t2]))
(def ^:private ^:dynamic *max-reported-broken-transforms* 10)
@@ -43,9 +44,10 @@
{:question card :errors (get card-errors (:id card))})
broken-cards))}))
(defn check-transform-dependencies
(defenterprise check-transform-dependencies
"Check a proposed edit to a SQL transform, and return transforms that will break in a format
suitable for Metabot. Takes a map with :id and :source keys."
:feature :none
[{:keys [id source]}]
(try
(let [transform-to-check (api/check-404 (t2/select-one :model/Transform :id id))
@@ -65,4 +67,4 @@
{:success true :bad_transforms []})]
{:structured_output result})
(catch Exception e
(metabot-v3.tools.u/handle-agent-error e))))
(metabot.tools.u/handle-agent-error e))))

View File

@@ -0,0 +1,13 @@
(ns metabase-enterprise.metabot.tools.transforms
(:require
[metabase-enterprise.transforms-python.api :as transforms-python.api]
[metabase.metabot.tools.util :as metabot.tools.u]))
(defn get-transform-python-library-details
"Get information about a Python library by path."
[{:keys [path]}]
(try
{:structured_output
(transforms-python.api/get-python-library-by-path path)}
(catch Exception e
(metabot.tools.u/handle-agent-error e))))

View File

@@ -0,0 +1,89 @@
(ns metabase-enterprise.metabot.tools.transforms-write
"Tools for writing/editing Python transform source code."
(:require
[metabase.metabot.agent.streaming :as streaming]
[metabase.metabot.tools.transforms-write :as transforms-write]
[metabase.util.log :as log]))
(set! *warn-on-reflection* true)
;;; Write Transform Python Tool
(defn write-transform-python
"Write or edit Python transform source code.
Parameters:
- transform-id: Optional ID of existing transform to edit
- edit-action: Either {:mode \"edit\" :edits [...]} or {:mode \"replace\" :new-content \"...\"}
- thinking: Explanation of what changes are being made
- transform-name: Name for new transform (required if no transform-id)
- transform-description: Description for new transform
- source-database: Database ID for source tables
- source-tables: Map of table aliases to table IDs
- memory-atom: Atom containing agent memory (injected by wrapper)
- context: Request context with current transform if editing
Returns map with:
- :structured-output - The suggested transform
- :data-parts - Transform suggestion data part for streaming"
[{:keys [transform_id edit_action thinking transform_name transform_description
source_database source_tables memory-atom context]}]
(log/info "Writing Python transform" {:transform-id transform_id
:edit-mode (:mode edit_action)
:has-context (some? context)})
(let [;; Get current transform from context or memory, or create fresh one
current-transform (cond
;; Try to get from context first
(and transform_id context)
(get-in context [:transforms (str transform_id)])
;; Then try memory
(and transform_id memory-atom)
(get-in @memory-atom [:state :transforms (str transform_id)])
;; Create fresh transform
:else
(transforms-write/create-fresh-transform :python transform_name transform_description
source_database source_tables))
_ (when (and transform_id (nil? current-transform))
(throw (ex-info (str "Transform with ID " transform_id " not found. "
"Use search to find available transforms.")
{:agent-error? true
:transform-id transform_id})))
current-python (get-in current-transform [:source :query] "")
;; Apply edits based on mode
new-python (case (:mode edit_action)
"edit" (transforms-write/apply-edits current-python (:edits edit_action))
"replace" (:new_content edit_action)
;; Default to replace if no mode specified but new_content provided
(if (:new_content edit_action)
(:new_content edit_action)
(throw (ex-info "Invalid edit_action: must specify mode 'edit' with edits or mode 'replace' with new_content"
{:agent-error? true
:edit-action edit_action}))))
;; Build suggested transform
suggested-transform (cond-> current-transform
true (assoc-in [:source :query] new-python)
true (assoc-in [:source :type] "python")
transform_name (assoc :name transform_name)
transform_description (assoc :description transform_description)
source_database (assoc-in [:source :source-database] source_database)
source_tables (assoc-in [:source :source-tables] source_tables))]
;; Store in memory if we have an ID
(when (and transform_id memory-atom)
(swap! memory-atom assoc-in [:state :transforms (str transform_id)] suggested-transform))
(log/debug "Python transform written" {:transform-id transform_id
:python-length (count new-python)})
{:structured-output {:transform suggested-transform
:thinking thinking
:message "Transform Python code updated successfully."}
:data-parts [(streaming/transform-suggestion-part suggested-transform)]
:instructions "The transform suggestion has been created and displayed to the user. Do not repeat the Python content."}))

View File

@@ -1,123 +0,0 @@
(ns metabase-enterprise.metabot-v3.agent.tools
"Tool registry for the agent loop.
Provides a unified namespace for tool vars and state-aware wrapping."
(:require
[metabase-enterprise.metabot-v3.agent.tools.analyze-chart :as tools.analyze-chart]
[metabase-enterprise.metabot-v3.agent.tools.autogen-dashboard :as tools.autogen-dashboard]
[metabase-enterprise.metabot-v3.agent.tools.charts :as tools.charts]
[metabase-enterprise.metabot-v3.agent.tools.clarification :as tools.clarification]
[metabase-enterprise.metabot-v3.agent.tools.construct :as tools.construct]
[metabase-enterprise.metabot-v3.agent.tools.create-alert :as tools.create-alert]
[metabase-enterprise.metabot-v3.agent.tools.create-dashboard-subscription :as tools.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.agent.tools.document :as tools.document]
[metabase-enterprise.metabot-v3.agent.tools.metadata :as tools.metadata]
[metabase-enterprise.metabot-v3.agent.tools.navigation :as tools.navigation]
[metabase-enterprise.metabot-v3.agent.tools.resources :as tools.resources]
[metabase-enterprise.metabot-v3.agent.tools.search :as tools.search]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.agent.tools.slackbot-query :as tools.slackbot-query]
[metabase-enterprise.metabot-v3.agent.tools.snippets :as tools.snippets]
[metabase-enterprise.metabot-v3.agent.tools.sql :as tools.sql]
[metabase-enterprise.metabot-v3.agent.tools.static-viz :as tools.static-viz]
[metabase-enterprise.metabot-v3.agent.tools.subscriptions :as tools.subscriptions]
[metabase-enterprise.metabot-v3.agent.tools.todo :as tools.todo]
[metabase-enterprise.metabot-v3.agent.tools.transforms :as tools.transforms]
[potemkin :as p]))
(set! *warn-on-reflection* true)
(p/import-vars
[tools.analyze-chart
analyze-chart-tool]
[tools.search search-tool
sql-search-tool
nlq-search-tool
transform-search-tool]
[tools.construct
construct-notebook-query-tool]
[tools.document
document-schema-collect-tool
document-construct-sql-chart-tool
document-construct-model-chart-tool]
[tools.metadata
list-available-data-sources-tool
list-available-fields-tool
get-field-values-tool]
[tools.transforms
get-transform-details-tool
get-transform-python-library-details-tool
write-transform-sql-tool
write-transform-python-tool]
[tools.sql
create-sql-query-tool
create-sql-query-code-edit-tool
edit-sql-query-tool
edit-sql-query-code-edit-tool
replace-sql-query-tool
replace-sql-query-code-edit-tool]
[tools.charts
create-chart-tool
edit-chart-tool]
[tools.resources
read-resource-tool]
[tools.todo
todo-write-tool
todo-read-tool]
[tools.navigation
navigate-user-tool]
[tools.snippets
list-snippets-tool
get-snippet-details-tool]
[tools.clarification
ask-for-sql-clarification-tool]
[tools.autogen-dashboard
create-autogenerated-dashboard-tool]
[tools.subscriptions
create-dashboard-subscription-tool]
[tools.slackbot-query
slackbot-construct-notebook-query-tool]
[tools.static-viz
static-viz-tool]
[tools.create-alert
create-alert-tool]
[tools.create-dashboard-subscription
slackbot-create-dashboard-subscription-tool])
(def ^:dynamic ^:private *memory-atom* nil)
(def ^:private state-dependent-tools
"Set of tool names that require access to agent state."
#{"analyze_chart"
"create_chart" "edit_chart" "todo_write" "todo_read" "navigate_user"
"write_transform_sql" "write_transform_python" "create_autogenerated_dashboard"
"create_sql_query" "edit_sql_query" "replace_sql_query"
"document_schema_collect" "document_construct_sql_chart" "document_construct_model_chart"
"create_alert" "create_dashboard_subscription" "static_viz"})
(defn wrap-tools-with-state
"Wrap state-dependent tools with access to the memory atom.
Tools in `state-dependent-tools` will have access to the memory atom
via the dynamic `*memory-atom*` binding.
Returns a new tools map where state-dependent tools are wrapped.
Wrapped tools are returned as maps with :doc, :schema, :prompt, and :fn keys
to satisfy the Claude API schema validation. Tool-specific instructions are
loaded from `resources/metabot/prompts/tools/` by `extract-tool-instructions`
in `prompts.clj`, keyed by `:prompt` metadata or `<tool-name>.md` by default."
[tools memory-atom]
(reduce-kv
(fn [acc tool-name tool-var]
(if (contains? state-dependent-tools tool-name)
(let [{:keys [doc schema prompt decode]} (meta tool-var)
wrapped-fn (fn [args]
(binding [shared/*memory-atom* memory-atom
*memory-atom* memory-atom]
(tool-var args)))]
(assoc acc tool-name (cond-> {:doc doc
:schema schema
:prompt prompt
:fn wrapped-fn}
decode (assoc :decode decode))))
(assoc acc tool-name tool-var)))
{}
tools))

View File

@@ -1,126 +0,0 @@
(ns metabase-enterprise.metabot-v3.agent.tools.search
"Search tool wrappers for Metabot v3."
(:require
[metabase-enterprise.metabot-v3.tmpl :as te]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase-enterprise.metabot-v3.tools.search :as search-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-search-output
"Format search results as an LLM-ready string."
[results]
(let [results-xml (llm-rep/search-results->xml results)]
(te/lines
"<result>"
results-xml
""
(str "Total results: " (count results))
"</result>"
"<instructions>"
instructions/search-result-instructions "</instructions>")))
(defn- invalid-entity-types
[entity-types allowed]
(when (seq entity-types)
(seq (remove allowed entity-types))))
(mu/defn ^{:tool-name "search"} search-tool
"Search for tables, models, metrics, dashboards, and saved questions."
[{:keys [semantic_queries keyword_queries entity_types]} :- [:map {:closed true}
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model" "metric" "dashboard" "question"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"table" "model" "metric" "dashboard" "question"})]
{:output (str "Invalid entity_types for search: " (pr-str (vec invalid))
". Allowed types: table, model, metric, dashboard, question.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "search" :prompt "sql_search.md"} sql-search-tool
"Search for SQL-queryable data sources (tables and models) within a database."
[{:keys [semantic_queries keyword_queries entity_types database_id]} :- [:map {:closed true}
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:database_id :int]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"table" "model"})]
{:output (str "Invalid entity_types for SQL search: " (pr-str (vec invalid))
". Allowed types: table, model.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:database-id database_id
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in SQL search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "search" :prompt "nql_search.md"} nlq-search-tool
"Search for NLQ-queryable data sources (models, metrics, tables)."
[{:keys [semantic_queries keyword_queries entity_types]} :- [:map {:closed true}
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "model" "metric" "table"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"model" "metric" "table"})]
{:output (str "Invalid entity_types for NLQ search: " (pr-str (vec invalid))
". Allowed types: model, metric, table.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:profile-id "nlq"
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in NLQ search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "search" :prompt "transform_search"} transform-search-tool
"Search for transforms, tables, and models."
[{:keys [semantic_queries keyword_queries entity_types search_native_query]} :- [:map {:closed true}
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:search_native_query {:optional true} [:maybe :boolean]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model" "transform"]]]]]]
(if-let [invalid (invalid-entity-types entity_types #{"table" "model" "transform"})]
{:output (str "Invalid entity_types for transform search: " (pr-str (vec invalid))
". Allowed types: table, model, transform.")}
(try
(let [results (search-tools/search {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:search-native-query search_native_query
:limit 10})]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e "Error in transform search")
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))

View File

@@ -1,4 +0,0 @@
(ns metabase-enterprise.metabot-v3.init
(:require
[metabase-enterprise.metabot-v3.settings]
[metabase-enterprise.metabot-v3.task.suggested-prompts-generator]))

View File

@@ -0,0 +1,133 @@
(ns metabase-enterprise.oauth-server.admin-api
"Admin-only CRUD endpoints for managing OAuth clients."
(:require
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.util.malli.schema :as ms]
[oidc-provider.util :as oidc-util]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(def ^:private OAuthClientResponse
"Malli schema for an OAuth client in API responses (no secrets)."
[:map
[:id ms/PositiveInt]
[:client_id ms/NonBlankString]
[:redirect_uris [:sequential ms/NonBlankString]]
[:grant_types [:sequential ms/NonBlankString]]
[:response_types [:sequential ms/NonBlankString]]
[:scopes [:sequential :string]]
[:client_name {:optional true} [:maybe :string]]
[:application_type {:optional true} [:maybe ms/NonBlankString]]
[:registration_type ms/NonBlankString]
[:created_at {:optional true} :any]
[:updated_at {:optional true} :any]])
(def ^:private OAuthClientCreateResponse
"Malli schema for the POST response — includes the plaintext secret (shown once)."
[:merge
OAuthClientResponse
[:map
[:client_secret ms/NonBlankString]]])
(def ^:private client-public-keys
"Keys safe to return in API responses (no secret hashes)."
[:id :client_id :redirect_uris :grant_types :response_types :scopes
:client_name :application_type :registration_type :created_at :updated_at])
(defn- sanitize-client
"Remove secret-related fields from a client row for API responses."
[client]
(select-keys client client-public-keys))
(api.macros/defendpoint :post "/clients" :- OAuthClientCreateResponse
"Register a new static OAuth client. Returns the client with the plaintext secret (shown only once)."
[_route-params
_query-params
{:keys [redirect_uris client_name grant_types response_types scopes application_type]}
:- [:map
[:redirect_uris [:sequential ms/NonBlankString]]
[:client_name {:optional true} [:maybe ms/NonBlankString]]
[:grant_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:response_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:scopes {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:application_type {:optional true} [:maybe ms/NonBlankString]]]]
(let [client-id (str (java.util.UUID/randomUUID))
client-secret (oidc-util/generate-client-secret)
secret-hash (oidc-util/hash-client-secret client-secret)
grant-types (or (not-empty grant_types) ["authorization_code"])
response-types (or (not-empty response_types) ["code"])
scopes (or (not-empty scopes) ["openid" "profile"])
row {:client_id client-id
:client_type "confidential"
:client_secret_hash secret-hash
:redirect_uris redirect_uris
:grant_types grant-types
:response_types response-types
:scopes scopes
:client_name client_name
:application_type (or application_type "web")
:registration_type "static"}
[inserted] (t2/insert-returning-instances! :model/OAuthClient row)]
(-> (sanitize-client inserted)
(assoc :client_secret client-secret))))
(api.macros/defendpoint :get "/clients" :- [:sequential OAuthClientResponse]
"List all OAuth clients. Supports optional `?registration_type=static|dynamic` filter."
[_route-params
{:keys [registration-type]} :- [:map
[:registration-type {:optional true} [:maybe ms/NonBlankString]]]]
(let [clients (if registration-type
(t2/select :model/OAuthClient :registration_type registration-type)
(t2/select :model/OAuthClient))]
(mapv sanitize-client clients)))
(api.macros/defendpoint :get "/clients/:id" :- OAuthClientResponse
"Get a single OAuth client by its database ID."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
(-> (t2/select-one :model/OAuthClient :id id)
api/check-404
sanitize-client))
(api.macros/defendpoint :put "/clients/:id" :- OAuthClientResponse
"Update an OAuth client. Cannot change client_id or client_secret."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
_query-params
body :- [:map
[:client_name {:optional true} [:maybe ms/NonBlankString]]
[:redirect_uris {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:grant_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:response_types {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:scopes {:optional true} [:maybe [:sequential ms/NonBlankString]]]
[:application_type {:optional true} [:maybe ms/NonBlankString]]]]
(let [updates (into {} (filter (comp some? val)) (select-keys body [:client_name :redirect_uris :grant_types :response_types :scopes :application_type]))]
(api/check-404 (t2/select-one :model/OAuthClient :id id))
(when (seq updates)
(t2/update! :model/OAuthClient id updates))
(-> (t2/select-one :model/OAuthClient :id id)
sanitize-client)))
(api.macros/defendpoint :delete "/clients/:id" :- :nil
"Delete an OAuth client."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
(api/check-404 (t2/select-one :model/OAuthClient :id id))
(t2/delete! :model/OAuthClient :id id)
nil)
(defn- +no-cache-on-create
"Middleware that adds Cache-Control: no-store to POST responses (client creation returns a secret)."
[handler]
(fn [request respond raise]
(handler request
(fn [resp]
(respond
(cond-> resp
(= :post (:request-method request)) (update :headers merge {"Cache-Control" "no-store"
"Pragma" "no-cache"}))))
raise)))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/oauth-server` admin routes."
(api.macros/ns-handler *ns* +no-cache-on-create api/+check-superuser +auth))

View File

@@ -279,7 +279,7 @@
[]
(let [api-key (semantic-settings/openai-api-key)]
(when-not api-key
(throw (ex-info "OpenAI API key not configured" {:setting "ee-openai-api-key"})))
(throw (ex-info "OpenAI API key not configured" {:setting "llm-openai-api-key"})))
[(str (semantic-settings/openai-api-base-url) "/v1/embeddings") api-key]))
(defmethod get-embedding "openai" [embedding-model text & {:as opts}]

View File

@@ -1,6 +1,6 @@
(ns metabase-enterprise.semantic-search.settings
(:require
[metabase-enterprise.llm.settings :as llm-settings]
[metabase.llm.settings :as llm-settings]
[metabase.premium-features.core :as premium-features]
[metabase.settings.core :as setting :refer [defsetting]]
[metabase.util.i18n :refer [deferred-tru]]))
@@ -45,12 +45,12 @@
(defn openai-api-base-url
"Get the OpenAI API base url from the existing LLM settings."
[]
(llm-settings/ee-openai-api-base-url))
(llm-settings/llm-openai-api-base-url))
(defn openai-api-key
"Get the OpenAI API key from the existing LLM settings."
[]
(llm-settings/ee-openai-api-key))
(llm-settings/llm-openai-api-key))
(defsetting ee-embedding-service-base-url
(deferred-tru "URL of the OpenAI-compatible embedding service (e.g. a LiteLLM proxy).")

View File

@@ -1,3 +0,0 @@
(ns metabase-enterprise.slackbot.init
(:require
[metabase-enterprise.slackbot.settings]))

View File

@@ -1,37 +0,0 @@
(ns metabase-enterprise.slackbot.settings
(:require
[metabase.premium-features.core :refer [defenterprise]]
[metabase.settings.core :as setting :refer [defsetting]]
[metabase.util.i18n :refer [deferred-tru]]
[metabase.util.string :as u.str]))
(defsetting metabot-slack-signing-secret
(deferred-tru "Signing secret for verifying requests from the Metabot Slack app")
:type :string
:visibility :admin
:encryption :when-encryption-key-set
:feature :metabot-v3
:export? false
:audit :no-value
:getter (fn []
(-> (setting/get-value-of-type :string :metabot-slack-signing-secret)
(u.str/mask 4))))
(defn unobfuscated-metabot-slack-signing-secret
"Get the unobfuscated value of [[metabot-slack-signing-secret]]."
[]
(setting/get-value-of-type :string :metabot-slack-signing-secret))
(defsetting slackbot-event-handler-pool-size
(deferred-tru "Maximum number of concurrent Slack event handler threads.")
:visibility :internal
:type :integer
:default 64
:setter :none
:export? false)
(defenterprise metabot-slack-signing-secret-setting
"Returns the Slack signing secret for Metabot."
:feature :metabot-v3
[]
(unobfuscated-metabot-slack-signing-secret))

View File

@@ -8,11 +8,11 @@
[metabase-enterprise.sso.integrations.jwt :as jwt]
[metabase-enterprise.sso.integrations.oidc :as oidc-integration]
[metabase-enterprise.sso.integrations.saml]
[metabase-enterprise.sso.integrations.slack-connect :as slack-connect-integration]
[metabase-enterprise.sso.settings :as sso-settings]
[metabase.api.macros :as api.macros]
[metabase.request.core :as request]
[metabase.session.core :as session]
[metabase.sso.integrations.slack-connect :as slack-connect-integration]
[metabase.system.core :as system]
[metabase.util :as u]
[metabase.util.log :as log]

View File

@@ -4,5 +4,5 @@
[metabase-enterprise.sso.providers.jwt]
[metabase-enterprise.sso.providers.oidc]
[metabase-enterprise.sso.providers.saml]
[metabase-enterprise.sso.providers.slack-connect]
[metabase-enterprise.sso.settings]))
[metabase-enterprise.sso.settings]
[metabase.sso.providers.slack-connect]))

View File

@@ -5,6 +5,7 @@
[metabase-enterprise.sso.settings :as sso-settings]
[metabase.api.common :as api]
[metabase.appearance.core :as appearance]
[metabase.sso.settings :as oss-sso-settings]
[metabase.system.core :as system]
[metabase.util :as u]
[metabase.util.i18n :refer [trs tru]]
@@ -40,7 +41,7 @@
(defmethod check-user-provisioning :slack-connect
[_]
(maybe-throw-user-provisioning (sso-settings/slack-connect-user-provisioning-enabled)))
(maybe-throw-user-provisioning (oss-sso-settings/slack-connect-user-provisioning-enabled)))
(defmethod check-user-provisioning :oidc
[_]

View File

@@ -13,7 +13,6 @@
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]
[metabase.util.string :as u.str]
[saml20-clj.core :as saml]))
(set! *warn-on-reflection* true)
@@ -349,96 +348,6 @@ using, this usually looks like `https://your-org-name.example.com` or `https://e
:default "(member={dn})"
:audit :getter)
;;; ------------------------------------------------ Slack Connect ------------------------------------------------
(defsetting slack-connect-client-id
(deferred-tru "Client ID for your Slack app. Get this from https://api.slack.com/apps")
:encryption :when-encryption-key-set
:export? false
:feature :sso-slack
:audit :getter)
(defsetting slack-connect-client-secret
(deferred-tru "Client Secret for your Slack app")
:encryption :when-encryption-key-set
:export? false
:feature :sso-slack
:audit :no-value
:getter (fn []
(-> (setting/get-value-of-type :string :slack-connect-client-secret)
(u.str/mask 4))))
(defn unobfuscated-slack-connect-client-secret
"Get the unobfuscated value of [[slack-connect-client-secret]]."
[]
(setting/get-value-of-type :string :slack-connect-client-secret))
(def slack-connect-auth-mode-sso
"Authentication mode for full SSO login."
"sso")
(def slack-connect-auth-mode-link-only
"Authentication mode for account linking only (no session creation)."
"link-only")
(defsetting slack-connect-authentication-mode
(deferred-tru "Controls whether Slack can be used for SSO login or just account linking. Valid values: \"sso\" or \"link-only\" (default)")
:type :string
:export? false
:default slack-connect-auth-mode-link-only
:feature :sso-slack
:audit :getter
:encryption :no
:setter (fn [new-value]
(when (and new-value
(not (contains? #{slack-connect-auth-mode-sso slack-connect-auth-mode-link-only} new-value)))
(throw (ex-info (tru "Invalid authentication mode. Must be \"sso\" or \"link-only\"")
{:status-code 400})))
(setting/set-value-of-type! :string :slack-connect-authentication-mode new-value)))
(defsetting slack-connect-user-provisioning-enabled
(deferred-tru "When a user logs in via Slack Connect, create a Metabase account for them automatically if they don''t have one.")
:type :boolean
:export? false
:default true
:feature :sso-slack
:getter (fn []
(if (= (slack-connect-authentication-mode) slack-connect-auth-mode-link-only)
false
(setting/get-value-of-type :boolean :slack-connect-user-provisioning-enabled)))
:audit :getter)
(defsetting slack-connect-attribute-team-id
(deferred-tru "Slack OIDC claim for the team/workspace ID")
:default "https://slack.com/team_id"
:export? false
:feature :sso-slack
:encryption :when-encryption-key-set
:audit :getter)
(defsetting slack-connect-configured
(deferred-tru "Are the mandatory Slack Connect settings configured?")
:type :boolean
:export? false
:default false
:feature :sso-slack
:setter :none
:getter (fn [] (boolean
(and (slack-connect-client-id)
(slack-connect-client-secret)))))
(defsetting slack-connect-enabled
(deferred-tru "Is Slack Connect authentication configured and enabled?")
:type :boolean
:export? false
:default false
:feature :sso-slack
:audit :getter
:getter (fn []
(if (slack-connect-configured)
(setting/get-value-of-type :boolean :slack-connect-enabled)
false)))
;;; ------------------------------------------------ OIDC (Custom) ------------------------------------------------
(defsetting oidc-providers

View File

@@ -80,7 +80,8 @@
(get-manifest [user status]
(testing (format "get slack manifest %s user" (mt/user-descriptor user))
(mt/user-http-request user :get status "slack/manifest")))]
(mt/with-temporary-setting-values [site-url "http://localhost:3000"]
(mt/user-http-request user :get status "slack/manifest"))))]
(testing "if `advanced-permissions` is disabled, require admins"
(mt/with-premium-features #{}

View File

@@ -5,12 +5,12 @@
[environ.core :as env]
[java-time.api :as t]
[medley.core :as m]
[metabase-enterprise.metabot-v3.settings] ; for setting definitions
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase-enterprise.sso.test-setup :as sso.test-setup]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.normalize :as lib.normalize]
[metabase.metabot.settings] ; for setting definitions
[metabase.metabot.tools.util :as metabot.tools.u]
[metabase.search.ingestion :as search.ingestion]
[metabase.search.test-util :as search.tu]
[metabase.session.models.session :as session.models]
@@ -259,7 +259,7 @@
[table-id field-display-name]
(let [mp (mt/metadata-provider)
query (lib/query mp (lib.metadata/table mp table-id))
field-prefix (metabot-v3.tools.u/table-field-id-prefix table-id)
field-prefix (metabot.tools.u/table-field-id-prefix table-id)
visible-cols (lib/visible-columns query)]
(->> (keep-indexed (fn [i col]
(when (= (lib/display-name query col) field-display-name)

View File

@@ -1,7 +1,7 @@
(ns metabase-enterprise.ai-entity-analysis.api-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.client :as metabot-client]
[metabase.metabot.client :as metabot-client]
[metabase.test :as mt]))
(deftest analyze-chart-test

View File

@@ -2,8 +2,8 @@
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
[metabase.lib.core :as lib]
[metabase.metabot.table-utils :as table-utils]
[metabase.test :as mt]))
(deftest ^:parallel schema-sample-test

View File

@@ -28,7 +28,6 @@
:embedding-simple
:embedding-hub
:hosting
:llm-autodescription
:metabot-v3
:ai-entity-analysis
:ai-sql-fixer
@@ -79,7 +78,6 @@
:embedding_sdk true
:embedding_simple true
:hosting true
:llm_autodescription true
:metabot_v3 true
:ai_entity_analysis true
:ai_sql_fixer true

View File

@@ -1,97 +0,0 @@
(ns metabase-enterprise.llm.api-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.llm.client :as llm-client]
[metabase.test :as mt]
[metabase.util.json :as json]))
(deftest summarize-card-test
(testing "POST /api/ee/autodescribe/card/summarize"
(mt/dataset test-data
(mt/with-temp [:model/Card card {:name "Orders"
:dataset_query
{:database (mt/id)
:type :query
:query {:source-table (mt/id :orders)}}}]
(let [fake-response {:title "Title" :description "Description"}
json-response (json/encode fake-response)
expected {:summary fake-response}]
(testing "Card summarization works in the happy path"
(mt/with-premium-features #{:llm-autodescription}
(with-redefs [llm-client/*create-chat-completion-endpoint*
(fn [_ _]
{:choices [{:message {:content json-response}}]})]
(is (= expected
(mt/user-http-request :rasta :post 200 "ee/autodescribe/card/summarize" card))))))
(testing "We can handle json in markdown"
(mt/with-premium-features #{:llm-autodescription}
(with-redefs [llm-client/*create-chat-completion-endpoint*
(fn [_ _]
{:choices [{:message {:content
(format
"```json%s```"
json-response)}}]})]
(is (= expected
(mt/user-http-request :rasta :post 200 "ee/autodescribe/card/summarize" card))))))
(testing "We can't handle bad responses"
(mt/with-premium-features #{:llm-autodescription}
(with-redefs [llm-client/*create-chat-completion-endpoint*
(fn [_ _]
{:choices [{:message {:content
(format
"This is not a good json message -- %s"
json-response)}}]})]
(is (= 500
(get-in
(mt/user-http-request :rasta :post 500 "ee/autodescribe/card/summarize" card)
[:data :status-code]))))))
(testing "When the `:llm-autodescription` feature is disabled, you get a 402 with message"
(mt/with-premium-features #{}
(mt/assert-has-premium-feature-error "LLM Auto-description"
(mt/user-http-request :rasta :post 402 "ee/autodescribe/card/summarize" card)))))))))
(deftest summarize-dashboard-test
(testing "POST /api/ee/autodescribe/dashboard/summarize/:id"
(mt/dataset test-data
(mt/with-temp [:model/Card {card-id :id} {:name "Orders"
:dataset_query
{:database (mt/id)
:type :query
:query {:source-table (mt/id :orders)}}}
:model/Dashboard {dash-id :id} {:name "Dashboard"}
:model/DashboardCard {_ :id} {:dashboard_id dash-id
:card_id card-id}]
(let [url (format "ee/autodescribe/dashboard/summarize/%s" dash-id)
fake-response {:description "Description"
:keywords "awesome, amazing"
:questions "- What is this?"}
json-response (json/encode fake-response)
expected {:summary {:description "Keywords: awesome, amazing\n\nDescription: Description\n\nQuestions:\n- What is this?"}}]
(testing "Card summarization works in the happy path"
(mt/with-premium-features #{:llm-autodescription}
(with-redefs [llm-client/*create-chat-completion-endpoint*
(fn [_ _]
{:choices [{:message {:content json-response}}]})]
(is (= expected
(mt/user-http-request :rasta :post 200 url))))))
(testing "We can handle json in markdown"
(mt/with-premium-features #{:llm-autodescription}
(with-redefs [llm-client/*create-chat-completion-endpoint*
(fn [_ _]
{:choices [{:message {:content (format "```json%s```" json-response)}}]})]
(is (= expected
(mt/user-http-request :rasta :post 200 url))))))
(testing "We can't handle bad responses"
(mt/with-premium-features #{:llm-autodescription}
(with-redefs [llm-client/*create-chat-completion-endpoint*
(fn [_ _]
{:choices [{:message {:content
(format "This is not a good json message -- %s" json-response)}}]})]
(is (= 500
(get-in
(mt/user-http-request :rasta :post 500 url)
[:data :status-code]))))))
(testing "When the `:llm-autodescription` feature is disabled, you get a 402 with message"
(mt/with-premium-features #{}
(mt/assert-has-premium-feature-error "LLM Auto-description"
(mt/user-http-request :rasta :post 402 url)))))))))

View File

@@ -1,19 +0,0 @@
(ns metabase-enterprise.llm.settings-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.llm.settings :as llm.settings]
[metabase.test :as mt]))
(deftest ai-features-enabled-test
(testing "LLM setting `ee-ai-features-enabled`"
(mt/with-premium-features #{:llm-autodescription}
(mt/with-temporary-setting-values [ee-openai-api-key nil
ee-ai-features-enabled false]
(testing "cannot be set and remains `false` when `ee-openai-api-key` is not set."
(llm.settings/ee-ai-features-enabled! true)
(is (= false
(llm.settings/ee-ai-features-enabled))))
(testing "can be set to `true` when `ee-openai-api-key` is set."
(llm.settings/ee-openai-api-key! "TOTALLY_REAL_KEY")
(llm.settings/ee-ai-features-enabled! true)
(is (true? (llm.settings/ee-ai-features-enabled))))))))

View File

@@ -0,0 +1,47 @@
(ns metabase-enterprise.metabot.api-test
(:require
[clj-http.client :as http]
[clojure.test :refer :all]
[metabase.premium-features.core :as premium-features]
[metabase.test :as mt]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)
(deftest feedback-endpoint-test
(mt/with-premium-features #{:metabot-v3}
(let [store-url "http://hm.example"
fake-token "test-fake-token-for-feedback"]
(testing "Submits feedback to Harbormaster with token and base URL"
(mt/with-temporary-setting-values [store-api-url store-url]
(let [captured (atom nil)
feedback {:metabot_id 1
:feedback {:positive true
:message_id "m-1"
:freeform_feedback "ok"}
:conversation_data {}
:version "v0.0.0"
:submission_time "2025-01-01T00:00:00Z"
:is_admin false}
expected-url (str store-url "/api/v2/metabot/feedback/" fake-token)]
(mt/with-dynamic-fn-redefs
[premium-features/premium-embedding-token (constantly fake-token)
http/post (fn [url opts]
(reset! captured {:url url
:body (json/decode+kw (:body opts))}))]
(let [_resp (mt/user-http-request :rasta :post 204 "metabot/feedback" feedback)]
(is (= {:url expected-url :body feedback}
@captured)))))))
(testing "Returns 500 when http post fails"
(mt/with-temporary-setting-values [store-api-url store-url]
(mt/with-dynamic-fn-redefs
[premium-features/premium-embedding-token (constantly fake-token)
http/post (fn [_url _opts]
(throw (ex-info "boom" {:status 404})))]
(mt/user-http-request :rasta :post 500 "metabot/feedback" {:any "payload"}))))
(testing "Throws when premium token is missing"
(mt/with-dynamic-fn-redefs
[premium-features/premium-embedding-token (constantly nil)]
(mt/user-http-request :rasta :post 400 "metabot/feedback" {:foo "bar"}))))))

View File

@@ -0,0 +1,141 @@
(ns metabase-enterprise.metabot.tools.api-test
"Tests for EE-only metabot tool endpoints (transforms, python libraries)."
(:require
[clojure.test :refer :all]
[metabase.lib.core :as lib]
[metabase.metabot.client :as metabot.client]
[metabase.test :as mt]
[toucan2.core :as t2]))
(defn- ai-session-token
([] (ai-session-token :rasta (str (random-uuid))))
([metabot-id] (ai-session-token :rasta metabot-id))
([user metabot-id]
(-> user mt/user->id (#'metabot.client/get-ai-service-token metabot-id))))
(deftest get-transforms-test
(mt/with-premium-features #{:metabot-v3 :transforms-basic :transforms-python}
(let [conversation-id (str (random-uuid))
rasta-ai-token (ai-session-token)
crowberto-ai-token (ai-session-token :crowberto (str (random-uuid)))]
(mt/with-temp [:model/Transform t1 {:name "People Transform"
:description "Simple select on People table"
:source {:type "query"
:query (lib/native-query (mt/metadata-provider) "SELECT * FROM PEOPLE")}
:target {:type "table"
:name "t1_table"}}
:model/Transform t2 {:name "MBQL Transform"
:description "Simple MQBL query on Products table"
:source {:type "query"
:query (mt/mbql-query products)}
:target {:type "table"
:name "t2_table"}}
:model/Transform t3 {:name "Python Transform"
:description "Simple python transform"
:source {:type "python"
:source-database (mt/id)
:body "print('hello world')"
:source-tables []}
:target {:type "table"
:name "t2_table"}}]
(testing "With insufficient permissions"
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :post 403 "ee/metabot-tools/get-transforms"
{:request-options {:headers {"x-metabase-session" rasta-ai-token}}}
{:conversation_id conversation-id}))))
(testing "With superuser permissions"
(is (=? {:structured_output [(mt/obj->json->obj (select-keys t1 [:id :entity_id :name :description :source]))
;; note: t2 not included because it's a (non-native) MBQL query
(mt/obj->json->obj (select-keys t3 [:id :entity_id :name :description :source]))]
:conversation_id conversation-id}
(-> (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-transforms"
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
{:conversation_id conversation-id})
(update :structured_output (fn [output]
(->> output
(filter #(#{(:id t1) (:id t2) (:id t3)} (:id %)))
(sort-by :id))))))))))))
(deftest get-transform-test
(mt/with-premium-features #{:metabot-v3 :transforms-basic :transforms-python}
(let [conversation-id (str (random-uuid))
rasta-ai-token (ai-session-token)
crowberto-ai-token (ai-session-token :crowberto (str (random-uuid)))]
(mt/with-temp [:model/Transform t1 {:name "People Transform"
:description "Simple select on People table"
:source {:type "query"
:query (mt/native-query {:query "SELECT * FROM PEOPLE"})}
:target {:type "table"
:name "t1_table"}}
:model/Transform t2 {:name "Python Transform"
:description "Simple Python transform"
:source {:type "python"
:body "print('hello world')"
:source-database (mt/id)
:source-tables []}
:target {:type "table"
:name "t2_table"
:database (mt/id)}}]
(testing "With insufficient permissions"
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :post 403 "ee/metabot-tools/get-transform-details"
{:request-options {:headers {"x-metabase-session" rasta-ai-token}}}
{:arguments {:transform_id (:id t1)}
:conversation_id conversation-id}))))
(testing "With non-existent transform"
(is (= "Not found."
(mt/user-http-request :rasta :post 404 "ee/metabot-tools/get-transform-details"
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
{:arguments {:transform_id (+ 10000 (:id t2))}
:conversation_id conversation-id}))))
(testing "With superuser permissions"
(doseq [transform [t1 t2]]
(testing (:name transform)
(is (=? {:structured_output (mt/obj->json->obj (select-keys transform [:id :entity_id :name :description :source :target]))
:conversation_id conversation-id}
(mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-transform-details"
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
{:arguments {:transform_id (:id transform)}
:conversation_id conversation-id}))))))))))
(deftest get-transform-python-library-details-test
(mt/with-premium-features #{:metabot-v3 :python-transforms :transforms-basic}
(let [conversation-id (str (random-uuid))
rasta-ai-token (ai-session-token)
crowberto-ai-token (ai-session-token :crowberto (str (random-uuid)))
saved-python-library (t2/select-one :model/PythonLibrary :path "common.py")]
(when (seq saved-python-library)
(t2/delete! :model/PythonLibrary))
(try
(testing "With no Python library present"
(is (= "Not found."
(mt/user-http-request :rasta :post 404 "ee/metabot-tools/get-transform-python-library-details"
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
{:arguments {:path "common.py"}
:conversation_id conversation-id}))))
(mt/with-temp [:model/PythonLibrary lib1 {:path "common.py"
:source "def hello():\n return 'world'"}]
(testing "With insufficient permissions"
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :post 403 "ee/metabot-tools/get-transform-python-library-details"
{:request-options {:headers {"x-metabase-session" rasta-ai-token}}}
{:arguments {:path (:path lib1)}
:conversation_id conversation-id}))))
(testing "With non-existent library path"
(is (=? {:allowed-paths ["common.py"]
:message "Invalid library path. Only 'common' is currently supported."
:path "nonexistent.py"}
(mt/user-http-request :rasta :post 400 "ee/metabot-tools/get-transform-python-library-details"
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
{:arguments {:path "nonexistent.py"}
:conversation_id conversation-id}))))
(testing "With superuser permissions"
(is (=? {:structured_output (select-keys lib1 [:source :path :created_at :updated_at])
:conversation_id conversation-id}
(mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-transform-python-library-details"
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
{:arguments {:path (:path lib1)}
:conversation_id conversation-id})))))
(finally
(when (seq saved-python-library)
(t2/insert! :model/PythonLibrary saved-python-library)))))))

View File

@@ -1,7 +1,7 @@
(ns metabase-enterprise.metabot-v3.tools.dependencies-test
(ns metabase-enterprise.metabot.tools.dependencies-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.tools.dependencies :as metabot.dependencies]
[metabase-enterprise.metabot.tools.dependencies :as metabot.dependencies]
[metabase.lib.core :as lib]
[metabase.models.interface :as mi]
[metabase.permissions.models.permissions :as perms]

View File

@@ -0,0 +1,26 @@
(ns metabase-enterprise.metabot.tools.entity-details-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.test :as met]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.metabot.tools.entity-details :as entity-details]
[metabase.test :as mt]
[toucan2.core :as t2]))
(defn- sandboxed-query []
(let [mp (mt/metadata-provider)
table (lib.metadata/table mp (mt/id :categories))
id-field (lib.metadata/field mp (mt/id :categories :id))]
(lib/filter (lib/query mp table) (lib/< id-field 3))))
(deftest sandboxed-field-values-test
(met/with-gtaps! {:gtaps {:categories {:query (sandboxed-query)}}}
(let [field-id (mt/id :categories :name)]
(try
(let [result (entity-details/get-table-details {:table-id (mt/id :categories)})
name-field (some #(when (= "NAME" (:name %)) %) (get-in result [:structured-output :fields]))]
(testing "returns sandboxed field values"
(is (= ["African" "American"] (:field_values name-field)))))
(finally
(t2/delete! :model/FieldValues :field_id field-id :type :advanced))))))

View File

@@ -0,0 +1,69 @@
(ns metabase-enterprise.metabot.tools.field-stats-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.test :as met]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.metabot.tools.field-stats :as metabot.tools.field-stats]
[metabase.metabot.tools.util :as metabot.tools.u]
[metabase.test :as mt]
[toucan2.core :as t2]))
(defn- table-query
[metadata-provider table-id]
(lib/query metadata-provider (lib.metadata/table metadata-provider table-id)))
(defn- visible-field-id [query field-id-prefix field-display-name]
(->> (keep-indexed (fn [i col]
(when (= (lib/display-name query col) field-display-name)
i))
(lib/visible-columns query))
first
(str field-id-prefix)))
(defn- sandboxed-query []
(let [mp (mt/metadata-provider)
table (lib.metadata/table mp (mt/id :categories))
id-field (lib.metadata/field mp (mt/id :categories :id))]
(lib/filter (lib/query mp table) (lib/< id-field 3))))
(deftest sandboxed-field-values-test
(met/with-gtaps! {:gtaps {:categories {:query (sandboxed-query)}}}
(let [field-id (mt/id :categories :name)
table-id (mt/id :categories)
mp (mt/metadata-provider)
tq (table-query mp table-id)
agent-field-id (visible-field-id tq (metabot.tools.u/table-field-id-prefix table-id) "Name")]
(try
(let [result (metabot.tools.field-stats/field-values
{:entity-type "table", :entity-id table-id, :field-id agent-field-id, :limit 10})]
(testing "returns sandboxed field values"
(is (= ["African" "American"] (get-in result [:structured-output :value_metadata :field_values])))))
(finally
(t2/delete! :model/FieldValues :field_id field-id :type :advanced))))))
(deftest refingerprint-bypasses-sandboxing-test
(testing "When Metabot triggers re-fingerprinting for a missing fingerprint, the fingerprint reflects the full
(unsandboxed) data, not the sandboxed view of the current user."
(met/with-gtaps! {:gtaps {:categories {:query (sandboxed-query)}}}
(let [field-id (mt/id :categories :name)
table-id (mt/id :categories)
original-fp (t2/select-one-fn :fingerprint :model/Field :id field-id)
full-distinct-count (get-in original-fp [:global :distinct-count])]
(testing "precondition: the field has a fingerprint with a distinct count greater than 0"
(is (> full-distinct-count 0)))
(try
;; Clear the fingerprint to force re-fingerprinting via get-or-create-fingerprint!
(t2/update! :model/Field field-id {:fingerprint nil :fingerprint_version 0})
(let [mp (mt/metadata-provider)
tq (table-query mp table-id)
agent-field-id (visible-field-id tq (metabot.tools.u/table-field-id-prefix table-id) "Name")]
(metabot.tools.field-stats/field-values
{:entity-type "table", :entity-id table-id, :field-id agent-field-id})
(let [new-fp (t2/select-one-fn :fingerprint :model/Field :id field-id)]
(testing "fingerprint was saved"
(is (some? new-fp)))
(testing "fingerprint reflects full table data, not the sandboxed subset"
(is (= full-distinct-count (get-in new-fp [:global :distinct-count]))))))
(finally
(t2/update! :model/Field field-id {:fingerprint original-fp})))))))

View File

@@ -1,10 +1,10 @@
(ns metabase-enterprise.metabot-v3.tools.semantic-search-test
(ns metabase-enterprise.metabot.tools.semantic-search-test
"Semantic search tests specific to the Metabot search tool integration."
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.tools.search :as search]
[metabase-enterprise.semantic-search.test-util :as semantic.tu]
[metabase.api.common :as api]
[metabase.metabot.tools.search :as search]
[metabase.permissions.core :as perms]
[metabase.search.core :as search-core]
[metabase.search.engine :as search.engine]

View File

@@ -0,0 +1,12 @@
(ns metabase-enterprise.metabot.tools.transforms-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot.tools.transforms :as metabot.tools.transforms]))
;; Tests for get-transforms and get-transform-details have been moved to
;; metabase.metabot.tools.transforms-test (OSS).
(deftest get-transform-python-library-details-test
(testing "get-transform-python-library-details is available"
;; Basic smoke test that the function exists and is callable
(is (fn? metabot.tools.transforms/get-transform-python-library-details))))

View File

@@ -0,0 +1,25 @@
(ns metabase-enterprise.metabot.tools.transforms-write-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot.tools.transforms-write :as transforms-write]
[metabase.metabot.tools.transforms-write :as oss-transforms-write]))
;; SQL edit/write tests have been moved to metabase.metabot.tools.transforms-write-test (OSS).
(deftest create-fresh-python-template-test
(testing "fresh Python transforms include common import"
(let [result (oss-transforms-write/create-fresh-transform
:python "Python Transform" nil nil nil)]
(is (= "import common\nimport pandas as pd\n\ndef transform():\n # Your transformation logic here\n return pd.DataFrame([{\"message\": \"Hello from Python transform!\"}])\n"
(get-in result [:source :query]))))))
(deftest create-fresh-python-transform-test
(testing "creates fresh Python transform"
(let [memory-atom (atom {:state {}})
result (transforms-write/write-transform-python
{:edit_action {:mode "replace"
:new_content "def transform():\n return pd.DataFrame()"}
:transform_name "Python Transform"
:memory-atom memory-atom})]
(is (= "Python Transform" (get-in result [:structured-output :transform :name])))
(is (= "python" (get-in result [:structured-output :transform :source :type]))))))

View File

@@ -1,78 +0,0 @@
(ns metabase-enterprise.metabot-v3.config-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
[metabase.test :as mt]))
(deftest resolve-dynamic-metabot-id-test
(testing "metabot ID resolution precedence"
(mt/with-premium-features #{:metabot-v3}
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id nil]
(testing "explicit metabot-id takes precedence"
(is (= "explicit-id"
(metabot-v3.config/resolve-dynamic-metabot-id "explicit-id"))))
(testing "falls back to environment variable"
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id "env-metabot-id"]
(is (= "env-metabot-id"
(metabot-v3.config/resolve-dynamic-metabot-id nil)))))
(testing "falls back to internal default when no explicit or env value"
(is (= metabot-v3.config/internal-metabot-id
(metabot-v3.config/resolve-dynamic-metabot-id nil))))))))
(deftest resolve-dynamic-profile-id-test
(testing "profile ID resolution precedence"
(mt/with-premium-features #{:metabot-v3}
(mt/with-temporary-setting-values [metabot-v3.settings/ai-service-profile-id nil]
(testing "explicit profile-id takes highest precedence"
(is (= "explicit-profile"
(metabot-v3.config/resolve-dynamic-profile-id "explicit-profile" "any-metabot-id"))))
(testing "environment variable takes second precedence"
(mt/with-temporary-setting-values [metabot-v3.settings/ai-service-profile-id "env-profile"]
(is (= "env-profile"
(metabot-v3.config/resolve-dynamic-profile-id nil "any-metabot-id")))))
(testing "metabot-id mapping takes third precedence"
(is (= "internal"
(metabot-v3.config/resolve-dynamic-profile-id nil metabot-v3.config/internal-metabot-id)))
(is (= "embedding_next"
(metabot-v3.config/resolve-dynamic-profile-id nil metabot-v3.config/embedded-metabot-id))))
(testing "falls back to default when no matches"
(is (= "embedding_next"
(metabot-v3.config/resolve-dynamic-profile-id nil "unknown-metabot-id"))))
(testing "single arity version uses dynamic metabot resolution"
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id metabot-v3.config/embedded-metabot-id]
(is (= "embedding_next"
(metabot-v3.config/resolve-dynamic-profile-id nil)))))))))
(deftest integrated-resolution-test
(testing "combination of metabot-id and profile-id precedence resolution"
(mt/with-premium-features #{:metabot-v3}
(testing "explicit params override everything"
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id nil
metabot-v3.settings/ai-service-profile-id nil]
(let [metabot-id (metabot-v3.config/resolve-dynamic-metabot-id "custom-metabot")
profile-id (metabot-v3.config/resolve-dynamic-profile-id "custom-profile" metabot-id)]
(is (= "custom-metabot" metabot-id))
(is (= "custom-profile" profile-id)))))
(testing "environment variables work together"
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id "env-metabot"
metabot-v3.settings/ai-service-profile-id "env-profile"]
(let [metabot-id (metabot-v3.config/resolve-dynamic-metabot-id nil)
profile-id (metabot-v3.config/resolve-dynamic-profile-id nil metabot-id)]
(is (= "env-metabot" metabot-id))
(is (= "env-profile" profile-id)))))
(testing "defaults work together"
(mt/with-temporary-setting-values [metabot-v3.settings/metabot-id nil
metabot-v3.settings/ai-service-profile-id nil]
(let [metabot-id (metabot-v3.config/resolve-dynamic-metabot-id nil)
profile-id (metabot-v3.config/resolve-dynamic-profile-id nil metabot-id)]
(is (= metabot-v3.config/internal-metabot-id metabot-id))
(is (= "internal" profile-id))))))))

View File

@@ -0,0 +1,172 @@
(ns metabase-enterprise.oauth-server.admin-api-test
(:require
[clojure.test :refer [deftest is testing]]
[metabase.test :as mt]
[toucan2.core :as t2]))
;;; ------------------------------------------------ POST /api/ee/oauth-server/clients ----------------------------
(deftest create-client-test
(testing "POST /api/ee/oauth-server/clients"
(testing "creates a static client and returns it with the plaintext secret"
(mt/with-premium-features #{:metabot-v3}
(t2/with-transaction [_conn nil {:rollback-only true}]
(let [response (mt/user-http-request :crowberto :post 200
"ee/oauth-server/clients"
{:redirect_uris ["https://example.com/callback"]
:client_name "My Test App"
:grant_types ["authorization_code"]
:scopes ["openid" "profile"]})]
(is (=? {:id pos-int?
:client_id string?
:client_secret string?
:redirect_uris ["https://example.com/callback"]
:client_name "My Test App"
:registration_type "static"}
response)
"client_secret should be returned on creation")))))
(testing "returns 400 when redirect_uris is missing"
(mt/with-premium-features #{:metabot-v3}
(mt/user-http-request :crowberto :post 400
"ee/oauth-server/clients"
{:client_name "No Redirects"})))
(testing "returns 403 for non-admin user"
(mt/with-premium-features #{:metabot-v3}
(mt/user-http-request :rasta :post 403
"ee/oauth-server/clients"
{:redirect_uris ["https://example.com/callback"]})))
(testing "returns 402 without feature flag"
(mt/with-premium-features #{}
(mt/user-http-request :crowberto :post 402
"ee/oauth-server/clients"
{:redirect_uris ["https://example.com/callback"]})))))
;;; ------------------------------------------------ GET /api/ee/oauth-server/clients -----------------------------
(deftest list-clients-test
(testing "GET /api/ee/oauth-server/clients"
(testing "returns list of clients without secrets"
(mt/with-premium-features #{:metabot-v3}
(t2/with-transaction [_conn nil {:rollback-only true}]
(let [created (mt/user-http-request :crowberto :post 200
"ee/oauth-server/clients"
{:redirect_uris ["https://example.com/callback"]
:client_name "List Test"})
clients (mt/user-http-request :crowberto :get 200
"ee/oauth-server/clients")]
(is (sequential? clients))
(is (some (fn [c] (= (:id created) (:id c))) clients)
"created client should appear in list")
(is (every? (fn [c] (and (nil? (:client_secret c))
(nil? (:client_secret_hash c))))
clients)
"secrets and hashes must not be returned")))))
(testing "supports registration_type filter"
(mt/with-premium-features #{:metabot-v3}
(t2/with-transaction [_conn nil {:rollback-only true}]
(mt/user-http-request :crowberto :post 200
"ee/oauth-server/clients"
{:redirect_uris ["https://example.com/callback"]
:client_name "Static Client"})
(let [clients (mt/user-http-request :crowberto :get 200
"ee/oauth-server/clients"
:registration-type "static")]
(is (every? #(= "static" (:registration_type %)) clients))))))
(testing "returns 403 for non-admin"
(mt/with-premium-features #{:metabot-v3}
(mt/user-http-request :rasta :get 403 "ee/oauth-server/clients")))
(testing "returns 402 without feature flag"
(mt/with-premium-features #{}
(mt/user-http-request :crowberto :get 402 "ee/oauth-server/clients")))))
;;; ------------------------------------------------ GET /api/ee/oauth-server/clients/:id -------------------------
(deftest get-client-test
(testing "GET /api/ee/oauth-server/clients/:id"
(testing "returns single client without secret"
(mt/with-premium-features #{:metabot-v3}
(t2/with-transaction [_conn nil {:rollback-only true}]
(let [created (mt/user-http-request :crowberto :post 200
"ee/oauth-server/clients"
{:redirect_uris ["https://example.com/callback"]
:client_name "Get Test"})
response (mt/user-http-request :crowberto :get 200
(str "ee/oauth-server/clients/" (:id created)))]
(is (= (:id created) (:id response)))
(is (= (:client_id created) (:client_id response)))
(is (nil? (:client_secret response)))
(is (nil? (:client_secret_hash response)))))))
(testing "returns 404 for non-existent id"
(mt/with-premium-features #{:metabot-v3}
(mt/user-http-request :crowberto :get 404
"ee/oauth-server/clients/999999")))
(testing "returns 403 for non-admin"
(mt/with-premium-features #{:metabot-v3}
(mt/user-http-request :rasta :get 403
"ee/oauth-server/clients/1")))))
;;; ------------------------------------------------ PUT /api/ee/oauth-server/clients/:id -------------------------
(deftest update-client-test
(testing "PUT /api/ee/oauth-server/clients/:id"
(testing "updates client_name and redirect_uris"
(mt/with-premium-features #{:metabot-v3}
(t2/with-transaction [_conn nil {:rollback-only true}]
(let [created (mt/user-http-request :crowberto :post 200
"ee/oauth-server/clients"
{:redirect_uris ["https://example.com/callback"]
:client_name "Before Update"})
response (mt/user-http-request :crowberto :put 200
(str "ee/oauth-server/clients/" (:id created))
{:client_name "After Update"
:redirect_uris ["https://new.example.com/callback"]})]
(is (= "After Update" (:client_name response)))
(is (= ["https://new.example.com/callback"] (:redirect_uris response)))
(is (nil? (:client_secret response)))))))
(testing "returns 404 for non-existent id"
(mt/with-premium-features #{:metabot-v3}
(mt/user-http-request :crowberto :put 404
"ee/oauth-server/clients/999999"
{:client_name "Nope"})))
(testing "returns 403 for non-admin"
(mt/with-premium-features #{:metabot-v3}
(mt/user-http-request :rasta :put 403
"ee/oauth-server/clients/1"
{:client_name "Nope"})))))
;;; ------------------------------------------------ DELETE /api/ee/oauth-server/clients/:id ----------------------
(deftest delete-client-test
(testing "DELETE /api/ee/oauth-server/clients/:id"
(testing "deletes the client"
(mt/with-premium-features #{:metabot-v3}
(t2/with-transaction [_conn nil {:rollback-only true}]
(let [created (mt/user-http-request :crowberto :post 200
"ee/oauth-server/clients"
{:redirect_uris ["https://example.com/callback"]
:client_name "Delete Me"})]
(mt/user-http-request :crowberto :delete 204
(str "ee/oauth-server/clients/" (:id created)))
;; Verify it's gone
(mt/user-http-request :crowberto :get 404
(str "ee/oauth-server/clients/" (:id created)))))))
(testing "returns 404 for non-existent id"
(mt/with-premium-features #{:metabot-v3}
(mt/user-http-request :crowberto :delete 404
"ee/oauth-server/clients/999999")))
(testing "returns 403 for non-admin"
(mt/with-premium-features #{:metabot-v3}
(mt/user-http-request :rasta :delete 403
"ee/oauth-server/clients/1")))))

View File

@@ -63,7 +63,7 @@
(let [embedding-model {:provider "openai"
:model-name "text-embedding-3-small"
:vector-dimensions 1536}]
(mt/with-temporary-setting-values [ee-openai-api-key nil]
(mt/with-temporary-setting-values [llm-openai-api-key nil]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"OpenAI API key not configured"
@@ -174,7 +174,7 @@
(is (thrown? Exception (decode [{:embedding invalid-base64}]))))))))
(deftest test-get-embedding
(mt/with-temporary-setting-values [ee-openai-api-key "mock-openai-api-key"
(mt/with-temporary-setting-values [llm-openai-api-key "mock-openai-api-key"
ee-embedding-service-base-url "http://mock-embedding-service"
ee-embedding-service-api-key "mock-embedding-service-key"]
(let [mock-embedding [1.0 2.0 3.0 4.0]

View File

@@ -2,10 +2,10 @@
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.sso.settings :as sso-settings]
[metabase-enterprise.sso.test-setup :as sso.test-setup]
[metabase.auth-identity.core :as auth-identity]
[metabase.sso.oidc.state :as oidc.state]
[metabase.sso.settings :as sso-settings]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.test.http-client :as client]
@@ -68,49 +68,40 @@
(with-test-encryption!
(sso.test-setup/do-with-other-sso-types-disabled!
(fn []
(mt/with-additional-premium-features #{:sso-slack}
(testing "SSO requests fail if Slack Connect hasn't been configured or enabled"
(mt/with-temporary-setting-values
[slack-connect-enabled false
slack-connect-client-id nil
slack-connect-client-secret nil]
(is (= "Slack Connect is not enabled"
(mt/client :get 400 "/auth/sso/slack-connect"
{:request-options {:redirect-strategy :none}})))))
(testing "SSO requests fail if Slack Connect hasn't been configured or enabled"
(mt/with-temporary-setting-values
[slack-connect-enabled false
slack-connect-client-id nil
slack-connect-client-secret nil]
(is (= "Slack Connect is not enabled"
(mt/client :get 400 "/auth/sso/slack-connect"
{:request-options {:redirect-strategy :none}})))))
(testing "SSO requests fail if Slack Connect is enabled but hasn't been configured"
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id nil]
(is (= "Slack Connect is not enabled"
(mt/client :get 400 "/auth/sso/slack-connect"
{:request-options {:redirect-strategy :none}})))))
(testing "SSO requests fail if Slack Connect is enabled but hasn't been configured"
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id nil]
(is (= "Slack Connect is not enabled"
(mt/client :get 400 "/auth/sso/slack-connect"
{:request-options {:redirect-strategy :none}})))))
(testing "SSO requests fail if Slack Connect is configured but hasn't been enabled"
(mt/with-temporary-setting-values
[slack-connect-enabled false
slack-connect-client-id "test-slack-client-id"
slack-connect-client-secret "test-slack-client-secret"]
(is (= "Slack Connect is not enabled"
(mt/client :get 400 "/auth/sso/slack-connect"
{:request-options {:redirect-strategy :none}})))))
(testing "SSO requests fail if Slack Connect is configured but hasn't been enabled"
(mt/with-temporary-setting-values
[slack-connect-enabled false
slack-connect-client-id "test-slack-client-id"
slack-connect-client-secret "test-slack-client-secret"]
(is (= "Slack Connect is not enabled"
(mt/client :get 400 "/auth/sso/slack-connect"
{:request-options {:redirect-strategy :none}})))))
(testing "The client secret must also be included for SSO to be configured"
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-slack-client-id"
slack-connect-client-secret nil]
(is (= "Slack Connect is not enabled"
(mt/client :get 400 "/auth/sso/slack-connect"
{:request-options {:redirect-strategy :none}}))))))
(testing "SSO requests fail if they don't have a valid premium-features token"
(sso.test-setup/call-with-default-slack-config!
(fn []
(mt/with-premium-features #{}
(let [response (mt/client :get 402 "/auth/sso/slack-connect"
{:request-options {:redirect-strategy :none}})]
(is (str/includes? (str (:message response) response) "paid feature")))))))))))
(testing "The client secret must also be included for SSO to be configured"
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-slack-client-id"
slack-connect-client-secret nil]
(is (= "Slack Connect is not enabled"
(mt/client :get 400 "/auth/sso/slack-connect"
{:request-options {:redirect-strategy :none}})))))))))
;;; -------------------------------------------------- Redirect Tests --------------------------------------------------
@@ -348,17 +339,16 @@
;; Don't use sso.test-setup/with-slack-default-setup! here because it sets env vars
;; which take precedence over DB values when reading settings.
;; This test only needs to verify the setter validates input correctly.
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-authentication-mode nil]
(testing "valid values are accepted"
(sso-settings/slack-connect-authentication-mode! "sso")
(is (= "sso" (sso-settings/slack-connect-authentication-mode)))
(sso-settings/slack-connect-authentication-mode! "link-only")
(is (= "link-only" (sso-settings/slack-connect-authentication-mode))))
(mt/with-temporary-setting-values
[slack-connect-authentication-mode nil]
(testing "valid values are accepted"
(sso-settings/slack-connect-authentication-mode! "sso")
(is (= "sso" (sso-settings/slack-connect-authentication-mode)))
(sso-settings/slack-connect-authentication-mode! "link-only")
(is (= "link-only" (sso-settings/slack-connect-authentication-mode))))
(testing "invalid values are rejected"
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Invalid authentication mode"
(sso-settings/slack-connect-authentication-mode! "invalid"))))))))
(testing "invalid values are rejected"
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Invalid authentication mode"
(sso-settings/slack-connect-authentication-mode! "invalid")))))))

View File

@@ -2,13 +2,13 @@
(:require
[clj-http.client :as http]
[clojure.test :refer :all]
[metabase-enterprise.sso.providers.slack-connect]
[metabase-enterprise.sso.settings :as sso-settings]
[metabase.auth-identity.core :as auth-identity]
[metabase.auth-identity.provider :as provider]
[metabase.sso.oidc.discovery :as oidc.discovery]
[metabase.sso.oidc.state :as oidc.state]
[metabase.sso.oidc.tokens :as oidc.tokens]
[metabase.sso.providers.slack-connect]
[metabase.sso.settings :as sso-settings]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
@@ -16,7 +16,6 @@
(set! *warn-on-reflection* true)
(use-fixtures :once (fixtures/initialize :db :test-users))
(use-fixtures :each (fn [f] (mt/with-premium-features #{:sso-slack} (f))))
(def ^:private slack-discovery-doc
{:authorization_endpoint "https://slack.com/oauth/v2/authorize"
@@ -41,7 +40,7 @@
[slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"]
(let [request {:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
config (#'metabase-enterprise.sso.providers.slack-connect/build-slack-oidc-config request)]
config (#'metabase.sso.providers.slack-connect/build-slack-oidc-config request)]
(is (= "test-client-id" (:client-id config)))
(is (= "test-secret" (:client-secret config)))
(is (= "https://slack.com" (:issuer-uri config)))
@@ -53,7 +52,7 @@
[slack-connect-client-id nil
slack-connect-client-secret "test-secret"]
(let [request {:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
config (#'metabase-enterprise.sso.providers.slack-connect/build-slack-oidc-config request)]
config (#'metabase.sso.providers.slack-connect/build-slack-oidc-config request)]
(is (nil? config)))))
(testing "Returns nil when client secret is missing"
@@ -61,7 +60,7 @@
[slack-connect-client-id "test-client-id"
slack-connect-client-secret nil]
(let [request {:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
config (#'metabase-enterprise.sso.providers.slack-connect/build-slack-oidc-config request)]
config (#'metabase.sso.providers.slack-connect/build-slack-oidc-config request)]
(is (nil? config))))))
;;; -------------------------------------------------- Claim Extraction Tests --------------------------------------------------
@@ -75,7 +74,7 @@
"https://slack.com/team_image_230" "https://example.com/team.png"
:email_verified true
:locale "en-US"}
slack-attrs (#'metabase-enterprise.sso.providers.slack-connect/extract-slack-claims id-token-claims)]
slack-attrs (#'metabase.sso.providers.slack-connect/extract-slack-claims id-token-claims)]
(is (= "U12345678" (get slack-attrs "slack-user-id")))
(is (= "T12345678" (get slack-attrs "slack-team-id")))
(is (= "https://example.com/team.png" (get slack-attrs "slack-team-image")))
@@ -86,7 +85,7 @@
(mt/with-temporary-setting-values
[slack-connect-attribute-team-id "https://slack.com/team_id"]
(let [id-token-claims {:sub "U12345678"}
slack-attrs (#'metabase-enterprise.sso.providers.slack-connect/extract-slack-claims id-token-claims)]
slack-attrs (#'metabase.sso.providers.slack-connect/extract-slack-claims id-token-claims)]
(is (= "U12345678" (get slack-attrs "slack-user-id")))
(is (nil? (get slack-attrs "slack-team-id")))
(is (nil? (get slack-attrs "slack-team-image")))
@@ -95,52 +94,40 @@
;;; -------------------------------------------------- Authentication Tests --------------------------------------------------
(deftest authenticate-missing-premium-feature-test
(testing "Returns error when premium feature is not available"
(mt/with-premium-features #{}
(deftest authenticate-not-enabled-test
(testing "Returns error when Slack Connect is not enabled"
(mt/with-temporary-setting-values
[slack-connect-enabled false]
(let [request {}
result (auth-identity/authenticate :provider/slack-connect request)]
(is (false? (:success? result)))
(is (= :feature-not-available (:error result)))
(is (= :slack-connect-not-enabled (:error result)))
(is (some? (:message result)))))))
(deftest authenticate-not-enabled-test
(testing "Returns error when Slack Connect is not enabled"
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-enabled false]
(let [request {}
result (auth-identity/authenticate :provider/slack-connect request)]
(is (false? (:success? result)))
(is (= :slack-connect-not-enabled (:error result)))
(is (some? (:message result))))))))
(deftest authenticate-not-configured-test
(testing "Returns error when Slack Connect is not configured"
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id nil
slack-connect-client-secret nil]
(let [request {}
result (auth-identity/authenticate :provider/slack-connect request)]
(is (false? (:success? result)))
(is (= :slack-connect-not-enabled (:error result)))
(is (some? (:message result))))))))
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id nil
slack-connect-client-secret nil]
(let [request {}
result (auth-identity/authenticate :provider/slack-connect request)]
(is (false? (:success? result)))
(is (= :slack-connect-not-enabled (:error result)))
(is (some? (:message result)))))))
(deftest authenticate-link-only-requires-session-test
(testing "Returns error in link-only mode when no authenticated session"
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-authentication-mode "link-only"]
(let [request {:authenticated-user (delay nil)}
result (auth-identity/authenticate :provider/slack-connect request)]
(is (false? (:success? result)))
(is (= :authentication-required (:error result)))
(is (some? (:message result))))))))
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-authentication-mode "link-only"]
(let [request {:authenticated-user (delay nil)}
result (auth-identity/authenticate :provider/slack-connect request)]
(is (false? (:success? result)))
(is (= :authentication-required (:error result)))
(is (some? (:message result)))))))
(deftest authenticate-link-only-allows-callback-test
(testing "Does not require session for callback in link-only mode"
@@ -175,14 +162,56 @@
(deftest authenticate-enhances-with-slack-data-test
(testing "Enhances successful auth result with Slack-specific data"
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-attribute-team-id "https://slack.com/team_id"]
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-attribute-team-id "https://slack.com/team_id"]
(with-redefs [oidc.discovery/discover-oidc-configuration
(fn [_issuer] slack-discovery-doc)
http/post
(fn [_url _opts]
{:status 200
:body {:id_token "valid-token"
:access_token "access-token-123"}})
oidc.tokens/validate-id-token
(fn [_token _config _nonce]
{:valid? true
:claims {:sub "U12345678"
:iss "https://slack.com"
:aud "test-client-id"
:email "test@example.com"
"https://slack.com/team_id" "T12345678"
:email_verified true}})]
(let [request {:code "test-code"
:state "test-state"
:oidc-nonce "test-nonce"
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
result (auth-identity/authenticate :provider/slack-connect request)]
(is (= :slack (get-in result [:user-data :sso_source])))
(is (= "U12345678" (get-in result [:user-data :login_attributes "slack-user-id"])))
(is (= "T12345678" (get-in result [:user-data :login_attributes "slack-team-id"])))
(is (= "true" (get-in result [:user-data :login_attributes "slack-email-verified"])))
(is (some? (:slack-data result))))))))
;;; -------------------------------------------------- Login Tests --------------------------------------------------
(deftest link-only-mode-no-session-creation-test
(testing "No session is created when authentication mode is link-only"
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-authentication-mode "link-only"
slack-connect-user-provisioning-enabled false]
(mt/with-temp [:model/User user {:email "link-only-no-session@example.com"}]
(with-redefs [oidc.discovery/discover-oidc-configuration
(fn [_issuer] slack-discovery-doc)
oidc.state/validate-oidc-callback
(fn [_request _state _provider & _opts]
{:valid? true
:nonce "test-nonce"
:redirect "/"})
http/post
(fn [_url _opts]
{:status 200
@@ -194,79 +223,88 @@
:claims {:sub "U12345678"
:iss "https://slack.com"
:aud "test-client-id"
:email "test@example.com"
"https://slack.com/team_id" "T12345678"
:email_verified true}})]
(let [request {:code "test-code"
:email "link-only-no-session@example.com"}})]
(let [initial-session-count (t2/count :model/Session :user_id (:id user))
request {:code "test-code"
:state "test-state"
:oidc-nonce "test-nonce"
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"}
result (auth-identity/authenticate :provider/slack-connect request)]
(is (= :slack (get-in result [:user-data :sso_source])))
(is (= "U12345678" (get-in result [:user-data :login_attributes "slack-user-id"])))
(is (= "T12345678" (get-in result [:user-data :login_attributes "slack-team-id"])))
(is (= "true" (get-in result [:user-data :login_attributes "slack-email-verified"])))
(is (some? (:slack-data result)))))))))
;;; -------------------------------------------------- Login Tests --------------------------------------------------
(deftest link-only-mode-no-session-creation-test
(testing "No session is created when authentication mode is link-only"
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-authentication-mode "link-only"
slack-connect-user-provisioning-enabled false]
(mt/with-temp [:model/User user {:email "link-only-no-session@example.com"}]
(with-redefs [oidc.discovery/discover-oidc-configuration
(fn [_issuer] slack-discovery-doc)
oidc.state/validate-oidc-callback
(fn [_request _state _provider & _opts]
{:valid? true
:nonce "test-nonce"
:redirect "/"})
http/post
(fn [_url _opts]
{:status 200
:body {:id_token "valid-token"
:access_token "access-token-123"}})
oidc.tokens/validate-id-token
(fn [_token _config _nonce]
{:valid? true
:claims {:sub "U12345678"
:iss "https://slack.com"
:aud "test-client-id"
:email "link-only-no-session@example.com"}})]
(let [initial-session-count (t2/count :model/Session :user_id (:id user))
request {:code "test-code"
:state "test-state"
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
:authenticated-user (delay user)
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
result (auth-identity/login! :provider/slack-connect request)]
(is (true? (:success? result)) "Login should succeed")
(is (nil? (:session result)) "Result should not contain a session")
(is (nil? (:user result)) "Result should not contain a user (dissoc'd by :after method)")
(is (= initial-session-count (t2/count :model/Session :user_id (:id user)))
"No new session should be created in the database"))))))))
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
:authenticated-user (delay user)
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
result (auth-identity/login! :provider/slack-connect request)]
(is (true? (:success? result)) "Login should succeed")
(is (nil? (:session result)) "Result should not contain a session")
(is (nil? (:user result)) "Result should not contain a user (dissoc'd by :after method)")
(is (= initial-session-count (t2/count :model/Session :user_id (:id user)))
"No new session should be created in the database")))))))
;;; -------------------------------------------------- AuthIdentity Creation Tests --------------------------------------------------
(deftest sso-mode-creates-auth-identity-for-new-user-test
(testing "SSO mode creates AuthIdentity when a new user logs in via Slack"
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-authentication-mode "sso"
slack-connect-user-provisioning-enabled true]
(let [test-email "newuser-slack@example.com"
slack-user-id "U_NEW_12345"]
;; Ensure user doesn't exist
(t2/delete! :model/User :email test-email)
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-authentication-mode "sso"
slack-connect-user-provisioning-enabled true]
(let [test-email "newuser-slack@example.com"
slack-user-id "U_NEW_12345"]
;; Ensure user doesn't exist
(t2/delete! :model/User :email test-email)
(with-redefs [oidc.discovery/discover-oidc-configuration
(fn [_issuer] slack-discovery-doc)
oidc.state/validate-oidc-callback
(fn [_request _state _provider & _opts]
{:valid? true
:nonce "test-nonce"
:redirect "/"})
http/post
(fn [_url _opts]
{:status 200
:body {:id_token "valid-token"
:access_token "access-token-123"}})
oidc.tokens/validate-id-token
(fn [_token _config _nonce]
{:valid? true
:claims {:sub slack-user-id
:iss "https://slack.com"
:aud "test-client-id"
:email test-email
:given_name "New"
:family_name "User"}})]
(try
(let [request {:code "test-code"
:state "test-state"
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
result (auth-identity/login! :provider/slack-connect request)]
(is (true? (:success? result)) "Login should succeed")
(is (some? (:user result)) "Result should contain a user")
;; Verify AuthIdentity was created
(let [user (t2/select-one :model/User :email test-email)
auth-identity (t2/select-one :model/AuthIdentity
:user_id (:id user)
:provider "slack-connect")]
(is (some? user) "User should be created")
(is (some? auth-identity) "AuthIdentity should be created")
(is (= slack-user-id (:provider_id auth-identity))
"AuthIdentity should have correct provider_id")))
(finally
(t2/delete! :model/User :email test-email))))))))
(deftest sso-mode-creates-auth-identity-for-existing-user-test
(testing "SSO mode creates AuthIdentity when an existing user logs in via Slack"
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-authentication-mode "sso"]
(let [slack-user-id "U_EXISTING_789"]
(mt/with-temp [:model/User user {:email "existing-slack@example.com"
:first_name "Existing"
:last_name "User"}]
;; Ensure no AuthIdentity exists for this user
(t2/delete! :model/AuthIdentity :user_id (:id user) :provider "slack-connect")
(with-redefs [oidc.discovery/discover-oidc-configuration
(fn [_issuer] slack-discovery-doc)
oidc.state/validate-oidc-callback
@@ -285,128 +323,72 @@
:claims {:sub slack-user-id
:iss "https://slack.com"
:aud "test-client-id"
:email test-email
:given_name "New"
:family_name "User"}})]
(try
(let [request {:code "test-code"
:state "test-state"
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
result (auth-identity/login! :provider/slack-connect request)]
(is (true? (:success? result)) "Login should succeed")
(is (some? (:user result)) "Result should contain a user")
;; Verify AuthIdentity was created
(let [user (t2/select-one :model/User :email test-email)
auth-identity (t2/select-one :model/AuthIdentity
:user_id (:id user)
:provider "slack-connect")]
(is (some? user) "User should be created")
(is (some? auth-identity) "AuthIdentity should be created")
(is (= slack-user-id (:provider_id auth-identity))
"AuthIdentity should have correct provider_id")))
(finally
(t2/delete! :model/User :email test-email)))))))))
(deftest sso-mode-creates-auth-identity-for-existing-user-test
(testing "SSO mode creates AuthIdentity when an existing user logs in via Slack"
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-authentication-mode "sso"]
(let [slack-user-id "U_EXISTING_789"]
(mt/with-temp [:model/User user {:email "existing-slack@example.com"
:first_name "Existing"
:last_name "User"}]
;; Ensure no AuthIdentity exists for this user
(t2/delete! :model/AuthIdentity :user_id (:id user) :provider "slack-connect")
(with-redefs [oidc.discovery/discover-oidc-configuration
(fn [_issuer] slack-discovery-doc)
oidc.state/validate-oidc-callback
(fn [_request _state _provider & _opts]
{:valid? true
:nonce "test-nonce"
:redirect "/"})
http/post
(fn [_url _opts]
{:status 200
:body {:id_token "valid-token"
:access_token "access-token-123"}})
oidc.tokens/validate-id-token
(fn [_token _config _nonce]
{:valid? true
:claims {:sub slack-user-id
:iss "https://slack.com"
:aud "test-client-id"
:email "existing-slack@example.com"}})]
(let [request {:code "test-code"
:state "test-state"
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
result (auth-identity/login! :provider/slack-connect request)]
(is (true? (:success? result)) "Login should succeed")
;; Verify AuthIdentity was created
(let [auth-identity (t2/select-one :model/AuthIdentity
:user_id (:id user)
:provider "slack-connect")]
(is (some? auth-identity) "AuthIdentity should be created for existing user")
(is (= slack-user-id (:provider_id auth-identity))
"AuthIdentity should have correct provider_id"))))))))))
:email "existing-slack@example.com"}})]
(let [request {:code "test-code"
:state "test-state"
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
result (auth-identity/login! :provider/slack-connect request)]
(is (true? (:success? result)) "Login should succeed")
;; Verify AuthIdentity was created
(let [auth-identity (t2/select-one :model/AuthIdentity
:user_id (:id user)
:provider "slack-connect")]
(is (some? auth-identity) "AuthIdentity should be created for existing user")
(is (= slack-user-id (:provider_id auth-identity))
"AuthIdentity should have correct provider_id")))))))))
(deftest link-only-mode-creates-auth-identity-test
(testing "Link-only mode creates AuthIdentity for the authenticated user"
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-authentication-mode "link-only"]
(let [slack-user-id "U_LINK_456"]
(mt/with-temp [:model/User user {:email "linkuser@example.com"
:first_name "Link"
:last_name "User"}]
;; Ensure no AuthIdentity exists for this user
(t2/delete! :model/AuthIdentity :user_id (:id user) :provider "slack-connect")
(with-redefs [oidc.discovery/discover-oidc-configuration
(fn [_issuer] slack-discovery-doc)
oidc.state/validate-oidc-callback
(fn [_request _state _provider & _opts]
{:valid? true
:nonce "test-nonce"
:redirect "/"})
http/post
(fn [_url _opts]
{:status 200
:body {:id_token "valid-token"
:access_token "access-token-123"}})
oidc.tokens/validate-id-token
(fn [_token _config _nonce]
{:valid? true
:claims {:sub slack-user-id
:iss "https://slack.com"
:aud "test-client-id"
:email "linkuser@example.com"}})]
(let [initial-session-count (t2/count :model/Session :user_id (:id user))
request {:code "test-code"
:state "test-state"
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
:authenticated-user (delay user)
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
result (auth-identity/login! :provider/slack-connect request)]
(is (true? (:success? result)) "Login should succeed")
(is (nil? (:session result)) "No session should be created in link-only mode")
;; Verify AuthIdentity was created
(let [auth-identity (t2/select-one :model/AuthIdentity
:user_id (:id user)
:provider "slack-connect")]
(is (some? auth-identity) "AuthIdentity should be created in link-only mode")
(is (= slack-user-id (:provider_id auth-identity))
"AuthIdentity should have correct provider_id"))
;; Verify no new session was created
(is (= initial-session-count (t2/count :model/Session :user_id (:id user)))
"No new session should be created")))))))))
(mt/with-temporary-setting-values
[slack-connect-enabled true
slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-authentication-mode "link-only"]
(let [slack-user-id "U_LINK_456"]
(mt/with-temp [:model/User user {:email "linkuser@example.com"
:first_name "Link"
:last_name "User"}]
;; Ensure no AuthIdentity exists for this user
(t2/delete! :model/AuthIdentity :user_id (:id user) :provider "slack-connect")
(with-redefs [oidc.discovery/discover-oidc-configuration
(fn [_issuer] slack-discovery-doc)
oidc.state/validate-oidc-callback
(fn [_request _state _provider & _opts]
{:valid? true
:nonce "test-nonce"
:redirect "/"})
http/post
(fn [_url _opts]
{:status 200
:body {:id_token "valid-token"
:access_token "access-token-123"}})
oidc.tokens/validate-id-token
(fn [_token _config _nonce]
{:valid? true
:claims {:sub slack-user-id
:iss "https://slack.com"
:aud "test-client-id"
:email "linkuser@example.com"}})]
(let [initial-session-count (t2/count :model/Session :user_id (:id user))
request {:code "test-code"
:state "test-state"
:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"
:authenticated-user (delay user)
:device-info {:device_id "test-device" :device_description "Test Device" :ip_address "127.0.0.1" :embedded false}}
result (auth-identity/login! :provider/slack-connect request)]
(is (true? (:success? result)) "Login should succeed")
(is (nil? (:session result)) "No session should be created in link-only mode")
;; Verify AuthIdentity was created
(let [auth-identity (t2/select-one :model/AuthIdentity
:user_id (:id user)
:provider "slack-connect")]
(is (some? auth-identity) "AuthIdentity should be created in link-only mode")
(is (= slack-user-id (:provider_id auth-identity))
"AuthIdentity should have correct provider_id"))
;; Verify no new session was created
(is (= initial-session-count (t2/count :model/Session :user_id (:id user)))
"No new session should be created"))))))))
;;; -------------------------------------------------- Settings Validation Tests --------------------------------------------------
@@ -431,28 +413,25 @@
(deftest slack-connect-enabled-test
(testing "Returns true when configured and enabled"
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-enabled true]
(is (true? (sso-settings/slack-connect-enabled))))))
(mt/with-temporary-setting-values
[slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-enabled true]
(is (true? (sso-settings/slack-connect-enabled)))))
(testing "Returns false when configured but not enabled"
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-enabled false]
(is (false? (sso-settings/slack-connect-enabled))))))
(mt/with-temporary-setting-values
[slack-connect-client-id "test-client-id"
slack-connect-client-secret "test-secret"
slack-connect-enabled false]
(is (false? (sso-settings/slack-connect-enabled)))))
(testing "Returns false when not configured even if enabled is true"
(mt/with-additional-premium-features #{:sso-slack}
(mt/with-temporary-setting-values
[slack-connect-client-id nil
slack-connect-client-secret nil
slack-connect-enabled true]
(is (false? (sso-settings/slack-connect-enabled)))))))
(mt/with-temporary-setting-values
[slack-connect-client-id nil
slack-connect-client-secret nil
slack-connect-enabled true]
(is (false? (sso-settings/slack-connect-enabled))))))
(deftest slack-connect-authentication-mode-validation-test
(testing "Accepts valid authentication modes"

View File

@@ -229,9 +229,8 @@ export const createMockSettings = (
"deprecation-notice-version": undefined,
"development-mode?": false,
"disable-cors-on-localhost": false,
"ee-ai-features-enabled": false,
"ee-openai-model": "",
"ee-openai-api-key": "",
"llm-openai-model": "",
"llm-openai-api-key": "",
"llm-anthropic-model": "",
"email-configured?": false,
"email-smtp-host": null,

View File

@@ -509,7 +509,7 @@ interface AdminSettings {
}
interface SettingsManagerSettings {
"bcc-enabled?": boolean;
"ee-openai-api-key"?: string;
"llm-openai-api-key"?: string;
"openai-api-key": string | null;
"openai-available-models"?: OpenAiModel[];
"openai-model": string | null;
@@ -541,7 +541,6 @@ interface PublicSettings {
"custom-homepage": boolean;
"custom-homepage-dashboard": DashboardId | null;
"development-mode?": boolean;
"ee-ai-features-enabled"?: boolean;
"email-configured?": boolean;
"embedding-app-origin": string | null;
"embedding-app-origins-sdk": string | null;
@@ -710,9 +709,8 @@ export interface EnterpriseSettings extends Settings {
"no-object-illustration"?: IllustrationSettingValue;
"no-object-illustration-custom"?: string;
"landing-page"?: string;
"ee-ai-features-enabled"?: boolean;
"ee-openai-api-key"?: string;
"ee-openai-model"?: string;
"llm-openai-api-key"?: string;
"llm-openai-model"?: string;
"session-timeout": TimeoutValue | null;
"search-engine": SearchEngineSettingValue | null;
"scim-enabled"?: boolean | null;

View File

@@ -15,6 +15,12 @@ import { canAccessTransforms } from "./transforms/selectors";
type Props = { children: React.ReactElement };
/** Paths that are handled by the backend server, not the frontend SPA router. */
export const BACKEND_ONLY_PATH_PREFIXES = ["/oauth/"];
export const isBackendOnlyPath = (path: string): boolean =>
BACKEND_ONLY_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
const getRedirectUrl = () => {
const params = new URLSearchParams(window.location.search);
const redirectUrlParam = params.get("redirect");
@@ -68,7 +74,19 @@ const UserIsNotAuthenticated = connectedReduxRedirect<Props, State>({
authenticatingSelector: (state) =>
state.auth.loginPending || !state.auth.redirect,
authenticatedSelector: (state) => !state.currentUser,
redirectAction: routerActions.replace,
redirectAction: (location: {
pathname: string;
query?: Record<string, string>;
}) => {
if (isBackendOnlyPath(location.pathname)) {
const params = new URLSearchParams(location.query);
const qs = params.toString();
const url = qs ? `${location.pathname}?${qs}` : location.pathname;
window.location.replace(url);
return routerActions.replace("/");
}
return routerActions.replace(location);
},
context: MetabaseReduxContext,
});

View File

@@ -6,6 +6,7 @@ import { renderWithProviders, screen } from "__support__/ui";
import { createMockState } from "metabase-types/store/mocks";
import { MetabaseReduxContext } from "./lib/redux";
import { isBackendOnlyPath } from "./route-guards";
describe("route-guards", () => {
describe("patched redux-auth-wrapper", () => {
@@ -63,6 +64,25 @@ describe("route-guards", () => {
expect(screen.queryByText(text)).not.toBeInTheDocument();
});
});
describe("isBackendOnlyPath", () => {
it("should return true for /oauth/ paths", () => {
expect(isBackendOnlyPath("/oauth/authorize")).toBe(true);
expect(isBackendOnlyPath("/oauth/authorize/decision")).toBe(true);
expect(isBackendOnlyPath("/oauth/token")).toBe(true);
});
it("should return false for frontend paths", () => {
expect(isBackendOnlyPath("/")).toBe(false);
expect(isBackendOnlyPath("/auth/login")).toBe(false);
expect(isBackendOnlyPath("/collection/root")).toBe(false);
expect(isBackendOnlyPath("/question/1")).toBe(false);
});
it("should not match partial prefixes", () => {
expect(isBackendOnlyPath("/oauthx/foo")).toBe(false);
});
});
});
function setupRouteGuard(opts: {

View File

@@ -102,6 +102,7 @@
config
content-verification
dashboards
documents
eid-translation
embedding
enterprise/api
@@ -114,7 +115,9 @@
formatter
initialization-status
internal-stats
llm
login-history
metabot
notification
permissions
premium-features
@@ -128,6 +131,7 @@
session
settings
setup
slackbot
sso
startup
system
@@ -140,7 +144,8 @@
util
version
view-log
warehouse-schema})
warehouse-schema
workspaces})
(defn- affected-modules
"Set of modules that are direct or indirect dependents of `modules`, and thus are affected by changes to them.

View File

@@ -0,0 +1,432 @@
## OAuth/OIDC provider server tables for embedded OAuth support
databaseChangeLog:
- logicalFilePath: migrations/060_update_migrations.yaml
# oauth_client table
- changeSet:
id: v60.2026-03-11T00:00:00
author: epaget
comment: Create oauth_client table for embedded OAuth provider
changes:
- createTable:
tableName: oauth_client
remarks: Stores OAuth2/OIDC client registrations for the embedded provider
columns:
- column:
name: id
type: int
remarks: Auto-incrementing primary key
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: client_id
type: varchar(255)
remarks: OAuth client_id (UUID)
constraints:
nullable: false
- column:
name: client_secret_hash
type: ${text.type}
remarks: PBKDF2 hash of client secret
constraints:
nullable: true
- column:
name: redirect_uris
type: ${text.type}
remarks: JSON array of redirect URIs
constraints:
nullable: false
- column:
name: grant_types
type: ${text.type}
remarks: JSON array of grant types
constraints:
nullable: false
- column:
name: response_types
type: ${text.type}
remarks: JSON array of response types
constraints:
nullable: false
- column:
name: scopes
type: ${text.type}
remarks: JSON array of scopes
constraints:
nullable: false
- column:
name: token_endpoint_auth_method
type: varchar(50)
remarks: OAuth token endpoint auth method (e.g. client_secret_basic)
constraints:
nullable: true
- column:
name: client_name
type: varchar(255)
remarks: Human-readable client name
constraints:
nullable: true
- column:
name: client_uri
type: ${text.type}
remarks: URL of the client home page
constraints:
nullable: true
- column:
name: logo_uri
type: ${text.type}
remarks: URL of the client logo image
constraints:
nullable: true
- column:
name: contacts
type: ${text.type}
remarks: JSON array of contact strings
constraints:
nullable: true
- column:
name: registration_type
type: varchar(50)
remarks: "static or dynamic"
constraints:
nullable: false
- column:
name: client_type
type: varchar(50)
remarks: "confidential or public"
defaultValue: "confidential"
constraints:
nullable: false
- column:
name: registration_access_token_hash
type: ${text.type}
remarks: Hashed RFC 7592 registration access token
constraints:
nullable: true
- column:
name: created_at
type: ${timestamp_type}
remarks: Timestamp of client registration
defaultValueComputed: current_timestamp
constraints:
nullable: false
- column:
name: updated_at
type: ${timestamp_type}
remarks: Timestamp of last client update
defaultValueComputed: current_timestamp
constraints:
nullable: false
- changeSet:
id: v60.2026-03-11T00:00:01
author: epaget
comment: Add unique index on oauth_client.client_id
changes:
- addUniqueConstraint:
tableName: oauth_client
constraintName: uq_oauth_client_client_id
columnNames: client_id
# oauth_authorization_code table
- changeSet:
id: v60.2026-03-11T00:00:02
author: epaget
comment: Create oauth_authorization_code table
changes:
- createTable:
tableName: oauth_authorization_code
remarks: Stores authorization codes for the embedded OAuth provider
columns:
- column:
name: id
type: int
remarks: Auto-incrementing primary key
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: code
type: varchar(255)
remarks: The authorization code string
constraints:
nullable: false
- column:
name: user_id
type: int
remarks: FK to core_user.id
constraints:
nullable: false
- column:
name: client_id
type: varchar(255)
remarks: The OAuth client_id
constraints:
nullable: false
- column:
name: redirect_uri
type: ${text.type}
remarks: Redirect URI for this authorization
constraints:
nullable: false
- column:
name: scope
type: ${text.type}
remarks: JSON array of scope strings
constraints:
nullable: false
- column:
name: nonce
type: varchar(255)
remarks: OIDC nonce value
constraints:
nullable: true
- column:
name: expiry
type: bigint
remarks: Milliseconds since epoch
constraints:
nullable: false
- column:
name: code_challenge
type: varchar(255)
remarks: PKCE code challenge
constraints:
nullable: true
- column:
name: code_challenge_method
type: varchar(10)
remarks: PKCE code challenge method
constraints:
nullable: true
- column:
name: resource
type: ${text.type}
remarks: JSON array of resource URIs (RFC 8707)
constraints:
nullable: true
- column:
name: created_at
type: ${timestamp_type}
remarks: Timestamp of code creation
defaultValueComputed: current_timestamp
constraints:
nullable: false
- changeSet:
id: v60.2026-03-11T00:00:03
author: epaget
comment: Add unique index on oauth_authorization_code.code
changes:
- addUniqueConstraint:
tableName: oauth_authorization_code
constraintName: uq_oauth_authorization_code_code
columnNames: code
- changeSet:
id: v60.2026-03-11T00:00:04
author: epaget
comment: Add index on oauth_authorization_code.expiry for cleanup
changes:
- createIndex:
tableName: oauth_authorization_code
indexName: idx_oauth_authorization_code_expiry
columns:
- column:
name: expiry
- changeSet:
id: v60.2026-03-11T00:00:05
author: epaget
comment: Add FK on oauth_authorization_code.user_id
changes:
- addForeignKeyConstraint:
baseTableName: oauth_authorization_code
baseColumnNames: user_id
referencedTableName: core_user
referencedColumnNames: id
constraintName: fk_oauth_authorization_code_user_id
# oauth_access_token table
- changeSet:
id: v60.2026-03-11T00:00:06
author: epaget
comment: Create oauth_access_token table
changes:
- createTable:
tableName: oauth_access_token
remarks: Stores access tokens for the embedded OAuth provider
columns:
- column:
name: id
type: int
remarks: Auto-incrementing primary key
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: token
type: varchar(255)
remarks: The access token string
constraints:
nullable: false
- column:
name: user_id
type: int
remarks: FK to core_user.id (null for client_credentials)
constraints:
nullable: true
- column:
name: client_id
type: varchar(255)
remarks: The OAuth client_id
constraints:
nullable: false
- column:
name: scope
type: ${text.type}
remarks: JSON array of scopes
constraints:
nullable: false
- column:
name: expiry
type: bigint
remarks: Milliseconds since epoch
constraints:
nullable: false
- column:
name: resource
type: ${text.type}
remarks: JSON array of resource URIs
constraints:
nullable: true
- column:
name: revoked_at
type: ${timestamp_type}
remarks: Null if active
constraints:
nullable: true
- column:
name: created_at
type: ${timestamp_type}
remarks: Timestamp of token creation
defaultValueComputed: current_timestamp
constraints:
nullable: false
- changeSet:
id: v60.2026-03-11T00:00:07
author: epaget
comment: Add unique index on oauth_access_token.token
changes:
- addUniqueConstraint:
tableName: oauth_access_token
constraintName: uq_oauth_access_token_token
columnNames: token
- changeSet:
id: v60.2026-03-11T00:00:08
author: epaget
comment: Add index on oauth_access_token.expiry
changes:
- createIndex:
tableName: oauth_access_token
indexName: idx_oauth_access_token_expiry
columns:
- column:
name: expiry
- changeSet:
id: v60.2026-03-11T00:00:09
author: epaget
comment: Add FK on oauth_access_token.user_id
changes:
- addForeignKeyConstraint:
baseTableName: oauth_access_token
baseColumnNames: user_id
referencedTableName: core_user
referencedColumnNames: id
constraintName: fk_oauth_access_token_user_id
# oauth_refresh_token table
- changeSet:
id: v60.2026-03-11T00:00:10
author: epaget
comment: Create oauth_refresh_token table
changes:
- createTable:
tableName: oauth_refresh_token
remarks: Stores refresh tokens for the embedded OAuth provider
columns:
- column:
name: id
type: int
remarks: Auto-incrementing primary key
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: token
type: varchar(255)
remarks: The refresh token string
constraints:
nullable: false
- column:
name: user_id
type: int
remarks: FK to core_user.id
constraints:
nullable: true
- column:
name: client_id
type: varchar(255)
remarks: The OAuth client_id
constraints:
nullable: false
- column:
name: scope
type: ${text.type}
remarks: JSON array of scopes
constraints:
nullable: false
- column:
name: resource
type: ${text.type}
remarks: JSON array of resource URIs
constraints:
nullable: true
- column:
name: expiry
type: bigint
remarks: Milliseconds since epoch (null means no expiry)
constraints:
nullable: true
- column:
name: revoked_at
type: ${timestamp_type}
remarks: Null if active
constraints:
nullable: true
- column:
name: created_at
type: ${timestamp_type}
remarks: Timestamp of token creation
defaultValueComputed: current_timestamp
constraints:
nullable: false
- changeSet:
id: v60.2026-03-11T00:00:11
author: epaget
comment: Add unique index on oauth_refresh_token.token
changes:
- addUniqueConstraint:
tableName: oauth_refresh_token
constraintName: uq_oauth_refresh_token_token
columnNames: token

View File

@@ -0,0 +1,20 @@
## Add application_type column to oauth_client for OIDC provider 0.5.0
databaseChangeLog:
- logicalFilePath: migrations/060_update_migrations.yaml
- changeSet:
id: v60.2026-03-20T00:00:00
author: epaget
comment: Add application_type column to oauth_client (web or native)
changes:
- addColumn:
tableName: oauth_client
columns:
- column:
name: application_type
type: varchar(50)
remarks: OAuth application type (web or native), defaults to web
defaultValue: web
constraints:
nullable: true

View File

@@ -1,23 +1,21 @@
(ns metabase-enterprise.agent-api.api
(ns metabase.agent-api.api
"Customer-facing Agent API for headless BI applications.
Endpoints are versioned (e.g., /v1/search) and use standard HTTP semantics."
(:require
[clojure.string :as str]
[malli.core :as mc]
[metabase-enterprise.agent-api.workspace :as agent-workspace]
[metabase-enterprise.metabot-v3.tools.api :as tools.api]
[metabase-enterprise.metabot-v3.tools.deftool :as deftool]
[metabase-enterprise.metabot-v3.tools.entity-details :as entity-details]
[metabase-enterprise.metabot-v3.tools.field-stats :as field-stats]
[metabase-enterprise.metabot-v3.tools.filters :as metabot-filters]
[metabase-enterprise.metabot-v3.tools.search :as metabot-search]
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.macros.scope :as scope]
[metabase.api.routes.common :as api.routes.common]
[metabase.api.util.handlers :as handlers]
[metabase.auth-identity.core :as auth-identity]
[metabase.metabot.tools.api :as tools.api]
[metabase.metabot.tools.deftool :as deftool]
[metabase.metabot.tools.entity-details :as entity-details]
[metabase.metabot.tools.field-stats :as field-stats]
[metabase.metabot.tools.filters :as metabot-filters]
[metabase.metabot.tools.search :as metabot-search]
[metabase.metabot.util :as metabot.u]
[metabase.query-processor :as qp]
[metabase.query-processor.streaming :as qp.streaming]
[metabase.request.core :as request]
@@ -62,7 +60,7 @@
(mr/def ::field
"A field from a table or metric. The field_id format is '<prefix><entity-id>-<field-index>' where prefix indicates the source (t=table, c=metric) and index is the position in the entity's fields."
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
[:field_id :string]
[:name :string]
[:type {:optional true} [:maybe ::field-type]]
@@ -77,7 +75,7 @@
(mr/def ::metric-summary
"Summary of a metric associated with a table. Includes the field_id of the default time dimension for temporal breakouts."
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
[:id :int]
[:type [:= :metric]]
[:name :string]
@@ -86,7 +84,7 @@
(mr/def ::segment
"A predefined filter condition that can be applied to queries via the segment_id in filters."
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
[:id :int]
[:name :string]
[:display_name {:optional true} [:maybe :string]]
@@ -94,7 +92,7 @@
(mr/def ::measure
"A reusable aggregation expression associated with a table. Reference via measure_id in the aggregations array."
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
[:id :int]
[:name :string]
[:display_name {:optional true} [:maybe :string]]
@@ -102,7 +100,7 @@
(mr/def ::related-table
"A table related to the queried entity via foreign key. The related_by field indicates the FK field name."
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
[:id :int]
[:type [:= :table]]
[:name :string]
@@ -116,7 +114,7 @@
(mr/def ::table
"Full details of a table including its fields, related tables, metrics, and segments."
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
[:id :int]
[:type ::entity-type]
[:name :string]
@@ -133,7 +131,7 @@
(mr/def ::metric
"A metric with its queryable dimensions and segments. The default_time_dimension_field_id is the field_id of the recommended time dimension for temporal breakouts."
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
[:id :int]
[:type [:= :metric]]
[:name :string]
@@ -145,7 +143,7 @@
(mr/def ::statistics
"Statistical summary of a field's values computed during database sync. Includes counts, percentages, numeric summaries (min/max/avg/quartiles/sd), and date ranges."
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
[:distinct_count {:optional true} [:maybe :int]]
[:percent_null {:optional true} [:maybe number?]]
[:min {:optional true} [:maybe number?]]
@@ -164,14 +162,14 @@
(mr/def ::field-values
"Statistics and sample values for a specific field."
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
[:field_id {:optional true} [:maybe :string]]
[:statistics {:optional true} [:maybe ::statistics]]
[:values {:optional true} [:maybe [:sequential :any]]]])
(mr/def ::search-result-item
"A table or metric returned from search."
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
[:id :int]
[:type [:enum "table" "metric"]]
[:name :string]
@@ -185,7 +183,7 @@
(mr/def ::search-response
"Search results containing tables and metrics matching the query."
[:map {:encode/api #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:map {:encode/api #(update-keys % metabot.u/safe->snake_case_en)}
[:data [:sequential ::search-result-item]]
[:total_count :int]])
@@ -328,7 +326,7 @@
:description "Order by regular fields only. To order by aggregation results, use sort_order on the aggregation."}
[:maybe [:sequential ::tools.api/order-by]]]
[:limit {:optional true} [:maybe ms/PositiveInt]]]
[:map {:encode/tool-api-request #(update-keys % metabot-v3.u/safe->kebab-case-en)}]])
[:map {:encode/tool-api-request #(update-keys % metabot.u/safe->kebab-case-en)}]])
(mr/def ::construct-query-metric-request
"Request schema for constructing a query from a metric.
@@ -338,7 +336,7 @@
[:metric_id ms/PositiveInt]
[:filters {:optional true} [:maybe [:sequential ::tools.api/filter]]]
[:group_by {:optional true} [:maybe [:sequential ::tools.api/group-by]]]]
[:map {:encode/tool-api-request #(update-keys % metabot-v3.u/safe->kebab-case-en)}]])
[:map {:encode/tool-api-request #(update-keys % metabot.u/safe->kebab-case-en)}]])
(mr/def ::construct-query-request
"Request schema for /v1/construct-query. Accepts either table_id or metric_id."
@@ -551,13 +549,12 @@
(handler (assoc request :token-scopes (or (:scopes result) #{::scope/unrestricted}))
respond raise))
(respond (error-response (:error result) (:message result))))))))))
(def ^:private +auth
(def +auth
"Agent API authentication middleware. Supports both session-based and stateless JWT authentication."
(api.routes.common/wrap-middleware-for-open-api-spec-generation enforce-authentication))
;;; ---------------------------------------------------- Routes ------------------------------------------------------
(def ^{:arglists '([request respond raise])} routes
"`/api/agent/` routes."
(handlers/routes
(api.macros/ns-handler *ns* +auth)
(agent-workspace/workspace-handler +auth)))
"`/api/agent/` routes. Workspace routes are mounted separately via the EE routes file."
(api.macros/ns-handler *ns* +auth))

View File

@@ -177,7 +177,8 @@
input-schema (merge-input-schemas defs form)
resp-schema (response-schema->json-schema defs (:response-schema form))
annotations (infer-annotations method (:annotations tool-md))
task-support (:task-support tool-md)]
task-support (:task-support tool-md)
scope (get-in form [:metadata :scope])]
(cond-> {:name tool-name
:description description
:endpoint {:method (u/upper-case-en (name method))
@@ -185,7 +186,8 @@
input-schema (assoc :inputSchema input-schema)
resp-schema (assoc :responseSchema resp-schema)
(seq annotations) (assoc :annotations annotations)
task-support (assoc :execution {:taskSupport (name task-support)}))))
task-support (assoc :execution {:taskSupport (name task-support)})
(string? scope) (assoc :scope scope))))
(defn check-tool-uniqueness
"Throws if `tools` contains duplicate `:name` values. The exception message lists each

View File

@@ -3,6 +3,7 @@
[compojure.route :as route]
[metabase.actions-rest.api]
[metabase.activity-feed.api]
[metabase.agent-api.api]
[metabase.analytics.api]
[metabase.api-keys.api]
[metabase.api.docs]
@@ -30,7 +31,9 @@
[metabase.llm.api]
[metabase.logger.api]
[metabase.login-history.api]
[metabase.mcp.api]
[metabase.measures.api]
[metabase.metabot.api]
[metabase.metrics.api]
[metabase.model-persistence.api]
[metabase.native-query-snippets.api]
@@ -68,6 +71,7 @@
(comment metabase.actions-rest.api/keep-me
metabase.activity-feed.api/keep-me
metabase.agent-api.api/keep-me
metabase.analytics.api/keep-me
metabase.api-keys.api/keep-me
metabase.api.util/keep-me
@@ -86,6 +90,7 @@
metabase.indexed-entities.api/keep-me
metabase.logger.api/keep-me
metabase.login-history.api/keep-me
metabase.mcp.api/keep-me
metabase.measures.api/keep-me
metabase.metrics.api/keep-me
metabase.model-persistence.api/keep-me
@@ -147,6 +152,7 @@
(def ^:private route-map
{"/action" (+auth 'metabase.actions-rest.api)
"/activity" (+auth 'metabase.activity-feed.api)
"/agent" metabase.agent-api.api/routes
"/alert" (+auth metabase.pulse.api/alert-routes)
"/analytics" (+auth 'metabase.analytics.api)
"/api-key" (+auth 'metabase.api-keys.api)
@@ -177,7 +183,9 @@
"/llm" (+auth metabase.llm.api/routes)
"/logger" (+auth 'metabase.logger.api)
"/login-history" (+auth 'metabase.login-history.api)
"/mcp" metabase.mcp.api/handler
"/measure" (+auth 'metabase.measures.api)
"/metabot" metabase.metabot.api/routes
"/metric" (+auth 'metabase.metrics.api)
"/model-index" (+auth 'metabase.indexed-entities.api)
"/native-query-snippet" (+auth 'metabase.native-query-snippets.api)

View File

@@ -8,8 +8,10 @@
[metabase.channel.slack :as slack]
[metabase.config.core :as config]
[metabase.permissions.core :as perms]
[metabase.premium-features.core :refer [defenterprise defenterprise-schema]]
[metabase.settings.core :as setting]
[metabase.slackbot.api :as slackbot]
[metabase.slackbot.config :as slackbot.config]
[metabase.system.core :as system]
[metabase.util.i18n :refer [tru]]
[metabase.util.json :as json]
[metabase.util.log :as log]
@@ -17,12 +19,6 @@
(set! *warn-on-reflection* true)
(defenterprise clear-slack-bot-settings!
"Clears enterprise slackbot settings when the Slack token is cleared.
OSS implementation is a no-op."
metabase-enterprise.slackbot.api
[])
(defn- truncate-url
"Cut length of long URLs to avoid spamming the Slack channel"
[url]
@@ -112,7 +108,7 @@
:slack-app-token nil
:slack-bug-report-channel nil})
(slack/clear-channel-cache!)
(clear-slack-bot-settings!))
(slackbot/clear-slack-bot-settings!))
;; Set new token
(do
(when (and (not config/is-test?)
@@ -174,22 +170,13 @@
[:socket_mode_enabled :boolean]
[:token_rotation_enabled :boolean]]]])
(defenterprise-schema get-slack-manifest :- SlackManifest
"Returns the Slack app manifest. OSS returns basic manifest; EE with metabot-v3 returns full MetaBot manifest."
metabase-enterprise.slackbot.api
(defn get-slack-manifest
"Returns the Slack manifest for bootstrapping new Slack apps."
[]
{:display_information {:name "Metabot"
:description "Bringing the power of Metabase to your Slack #channels!"
:background_color "#509EE3"}
:features {:bot_user {:display_name "Metabot"}}
:oauth_config {:scopes {:bot ["users:read"
"channels:read"
"channels:join"
"files:write"
"chat:write"
"chat:write.customize"
"chat:write.public"
"groups:read"]}}})
(let [base-url (system/site-url)]
(when-not base-url
(throw (ex-info (tru "You must configure a site-url for Slack integration to work.") {:status-code 503})))
(slackbot.config/build-slack-manifest base-url)))
(api.macros/defendpoint :get "/manifest" :- SlackManifest
"Returns the JSON manifest file that should be used to bootstrap new Slack apps"

View File

@@ -124,15 +124,16 @@
:model/Comment
:model/CommentReaction
;; 59+
:model/Measure]
:model/Measure
;; 60+
:model/Metabot
:model/MetabotConversation
:model/MetabotMessage
:model/MetabotPrompt]
(when config/ee-available?
[:model/Sandbox
:model/Tenant
:model/ConnectionImpersonation
:model/Metabot
:model/MetabotConversation
:model/MetabotMessage
:model/MetabotPrompt])))
:model/ConnectionImpersonation])))
(defn- objects->columns+values
"Given a sequence of objects/rows fetched from the H2 DB, return a the `columns` that should be used in the `INSERT`

View File

@@ -37,6 +37,7 @@
[metabase.llm.init]
[metabase.logger.init]
[metabase.login-history.init]
[metabase.metabot.init]
[metabase.model-persistence.init]
[metabase.models.init]
[metabase.notification.init]
@@ -57,6 +58,7 @@
[metabase.session.init]
[metabase.settings.init]
[metabase.setup.init]
[metabase.slackbot.init]
[metabase.sql-tools.init]
[metabase.sso.init]
[metabase.store-api.init]

View File

@@ -1,6 +1,7 @@
(ns metabase.internal-stats.core
(:require
[metabase.internal-stats.embedding :as embedding-stats]
[metabase.internal-stats.metabot :as metabot-stats]
[metabase.internal-stats.query-executions :as query-execution-stats]
[metabase.internal-stats.questions :as question-stats]
[metabase.internal-stats.users :as user-stats]
@@ -26,3 +27,7 @@
(p/import-vars
[question-stats
question-statistics-all-time])
(p/import-vars
[metabot-stats
metabot-stats])

View File

@@ -1,31 +1,10 @@
(ns metabase-enterprise.metabot-v3.core
"API namespace for the `metabase-enterprise.metabot-v3` module."
(ns metabase.internal-stats.metabot
(:require
[java-time.api :as t]
[metabase-enterprise.metabot-v3.api]
[metabase-enterprise.metabot-v3.client]
[metabase-enterprise.metabot-v3.table-utils]
[metabase-enterprise.metabot-v3.util]
[metabase.premium-features.core :refer [defenterprise]]
[potemkin :as p]
[toucan2.core :as t2]))
(p/import-vars
[metabase-enterprise.metabot-v3.api
routes]
[metabase-enterprise.metabot-v3.client
analyze-chart
analyze-dashboard
fix-sql
generate-sql]
[metabase-enterprise.metabot-v3.table-utils
database-tables
used-tables
schema-sample])
(defenterprise metabot-stats
"Calculate total Metabot token usage over a window of the the previous UTC day 00:00-23:59"
:feature :none
(defn metabot-stats
"Calculate total Metabot token usage over a window of the previous UTC day 00:00-23:59"
[]
(let [yesterday-utc (-> (t/offset-date-time (t/zone-offset "+00"))
(t/minus (t/days 1)))]

View File

@@ -84,7 +84,7 @@
(memoize/ttl
(fn [api-key]
(try
(let [url (str (llm.settings/llm-anthropic-api-url) "/v1/models")
(let [url (str (llm.settings/llm-anthropic-api-base-url) "/v1/models")
response (http/get url
{:headers {"x-api-key" api-key
"anthropic-version" (llm.settings/llm-anthropic-api-version)}})
@@ -119,7 +119,7 @@
:messages messages}
start-time (u/start-timer)]
(try
(let [url (str (llm.settings/llm-anthropic-api-url) "/v1/messages")
(let [url (str (llm.settings/llm-anthropic-api-base-url) "/v1/messages")
response (http/post url
{:headers (build-request-headers (get-api-key-or-throw))
:body (json/encode (build-request-body request))

View File

@@ -1,10 +1,11 @@
(ns metabase.llm.api
"API endpoints for LLM-powered SQL generation (OSS)."
"API endpoints for LLM-powered features: SQL generation and autodescription."
(:require
[clojure.java.io :as io]
[clojure.set :as set]
[metabase.analytics.core :as analytics]
[metabase.analytics.snowplow :as snowplow]
[metabase.analyze.query-results :as qr]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
@@ -13,9 +14,13 @@
[metabase.llm.anthropic :as llm.anthropic]
[metabase.llm.context :as llm.context]
[metabase.llm.settings :as llm.settings]
[metabase.llm.tasks.describe-dashboard :refer [describe-dashboard]]
[metabase.llm.tasks.describe-question :refer [describe-question]]
[metabase.parameters.schema :as parameters.schema]
[metabase.request.core :as request]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.malli.schema :as ms]
[stencil.core :as stencil]
[throttle.core :as throttle]
[toucan2.core :as t2])
@@ -245,6 +250,40 @@
:engine engine})
(throw e))))))))
;;; ------------------------------------------------ Autodescribe ------------------------------------------------
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
;;
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
(api.macros/defendpoint :post "/describe/card"
"Summarize a question."
[_route-params
_query-params
body :- [:map
[:dataset {:optional true} [:maybe :boolean]]
[:dataset_query ms/Map]
[:parameters {:optional true} [:maybe [:sequential ::parameters.schema/parameter]]]
[:parameter_mappings {:optional true} [:maybe [:sequential ::parameters.schema/parameter-mapping]]]
[:description {:optional true} [:maybe ms/NonBlankString]]
[:display ms/NonBlankString]
[:visualization_settings ms/Map]
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
[:collection_position {:optional true} [:maybe ms/PositiveInt]]
[:result_metadata {:optional true} [:maybe qr/ResultsMetadata]]
[:cache_ttl {:optional true} [:maybe ms/PositiveInt]]]]
{:summary (describe-question body)})
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
;;
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
(api.macros/defendpoint :post "/describe/dashboard/:id"
"Provide a summary of a dashboard."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
{:summary (describe-dashboard id)})
(def ^{:arglists '([request respond raise])} routes
"`/api/llm` routes."
(handlers/routes

View File

@@ -1,4 +1,4 @@
(ns metabase-enterprise.llm.client
(ns metabase.llm.client
"A wrapper around the OpenAI client API.
The `create-chat-completion` function combines several useful middlewares to create a client that can be used for AI
@@ -19,8 +19,8 @@
[OpenAI Client](https://platform.openai.com/docs/libraries) with [Llama API](https://docs.llama-api.com/essentials/chat).
"
(:require
[metabase-enterprise.llm.settings :as llm-settings]
[metabase.analytics.core :as analytics]
[metabase.llm.settings :as llm-settings]
[metabase.util.json :as json]
[metabase.util.log :as log]
[wkok.openai-clojure.api :as openai.api]))
@@ -40,8 +40,6 @@
(assoc
usage-summary
:event :llm-usage))
;; TODO -- Remove before final PR/merge
;(tap> usage-summary)
response))
([params] (wrap-usage* params nil))))
@@ -94,29 +92,29 @@
([params] (wrap-openai-exceptions* params nil))))
(defn wrap-model-defaults
"Add the EE default model from settings into the request"
"Add the default model from settings into the request."
[openai-fn]
(fn wrap-model-defaults*
([params options]
(openai-fn
(merge
{:model (llm-settings/ee-openai-model)}
{:model (llm-settings/llm-openai-model)}
params)
options))
([params] (wrap-model-defaults* params nil))))
(defn wrap-ee-auth
"Add the EE API key from settings into the request"
(defn wrap-auth
"Add the API key from settings into the request."
[openai-fn]
(fn wrap-ee-auth*
(fn wrap-auth*
([params options]
(openai-fn
params
(merge
{:api-key (llm-settings/ee-openai-api-key)}
{:api-key (llm-settings/llm-openai-api-key)}
options)))
([params]
(wrap-ee-auth* params nil))))
(wrap-auth* params nil))))
(defn wrap-find-result
"Return the first choice for which a message is present and has content."
@@ -190,7 +188,7 @@
bottom up and postprocessing middlewares happen from the top down."
[]
(-> *create-chat-completion-endpoint*
wrap-ee-auth
wrap-auth
wrap-model-defaults
wrap-openai-exceptions
wrap-usage

View File

@@ -1,26 +1,29 @@
(ns metabase.llm.settings
"Settings for OSS LLM integration."
"Settings for LLM integration (API keys, model defaults, provider configuration)."
(:require
[clojure.string :as str]
[metabase.settings.core :as setting :refer [defsetting]]
[metabase.util.i18n :refer [deferred-tru tru]]))
;;; ------------------------------------------------- Anthropic -------------------------------------------------
(defsetting llm-anthropic-api-key
(deferred-tru "Anthropic API key for AI-assisted SQL generation.")
:sensitive? true
:visibility :settings-manager
:export? false
:setter (fn [new-value]
(let [trimmed (when (string? new-value)
(not-empty (str/trim new-value)))]
(when (and trimmed (not (str/starts-with? trimmed "sk-ant-")))
(throw (ex-info (tru "Invalid Anthropic API key format. Key must start with ''sk-ant-''.")
{:status-code 400})))
(setting/set-value-of-type! :string :llm-anthropic-api-key trimmed)))
(deferred-tru "The Anthropic API Key.")
:sensitive? true
:visibility :settings-manager
:export? false
:deprecated-name :ee-anthropic-api-key
:setter (fn [new-value]
(let [trimmed (when (string? new-value)
(not-empty (str/trim new-value)))]
(when (and trimmed (not (str/starts-with? trimmed "sk-ant-")))
(throw (ex-info (tru "Invalid Anthropic API key format. Key must start with ''sk-ant-''.")
{:status-code 400})))
(setting/set-value-of-type! :string :llm-anthropic-api-key trimmed)))
:doc false)
(defsetting llm-anthropic-api-key-configured?
"Whether an Anthropic API key has been configured for AI-assisted SQL generation."
"Whether an Anthropic API key has been configured."
:type :boolean
:visibility :public
:setter :none
@@ -29,29 +32,141 @@
:doc false)
(defsetting llm-anthropic-model
(deferred-tru "Anthropic model for AI-assisted SQL generation.")
(deferred-tru "The Anthropic model to use.")
:encryption :no
:visibility :settings-manager
:default "claude-opus-4-5-20251101"
:export? false
:doc false)
(defsetting llm-anthropic-api-url
(deferred-tru "Anthropic Base API URL for AI-assisted SQL generation.")
:encryption :no
:visibility :internal
:default "https://api.anthropic.com"
:export? false
:doc false)
(defsetting llm-anthropic-api-base-url
(deferred-tru "The Anthropic API base URL.")
:encryption :no
:visibility :settings-manager
:default "https://api.anthropic.com"
:export? false
:deprecated-name :ee-anthropic-api-base-url
:doc false)
(defsetting llm-anthropic-api-version
(deferred-tru "Anthropic API version for AI-assisted SQL generation.")
(deferred-tru "The Anthropic API version.")
:encryption :no
:visibility :internal
:default "2023-06-01"
:export? false
:doc false)
;;; -------------------------------------------------- OpenAI ---------------------------------------------------
(defsetting llm-openai-model
(deferred-tru "The OpenAI Model (e.g. ''gpt-4'', ''gpt-3.5-turbo'')")
:encryption :no
:visibility :settings-manager
:default "gpt-4.1-mini"
:export? false
:deprecated-name :ee-openai-model
:doc false)
(defsetting llm-openai-api-base-url
(deferred-tru "The OpenAI API base URL.")
:encryption :no
:visibility :settings-manager
:default "https://api.openai.com"
:export? false
:deprecated-name :ee-openai-api-base-url
:doc false)
(defsetting llm-openai-api-key
(deferred-tru "The OpenAI API Key.")
:sensitive? true
:visibility :settings-manager
:export? false
:deprecated-name :ee-openai-api-key
:doc false)
;;; ------------------------------------------------- OpenRouter ------------------------------------------------
(defsetting llm-openrouter-api-base-url
(deferred-tru "The OpenRouter API base URL used for Chat Completions.")
:encryption :no
:visibility :settings-manager
:default "https://openrouter.ai/api"
:export? false
:deprecated-name :ee-openrouter-api-base-url
:doc false)
(defsetting llm-openrouter-api-key
(deferred-tru "The OpenRouter API Key.")
:sensitive? true
:visibility :settings-manager
:export? false
:deprecated-name :ee-openrouter-api-key
:doc false)
;;; -------------------------------------------------- Metabot --------------------------------------------------
(def ^:private supported-metabot-providers
"Set of supported LLM provider prefixes for the `llm-metabot-provider` setting."
#{"anthropic" "openai" "openrouter"})
(defn- validate-metabot-provider!
"Validate that `value` has the format `provider/model` with a supported provider prefix.
Throws an exception with `:status-code 400` on invalid input."
[value]
(when-not (string? value)
(throw (ex-info (tru "Metabot provider must be a string, got: {0}" (pr-str value))
{:status-code 400})))
(let [[provider model] (str/split value #"/" 2)]
(when-not (contains? supported-metabot-providers provider)
(throw (ex-info (tru "Unknown provider {0}. Supported providers: {1}"
(pr-str provider) (str/join ", " (sort supported-metabot-providers)))
{:status-code 400
:provider provider
:supported supported-metabot-providers})))
(when (str/blank? model)
(throw (ex-info (tru "Model name is required. Expected format: provider/model, e.g. \"anthropic/claude-haiku-4-5\"")
{:status-code 400
:value value})))))
(defsetting llm-metabot-provider
(deferred-tru "The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-4.1-mini`, `openrouter/anthropic/claude-haiku-4-5`.")
:type :string
:encryption :no
:default "openrouter/anthropic/claude-haiku-4-5"
:visibility :settings-manager
:export? false
:deprecated-name :ee-ai-metabot-provider
:doc false
:setter (fn [new-value]
(when new-value
(validate-metabot-provider! new-value))
(setting/set-value-of-type! :string :llm-metabot-provider new-value)))
(defsetting llm-metabot-provider-lite
(deferred-tru "The AI provider and model for lightweight Metabot tasks (e.g. user intent classification).")
:type :string
:encryption :no
:default "openrouter/openai/gpt-oss-20b"
:visibility :settings-manager
:export? false
:deprecated-name :ee-ai-metabot-provider-lite
:doc false
:setter (fn [new-value]
(when new-value
(validate-metabot-provider! new-value))
(setting/set-value-of-type! :string :llm-metabot-provider-lite new-value)))
(defsetting llm-metabot-internal-tasks-enabled?
(deferred-tru "Controls whether Metabot performs internal tasks that might require background tasks or additional LLM calls (e.g. user intent classification).")
:type :boolean
:visibility :settings-manager
:default true
:export? false
:deprecated-name :ee-ai-metabot-internal-tasks-enabled?
:doc false)
;;; -------------------------------------------------- General --------------------------------------------------
(defsetting llm-max-tokens
(deferred-tru "Maximum tokens for LLM responses.")
:type :integer

View File

@@ -1,9 +1,9 @@
(ns metabase-enterprise.llm.tasks.describe-dashboard
(ns metabase.llm.tasks.describe-dashboard
"LLM task(s) for generating dashboard descriptions"
(:require
[clojure.set :refer [rename-keys]]
[clojure.walk :as walk]
[metabase-enterprise.llm.client :as llm-client]
[metabase.llm.client :as llm-client]
[metabase.util :as u]
[metabase.util.json :as json]
[toucan2.core :as t2]))

View File

@@ -1,8 +1,8 @@
(ns metabase-enterprise.llm.tasks.describe-question
(ns metabase.llm.tasks.describe-question
"LLM task(s) for generating question descriptions"
(:require
[clojure.set :refer [rename-keys]]
[metabase-enterprise.llm.client :as llm-client]
[metabase.llm.client :as llm-client]
[metabase.query-processor.compile :as qp.compile]
[metabase.util :as u]
[metabase.util.json :as json]))

344
src/metabase/mcp/api.clj Normal file
View File

@@ -0,0 +1,344 @@
(ns metabase.mcp.api
"MCP (Model Context Protocol) Streamable HTTP transport handler.
Exposes Metabase's agent tools via JSON-RPC 2.0 over a single `/api/mcp` endpoint."
(:require
[clojure.core.async :as a]
[clojure.string :as str]
[compojure.response :as compojure.response]
[java-time.api :as t]
[metabase.api.common :as api]
[metabase.api.macros.scope :as scope]
[metabase.api.open-api :as open-api]
[metabase.mcp.tools :as mcp.tools]
[metabase.oauth-server.core :as oauth-server]
[metabase.request.core :as request]
[metabase.server.streaming-response :as streaming-response]
[metabase.system.core :as system]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
[oidc-provider.protocol :as proto])
(:import
(java.io BufferedWriter OutputStreamWriter)
(java.net URI)
(java.nio.charset StandardCharsets)
(java.util UUID)))
(set! *warn-on-reflection* true)
;;; --------------------------------------------------- Sessions ----------------------------------------------------
(defonce ^:private sessions
(atom {}))
(def ^:private session-ttl-ms
"Sessions expire after 1 hour."
(* 60 60 1000))
(defn- sweep-expired-sessions!
"Remove all sessions whose TTL has elapsed. Called on session creation to
prevent abandoned sessions from accumulating in memory."
[]
(swap! sessions (fn [m]
(into {} (remove (fn [[_ {:keys [timer]}]]
(>= (u/since-ms timer) session-ttl-ms)))
m))))
(defn- create-session!
[user-id]
(sweep-expired-sessions!)
(let [session-id (str (UUID/randomUUID))]
(swap! sessions assoc session-id {:timer (u/start-timer)
:initialized? false
:user-id user-id})
session-id))
(defn- delete-session! [session-id]
(swap! sessions dissoc session-id))
(defn- session-for-user
"Return session state when the session exists, is unexpired, and belongs to
`user-id`; otherwise return nil."
[session-id user-id]
(when-let [session (get @sessions session-id)]
(if (>= (u/since-ms (:timer session)) session-ttl-ms)
(do (delete-session! session-id) nil)
(when (= user-id (:user-id session))
session))))
(defn- session-initialized? [session-id]
(get-in @sessions [session-id :initialized?]))
(defn- mark-session-initialized! [session-id]
(swap! sessions assoc-in [session-id :initialized?] true))
;;; -------------------------------------------------- Auth --------------------------------------------------------
(defn- validate-bearer-token
"Look up and validate an OAuth bearer token. Returns `{:user-id <int> :scopes <set>}` on success, nil on failure."
[token-string]
(when-let [provider (oauth-server/get-provider)]
(when-let [token-data (proto/get-access-token (:token-store provider) token-string)]
(let [expiry (:expiry token-data)]
(when (or (nil? expiry)
(t/after? (t/instant expiry) (t/instant)))
(let [user-id (some-> (:user-id token-data) parse-long)
scopes (when-let [scope-vec (:scope token-data)]
(into #{} scope-vec))]
(when user-id
{:user-id user-id
:scopes (or scopes #{})})))))))
;;; ------------------------------------------------- JSON-RPC 2.0 --------------------------------------------------
(def ^:private server-info
{:name "metabase"
:version "0.1.0"})
(def ^:private protocol-version "2025-03-26")
(defn- jsonrpc-response [id result]
{:jsonrpc "2.0" :id id :result result})
(defn- jsonrpc-error [id code message]
{:jsonrpc "2.0" :id id :error {:code code :message message}})
(defn- handle-initialize [id params]
(when-let [client-info (:clientInfo params)]
(log/infof "MCP client connected: %s %s" (:name client-info) (:version client-info)))
(jsonrpc-response
id
{:protocolVersion protocol-version
:capabilities {:tools {}}
:serverInfo server-info}))
(defn- handle-tools-list [id _params token-scopes]
(jsonrpc-response id {:tools (mcp.tools/list-tools token-scopes)}))
(defn- handle-tools-call [id params token-scopes]
(let [tool-name (:name params)
arguments (or (:arguments params) {})]
(jsonrpc-response id (mcp.tools/call-tool token-scopes tool-name arguments))))
(defn- handle-ping [id _params]
(jsonrpc-response id {}))
(defmacro ^:private when-initialized
"Return a JSON-RPC error if the session is not yet initialized, otherwise evaluate `body`."
[id session-id & body]
`(if (session-initialized? ~session-id)
(do ~@body)
(jsonrpc-error ~id -32600 "Session not initialized: send notifications/initialized first")))
(defn- dispatch-request
"Dispatch a single JSON-RPC request. Returns a response map or nil for notifications."
[msg session-id token-scopes]
(let [id (:id msg)
method (:method msg)
params (:params msg)]
(try
(case method
"notifications/initialized" (do (mark-session-initialized! session-id) nil)
"tools/list" (when-initialized id session-id (handle-tools-list id params token-scopes))
"tools/call" (when-initialized id session-id (handle-tools-call id params token-scopes))
"ping" (when-initialized id session-id (handle-ping id params))
(if id
(jsonrpc-error id -32601 (str "Method not found: " method))
nil))
(catch Throwable e
(log/error e "Error dispatching JSON-RPC method" method)
(jsonrpc-error id -32603 (or (ex-message e) "Internal error"))))))
;;; ----------------------------------------------------- SSE ------------------------------------------------------
(defn- accepts-sse?
"Return true if the request's Accept header includes text/event-stream."
[request]
(some-> (get-in request [:headers "accept"])
(str/includes? "text/event-stream")))
(defn- sse-body
"Format a sequence of JSON-RPC messages as SSE event text."
[messages]
(str/join (map #(str "event: message\ndata: " (json/encode %) "\n\n") messages)))
;;; -------------------------------------------------- Responses ---------------------------------------------------
(defn- json-response
([status body]
(json-response status body nil))
([status body extra-headers]
{:status status
:headers (merge {"Content-Type" "application/json"} extra-headers)
:body (json/encode body)}))
(defn- sse-response
"Return a plain Ring response with SSE-formatted body for POST requests."
([messages]
(sse-response messages nil))
([messages extra-headers]
{:status 200
:headers (merge {"Content-Type" "text/event-stream"
"Cache-Control" "no-cache"}
extra-headers)
:body (sse-body messages)}))
;;; ------------------------------------------------- Validation --------------------------------------------------
(defn- validate-origin
"Validate the Origin header to prevent DNS rebinding attacks (MCP spec requirement).
Returns a 403 response if Origin is present and doesn't match the request's Host header.
Non-browser clients that omit the Origin header are allowed through."
[request]
(when-let [origin (get-in request [:headers "origin"])]
(let [host (get-in request [:headers "host"])]
(when-not (try
(let [origin-host (.getHost (URI. origin))
request-host (first (str/split (str host) #":"))]
(= origin-host request-host))
(catch Exception _ false))
(json-response 403 (jsonrpc-error nil -32600 "Origin not allowed"))))))
(defn- session-error-response
"Return an HTTP error response when the session is invalid, or nil if valid.
Returns 400 for a missing session header, 404 for an invalid/expired session (MCP spec)."
[session-id user-id]
(cond
(str/blank? session-id)
(json-response 400 (jsonrpc-error nil -32600 "Missing Mcp-Session-Id header"))
(nil? (session-for-user session-id user-id))
(json-response 404 (jsonrpc-error nil -32600 "Session not found or expired"))))
;;; -------------------------------------------------- Handlers ---------------------------------------------------
(defn- handle-post
"Handle a POST request containing one or more JSON-RPC messages."
[user-id request]
(let [body (:body request)
session-id (get-in request [:headers "mcp-session-id"])
batch? (sequential? body)
session-err (delay (session-error-response session-id user-id))]
(cond
(nil? body)
(json-response 400 (jsonrpc-error nil -32700 "Parse error: empty body"))
(and (not (map? body)) (not batch?))
(json-response 400 (jsonrpc-error nil -32600 "Invalid request: expected object or array"))
;; JSON-RPC 2.0: empty batch is invalid
(and batch? (empty? body))
(json-response 400 (jsonrpc-error nil -32600 "Invalid request: empty batch"))
;; MCP spec: "The initialize request MUST NOT be part of a JSON-RPC batch"
(and batch? (some #(= "initialize" (:method %)) body))
(json-response 400 (jsonrpc-error nil -32600 "initialize must not be batched"))
;; Initialize: create session and return response with session header
(and (not batch?) (= "initialize" (:method body)))
(let [session-id (create-session! user-id)
init-response (handle-initialize (:id body) (:params body))]
(if (accepts-sse? request)
(sse-response [init-response] {"Mcp-Session-Id" session-id})
(json-response 200 init-response {"Mcp-Session-Id" session-id})))
;; All other requests require a valid session (400 for missing header, 404 for invalid)
(some? @session-err) @session-err
:else
(let [messages (if batch? body [body])
results (mapv #(dispatch-request % session-id (:token-scopes request)) messages)
responses (filterv some? results)]
(cond
(empty? responses)
{:status 202 :headers {} :body ""}
(accepts-sse? request)
(sse-response responses)
(and (not batch?) (= 1 (count responses)))
(json-response 200 (first responses))
:else
(json-response 200 responses))))))
(defn- handle-get
"Handle a GET request for SSE stream (keepalive for server-initiated notifications)."
[user-id request respond raise]
(let [session-id (get-in request [:headers "mcp-session-id"])
session-err (session-error-response session-id user-id)]
(cond
(some? session-err)
(respond session-err)
(not (session-initialized? session-id))
(respond (json-response 400 (jsonrpc-error nil -32600 "Session not initialized")))
:else
(let [resp (streaming-response/streaming-response
{:content-type "text/event-stream"
:headers {"Cache-Control" "no-cache"}
:status 200}
[os canceled-chan]
(let [writer (BufferedWriter. (OutputStreamWriter. os StandardCharsets/UTF_8))]
(loop []
(when-not (a/poll! canceled-chan)
(.write writer ": keepalive\n\n")
(.flush writer)
(Thread/sleep 30000)
(recur)))))]
(compojure.response/send* resp request respond raise)))))
(defn- handle-delete
"Handle a DELETE request to tear down a session."
[user-id request]
(let [session-id (get-in request [:headers "mcp-session-id"])
session-err (session-error-response session-id user-id)]
(or session-err
(do (delete-session! session-id)
{:status 200 :headers {"Content-Type" "application/json"} :body ""}))))
;;; ---------------------------------------------------- Handler ---------------------------------------------------
(defn- www-authenticate-discovery []
(str "Bearer realm=\"mcp\" resource_metadata=\"" (system/site-url) "/.well-known/oauth-protected-resource/api/mcp\""))
(def ^{:arglists '([request respond raise])} handler
"Ring async handler for the MCP endpoint.
Uses JSON-RPC 2.0 over HTTP rather than REST, so the OpenAPI spec is empty."
(open-api/handler-with-open-api-spec
(fn [request respond raise]
(let [origin-error (validate-origin request)
bearer-token (oauth-server/extract-bearer-token request)
session-auth api/*current-user-id*]
(letfn [(dispatch [user-id token-scopes]
(request/with-current-user user-id
(try
(let [request (assoc request :token-scopes token-scopes)]
(case (:request-method request)
:post (respond (handle-post user-id request))
:get (handle-get user-id request respond raise)
:delete (respond (handle-delete user-id request))
(respond (json-response 405 (jsonrpc-error nil -32600 "Method not allowed")))))
(catch Throwable e
(raise e)))))]
(cond
(some? origin-error)
(respond origin-error)
;; Session auth (browser/cookie) — unrestricted scopes
session-auth
(dispatch session-auth #{::scope/unrestricted})
;; Bearer token auth — validate and extract scopes
bearer-token
(if-let [{:keys [user-id scopes]} (validate-bearer-token bearer-token)]
(dispatch user-id scopes)
(respond (json-response 401 (jsonrpc-error nil -32603 "Invalid bearer token")
{"WWW-Authenticate" "Bearer error=\"invalid_token\""})))
;; No auth at all — return 401 with discovery
:else
(respond (json-response 401 (jsonrpc-error nil -32603 "Authentication required")
{"WWW-Authenticate" (www-authenticate-discovery)}))))))
(constantly nil)))

214
src/metabase/mcp/tools.clj Normal file
View File

@@ -0,0 +1,214 @@
(ns metabase.mcp.tools
"MCP tool dispatch. Generates tool definitions from defendpoint metadata and delegates
tool calls to existing agent API endpoints."
(:require
[clojure.core.async :as a]
[clojure.string :as str]
[metabase.agent-api.api :as agent-api]
[metabase.api.common :as api]
[metabase.api.macros.defendpoint.tools-manifest :as tools-manifest]
[metabase.api.macros.scope :as scope]
[metabase.config.core :as config]
[metabase.server.streaming-response :as streaming-response]
[metabase.util :as u]
[metabase.util.json :as json])
(:import
(java.io ByteArrayOutputStream)
(java.net URLEncoder)
(metabase.server.streaming_response StreamingResponse)))
(set! *warn-on-reflection* true)
(comment streaming-response/keep-me) ; ensure StreamingResponse class is loaded
(defn- generate-manifest
"Generate tools manifest from agent API endpoint metadata."
[]
(tools-manifest/generate-tools-manifest
{'metabase.agent-api.api "/api/agent"}))
(def ^:private manifest-delay
(delay (generate-manifest)))
(defn- manifest
"Return the tools manifest. Cached in prod, recomputed each call in dev."
[]
(if config/is-dev?
(generate-manifest)
@manifest-delay))
(defn- scope-matches?
"Does `token-scopes` grant access to a tool with the given `tool-scope`?
- nil/empty token-scopes or ::scope/unrestricted → always matches
- nil tool-scope → always matches (tool has no scope restriction)
- wildcard scopes like \"agent:*\" match any tool scope starting with \"agent:\""
[token-scopes tool-scope]
(or (nil? token-scopes)
(empty? token-scopes)
(contains? token-scopes ::scope/unrestricted)
(nil? tool-scope)
(contains? token-scopes tool-scope)
(some (fn [s]
(when (string? s)
(when-let [prefix (when (str/ends-with? s ":*")
(subs s 0 (- (count s) 1)))]
(str/starts-with? tool-scope prefix))))
token-scopes)))
(defn list-tools
"Return the tool definitions suitable for MCP `tools/list` responses.
When `token-scopes` is provided, only tools whose scope matches are included."
[token-scopes]
(into []
(comp (filter #(= "execute_query" (:name %)))
;; TODO: tool manifest is broken so we scope to only known good tools for testing
#_(filter #(scope-matches? token-scopes (:scope %)))
(map (fn [tool]
{:name (:name tool)
:description (:description tool)
:inputSchema (:inputSchema tool)})))
(:tools (manifest))))
(defn all-tool-scopes
"Return the set of all distinct OAuth scopes across all tools."
[]
(into #{}
(keep :scope)
(:tools (manifest))))
(defn- build-tool-index []
(into {} (map (juxt :name identity)) (:tools (manifest))))
(def ^:private tool-index-delay
(delay (build-tool-index)))
(defn- tool-index
"Lookup from tool name to its full manifest definition. Cached in prod, recomputed each call in dev."
[]
(if config/is-dev?
(build-tool-index)
@tool-index-delay))
;;; ------------------------------------------------- Tool Dispatch -------------------------------------------------
(defn- text-content
"Wrap a value as MCP text content."
[v]
{:content [{:type "text" :text (if (string? v) v (json/encode v))}]})
(defn- error-content
"Wrap an error message as MCP error content."
[message]
{:content [{:type "text" :text message}] :isError true})
(defn- capture-streaming-response
"Execute a StreamingResponse in-process by writing to a ByteArrayOutputStream,
then parse the JSON output and return it as MCP text content.
NOTE: This intentionally bypasses the normal StreamingResponse lifecycle (thread pool,
gzip, donechan delivery) because the agent-api streaming functions only write JSON to
the output stream and don't depend on `streaming-response/*response*` or other
infrastructure bindings. BOT-1120 tracks streaming MCP responses without buffering
in memory, which will replace this approach."
[^StreamingResponse response]
(let [baos (ByteArrayOutputStream.)
canceled-chan (a/promise-chan)
f (.f response)]
(f baos canceled-chan)
(text-content (json/decode+kw (.toString baos "UTF-8")))))
(defn- deliver-agent-api-response
"Dispatch to agent API routes and deliver response to promise."
[result method path token-scopes body]
(agent-api/routes
(cond-> {:request-method method
:uri path
:metabase-user-id api/*current-user-id*
:token-scopes token-scopes}
body (assoc :body body))
(fn [response] (deliver result response))
(fn [error] (deliver result {:status 500 :body {:message (ex-message error)}}))))
(defn- invoke-agent-api
"Invoke an Agent API endpoint with a synthetic Ring request.
Returns MCP content (text-content on success, error-content on failure).
Propagates `token-scopes` from the original MCP request so that scope restrictions
are preserved through the synthetic request."
[method path token-scopes & [body]]
(let [result (promise)]
(deliver-agent-api-response result method path token-scopes body)
(let [response (deref result 30000 {:status 504 :body {:message "Timeout"}})]
(cond
(instance? StreamingResponse (:body response))
(capture-streaming-response (:body response))
(= 200 (:status response))
(text-content (:body response))
:else
(error-content (or (some-> response :body :message)
(some-> response :body :error)
(str "Agent API error: " (:status response))))))))
(defn- interpolate-path
"Replace `{param}` placeholders in `path` with values from `arguments`.
Returns `[resolved-path remaining-args]` where remaining-args has path params removed."
[path arguments]
(let [params (re-seq #"\{([^}]+)\}" path)
resolved (reduce (fn [p [placeholder k]]
(let [v (get arguments (keyword k))]
(when (nil? v)
(throw (ex-info (str "Missing required path parameter: " k)
{:parameter k :path path})))
(str/replace p placeholder (URLEncoder/encode (str v) "UTF-8"))))
path
params)
path-keys (set (map (comp keyword second) params))]
[resolved (apply dissoc arguments path-keys)]))
(defn- strip-api-prefix
"Strip the `/api/agent` prefix from manifest paths, since `invoke-agent-api`
calls `agent-api/routes` directly which expects `/v1/...` paths."
[path]
(str/replace-first path #"^/api/agent" ""))
(defn- invoke-agent-api-with-params
"Invoke an Agent API endpoint with query parameters for GET/DELETE requests.
Appends `params` as a query string to `path`."
[method path token-scopes params]
(if (and (seq params) (not= :post method))
(let [query-string (->> params
(map (fn [[k v]] (str (name k) "=" (URLEncoder/encode (str v) "UTF-8"))))
(str/join "&"))]
(invoke-agent-api method (str path "?" query-string) token-scopes))
(invoke-agent-api method path token-scopes (when (= :post method) params))))
(defn- dispatch-via-agent-api
"Generic dispatch for tools whose responseFormat is \"json\".
Looks up method/path from the tool definition, interpolates path params,
and calls `invoke-agent-api`. For POST requests, remaining args are sent as the
request body. For GET/DELETE requests, remaining args are sent as query params."
[tool-def arguments token-scopes]
(let [{:keys [method path]} (:endpoint tool-def)
method (keyword (u/lower-case-en method))
[resolved-path
remaining-args] (interpolate-path path arguments)
api-path (strip-api-prefix resolved-path)]
(invoke-agent-api-with-params method api-path token-scopes remaining-args)))
(defn call-tool
"Dispatch an MCP `tools/call` request to the appropriate handler.
`token-scopes` from the original MCP session are propagated to the synthetic
agent-api request so that scope restrictions are preserved.
Returns MCP content on success, or error content on failure."
[token-scopes tool-name arguments]
(if-let [tool-def (get (tool-index) tool-name)]
(if-not (scope-matches? token-scopes (:scope tool-def))
(error-content (str "Insufficient scope to call tool: " tool-name
". Required scope: " (:scope tool-def)))
(try
(dispatch-via-agent-api tool-def arguments token-scopes)
(catch Exception e
(error-content (or (ex-message e) "Internal error")))))
(error-content (str "Unknown tool: " tool-name))))

View File

@@ -1,11 +1,11 @@
(ns metabase-enterprise.metabot-v3.agent.analytics
(ns metabase.metabot.agent.analytics
"Snowplow and Prometheus analytics and intent-classification helpers for the metabot agent."
(:require
[clojure.string :as str]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.self :as self]
[metabase.analytics.core :as analytics]
[metabase.api.common :as api]
[metabase.llm.settings :as llm]
[metabase.metabot.self :as self]
[metabase.util.json :as json]
[metabase.util.log :as log]))
@@ -90,7 +90,7 @@
(let [context (json/encode (take-last 3 messages))
text (transduce (keep (fn [{:keys [type text]}] (when (= type :text) text)))
str
(self/call-llm (llm/ee-ai-metabot-provider-lite)
(self/call-llm (llm/llm-metabot-provider-lite)
nil
[{:role "user" :content (build-intent-prompt context)}]
[]

View File

@@ -1,18 +1,18 @@
(ns metabase-enterprise.metabot-v3.agent.core
(ns metabase.metabot.agent.core
"Main agent loop implementation using reducible streaming infrastructure."
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[metabase-enterprise.llm.settings :as llm.settings]
[metabase-enterprise.metabot-v3.agent.analytics :as agent-analytics]
[metabase-enterprise.metabot-v3.agent.memory :as memory]
[metabase-enterprise.metabot-v3.agent.messages :as messages]
[metabase-enterprise.metabot-v3.agent.profiles :as profiles]
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
[metabase-enterprise.metabot-v3.agent.tools :as agent-tools]
[metabase-enterprise.metabot-v3.self :as self]
[metabase.analytics.prometheus :as prometheus]
[metabase.config.core :as config]
[metabase.llm.settings :as llm.settings]
[metabase.metabot.agent.analytics :as agent-analytics]
[metabase.metabot.agent.memory :as memory]
[metabase.metabot.agent.messages :as messages]
[metabase.metabot.agent.profiles :as profiles]
[metabase.metabot.agent.streaming :as streaming]
[metabase.metabot.agent.tools :as agent-tools]
[metabase.metabot.self :as self]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
@@ -400,7 +400,7 @@
Streams parts to the consumer as they arrive while simultaneously accumulating
them for memory updates and control flow decisions."
[{:keys [agent rf result iteration usage-atom] :as loop-state}]
(with-span :debug {:name :metabot-v3.agent/loop-step
(with-span :debug {:name :metabot.agent/loop-step
:iteration iteration}
(let [{:keys [profile tools context memory-atom tracking-opts]} agent
max-iter (:max-iterations profile 10)
@@ -493,11 +493,11 @@
(let [profile-id (:profile-id opts)
debug? (:debug? opts)
labels {:profile-id (name profile-id)}
track-user-intent? (and (llm.settings/ee-ai-metabot-internal-tasks-enabled?)
track-user-intent? (and (llm.settings/llm-metabot-internal-tasks-enabled?)
(some-> opts :tracking-opts :track-user-intent?))]
(reify clojure.lang.IReduceInit
(reduce [_ rf init]
(with-span :info {:name :metabot-v3.agent/run-agent-loop
(with-span :info {:name :metabot.agent/run-agent-loop
:profile-id profile-id
:msg-count (count (:messages opts))}
(prometheus/inc! :metabase-metabot/agent-requests labels)

View File

@@ -1,4 +1,4 @@
(ns metabase-enterprise.metabot-v3.agent.links
(ns metabase.metabot.agent.links
"Link resolution for metabase:// URIs in streaming text.
Converts internal metabase:// links to proper Metabase URLs using agent memory state."
(:require

View File

@@ -1,4 +1,4 @@
(ns metabase-enterprise.metabot-v3.agent.markdown-link-buffer
(ns metabase.metabot.agent.markdown-link-buffer
"Streaming markdown link buffer for resolving metabase:// links.
Buffers markdown links during streaming to prevent incomplete markdown rendering
@@ -7,7 +7,7 @@
Provides both a pure functional state machine and a transducer for stream processing."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.agent.links :as links]
[metabase.metabot.agent.links :as links]
[metabase.system.core :as system]
[metabase.util.log :as log]))

View File

@@ -1,4 +1,4 @@
(ns metabase-enterprise.metabot-v3.agent.memory
(ns metabase.metabot.agent.memory
"Memory and state management for the agent loop.
Tracks conversation history, tool execution steps, and conversation state."
(:require

View File

@@ -1,4 +1,4 @@
(ns metabase-enterprise.metabot-v3.agent.messages
(ns metabase.metabot.agent.messages
"Message formatting and history construction for the agent loop.
The agent stores conversation history as AISDK parts — the format-neutral
@@ -6,9 +6,9 @@
is responsible for converting these parts into its own wire format (Claude
messages, Chat Completions messages, OpenAI Responses input items, etc.)."
(:require
[metabase-enterprise.metabot-v3.agent.memory :as memory]
[metabase-enterprise.metabot-v3.agent.prompts :as prompts]
[metabase-enterprise.metabot-v3.agent.user-context :as user-context]
[metabase.metabot.agent.memory :as memory]
[metabase.metabot.agent.prompts :as prompts]
[metabase.metabot.agent.user-context :as user-context]
[metabase.util.json :as json]
[metabase.util.log :as log]))

View File

@@ -1,33 +1,41 @@
(ns metabase-enterprise.metabot-v3.agent.profiles
(ns metabase.metabot.agent.profiles
"Profile configurations for different metabot use cases.
Each profile defines model settings, iteration limits, and available tools."
Each profile defines model settings, iteration limits, and available tools.
Tools are referenced as vars. Metadata on the var (`:tool-name`, `:schema`,
`:capabilities`, etc.) describes the tool; the var itself is invoked to run it.
`defenterprise` tools with `:ee-feature` metadata are filtered out at runtime
when the feature is not available."
(:require
[clojure.string :as str]
[malli.error :as me]
[metabase-enterprise.llm.settings :as llm]
[metabase-enterprise.metabot-v3.agent.tools :as agent-tools]
[metabase.llm.settings :as llm]
[metabase.metabot.agent.tools :as agent-tools]
[metabase.premium-features.core :as premium-features]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]))
(def ^:private tool-definition-schema
[:map
[:tool-name [:and :string [:fn #(not (str/blank? %))]]]
[:schema [:fn some?]]
[:doc {:optional true} [:maybe :string]]
[:prompt {:optional true} [:maybe :string]]])
(def ^:private tool-var-schema
"Schema for a tool var: must be a var with :tool-name and :schema in metadata."
[:fn {:error/message "Expected a var with :tool-name and :schema metadata"}
(fn [v]
(and (var? v)
(string? (:tool-name (meta v)))
(some? (:schema (meta v)))))])
(def ^:private *profiles
"Map of profile-id to profile configuration"
(atom {}))
(defn- validate-tool-definition!
(defn- validate-tool-var!
"Validate that a var has the required tool metadata."
[tool-var]
(let [definition (select-keys (meta tool-var) [:tool-name :schema :doc :prompt])]
(when-not (mr/validate tool-definition-schema definition)
(throw (ex-info "Invalid tool definition metadata"
{:tool tool-var
:definition definition
:errors (me/humanize (mu/explain tool-definition-schema definition))})))))
(when-not (mr/validate tool-var-schema tool-var)
(throw (ex-info "Invalid tool var: missing :tool-name or :schema metadata"
{:tool-var tool-var
:metadata (meta tool-var)
:errors (me/humanize (mu/explain tool-var-schema tool-var))})))
true)
(mu/defn ^:private register-profile!
"Register new profile configuration.
@@ -37,22 +45,22 @@
- :prompt-template - Selmer template name from resources/metabot/prompts/system/
- :max-iterations - Maximum agent loop iterations
- :temperature - LLM temperature setting
- :tools - Vector of tool vars available to this profile
- :tools - Vector of tool vars (e.g. #'agent-tools/search-tool)
Note: `:model` is resolved at runtime from the `ee-ai-metabot-provider` setting
(see [[get-profile]]), not stored in the profile."
Tool vars are validated at registration time to ensure they have required metadata."
[profile :- [:map
[:name :keyword]
[:prompt-template :string]
[:max-iterations :int]
[:temperature :float]
[:tools [:vector :any]]]]
(when-not (apply distinct? (map #(:tool-name (meta %)) (:tools profile)))
(let [dups (->> (frequencies (map #(:tool-name (meta %)) (:tools profile)))
(filter (fn [[_ cnt]] (< 1 cnt))))]
(throw (ex-info "Duplicate tool names in profile" {:tool-names (map first dups)}))))
(doseq [tool (:tools profile)]
(validate-tool-definition! tool))
(let [tool-vars (:tools profile)]
(doseq [tool-var tool-vars]
(validate-tool-var! tool-var))
(when-not (apply distinct? (map #(:tool-name (meta %)) tool-vars))
(let [dups (->> (frequencies (map #(:tool-name (meta %)) tool-vars))
(filter (fn [[_ cnt]] (< 1 cnt))))]
(throw (ex-info "Duplicate tool names in profile" {:tool-names (map first dups)})))))
(swap! *profiles assoc (:name profile) profile))
(register-profile!
@@ -179,7 +187,7 @@
"Filter tool vars by user capabilities.
Removes tools that require capabilities the user doesn't have.
Capabilities from the API arrive as strings (e.g. \"frontend:navigate_user_v1\")
while tool metadata uses keywords (e.g. :frontend-navigate-user-v1), so we
while tool definitions use keywords (e.g. :frontend-navigate-user-v1), so we
normalize to keywords before comparing."
[tool-vars capabilities]
(let [capabilities-set (into #{} (map capability->keyword) capabilities)]
@@ -187,24 +195,35 @@
(every? capabilities-set (:capabilities (meta tool-var))))
tool-vars)))
(defn- ee-feature-available?
"Check if a tool's :ee-feature is available (or if the tool has no :ee-feature gate)."
[tool-var]
(if-let [feature (:ee-feature (meta tool-var))]
(premium-features/has-feature? feature)
true))
(defn- tool-map
[tools]
(into {} (map (juxt #(:tool-name (meta %)) identity) tools)))
"Create a map of tool-name -> tool-var from a sequence of tool vars."
[tool-vars]
(into {} (map (juxt #(:tool-name (meta %)) identity) tool-vars)))
;;; API
(defn get-profile
"Get profile configuration by profile-id keyword.
The `:model` in the returned profile is resolved from the `ee-ai-metabot-provider`
The `:model` in the returned profile is resolved from the `llm-metabot-provider`
setting at call time, so it always reflects the current admin configuration."
[profile-id]
(when-let [profile (get @*profiles profile-id)]
(assoc profile :model (llm/ee-ai-metabot-provider))))
(assoc profile :model (llm/llm-metabot-provider))))
(defn get-tools-for-profile
"Get tool registry filtered by profile configuration and user capabilities."
"Get tool registry filtered by profile configuration and user capabilities.
Filters out EE-only tools when the feature is not available, then filters by
capabilities. Returns a map of tool-name -> tool-var."
[profile-id capabilities]
(some-> (get-profile profile-id)
:tools
(->> (filter ee-feature-available?))
(filter-by-capabilities capabilities)
tool-map))

View File

@@ -1,4 +1,4 @@
(ns metabase-enterprise.metabot-v3.agent.prompts
(ns metabase.metabot.agent.prompts
"System for loading and rendering prompt templates using Selmer.
Handles:
@@ -119,7 +119,7 @@
"Load template with caching. Cache is simple in-memory map.
In development, you can clear the cache to reload templates:
(reset! metabot-v3.agent.prompts/template-cache {})"
(reset! metabot.agent.prompts/template-cache {})"
[resource-type resource-name loader-fn]
(let [key (cache-key resource-type resource-name)]
(or (get @template-cache key)
@@ -167,7 +167,7 @@
For each tool, loads a markdown prompt file from `resources/metabot/prompts/tools/`.
The filename is determined by:
1. `:prompt` key in the tool's metadata (if present), or
1. `:prompt` key in the tool definition map (if present), or
2. `\"<tool-name>.md\"` as default.
Only tools with a corresponding prompt resource file are included.
@@ -175,8 +175,8 @@
Returns vector of maps: [{:tool_name \"search\" :instructions \"...\"}]"
[tools]
(vec
(for [[tool-name tool] tools
:let [fname (or (-> tool meta :prompt)
(for [[tool-name tool-def] tools
:let [fname (or (:prompt tool-def)
(str tool-name ".md"))
prompt (some-> (io/resource (str "metabot/prompts/tools/" fname))
slurp)]

View File

@@ -1,10 +1,10 @@
(ns metabase-enterprise.metabot-v3.agent.streaming
(ns metabase.metabot.agent.streaming
"Streaming helpers for the agent loop.
Provides utilities for creating AI-SDK data parts, including navigation,
and transducers for stream processing."
(:require
[buddy.core.codecs :as codecs]
[metabase-enterprise.metabot-v3.agent.markdown-link-buffer :as markdown-link-buffer]
[metabase.metabot.agent.markdown-link-buffer :as markdown-link-buffer]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)

View File

@@ -0,0 +1,131 @@
(ns metabase.metabot.agent.tools
"Tool registry for the agent loop.
Provides tool vars and state-aware wrapping.
Each tool is a var whose metadata contains `:tool-name`, `:schema`, and optionally
`:capabilities`, `:prompt`, `:decode`, and `:system-instructions`. The var itself
is a function that takes the tool arguments and returns a result map."
(:require
[metabase.metabot.agent.tools.analyze-chart :as tools.analyze-chart]
[metabase.metabot.agent.tools.autogen-dashboard :as tools.autogen-dashboard]
[metabase.metabot.agent.tools.charts :as tools.charts]
[metabase.metabot.agent.tools.clarification :as tools.clarification]
[metabase.metabot.agent.tools.construct :as tools.construct]
[metabase.metabot.agent.tools.create-alert :as tools.create-alert]
[metabase.metabot.agent.tools.create-dashboard-subscription :as tools.create-dashboard-subscription]
[metabase.metabot.agent.tools.document :as tools.document]
[metabase.metabot.agent.tools.metadata :as tools.metadata]
[metabase.metabot.agent.tools.navigation :as tools.navigation]
[metabase.metabot.agent.tools.resources :as tools.resources]
[metabase.metabot.agent.tools.search :as tools.search]
[metabase.metabot.agent.tools.shared :as shared]
[metabase.metabot.agent.tools.slackbot-query :as tools.slackbot-query]
[metabase.metabot.agent.tools.snippets :as tools.snippets]
[metabase.metabot.agent.tools.sql :as tools.sql]
[metabase.metabot.agent.tools.static-viz :as tools.static-viz]
[metabase.metabot.agent.tools.subscriptions :as tools.subscriptions]
[metabase.metabot.agent.tools.todo :as tools.todo]
[metabase.metabot.agent.tools.transforms :as tools.transforms]
[potemkin :as p]))
(set! *warn-on-reflection* true)
(p/import-vars
[tools.analyze-chart
analyze-chart-tool]
[tools.search search-tool
sql-search-tool
nlq-search-tool
transform-search-tool]
[tools.construct
construct-notebook-query-tool]
[tools.document
document-schema-collect-tool
document-construct-sql-chart-tool
document-construct-model-chart-tool]
[tools.metadata
list-available-data-sources-tool
list-available-fields-tool
get-field-values-tool]
[tools.transforms
get-transform-details-tool
get-transform-python-library-details-tool
write-transform-sql-tool
write-transform-python-tool]
[tools.sql
create-sql-query-tool
create-sql-query-code-edit-tool
edit-sql-query-tool
edit-sql-query-code-edit-tool
replace-sql-query-tool
replace-sql-query-code-edit-tool]
[tools.charts
create-chart-tool
edit-chart-tool]
[tools.resources
read-resource-tool]
[tools.todo
todo-write-tool
todo-read-tool]
[tools.navigation
navigate-user-tool]
[tools.snippets
list-snippets-tool
get-snippet-details-tool]
[tools.clarification
ask-for-sql-clarification-tool]
[tools.autogen-dashboard
create-autogenerated-dashboard-tool]
[tools.subscriptions
create-dashboard-subscription-tool]
[tools.slackbot-query
slackbot-construct-notebook-query-tool]
[tools.static-viz
static-viz-tool]
[tools.create-alert
create-alert-tool]
[tools.create-dashboard-subscription
slackbot-create-dashboard-subscription-tool])
(def ^:private state-dependent-tools
"Set of tool names that require access to agent state."
#{"analyze_chart"
"create_chart" "edit_chart" "todo_write" "todo_read" "navigate_user"
"write_transform_sql" "write_transform_python" "create_autogenerated_dashboard"
"create_sql_query" "edit_sql_query" "replace_sql_query"
"document_schema_collect" "document_construct_sql_chart" "document_construct_model_chart"
"create_alert" "create_dashboard_subscription" "static_viz"})
(defn wrap-tools-with-state
"Wrap state-dependent tools with access to the memory atom.
Tools in `state-dependent-tools` will have access to the memory atom
via the dynamic `shared/*memory-atom*` binding.
Takes a tools map where values are tool vars. Returns a tools map where
values are tool definition maps (with :doc, :schema, :prompt, :fn, and
optionally :decode keys).
Tool-specific instructions are loaded from `resources/metabot/prompts/tools/`
by `extract-tool-instructions` in `prompts.clj`, keyed by `:prompt` or
`<tool-name>.md` by default."
[tools memory-atom]
(reduce-kv
(fn [acc tool-name tool-var]
(let [m (meta tool-var)
tool-def {:tool-name (:tool-name m)
:doc (:doc m)
:schema (:schema m)
:prompt (:prompt m)
:decode (:decode m)
:system-instructions (:system-instructions m)
:capabilities (:capabilities m)
:fn (if (contains? state-dependent-tools tool-name)
(fn [args]
(binding [shared/*memory-atom* memory-atom]
(tool-var args)))
#_:clj-kondo/ignore
(fn [args] ; fn wrapper because `tool-var` fails `fn?` schema
(tool-var args)))}]
(assoc acc tool-name tool-def)))
{}
tools))

View File

@@ -1,13 +1,13 @@
(ns metabase-enterprise.metabot-v3.agent.tools.analyze-chart
(ns metabase.metabot.agent.tools.analyze-chart
"Native agent tool for chart statistics analysis.
Analyzes charts from viewing context. The frontend provides pre-materialized
series data in chart_configs, which is seeded into agent memory during
initialization. This avoids re-executing queries."
(:require
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.stats.core :as stats.core]
[metabase-enterprise.metabot-v3.stats.repr :as stats.repr]
[metabase.metabot.agent.tools.shared :as shared]
[metabase.metabot.stats.core :as stats.core]
[metabase.metabot.stats.repr :as stats.repr]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[tech.v3.resource :as resource]))
@@ -49,18 +49,19 @@ Do not use headers (##). Do not list statistics. Do not analyze series separatel
(some-> (get (shared/current-chart-configs-state) chart-config-id)
stringify-series-keys))
(def ^:private schema
[:map {:closed true}
[:chart_config_id :string]
[:deep {:optional true :default true} [:maybe :boolean]]])
(mu/defn ^{:tool-name "analyze_chart"
:prompt "analyze_chart"}
analyze-chart-tool
:prompt "analyze_chart"} analyze-chart-tool
"Compute statistics and generate analysis context for a chart.
Use this to analyze trends, outliers, volatility, and patterns in chart data.
The chart_config_id references a chart from the user's current viewing context.
Available chart IDs are provided in the system context."
[{:keys [chart_config_id deep]}
:- [:map {:closed true}
[:chart_config_id :string]
[:deep {:optional true :default true} [:maybe :boolean]]]]
[{:keys [chart_config_id deep]} :- schema]
(try
(if-let [chart-config (resolve-chart-config-from-memory chart_config_id)]
;; Wrap TMD operations in resource context to ensure off-heap memory is released

View File

@@ -1,16 +1,24 @@
(ns metabase-enterprise.metabot-v3.agent.tools.autogen-dashboard
(ns metabase.metabot.agent.tools.autogen-dashboard
"Autogenerated dashboard tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.create-autogenerated-dashboard :as autogen-dashboard-tools]
[metabase.metabot.agent.tools.shared :as shared]
[metabase.metabot.tools.create-autogenerated-dashboard :as autogen-dashboard-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defn ^{:tool-name "create_autogenerated_dashboard"
:capabilities #{:automagic-dashboards}}
create-autogenerated-dashboard-tool
(def ^:private schema
[:map {:closed true}
[:source [:map
[:table_id {:optional true} [:maybe :int]]
[:model_id {:optional true} [:maybe :int]]
[:metric_id {:optional true} [:maybe :int]]
[:query_id {:optional true} [:maybe :string]]
[:report_id {:optional true} [:maybe :int]]]]])
(mu/defn ^{:tool-name "create_autogenerated_dashboard"
:capabilities #{:automagic-dashboards}} create-autogenerated-dashboard-tool
"Create an automatically generated dashboard using Metabase's X-ray feature.
X-ray builds complete dashboards with multiple visualizations that showcase
@@ -23,14 +31,7 @@
- metric_id: Create dashboard for a metric
- query_id: Create dashboard for query results from this conversation
- report_id: Create dashboard for an existing report/question"
[{:keys [source]}
:- [:map {:closed true}
[:source [:map
[:table_id {:optional true} [:maybe :int]]
[:model_id {:optional true} [:maybe :int]]
[:metric_id {:optional true} [:maybe :int]]
[:query_id {:optional true} [:maybe :string]]
[:report_id {:optional true} [:maybe :int]]]]]]
[{:keys [source]} :- schema]
(try
(let [result (autogen-dashboard-tools/create-autogenerated-dashboard
{:source source

View File

@@ -1,12 +1,12 @@
(ns metabase-enterprise.metabot-v3.agent.tools.charts
(ns metabase.metabot.agent.tools.charts
"Chart tool wrappers."
(:require
[medley.core :as m]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.create-chart :as create-chart-tools]
[metabase-enterprise.metabot-v3.tools.edit-chart :as edit-chart-tools]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase.metabot.agent.tools.shared :as shared]
[metabase.metabot.tools.create-chart :as create-chart-tools]
[metabase.metabot.tools.edit-chart :as edit-chart-tools]
[metabase.metabot.tools.instructions :as instructions]
[metabase.metabot.tools.llm-representations :as llm-rep]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
@@ -18,18 +18,23 @@
(str "<result>\n" chart-xml "\n</result>\n"
"<instructions>\n" (instructions/chart-created-instructions chart-id) "\n</instructions>")))
(def ^:private chart-type-enum
[:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
"smartscalar" "gauge" "progress" "funnel" "object" "map"])
(def ^:private create-chart-schema
[:map {:closed true}
[:data_source [:map {:closed true}
[:query_id :string]]]
[:viz_settings [:map {:closed true}
[:chart_type chart-type-enum]]]])
(mu/defn ^{:tool-name "create_chart"} create-chart-tool
"Create a chart from a query.
Provide a query_id in data_source and a chart_type in viz_settings."
[{:keys [data_source viz_settings]}
:- [:map {:closed true}
[:data_source [:map {:closed true}
[:query_id :string]]]
[:viz_settings [:map {:closed true}
[:chart_type [:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
"smartscalar" "gauge" "progress" "funnel" "object" "map"]]]]]]
[{:keys [data_source viz_settings]} :- create-chart-schema]
(try
(let [result (create-chart-tools/create-chart
{:query-id (get data_source :query_id)
@@ -46,17 +51,17 @@
{:output (ex-message e)}
{:output (str "Failed to create chart: " (or (ex-message e) "Unknown error"))}))))
(def ^:private edit-chart-schema
[:map {:closed true}
[:chart_id :string]
[:new_viz_settings [:map {:closed true}
[:chart_type chart-type-enum]]]])
(mu/defn ^{:tool-name "edit_chart"} edit-chart-tool
"Edit an existing chart's visualization type.
Provide a new chart_type in new_viz_settings."
[{:keys [chart_id new_viz_settings]}
:- [:map {:closed true}
[:chart_id :string]
[:new_viz_settings [:map {:closed true}
[:chart_type [:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
"smartscalar" "gauge" "progress" "funnel" "object" "map"]]]]]]
[{:keys [chart_id new_viz_settings]} :- edit-chart-schema]
(try
(let [result (edit-chart-tools/edit-chart
{:chart-id chart_id

View File

@@ -1,8 +1,8 @@
(ns metabase-enterprise.metabot-v3.agent.tools.clarification
(ns metabase.metabot.agent.tools.clarification
"Clarification tool wrappers."
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.tools.ask-clarification :as ask-clarification-tools]
[metabase.metabot.tools.ask-clarification :as ask-clarification-tools]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
@@ -13,15 +13,17 @@
(when (seq options)
(str "\n\nOptions:\n" (str/join "\n" (map #(str "- " %) options))))))
(def ^:private schema
[:map {:closed true}
[:question :string]
[:options {:optional true} [:maybe [:sequential :string]]]])
(mu/defn ^{:tool-name "ask_for_sql_clarification"} ask-for-sql-clarification-tool
"Ask the user for clarification about their SQL query request.
Use this tool when you need more information from the user to properly
complete their SQL request. The agent will stop and wait for the user's response."
[{:keys [question options]}
:- [:map {:closed true}
[:question :string]
[:options {:optional true} [:maybe [:sequential :string]]]]]
[{:keys [question options]} :- schema]
(try
(let [result (ask-clarification-tools/ask-for-sql-clarification {:question question
:options options})]

View File

@@ -1,17 +1,17 @@
(ns metabase-enterprise.metabot-v3.agent.tools.construct
(ns metabase.metabot.agent.tools.construct
"Notebook query construction tool wrappers."
(:require
[clojure.walk :as walk]
[malli.core :as mc]
[malli.transform :as mtx]
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
[metabase-enterprise.metabot-v3.tmpl :as te]
[metabase-enterprise.metabot-v3.tools.create-chart :as create-chart-tools]
[metabase-enterprise.metabot-v3.tools.filters :as filter-tools]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase-enterprise.metabot-v3.util :as metabot-u]
[metabase.lib.core :as lib]
[metabase.metabot.agent.streaming :as streaming]
[metabase.metabot.tmpl :as te]
[metabase.metabot.tools.create-chart :as create-chart-tools]
[metabase.metabot.tools.filters :as filter-tools]
[metabase.metabot.tools.instructions :as instructions]
[metabase.metabot.tools.llm-representations :as llm-rep]
[metabase.metabot.util :as metabot-u]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
@@ -316,8 +316,7 @@
{:output (str "Unsupported query_type: " query-type)})))
(mu/defn ^{:tool-name "construct_notebook_query"
:decode decode-tool-args}
construct-notebook-query-tool
:decode decode-tool-args} construct-notebook-query-tool
"Construct and visualize a notebook query from a metric, model, or table."
[{:keys [_reasoning query visualization]} :- construct-notebook-query-args-schema]
(try

View File

@@ -1,9 +1,9 @@
(ns metabase-enterprise.metabot-v3.agent.tools.create-alert
(ns metabase.metabot.agent.tools.create-alert
"Agent tool wrapper for creating alerts on saved questions.
Gets slack_channel_id from agent context and calls the create-alert tool endpoint directly."
(:require
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.create-alert :as tools.create-alert]
[metabase.metabot.agent.tools.shared :as shared]
[metabase.metabot.tools.create-alert :as tools.create-alert]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
@@ -59,15 +59,17 @@ CRITICAL: When a user asks to be alerted or notified about a saved question's re
NEVER tell the user you have created an alert without actually calling the create_alert tool. If you cannot call the tool
(e.g. missing required information), explain what is needed instead of pretending the alert was created.")
(mu/defn ^{:tool-name "create_alert"
:system-instructions create-alert-system-instructions}
create-alert-tool
(def ^:private schema
[:map {:closed true}
[:card_id :int]
[:send_condition [:enum "has_result" "goal_above" "goal_below"]]
[:schedule schedule-schema]
[:send_once {:optional true :default false} :boolean]])
(mu/defn ^{:tool-name "create_alert"
:system-instructions create-alert-system-instructions} create-alert-tool
"Create an alert based on a saved question's results on a recurring schedule."
[{:keys [card_id send_condition schedule send_once]} :- [:map {:closed true}
[:card_id :int]
[:send_condition [:enum "has_result" "goal_above" "goal_below"]]
[:schedule schedule-schema]
[:send_once {:optional true :default false} :boolean]]]
[{:keys [card_id send_condition schedule send_once]} :- schema]
(let [slack-channel-id (:slack_channel_id (shared/current-context))]
(when-not slack-channel-id
(throw (ex-info "This tool can only be used from a Slack channel"

View File

@@ -1,10 +1,10 @@
(ns metabase-enterprise.metabot-v3.agent.tools.create-dashboard-subscription
(ns metabase.metabot.agent.tools.create-dashboard-subscription
"Agent tool wrapper for creating dashboard subscriptions.
Gets slack_channel_id from agent context and calls the create-dashboard-subscription tool endpoint directly."
(:require
[metabase-enterprise.metabot-v3.agent.tools.create-alert :as tools.create-alert]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as tools.create-dashboard-subscription]
[metabase.metabot.agent.tools.create-alert :as tools.create-alert]
[metabase.metabot.agent.tools.shared :as shared]
[metabase.metabot.tools.create-dashboard-subscription :as tools.create-dashboard-subscription]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
@@ -31,13 +31,15 @@ Before calling the tool, ensure you have ALL of the following:
If any required information is missing, ask the user for it rather than assuming or fabricating values.")
(mu/defn ^{:tool-name "create_dashboard_subscription"
:system-instructions create-dashboard-subscription-system-instructions}
slackbot-create-dashboard-subscription-tool
(def ^:private schema
[:map {:closed true}
[:dashboard_id :int]
[:schedule tools.create-alert/schedule-schema]])
(mu/defn ^{:tool-name "create_dashboard_subscription"
:system-instructions create-dashboard-subscription-system-instructions} slackbot-create-dashboard-subscription-tool
"Create a recurring subscription that delivers a dashboard's contents to a Slack channel."
[{:keys [dashboard_id schedule]} :- [:map {:closed true}
[:dashboard_id :int]
[:schedule tools.create-alert/schedule-schema]]]
[{:keys [dashboard_id schedule]} :- schema]
(let [slack-channel-id (:slack_channel_id (shared/current-context))]
(when-not slack-channel-id
(throw (ex-info "This tool can only be used from a Slack channel"

View File

@@ -1,10 +1,10 @@
(ns metabase-enterprise.metabot-v3.agent.tools.document
(ns metabase.metabot.agent.tools.document
"Document-generation specific tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.tools.construct :as construct-tools]
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
[metabase-enterprise.metabot-v3.tools.create-sql-query :as create-sql-query-tools]
[metabase.metabot.agent.tools.construct :as construct-tools]
[metabase.metabot.agent.tools.shared :as shared]
[metabase.metabot.table-utils :as table-utils]
[metabase.metabot.tools.create-sql-query :as create-sql-query-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.warehouses.core :as warehouses]))
@@ -45,8 +45,7 @@
(remove nil?))
references))
(mu/defn ^{:tool-name "document_schema_collect"}
document-schema-collect-tool
(mu/defn ^{:tool-name "document_schema_collect"} document-schema-collect-tool
"Collects the schema of a database in order to construct a SQL query.
**Usage:**
@@ -87,19 +86,20 @@
(log/error e "Error collecting document schema")
{:output (str "Failed to collect schema: " (or (ex-message e) "Unknown error"))})))
(mu/defn ^{:tool-name "document_construct_sql_chart"}
document-construct-sql-chart-tool
(def ^:private sql-chart-schema
[:map {:closed true}
[:database_id :int]
[:name :string]
[:description :string]
[:analysis :string]
[:approach :string]
[:sql :string]
[:viz_settings [:map {:closed true}
[:chart_type chart-type-enum]]]])
(mu/defn ^{:tool-name "document_construct_sql_chart"} document-construct-sql-chart-tool
"Construct SQL-backed chart draft payload for document insertion."
[{:keys [database_id name description analysis approach sql viz_settings]}
:- [:map {:closed true}
[:database_id :int]
[:name :string]
[:description :string]
[:analysis :string]
[:approach :string]
[:sql :string]
[:viz_settings [:map {:closed true}
[:chart_type chart-type-enum]]]]]
[{:keys [database_id name description analysis approach sql viz_settings]} :- sql-chart-schema]
(try
(let [result (create-sql-query-tools/create-sql-query {:database-id database_id
:sql sql})
@@ -126,16 +126,17 @@
{:output (ex-message e)}
{:output (str "Failed to construct SQL chart draft: " (or (ex-message e) "Unknown error"))}))))
(mu/defn ^{:tool-name "document_construct_model_chart"}
document-construct-model-chart-tool
(def ^:private model-chart-schema
[:map {:closed true}
[:name :string]
[:description :string]
[:query :map]
[:viz_settings [:map {:closed true}
[:chart_type chart-type-enum]]]])
(mu/defn ^{:tool-name "document_construct_model_chart"} document-construct-model-chart-tool
"Construct notebook/model-backed chart draft payload for document insertion."
[{:keys [name description query viz_settings]}
:- [:map {:closed true}
[:name :string]
[:description :string]
[:query :map]
[:viz_settings [:map {:closed true}
[:chart_type chart-type-enum]]]]]
[{:keys [name description query viz_settings]} :- model-chart-schema]
(try
(let [chart-type (get viz_settings :chart_type)
result (construct-tools/construct-notebook-query-tool

View File

@@ -1,14 +1,14 @@
(ns metabase-enterprise.metabot-v3.agent.tools.metadata
(ns metabase.metabot.agent.tools.metadata
"Metadata tool wrappers."
(:require
[clojure.string :as str]
[medley.core :as m]
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
[metabase-enterprise.metabot-v3.tools.entity-details :as entity-details-tools]
[metabase-enterprise.metabot-v3.tools.field-stats :as field-stats-tools]
[metabase-enterprise.metabot-v3.tools.get-metadata :as metadata-tools]
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
[metabase.metabot.config :as metabot.config]
[metabase.metabot.tools.entity-details :as entity-details-tools]
[metabase.metabot.tools.field-stats :as field-stats-tools]
[metabase.metabot.tools.get-metadata :as metadata-tools]
[metabase.metabot.tools.instructions :as instructions]
[metabase.metabot.tools.llm-representations :as llm-rep]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
@@ -50,28 +50,34 @@
"List all data sources (metrics and models) available to the metabot instance."
[_args :- [:maybe [:map {:closed true}]]]
(add-output
(entity-details-tools/answer-sources {:metabot-id metabot-v3.config/embedded-metabot-id
(entity-details-tools/answer-sources {:metabot-id metabot.config/embedded-metabot-id
:with-field-values? false})
format-answer-sources-output))
(def ^:private list-available-fields-schema
[:map {:closed true}
[:table_ids [:sequential :int]]
[:model_ids [:sequential :int]]
[:metric_ids [:sequential :int]]])
(mu/defn ^{:tool-name "list_available_fields"} list-available-fields-tool
"Retrieve metadata for tables, models, and metrics."
[{:keys [table_ids model_ids metric_ids]} :- [:map {:closed true}
[:table_ids [:sequential :int]]
[:model_ids [:sequential :int]]
[:metric_ids [:sequential :int]]]]
[{:keys [table_ids model_ids metric_ids]} :- list-available-fields-schema]
(add-output
(metadata-tools/get-metadata {:table-ids table_ids
:model-ids model_ids
:metric-ids metric_ids})
format-metadata-output))
(def ^:private get-field-values-schema
[:map {:closed true}
[:data_source [:enum "table" "model" "metric"]]
[:source_id :int]
[:field_id :string]])
(mu/defn ^{:tool-name "get_field_values"} get-field-values-tool
"Return metadata for a given field of a given data source."
[{:keys [data_source source_id field_id]} :- [:map {:closed true}
[:data_source [:enum "table" "model" "metric"]]
[:source_id :int]
[:field_id :string]]]
[{:keys [data_source source_id field_id]} :- get-field-values-schema]
(add-output
(field-stats-tools/field-values {:entity-type data_source
:entity-id source_id

View File

@@ -1,37 +1,38 @@
(ns metabase-enterprise.metabot-v3.agent.tools.navigation
(ns metabase.metabot.agent.tools.navigation
"Navigation tool wrappers."
(:require
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
[metabase-enterprise.metabot-v3.tools.navigate :as navigate-tools]
[metabase.metabot.agent.tools.shared :as shared]
[metabase.metabot.tools.navigate :as navigate-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defn ^{:tool-name "navigate_user"
:capabilities #{:frontend-navigate-user-v1}}
navigate-user-tool
(def ^:private schema
[:map {:closed true}
[:destination [:or
[:map {:closed true}
[:page [:enum "notebook_editor" "metrics_browser" "model_browser" "database_browser"]]]
[:map {:closed true}
[:page [:enum "sql_editor"]]
[:database_id :int]]
[:map {:closed true}
[:entity_type [:enum "table" "model" "question" "metric" "dashboard"]]
[:entity_id :int]]
[:map {:closed true}
[:query_id :string]]
[:map {:closed true}
[:chart_id :string]]]]])
(mu/defn ^{:tool-name "navigate_user"
:capabilities #{:frontend-navigate-user-v1}} navigate-user-tool
"Navigate the user to a specific page or entity in Metabase.
Use this tool to direct users to:
- Pages: notebook_editor, metrics_browser, model_browser, database_browser, sql_editor
- Entities: table, model, question, metric, dashboard
- Query results or charts from the current conversation"
[{:keys [destination]}
:- [:map {:closed true}
[:destination [:or
[:map {:closed true}
[:page [:enum "notebook_editor" "metrics_browser" "model_browser" "database_browser"]]]
[:map {:closed true}
[:page [:enum "sql_editor"]]
[:database_id :int]]
[:map {:closed true}
[:entity_type [:enum "table" "model" "question" "metric" "dashboard"]]
[:entity_id :int]]
[:map {:closed true}
[:query_id :string]]
[:map {:closed true}
[:chart_id :string]]]]]]
[{:keys [destination]} :- schema]
(try
(let [result (navigate-tools/navigate {:destination destination
:memory-atom shared/*memory-atom*})

View File

@@ -1,12 +1,16 @@
(ns metabase-enterprise.metabot-v3.agent.tools.resources
(ns metabase.metabot.agent.tools.resources
"Resource reading tool wrappers."
(:require
[metabase-enterprise.metabot-v3.tools.read-resource :as read-resource-tools]
[metabase.metabot.tools.read-resource :as read-resource-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(def ^:private schema
[:map {:closed true}
[:uris [:sequential [:string {:description "Metabase resource URIs to fetch"}]]]])
(mu/defn ^{:tool-name "read_resource"} read-resource-tool
"Read detailed information about Metabase resources via URI patterns.
@@ -16,9 +20,7 @@
- metabase://metric/{id}/dimensions - Get metric dimensions
- metabase://transform/{id} - Get transform details
- metabase://dashboard/{id} - Get dashboard details"
[{:keys [uris]}
:- [:map {:closed true}
[:uris [:sequential [:string {:description "Metabase resource URIs to fetch"}]]]]]
[{:keys [uris]} :- schema]
(try
(read-resource-tools/read-resource {:uris uris})
(catch Exception e

View File

@@ -0,0 +1,103 @@
(ns metabase.metabot.agent.tools.search
"Search tool wrappers for Metabot v3."
(:require
[clojure.string :as str]
[metabase.metabot.tmpl :as te]
[metabase.metabot.tools.instructions :as instructions]
[metabase.metabot.tools.llm-representations :as llm-rep]
[metabase.metabot.tools.search :as search-tools]
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn- format-search-output
"Format search results as an LLM-ready string."
[results]
(let [results-xml (llm-rep/search-results->xml results)]
(te/lines
"<result>"
results-xml
""
(str "Total results: " (count results))
"</result>"
"<instructions>"
instructions/search-result-instructions "</instructions>")))
(defn- invalid-entity-types
[entity-types allowed]
(when (seq entity-types)
(seq (remove allowed entity-types))))
(defn- do-search
[label allowed-types search-opts {:keys [semantic_queries keyword_queries entity_types] :as _args}]
(if-let [invalid (invalid-entity-types entity_types allowed-types)]
{:output (str "Invalid entity_types for " label ": " (pr-str (vec invalid))
". Allowed types: " (str/join ", " (sort allowed-types)) ".")}
(try
(let [results (search-tools/search (merge {:semantic-queries semantic_queries
:term-queries keyword_queries
:entity-types entity_types
:limit 10}
search-opts))]
{:output (format-search-output results)
:structured-output {:result-type :search
:data results
:total_count (count results)}})
(catch Exception e
(log/error e (str "Error in " label))
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
(def ^:private search-schema
[:map {:closed true}
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model" "metric" "dashboard" "question"]]]]])
(mu/defn ^{:tool-name "search"} search-tool
"Search for tables, models, metrics, dashboards, and saved questions."
[args :- search-schema]
(do-search "search" #{"table" "model" "metric" "dashboard" "question"} {} args))
(def ^:private sql-search-schema
[:map {:closed true}
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:database_id :int]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model"]]]]])
(mu/defn ^{:tool-name "search"
:prompt "sql_search.md"} sql-search-tool
"Search for SQL-queryable data sources (tables and models) within a database."
[{:keys [database_id] :as args} :- sql-search-schema]
(do-search "SQL search" #{"table" "model"} {:database-id database_id} args))
(def ^:private nlq-search-schema
[:map {:closed true}
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "model" "metric" "table"]]]]])
(mu/defn ^{:tool-name "search"
:prompt "nql_search.md"} nlq-search-tool
"Search for NLQ-queryable data sources (models, metrics, tables)."
[args :- nlq-search-schema]
(do-search "NLQ search" #{"model" "metric" "table"} {:profile-id "nlq"} args))
(def ^:private transform-search-schema
[:map {:closed true}
[:semantic_queries {:feature :semantic-search} [:sequential :string]]
[:keyword_queries [:sequential :string]]
[:search_native_query {:optional true} [:maybe :boolean]]
[:entity_types {:optional true}
[:maybe [:sequential [:enum "table" "model" "transform"]]]]])
(mu/defn ^{:tool-name "search"
:prompt "transform_search"} transform-search-tool
"Search for transforms, tables, and models."
[{:keys [search_native_query] :as args} :- transform-search-schema]
(do-search "transform search" #{"table" "model" "transform"}
{:search-native-query search_native_query} args))

View File

@@ -1,4 +1,4 @@
(ns metabase-enterprise.metabot-v3.agent.tools.shared
(ns metabase.metabot.agent.tools.shared
"Shared tool helpers and state accessors."
(:require
[metabase.util.log :as log]))

Some files were not shown because too many files have changed in this diff Show More