diff --git a/.nextchanges/bundles/ai-gateway-mcp-service.md b/.nextchanges/bundles/ai-gateway-mcp-service.md new file mode 100644 index 00000000000..dbb33f57fae --- /dev/null +++ b/.nextchanges/bundles/ai-gateway-mcp-service.md @@ -0,0 +1 @@ +* Add bundle support for the AI Gateway `mcp_service` resource (direct engine). ([#6633](https://github.com/databricks/cli/pull/6633)) diff --git a/acceptance/bundle/deployment/bind/mcp_service/databricks.yml b/acceptance/bundle/deployment/bind/mcp_service/databricks.yml new file mode 100644 index 00000000000..c5958aa56b6 --- /dev/null +++ b/acceptance/bundle/deployment/bind/mcp_service/databricks.yml @@ -0,0 +1,9 @@ +bundle: + name: test-bundle + +resources: + mcp_services: + mcp1: + parent: schemas/main.myschema + mcp_service_id: mysvc + comment: bound service diff --git a/acceptance/bundle/deployment/bind/mcp_service/out.test.toml b/acceptance/bundle/deployment/bind/mcp_service/out.test.toml new file mode 100644 index 00000000000..27ec2a7fcd6 --- /dev/null +++ b/acceptance/bundle/deployment/bind/mcp_service/out.test.toml @@ -0,0 +1,3 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.DMS = [""] diff --git a/acceptance/bundle/deployment/bind/mcp_service/output.txt b/acceptance/bundle/deployment/bind/mcp_service/output.txt new file mode 100644 index 00000000000..39aac6934ba --- /dev/null +++ b/acceptance/bundle/deployment/bind/mcp_service/output.txt @@ -0,0 +1,30 @@ + +>>> [CLI] bundle deployment bind mcp1 main.myschema.mysvc --auto-approve +Successfully bound mcp_service with an id 'main.myschema.mysvc' +Run 'bundle deploy' to deploy changes to your workspace + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + MCP services: + mcp1: + Name: mysvc + URL: [DATABRICKS_URL]/explore/data/mcp-services/main/myschema/mysvc?w=[NUMID] + +>>> [CLI] bundle deployment unbind mcp1 + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + MCP services: + mcp1: + Name: mysvc + URL: (not deployed) diff --git a/acceptance/bundle/deployment/bind/mcp_service/script b/acceptance/bundle/deployment/bind/mcp_service/script new file mode 100644 index 00000000000..5314f84d153 --- /dev/null +++ b/acceptance/bundle/deployment/bind/mcp_service/script @@ -0,0 +1,5 @@ +trace $CLI bundle deployment bind mcp1 main.myschema.mysvc --auto-approve +trace $CLI bundle summary + +trace $CLI bundle deployment unbind mcp1 +trace $CLI bundle summary diff --git a/acceptance/bundle/deployment/bind/mcp_service/test.toml b/acceptance/bundle/deployment/bind/mcp_service/test.toml new file mode 100644 index 00000000000..61218f9b23e --- /dev/null +++ b/acceptance/bundle/deployment/bind/mcp_service/test.toml @@ -0,0 +1,18 @@ +# AI Gateway securables are direct-engine only. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +Cloud = false + +Ignore = [ + ".databricks", +] + +# The bind flow issues a GET to confirm the remote resource exists before binding. +[[Server]] +Pattern = "GET /api/2.1/unity-catalog/mcp-services/{name}" +Response.Body = ''' +{ + "name": "mcp-services/main.myschema.mysvc", + "comment": "bound service" +} +''' diff --git a/acceptance/bundle/invariant/configs/mcp_service.yml.tmpl b/acceptance/bundle/invariant/configs/mcp_service.yml.tmpl new file mode 100644 index 00000000000..4b463bc5b78 --- /dev/null +++ b/acceptance/bundle/invariant/configs/mcp_service.yml.tmpl @@ -0,0 +1,9 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + mcp_services: + foo: + parent: schemas/main.default + mcp_service_id: test-mcp-service-$UNIQUE_NAME + comment: test mcp service diff --git a/acceptance/bundle/python/mcp_services-support/databricks.yml b/acceptance/bundle/python/mcp_services-support/databricks.yml new file mode 100644 index 00000000000..c9b661e88bc --- /dev/null +++ b/acceptance/bundle/python/mcp_services-support/databricks.yml @@ -0,0 +1,17 @@ +bundle: + name: my_project + +sync: {paths: []} # don't need to copy files + +python: + resources: + - "resources:load_resources" + mutators: + - "mutators:update_mcp_service" + +resources: + mcp_services: + my_mcp_service_1: + parent: "schemas/main.default" + mcp_service_id: "my_mcp_service_1" + comment: "My MCP service" diff --git a/acceptance/bundle/python/mcp_services-support/mutators.py b/acceptance/bundle/python/mcp_services-support/mutators.py new file mode 100644 index 00000000000..59f13f65265 --- /dev/null +++ b/acceptance/bundle/python/mcp_services-support/mutators.py @@ -0,0 +1,11 @@ +from dataclasses import replace + +from databricks.bundles.mcp_services import McpService +from databricks.bundles.core import mcp_service_mutator + + +@mcp_service_mutator +def update_mcp_service(mcp_service: McpService) -> McpService: + assert isinstance(mcp_service.comment, str) + + return replace(mcp_service, comment=f"{mcp_service.comment} (updated)") diff --git a/acceptance/bundle/python/mcp_services-support/out.test.toml b/acceptance/bundle/python/mcp_services-support/out.test.toml new file mode 100644 index 00000000000..02da5baab89 --- /dev/null +++ b/acceptance/bundle/python/mcp_services-support/out.test.toml @@ -0,0 +1,4 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] +EnvMatrix.DMS = ["", "true"] +EnvMatrix.PYDAB_VERSION = ["current"] diff --git a/acceptance/bundle/python/mcp_services-support/output.txt b/acceptance/bundle/python/mcp_services-support/output.txt new file mode 100644 index 00000000000..da90e33dba3 --- /dev/null +++ b/acceptance/bundle/python/mcp_services-support/output.txt @@ -0,0 +1,28 @@ + +>>> uv run [UV_ARGS] -q [CLI] bundle validate --output json +{ + "experimental": { + "python": { + "mutators": [ + "mutators:update_mcp_service" + ], + "resources": [ + "resources:load_resources" + ] + } + }, + "resources": { + "mcp_services": { + "my_mcp_service_1": { + "comment": "My MCP service (updated)", + "mcp_service_id": "my_mcp_service_1", + "parent": "schemas/main.default" + }, + "my_mcp_service_2": { + "comment": "My MCP service (2) (updated)", + "mcp_service_id": "my_mcp_service_2", + "parent": "schemas/main.default" + } + } + } +} diff --git a/acceptance/bundle/python/mcp_services-support/resources.py b/acceptance/bundle/python/mcp_services-support/resources.py new file mode 100644 index 00000000000..7ec7c51d189 --- /dev/null +++ b/acceptance/bundle/python/mcp_services-support/resources.py @@ -0,0 +1,16 @@ +from databricks.bundles.core import Resources + + +def load_resources() -> Resources: + resources = Resources() + + resources.add_mcp_service( + "my_mcp_service_2", + { + "parent": "schemas/main.default", + "mcp_service_id": "my_mcp_service_2", + "comment": "My MCP service (2)", + }, + ) + + return resources diff --git a/acceptance/bundle/python/mcp_services-support/script b/acceptance/bundle/python/mcp_services-support/script new file mode 100644 index 00000000000..e273fb45a53 --- /dev/null +++ b/acceptance/bundle/python/mcp_services-support/script @@ -0,0 +1,5 @@ + +trace uv run $UV_ARGS -q $CLI bundle validate --output json | \ + jq "pick(.experimental.python, .resources)" + +rm -fr .databricks __pycache__ diff --git a/acceptance/bundle/python/mcp_services-support/test.toml b/acceptance/bundle/python/mcp_services-support/test.toml new file mode 100644 index 00000000000..4f1fe544200 --- /dev/null +++ b/acceptance/bundle/python/mcp_services-support/test.toml @@ -0,0 +1,4 @@ +Cloud = false # tests don't interact with APIs + +# mcp_services are only supported in the current version of the wheel +EnvMatrix.PYDAB_VERSION = ["current"] diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 05831799a70..750445182bd 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -2082,6 +2082,27 @@ resources.jobs.*.permissions[*].group_name string ALL resources.jobs.*.permissions[*].level iam.PermissionLevel ALL resources.jobs.*.permissions[*].service_principal_name string ALL resources.jobs.*.permissions[*].user_name string ALL +resources.mcp_services.*.comment string ALL +resources.mcp_services.*.config *catalog.McpServiceConfig ALL +resources.mcp_services.*.config.include_tool_selectors []string ALL +resources.mcp_services.*.config.include_tool_selectors[*] string ALL +resources.mcp_services.*.config.rate_limits []catalog.RateLimit ALL +resources.mcp_services.*.config.rate_limits[*] catalog.RateLimit ALL +resources.mcp_services.*.config.rate_limits[*].key catalog.RateLimitRateLimitKey ALL +resources.mcp_services.*.config.rate_limits[*].principal string ALL +resources.mcp_services.*.config.rate_limits[*].renewal_period catalog.RateLimitRateLimitRenewalPeriod ALL +resources.mcp_services.*.config.rate_limits[*].requests int64 ALL +resources.mcp_services.*.config.rate_limits[*].tokens int64 ALL +resources.mcp_services.*.config.source_connection *catalog.McpServiceConfigSourceConnection ALL +resources.mcp_services.*.config.source_connection.is_deleted bool ALL +resources.mcp_services.*.config.source_connection.name string ALL +resources.mcp_services.*.id string INPUT +resources.mcp_services.*.lifecycle resources.Lifecycle INPUT +resources.mcp_services.*.lifecycle.prevent_destroy bool INPUT +resources.mcp_services.*.mcp_service_id string ALL +resources.mcp_services.*.modified_status string INPUT +resources.mcp_services.*.parent string ALL +resources.mcp_services.*.url string INPUT resources.model_services.*.comment string ALL resources.model_services.*.config *catalog.ModelServiceConfig ALL resources.model_services.*.config.inference_table *catalog.InferenceTableConfig ALL diff --git a/acceptance/bundle/resources/mcp_services/basic/databricks.yml b/acceptance/bundle/resources/mcp_services/basic/databricks.yml new file mode 100644 index 00000000000..9b842c092b9 --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/basic/databricks.yml @@ -0,0 +1,9 @@ +bundle: + name: test-bundle + +resources: + mcp_services: + mcp1: + parent: schemas/main.myschema + mcp_service_id: myservice + comment: COMMENT1 diff --git a/acceptance/bundle/resources/mcp_services/basic/out.test.toml b/acceptance/bundle/resources/mcp_services/basic/out.test.toml new file mode 100644 index 00000000000..59b56a2037c --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/basic/out.test.toml @@ -0,0 +1,3 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/mcp_services/basic/output.txt b/acceptance/bundle/resources/mcp_services/basic/output.txt new file mode 100644 index 00000000000..88f6032984d --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/basic/output.txt @@ -0,0 +1,92 @@ + +=== Initial summary before deploy +>>> [CLI] bundle summary -o json +{ + "comment": "COMMENT1", + "mcp_service_id": "myservice", + "modified_status": "created", + "parent": "schemas/main.myschema" +} + +=== Verify it does not exist yet +>>> musterr [CLI] ai-gateway get-mcp-service mcp-services/main.myschema.myservice +Error: Resource catalog.McpService not found: main.myschema.myservice + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Created mcp_services.mcp1 +Files: 3 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity +{ + "method": "POST", + "path": "/api/2.1/unity-catalog/mcp-services", + "q": { + "mcp_service_id": "myservice", + "parent": "schemas/main.myschema" + }, + "body": { + "comment": "COMMENT1" + } +} + +=== Summary should show the id and the Catalog Explorer url +>>> [CLI] bundle summary -o json +{ + "id": "main.myschema.myservice", + "url": "[DATABRICKS_URL]/explore/data/mcp-services/main/myschema/myservice?w=[NUMID]" +} + +=== Verify deployment +>>> [CLI] ai-gateway get-mcp-service mcp-services/main.myschema.myservice +{ + "name": "mcp-services/main.myschema.myservice", + "comment": "COMMENT1" +} + +=== Update comment (should update in place, not recreate) +>>> update_file.py databricks.yml COMMENT1 COMMENT2 + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Updated mcp_services.mcp1 +Files: 1 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 0 unchanged + +>>> print_requests.py //unity +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/mcp-services/main.myschema.myservice", + "q": { + "update_mask": "*" + }, + "body": { + "comment": "COMMENT2" + } +} + +>>> [CLI] ai-gateway get-mcp-service mcp-services/main.myschema.myservice +"COMMENT2" + +=== Change an immutable field (should plan a recreate) +>>> update_file.py databricks.yml myservice myservice-renamed + +>>> [CLI] bundle plan +recreate mcp_services.mcp1 + +Plan: 1 to add, 0 to change, 1 to delete, 0 unchanged + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.mcp_services.mcp1 + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Destroy: 1 deleted + +>>> print_requests.py //unity +{ + "method": "DELETE", + "path": "/api/2.1/unity-catalog/mcp-services/main.myschema.myservice" +} diff --git a/acceptance/bundle/resources/mcp_services/basic/script b/acceptance/bundle/resources/mcp_services/basic/script new file mode 100644 index 00000000000..eedd128b1e6 --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/basic/script @@ -0,0 +1,29 @@ +title "Initial summary before deploy" +trace $CLI bundle summary -o json | jq .resources.mcp_services.mcp1 + +title "Verify it does not exist yet" +trace musterr $CLI ai-gateway get-mcp-service mcp-services/main.myschema.myservice + +trace $CLI bundle deploy +trace print_requests.py //unity + +title "Summary should show the id and the Catalog Explorer url" +trace $CLI bundle summary -o json | jq ".resources.mcp_services.mcp1 | {id, url}" + +title "Verify deployment" +trace $CLI ai-gateway get-mcp-service mcp-services/main.myschema.myservice | jq '{name, comment}' + +title "Update comment (should update in place, not recreate)" +trace update_file.py databricks.yml COMMENT1 COMMENT2 +trace $CLI bundle deploy +trace print_requests.py //unity +trace $CLI ai-gateway get-mcp-service mcp-services/main.myschema.myservice | jq .comment + +title "Change an immutable field (should plan a recreate)" +trace update_file.py databricks.yml myservice myservice-renamed +trace $CLI bundle plan + +trace $CLI bundle destroy --auto-approve +trace print_requests.py //unity + +rm -f out.requests.txt diff --git a/acceptance/bundle/resources/mcp_services/basic/test.toml b/acceptance/bundle/resources/mcp_services/basic/test.toml new file mode 100644 index 00000000000..116f2013783 --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/basic/test.toml @@ -0,0 +1,7 @@ +# Local only: this test inspects the recorded request stream (print_requests), +# which has no equivalent against a real workspace. +Cloud = false + +Ignore = [ + ".databricks", +] diff --git a/acceptance/bundle/resources/mcp_services/lifecycle/databricks.yml.tmpl b/acceptance/bundle/resources/mcp_services/lifecycle/databricks.yml.tmpl new file mode 100644 index 00000000000..a2b2a48aedb --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/lifecycle/databricks.yml.tmpl @@ -0,0 +1,12 @@ +bundle: + name: deploy-mcp-service-test-$UNIQUE_NAME + +resources: + mcp_services: + mcp: + parent: schemas/main.default + mcp_service_id: test_mcp_$UNIQUE_NAME + comment: "Points at a UC connection hosting an MCP server" + config: + source_connection: + name: connections/main.default.mcp_conn_$UNIQUE_NAME diff --git a/acceptance/bundle/resources/mcp_services/lifecycle/out.test.toml b/acceptance/bundle/resources/mcp_services/lifecycle/out.test.toml new file mode 100644 index 00000000000..ae5c7bd798f --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/lifecycle/out.test.toml @@ -0,0 +1,3 @@ +Cloud = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/mcp_services/lifecycle/output.txt b/acceptance/bundle/resources/mcp_services/lifecycle/output.txt new file mode 100644 index 00000000000..9a93606a352 --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/lifecycle/output.txt @@ -0,0 +1,23 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-mcp-service-test-[UNIQUE_NAME]/default/files... +Created mcp_services.mcp +Files: 4 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged + +>>> [CLI] ai-gateway get-mcp-service mcp-services/main.default.test_mcp_[UNIQUE_NAME] +{ + "name": "mcp-services/main.default.test_mcp_[UNIQUE_NAME]", + "comment": "Points at a UC connection hosting an MCP server" +} + +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.mcp_services.mcp + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-mcp-service-test-[UNIQUE_NAME]/default + +Destroy: 1 deleted diff --git a/acceptance/bundle/resources/mcp_services/lifecycle/script b/acceptance/bundle/resources/mcp_services/lifecycle/script new file mode 100644 index 00000000000..6e9514f3a38 --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/lifecycle/script @@ -0,0 +1,36 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +CONN="mcp_conn_$UNIQUE_NAME" + +cleanup() { + trace $CLI bundle destroy --auto-approve + # Only created on cloud (see below); tolerate its absence locally. + if [ -n "$CLOUD_ENV" ]; then + $CLI connections delete "main.default.$CONN" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +# On a real workspace the MCP service must point at an existing UC connection. +# Use a SCHEMA-SCOPED connection (parent schemas/main.default): schema-level +# connections need only schema privileges, not metastore CREATE CONNECTION. +# is_mcp_connection is a metastore-level option (rejected on schema-level +# connections), so this is a plain HTTP connection, which the mcp_service +# accepts as its source_connection. The mock testserver doesn't validate +# source_connection, so this only runs on cloud. +if [ -n "$CLOUD_ENV" ]; then + # DEBUG (temporary): silent on success; prints the error if create fails. + if ! conn_out=$($CLI connections create --json "{\"name\":\"$CONN\",\"connection_type\":\"HTTP\",\"parent\":\"schemas/main.default\",\"options\":{\"host\":\"https://mcp-$UNIQUE_NAME.example.invalid\",\"port\":\"443\",\"base_path\":\"/mcp\",\"bearer_token\":\"dummy-token\"}}" 2>&1); then + echo "DEBUG connections create failed:" + echo "$conn_out" + fi +fi + +trace $CLI bundle deploy + +MCP_ID=$($CLI bundle summary --output json | jq -r '.resources.mcp_services.mcp.id') + +trace $CLI ai-gateway get-mcp-service "mcp-services/$MCP_ID" | jq '{name, comment}' + +# Verify there is no drift right after deploy. +trace $CLI bundle plan diff --git a/acceptance/bundle/resources/mcp_services/lifecycle/test.toml b/acceptance/bundle/resources/mcp_services/lifecycle/test.toml new file mode 100644 index 00000000000..f89c69a11cb --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/lifecycle/test.toml @@ -0,0 +1,12 @@ +# MCP service lifecycle, also runs against a real workspace. An MCP service must +# point at an existing UC connection (config.source_connection). The script +# creates a SCHEMA-SCOPED connection out-of-band on cloud (under the deploy +# schema) and tears it down via trap: schema-level connections need only schema +# privileges (not metastore CREATE CONNECTION). They can't set is_mcp_connection +# (metastore-level only), but the mcp_service accepts a plain HTTP connection. +RecordRequests = false + +Ignore = [ + "databricks.yml", + ".databricks", +] diff --git a/acceptance/bundle/resources/mcp_services/remote-delete/databricks.yml b/acceptance/bundle/resources/mcp_services/remote-delete/databricks.yml new file mode 100644 index 00000000000..9b842c092b9 --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/remote-delete/databricks.yml @@ -0,0 +1,9 @@ +bundle: + name: test-bundle + +resources: + mcp_services: + mcp1: + parent: schemas/main.myschema + mcp_service_id: myservice + comment: COMMENT1 diff --git a/acceptance/bundle/resources/mcp_services/remote-delete/out.test.toml b/acceptance/bundle/resources/mcp_services/remote-delete/out.test.toml new file mode 100644 index 00000000000..59b56a2037c --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/remote-delete/out.test.toml @@ -0,0 +1,3 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/resources/mcp_services/remote-delete/output.txt b/acceptance/bundle/resources/mcp_services/remote-delete/output.txt new file mode 100644 index 00000000000..a619fe2e5d0 --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/remote-delete/output.txt @@ -0,0 +1,20 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... +Created mcp_services.mcp1 +Files: 3 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged + +=== Delete the resource out of band +>>> [CLI] ai-gateway delete-mcp-service mcp-services/main.myschema.myservice + +=== Plan should detect the resource is gone and re-create it +>>> [CLI] bundle plan +create mcp_services.mcp1 + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle destroy --auto-approve +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Destroy: 0 deleted diff --git a/acceptance/bundle/resources/mcp_services/remote-delete/script b/acceptance/bundle/resources/mcp_services/remote-delete/script new file mode 100644 index 00000000000..413e6174fd4 --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/remote-delete/script @@ -0,0 +1,9 @@ +trace $CLI bundle deploy + +title "Delete the resource out of band" +trace $CLI ai-gateway delete-mcp-service mcp-services/main.myschema.myservice + +title "Plan should detect the resource is gone and re-create it" +trace $CLI bundle plan + +trace $CLI bundle destroy --auto-approve diff --git a/acceptance/bundle/resources/mcp_services/remote-delete/test.toml b/acceptance/bundle/resources/mcp_services/remote-delete/test.toml new file mode 100644 index 00000000000..17400e40cab --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/remote-delete/test.toml @@ -0,0 +1,9 @@ +# Local only: simulates an out-of-band delete with a fixed resource name, so it +# can't run against a real workspace. +Cloud = false + +RecordRequests = false + +Ignore = [ + ".databricks", +] diff --git a/acceptance/bundle/resources/mcp_services/test.toml b/acceptance/bundle/resources/mcp_services/test.toml new file mode 100644 index 00000000000..90109467fba --- /dev/null +++ b/acceptance/bundle/resources/mcp_services/test.toml @@ -0,0 +1,7 @@ +# AI Gateway securables are only deployable via the direct deployment engine +# (there is no Terraform provider path for them in bundles). +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +# Lifecycle tests also run against a real workspace. Tests that inspect the +# recorded request stream (e.g. basic) can't run on cloud and override this. +Cloud = true diff --git a/acceptance/experimental/open/output.txt b/acceptance/experimental/open/output.txt index 881d659b71d..436050964c6 100644 --- a/acceptance/experimental/open/output.txt +++ b/acceptance/experimental/open/output.txt @@ -9,7 +9,7 @@ === unknown resource type >>> [CLI] experimental open --url unknown 123 -Error: unknown resource type "unknown", must be one of: alerts, apps, catalogs, cluster_policies, clusters, dashboards, database_catalogs, database_instances, experiments, genie_spaces, instance_pools, jobs, model_services, model_serving_endpoints, models, notebooks, pipelines, postgres_catalogs, postgres_synced_tables, quality_monitors, queries, registered_models, schemas, secrets, synced_database_tables, vector_search_endpoints, vector_search_indexes, volumes, warehouses +Error: unknown resource type "unknown", must be one of: alerts, apps, catalogs, cluster_policies, clusters, dashboards, database_catalogs, database_instances, experiments, genie_spaces, instance_pools, jobs, mcp_services, model_services, model_serving_endpoints, models, notebooks, pipelines, postgres_catalogs, postgres_synced_tables, quality_monitors, queries, registered_models, schemas, secrets, synced_database_tables, vector_search_endpoints, vector_search_indexes, volumes, warehouses === test auto-completion handler >>> [CLI] __complete experimental open , @@ -25,6 +25,7 @@ experiments genie_spaces instance_pools jobs +mcp_services model_services model_serving_endpoints models diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index 7aba169bd5e..f54d1b5a624 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -26,6 +26,7 @@ var unsupportedResources = []string{ "quality_monitors", "registered_models", "model_services", + "mcp_services", "database_catalogs", "synced_database_tables", "postgres_branches", diff --git a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go index d3645660eea..a0d3132af2b 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -111,6 +111,9 @@ func mockBundle(mode config.Mode) *bundle.Bundle { ModelServices: map[string]*resources.ModelService{ "modelservice1": {ModelServiceConfig: resources.ModelServiceConfig{Parent: "schemas/main.default", ModelServiceId: "modelservice1"}}, }, + McpServices: map[string]*resources.McpService{ + "mcpservice1": {McpServiceConfig: resources.McpServiceConfig{Parent: "schemas/main.default", McpServiceId: "mcpservice1"}}, + }, RegisteredModels: map[string]*resources.RegisteredModel{ "registeredmodel1": {CreateRegisteredModelRequest: catalog.CreateRegisteredModelRequest{Name: "registeredmodel1"}}, }, diff --git a/bundle/config/mutator/resourcemutator/capture_uc_dependencies.go b/bundle/config/mutator/resourcemutator/capture_uc_dependencies.go index 88b3853925f..5d95b8acc2f 100644 --- a/bundle/config/mutator/resourcemutator/capture_uc_dependencies.go +++ b/bundle/config/mutator/resourcemutator/capture_uc_dependencies.go @@ -217,6 +217,12 @@ func (m *captureUCDependencies) Apply(ctx context.Context, b *bundle.Bundle) dia } ms.Parent = resolveParent(b, ms.Parent) } + for _, ms := range b.Config.Resources.McpServices { + if ms == nil { + continue + } + ms.Parent = resolveParent(b, ms.Parent) + } // Schemas are resolved last because the schema catalog resolution modifies // schema.CatalogName, and findSchema (used by resolveSchema above) matches diff --git a/bundle/config/mutator/resourcemutator/capture_uc_dependencies_test.go b/bundle/config/mutator/resourcemutator/capture_uc_dependencies_test.go index 481df5d214a..d8c39e3276b 100644 --- a/bundle/config/mutator/resourcemutator/capture_uc_dependencies_test.go +++ b/bundle/config/mutator/resourcemutator/capture_uc_dependencies_test.go @@ -142,6 +142,11 @@ func TestCaptureUCDependencies(t *testing.T) { Name: "mycatalog.myschema.myindex", }}, }, + McpServices: map[string]*resources.McpService{ + "my_mcp_service": {McpServiceConfig: resources.McpServiceConfig{ + Parent: "schemas/mycatalog.myschema", McpServiceId: "mymcp", + }}, + }, }, }, } @@ -180,6 +185,9 @@ func TestCaptureUCDependencies(t *testing.T) { // Vector search index (three-part "catalog.schema.index" name). assert.Equal(t, catalogRef+"."+schemaRef+".myindex", b.Config.Resources.VectorSearchIndexes["my_index"].Name) + + // MCP service (same compound parent field). + assert.Equal(t, "schemas/"+catalogRef+"."+schemaRef, b.Config.Resources.McpServices["my_mcp_service"].Parent) } // Pipeline schema and target are mutually exclusive; only the populated field diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index 13a3491a9f3..d32f8538060 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -48,6 +48,7 @@ func allResourceTypes(t *testing.T) []string { "internal_immutable_snapshots", "job_runs", "jobs", + "mcp_services", "model_services", "model_serving_endpoints", "models", @@ -189,6 +190,7 @@ var allowList = []string{ "pipelines", "models", "model_services", + "mcp_services", "postgres_branches", "postgres_catalogs", "postgres_databases", diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 8f6a9de9a46..db61c4bdbd1 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -19,6 +19,7 @@ type Resources struct { Experiments map[string]*resources.MlflowExperiment `json:"experiments,omitempty"` ModelServingEndpoints map[string]*resources.ModelServingEndpoint `json:"model_serving_endpoints,omitempty"` ModelServices map[string]*resources.ModelService `json:"model_services,omitempty"` + McpServices map[string]*resources.McpService `json:"mcp_services,omitempty"` RegisteredModels map[string]*resources.RegisteredModel `json:"registered_models,omitempty"` QualityMonitors map[string]*resources.QualityMonitor `json:"quality_monitors,omitempty"` Catalogs map[string]*resources.Catalog `json:"catalogs,omitempty"` @@ -113,6 +114,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["experiments"], r.Experiments), collectResourceMap(descriptions["model_serving_endpoints"], r.ModelServingEndpoints), collectResourceMap(descriptions["model_services"], r.ModelServices), + collectResourceMap(descriptions["mcp_services"], r.McpServices), collectResourceMap(descriptions["registered_models"], r.RegisteredModels), collectResourceMap(descriptions["quality_monitors"], r.QualityMonitors), collectResourceMap(descriptions["catalogs"], r.Catalogs), @@ -186,6 +188,7 @@ func SupportedResources() map[string]resources.ResourceDescription { "instance_pools": (&resources.InstancePool{}).ResourceDescription(), "model_serving_endpoints": (&resources.ModelServingEndpoint{}).ResourceDescription(), "model_services": (&resources.ModelService{}).ResourceDescription(), + "mcp_services": (&resources.McpService{}).ResourceDescription(), "registered_models": (&resources.RegisteredModel{}).ResourceDescription(), "quality_monitors": (&resources.QualityMonitor{}).ResourceDescription(), "catalogs": (&resources.Catalog{}).ResourceDescription(), diff --git a/bundle/config/resources/mcp_service.go b/bundle/config/resources/mcp_service.go new file mode 100644 index 00000000000..db5f221b0d9 --- /dev/null +++ b/bundle/config/resources/mcp_service.go @@ -0,0 +1,96 @@ +package resources + +import ( + "context" + "net/url" + + "github.com/databricks/cli/libs/log" + "github.com/databricks/cli/libs/workspaceurls" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/marshal" + "github.com/databricks/databricks-sdk-go/service/catalog" +) + +// McpServiceConfig is the bundle-authored state for an AI Gateway MCP service. +// +// It mirrors ModelServiceConfig: the SDK models the create inputs `parent` and +// `mcp_service_id` as URL parameters (`json:"-"`) outside the McpService body +// and derives the resource `name` +// (`mcp-services/{catalog}.{schema}.{mcp_service}`) server-side, so we expose a +// flat struct with the immutable identity plus the mutable body. Owner is not +// exposed yet (the API returns effective_owner on read, not owner). +type McpServiceConfig struct { + // Parent schema, format `schemas/{catalog}.{schema}`. Immutable: the server + // derives `name` from parent + mcp_service_id, so changing it recreates the + // resource. + Parent string `json:"parent"` + // Leaf id of the MCP service, e.g. "my_mcp_service". Immutable. + McpServiceId string `json:"mcp_service_id"` + // User-provided description. + Comment string `json:"comment,omitempty"` + // Operational configuration: source connection, tool selectors, rate limit. + Config *catalog.McpServiceConfig `json:"config,omitempty"` + + ForceSendFields []string `json:"-" url:"-"` +} + +func (c *McpServiceConfig) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, c) +} + +func (c McpServiceConfig) MarshalJSON() ([]byte, error) { + return marshal.Marshal(c) +} + +type McpService struct { + BaseResource + McpServiceConfig +} + +// UnmarshalJSON / MarshalJSON are defined on the wrapper so it does not inherit +// McpServiceConfig's promoted marshaler, which would silently drop the +// BaseResource fields (id, url, lifecycle, modified_status). +func (m *McpService) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, m) +} + +func (m McpService) MarshalJSON() ([]byte, error) { + return marshal.Marshal(m) +} + +func (m *McpService) Exists(ctx context.Context, w *databricks.WorkspaceClient, id string) (bool, error) { + // The engine tracks the id as the bare {catalog}.{schema}.{mcp_service}; + // the API addresses the resource by its full name. + _, err := w.AiGateway.GetMcpService(ctx, catalog.GetMcpServiceRequest{Name: "mcp-services/" + id}) + if err != nil { + log.Debugf(ctx, "mcp service %s does not exist", id) + if apierr.IsMissing(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func (*McpService) ResourceDescription() ResourceDescription { + return ResourceDescription{ + SingularName: "mcp_service", + PluralName: "mcp_services", + SingularTitle: "MCP service", + PluralTitle: "MCP services", + } +} + +func (m *McpService) InitializeURL(baseURL url.URL) { + if m.ID == "" { + return + } + // The id is the bare {catalog}.{schema}.{mcp_service}; ResourceURL splits it + // into the Catalog Explorer path explore/data/mcp-services/... + m.URL = workspaceurls.ResourceURL(baseURL, "mcp_services", m.ID) +} + +func (m *McpService) GetName() string { + return m.McpServiceId +} diff --git a/bundle/config/resources_test.go b/bundle/config/resources_test.go index cc9d972374e..fb89d49c3c5 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -248,6 +248,11 @@ func TestResourcesBindSupport(t *testing.T) { ModelServiceConfig: resources.ModelServiceConfig{}, }, }, + McpServices: map[string]*resources.McpService{ + "my_mcp_service": { + McpServiceConfig: resources.McpServiceConfig{}, + }, + }, SecretScopes: map[string]*resources.SecretScope{ "my_secret_scope": { Name: "0", @@ -396,6 +401,7 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockQualityMonitorsAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockServingEndpointsAPI().EXPECT().Get(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockAiGatewayAPI().EXPECT().GetModelService(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockAiGatewayAPI().EXPECT().GetMcpService(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockSecretsAPI().EXPECT().ListScopesAll(mock.Anything).Return([]workspace.SecretScope{ {Name: "0"}, }, nil) diff --git a/bundle/deploy/terraform/lifecycle_test.go b/bundle/deploy/terraform/lifecycle_test.go index 1a1fd92ba5e..dc592750113 100644 --- a/bundle/deploy/terraform/lifecycle_test.go +++ b/bundle/deploy/terraform/lifecycle_test.go @@ -27,6 +27,7 @@ func TestConvertLifecycleForAllResources(t *testing.T) { "postgres_snapshot_schedules", // AI Gateway model service is deployed through the direct engine only. "model_services", + "mcp_services", "secrets", "vector_search_endpoints", "vector_search_indexes", diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index bb4c0314640..615a704c6ca 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -37,6 +37,7 @@ var SupportedResources = map[string]any{ "secret_scopes": (*ResourceSecretScope)(nil), "model_serving_endpoints": (*ResourceModelServingEndpoint)(nil), "model_services": (*ResourceModelService)(nil), + "mcp_services": (*ResourceMcpService)(nil), "quality_monitors": (*ResourceQualityMonitor)(nil), "vector_search_endpoints": (*ResourceVectorSearchEndpoint)(nil), "vector_search_indexes": (*ResourceVectorSearchIndex)(nil), diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 022be09694a..92229daad93 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -106,6 +106,14 @@ var testConfig map[string]any = map[string]any{ }, }, + "mcp_services": &resources.McpService{ + McpServiceConfig: resources.McpServiceConfig{ + Parent: "schemas/main.default", + McpServiceId: "my_mcp_service", + Comment: "Test mcp service", + }, + }, + "registered_models": &resources.RegisteredModel{ CreateRegisteredModelRequest: catalog.CreateRegisteredModelRequest{ Name: "my_registered_model", diff --git a/bundle/direct/dresources/apitypes.generated.yml b/bundle/direct/dresources/apitypes.generated.yml index 73d2f523c07..4d5aa8f4b7a 100644 --- a/bundle/direct/dresources/apitypes.generated.yml +++ b/bundle/direct/dresources/apitypes.generated.yml @@ -28,6 +28,8 @@ job_runs: jobs.RunNow jobs: jobs.JobSettings +mcp_services: catalog.CreateMcpServiceRequest + model_services: catalog.CreateModelServiceRequest model_serving_endpoints: serving.CreateServingEndpoint diff --git a/bundle/direct/dresources/apitypes.yml b/bundle/direct/dresources/apitypes.yml index 1ea84a73db2..a7257a525ae 100644 --- a/bundle/direct/dresources/apitypes.yml +++ b/bundle/direct/dresources/apitypes.yml @@ -11,6 +11,8 @@ # caller-supplied optimistic-concurrency token and that behavior is lost. genie_spaces: dashboards.GenieSpace +mcp_services: catalog.McpService + model_services: catalog.ModelService postgres_branches: postgres.BranchSpec diff --git a/bundle/direct/dresources/configs/mcp_services.generated.yml b/bundle/direct/dresources/configs/mcp_services.generated.yml new file mode 100644 index 00000000000..6de4a07c4c0 --- /dev/null +++ b/bundle/direct/dresources/configs/mcp_services.generated.yml @@ -0,0 +1,5 @@ +# Generated, do not edit. + +ignore_remote_changes: + - field: config.source_connection.is_deleted + reason: spec:output_only diff --git a/bundle/direct/dresources/configs/mcp_services.yml b/bundle/direct/dresources/configs/mcp_services.yml new file mode 100644 index 00000000000..419cf45197c --- /dev/null +++ b/bundle/direct/dresources/configs/mcp_services.yml @@ -0,0 +1,10 @@ +provided_id_fields: + # parent + mcp_service_id compose the server-derived resource name + # (mcp-services/{catalog}.{schema}.{mcp_service}), which is the ID the + # resource is fetched by. Both are immutable; a local change recreates. + # DoRead reconstructs them from the returned name, so a remote-only + # difference can only be normalization and is skipped. + - field: parent + reason: id_field + - field: mcp_service_id + reason: id_field diff --git a/bundle/direct/dresources/mcp_service.go b/bundle/direct/dresources/mcp_service.go new file mode 100644 index 00000000000..0882190dc5d --- /dev/null +++ b/bundle/direct/dresources/mcp_service.go @@ -0,0 +1,137 @@ +package dresources + +import ( + "context" + "fmt" + "strings" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/common/types/fieldmask" + "github.com/databricks/databricks-sdk-go/service/catalog" +) + +// AI Gateway MCP service. +// API: https://docs.databricks.com/api/workspace/aigateway +// Terraform: databricks_ai_gateway_mcp_service +// +// Mirrors ResourceModelService: the remote type returned by DoRead is the same +// bundle-local resources.McpServiceConfig used for state (so RemapState is not +// needed), and DoRead reconstructs the create-time identity (parent + +// mcp_service_id) from the server-derived resource name. +const mcpServiceNamePrefix = "mcp-services/" + +type ResourceMcpService struct { + client *databricks.WorkspaceClient +} + +func (*ResourceMcpService) New(client *databricks.WorkspaceClient) *ResourceMcpService { + return &ResourceMcpService{client: client} +} + +func (*ResourceMcpService) PrepareState(input *resources.McpService) *resources.McpServiceConfig { + return &input.McpServiceConfig +} + +// mcpServiceIdentityFromName reconstructs the create-time parent and leaf id +// from the server-derived resource name +// `mcp-services/{catalog}.{schema}.{mcp_service}`. +func mcpServiceIdentityFromName(name string) (parent, mcpServiceId string, err error) { + rest, ok := strings.CutPrefix(name, mcpServiceNamePrefix) + if !ok { + return "", "", fmt.Errorf("unexpected mcp service name %q (want mcp-services/{catalog}.{schema}.{mcp_service})", name) + } + parts := strings.Split(rest, ".") + if len(parts) != 3 { + return "", "", fmt.Errorf("unexpected mcp service name %q (want three dot-separated components)", name) + } + return "schemas/" + parts[0] + "." + parts[1], parts[2], nil +} + +func responseToMcpServiceConfig(ms *catalog.McpService) (*resources.McpServiceConfig, error) { + parent, id, err := mcpServiceIdentityFromName(ms.Name) + if err != nil { + return nil, err + } + return &resources.McpServiceConfig{ + Parent: parent, + McpServiceId: id, + Comment: ms.Comment, + Config: ms.Config, + ForceSendFields: nil, + }, nil +} + +// mcpServiceBody builds the McpService write payload from the bundle config. +// Only comment and config are client-settable; every other field is OUTPUT_ONLY +// (server-derived) and sent as its zero value. +func mcpServiceBody(config *resources.McpServiceConfig) catalog.McpService { + return catalog.McpService{ + Comment: config.Comment, + Config: config.Config, + CreateTime: nil, + CreatedBy: "", + EffectiveOwner: "", + Etag: "", + MetastoreId: "", + Name: "", + UpdateTime: nil, + UpdatedBy: "", + ForceSendFields: nil, + } +} + +func (r *ResourceMcpService) DoRead(ctx context.Context, id string) (*resources.McpServiceConfig, error) { + ms, err := r.client.AiGateway.GetMcpService(ctx, catalog.GetMcpServiceRequest{Name: mcpServiceNamePrefix + id}) + if err != nil { + return nil, err + } + return responseToMcpServiceConfig(ms) +} + +func (r *ResourceMcpService) DoCreate(ctx context.Context, config *resources.McpServiceConfig) (string, *resources.McpServiceConfig, error) { + resp, err := r.client.AiGateway.CreateMcpService(ctx, catalog.CreateMcpServiceRequest{ + Parent: config.Parent, + McpServiceId: config.McpServiceId, + McpService: mcpServiceBody(config), + }) + if err != nil { + return "", nil, err + } + state, err := responseToMcpServiceConfig(resp) + if err != nil { + return "", nil, err + } + return strings.TrimPrefix(resp.Name, mcpServiceNamePrefix), state, nil +} + +// DoUpdate sends update_mask "*" on every update. name, parent and +// mcp_service_id are immutable (provided_id_fields in resources.yml), so the +// wildcard replaces every client-settable field (comment + a full config +// replace), matching the mask the Terraform provider generates. +// +// Etag is intentionally left empty here and in DoDelete: an empty etag means no +// If-Match precondition (last-write-wins), matching the Terraform provider, +// which also does not send etag. We deliberately do not do optimistic +// concurrency on these resources. +func (r *ResourceMcpService) DoUpdate(ctx context.Context, id string, config *resources.McpServiceConfig, _ *PlanEntry) (*resources.McpServiceConfig, error) { + resp, err := r.client.AiGateway.UpdateMcpService(ctx, catalog.UpdateMcpServiceRequest{ + Etag: "", + McpService: mcpServiceBody(config), + Name: mcpServiceNamePrefix + id, + UpdateMask: fieldmask.FieldMask{Paths: []string{"*"}}, + ForceSendFields: nil, + }) + if err != nil { + return nil, err + } + return responseToMcpServiceConfig(resp) +} + +func (r *ResourceMcpService) DoDelete(ctx context.Context, id string, _ *resources.McpServiceConfig) error { + return r.client.AiGateway.DeleteMcpService(ctx, catalog.DeleteMcpServiceRequest{ + Etag: "", + Name: mcpServiceNamePrefix + id, + ForceSendFields: nil, + }) +} diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index b35ce0e4ae0..80fb71509fa 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -1194,6 +1194,25 @@ resources: "table_update": "description": |- PLACEHOLDER + "mcp_services": + "description": |- + PLACEHOLDER + "$fields": + "comment": + "description": |- + PLACEHOLDER + "config": + "description": |- + PLACEHOLDER + "lifecycle": + "description": |- + PLACEHOLDER + "mcp_service_id": + "description": |- + PLACEHOLDER + "parent": + "description": |- + PLACEHOLDER "model_services": "description": |- PLACEHOLDER diff --git a/bundle/internal/validation/generated/enum_fields.go b/bundle/internal/validation/generated/enum_fields.go index b696caa5ea3..5b1d69131f2 100644 --- a/bundle/internal/validation/generated/enum_fields.go +++ b/bundle/internal/validation/generated/enum_fields.go @@ -158,6 +158,9 @@ var EnumFields = map[string][]string{ "resources.jobs.*.triggers[*].sql_condition.trigger_mode": {"QUERY_RETURNS_ROWS", "RESULT_VALUE_CHANGES"}, "resources.jobs.*.triggers[*].table_update.condition": {"ALL_UPDATED", "ANY_UPDATED"}, + "resources.mcp_services.*.config.rate_limits[*].key": {"RATE_LIMIT_KEY_SERVICE", "RATE_LIMIT_KEY_SERVICE_PRINCIPAL", "RATE_LIMIT_KEY_USER", "RATE_LIMIT_KEY_USER_DEFAULT", "RATE_LIMIT_KEY_USER_GROUP"}, + "resources.mcp_services.*.config.rate_limits[*].renewal_period": {"RATE_LIMIT_RENEWAL_PERIOD_HOUR", "RATE_LIMIT_RENEWAL_PERIOD_MINUTE"}, + "resources.model_services.*.config.rate_limits[*].key": {"RATE_LIMIT_KEY_SERVICE", "RATE_LIMIT_KEY_SERVICE_PRINCIPAL", "RATE_LIMIT_KEY_USER", "RATE_LIMIT_KEY_USER_DEFAULT", "RATE_LIMIT_KEY_USER_GROUP"}, "resources.model_services.*.config.rate_limits[*].renewal_period": {"RATE_LIMIT_RENEWAL_PERIOD_HOUR", "RATE_LIMIT_RENEWAL_PERIOD_MINUTE"}, "resources.model_services.*.config.routing.destinations[*].destination_type": {"DESTINATION_TYPE_EXTERNAL_FOUNDATION_MODEL", "DESTINATION_TYPE_PAY_PER_TOKEN_FOUNDATION_MODEL", "DESTINATION_TYPE_PROVISIONED_THROUGHPUT_FOUNDATION_MODEL"}, diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index d48791e645a..a0ce0e286d3 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -203,6 +203,10 @@ var RequiredFields = map[string][]string{ "resources.jobs.*.webhook_notifications.on_streaming_backlog_exceeded[*]": {"id"}, "resources.jobs.*.webhook_notifications.on_success[*]": {"id"}, + "resources.mcp_services.*": {"parent", "mcp_service_id"}, + "resources.mcp_services.*.config.rate_limits[*]": {"key", "renewal_period"}, + "resources.mcp_services.*.config.source_connection": {"name"}, + "resources.model_services.*": {"parent", "model_service_id"}, "resources.model_services.*.config.inference_table": {"parent"}, "resources.model_services.*.config.rate_limits[*]": {"key", "renewal_period"}, diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 6c6d7ef5089..195b2972b16 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1589,6 +1589,39 @@ } ] }, + "resources.McpService": { + "oneOf": [ + { + "type": "object", + "properties": { + "comment": { + "$ref": "#/$defs/string" + }, + "config": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.McpServiceConfig" + }, + "lifecycle": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "mcp_service_id": { + "$ref": "#/$defs/string" + }, + "parent": { + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false, + "required": [ + "parent", + "mcp_service_id" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.MlflowExperiment": { "oneOf": [ { @@ -3959,6 +3992,9 @@ "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.Job", "markdownDescription": "The job definitions for the bundle, where each key is the name of the job. See [jobs](https://docs.databricks.com/dev-tools/bundles/resources.html#jobs)." }, + "mcp_services": { + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.McpService" + }, "model_services": { "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.ModelService" }, @@ -5403,6 +5439,59 @@ } ] }, + "catalog.McpServiceConfig": { + "oneOf": [ + { + "type": "object", + "description": "Operational configuration for an MCP service. Groups the source reference,\ntool selectors, and rate limits -- the fields that configure how the MCP\nservice behaves at invocation time.", + "properties": { + "include_tool_selectors": { + "description": "Tool names or prefix patterns to expose from the MCP server. Use exact\ntool names or prefix patterns such as `read_*`. An empty list exposes all\ntools. At most 1,024 selectors are allowed, and each selector can contain\nat most 256 characters.", + "$ref": "#/$defs/slice/string", + "x-databricks-launch-stage": "GA" + }, + "rate_limits": { + "description": "Rate limits for tool invocations. Supported scopes are user, group, service\nprincipal, the service as a whole, and each user by default. Request and\ntoken limits are supported. Empty when no rate limit is configured.", + "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/catalog.RateLimit", + "x-databricks-launch-stage": "GA" + }, + "source_connection": { + "description": "Unity Catalog connection referencing the MCP server. Required on Create.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/catalog.McpServiceConfigSourceConnection", + "x-databricks-launch-stage": "GA" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "catalog.McpServiceConfigSourceConnection": { + "oneOf": [ + { + "type": "object", + "description": "Unity Catalog connection that points to the MCP server. On Create, provide\n`name` in the schema-scoped form\n`connections/{catalog}.{schema}.{connection}`. On read, the service\npopulates the resolved connection metadata. If the connection is deleted,\nits reference remains visible so you can identify the broken dependency.", + "properties": { + "name": { + "description": "Resource name of the Unity Catalog connection used to access the MCP\nserver, in the form `connections/{catalog}.{schema}.{connection}`.", + "$ref": "#/$defs/string", + "x-databricks-launch-stage": "GA" + } + }, + "additionalProperties": false, + "required": [ + "name" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "catalog.ModelProviderServiceConfigModelTargetConfig": { "oneOf": [ { @@ -17169,6 +17258,20 @@ } ] }, + "resources.McpService": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.McpService" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.MlflowExperiment": { "oneOf": [ { diff --git a/bundle/statemgmt/state_load_test.go b/bundle/statemgmt/state_load_test.go index c4730fd9ae9..0a0dafee39f 100644 --- a/bundle/statemgmt/state_load_test.go +++ b/bundle/statemgmt/state_load_test.go @@ -33,6 +33,7 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { "resources.experiments.test_mlflow_experiment": {ID: "1"}, "resources.model_serving_endpoints.test_model_serving": {ID: "1"}, "resources.model_services.test_model_service": {ID: "main.default.test_model_service"}, + "resources.mcp_services.test_mcp_service": {ID: "main.default.test_mcp_service"}, "resources.registered_models.test_registered_model": {ID: "1"}, "resources.quality_monitors.test_monitor": {ID: "1"}, "resources.catalogs.test_catalog": {ID: "1"}, @@ -169,6 +170,9 @@ func TestStateToBundleEmptyLocalResources(t *testing.T) { assert.Equal(t, "main.default.test_model_service", config.Resources.ModelServices["test_model_service"].ID) assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.ModelServices["test_model_service"].ModifiedStatus) + assert.Equal(t, "main.default.test_mcp_service", config.Resources.McpServices["test_mcp_service"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.McpServices["test_mcp_service"].ModifiedStatus) + AssertFullResourceCoverage(t, &config) } @@ -436,6 +440,14 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { }, }, }, + McpServices: map[string]*resources.McpService{ + "test_mcp_service": { + McpServiceConfig: resources.McpServiceConfig{ + Parent: "schemas/main.default", + McpServiceId: "test_mcp_service", + }, + }, + }, }, } @@ -550,6 +562,9 @@ func TestStateToBundleEmptyRemoteResources(t *testing.T) { assert.Empty(t, config.Resources.ModelServices["test_model_service"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.ModelServices["test_model_service"].ModifiedStatus) + assert.Empty(t, config.Resources.McpServices["test_mcp_service"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.McpServices["test_mcp_service"].ModifiedStatus) + AssertFullResourceCoverage(t, &config) } @@ -990,6 +1005,20 @@ func TestStateToBundleModifiedResources(t *testing.T) { }, }, }, + McpServices: map[string]*resources.McpService{ + "test_mcp_service": { + McpServiceConfig: resources.McpServiceConfig{ + Parent: "schemas/main.default", + McpServiceId: "test_mcp_service", + }, + }, + "test_mcp_service_new": { + McpServiceConfig: resources.McpServiceConfig{ + Parent: "schemas/main.default", + McpServiceId: "test_mcp_service_new", + }, + }, + }, }, } state := ExportedResourcesMap{ @@ -1053,6 +1082,8 @@ func TestStateToBundleModifiedResources(t *testing.T) { "resources.cluster_policies.test_cluster_policy_old": {ID: "cp-2"}, "resources.model_services.test_model_service": {ID: "main.default.test_model_service"}, "resources.model_services.test_model_service_old": {ID: "main.default.test_model_service_old"}, + "resources.mcp_services.test_mcp_service": {ID: "main.default.test_mcp_service"}, + "resources.mcp_services.test_mcp_service_old": {ID: "main.default.test_mcp_service_old"}, "resources.secrets.test_secret": {ID: "main.default.test_secret"}, "resources.secrets.test_secret_old": {ID: "main.default.test_secret_old"}, } @@ -1271,6 +1302,13 @@ func TestStateToBundleModifiedResources(t *testing.T) { assert.Empty(t, config.Resources.ModelServices["test_model_service_new"].ID) assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.ModelServices["test_model_service_new"].ModifiedStatus) + assert.Equal(t, "main.default.test_mcp_service", config.Resources.McpServices["test_mcp_service"].ID) + assert.Empty(t, config.Resources.McpServices["test_mcp_service"].ModifiedStatus) + assert.Equal(t, "main.default.test_mcp_service_old", config.Resources.McpServices["test_mcp_service_old"].ID) + assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.McpServices["test_mcp_service_old"].ModifiedStatus) + assert.Empty(t, config.Resources.McpServices["test_mcp_service_new"].ID) + assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.McpServices["test_mcp_service_new"].ModifiedStatus) + assert.Equal(t, "main.default.test_secret", config.Resources.Secrets["test_secret"].ID) assert.Empty(t, config.Resources.Secrets["test_secret"].ModifiedStatus) assert.Equal(t, "main.default.test_secret_old", config.Resources.Secrets["test_secret_old"].ID) diff --git a/cmd/experimental/workspace_open_test.go b/cmd/experimental/workspace_open_test.go index 1504fed6a03..94e7b558998 100644 --- a/cmd/experimental/workspace_open_test.go +++ b/cmd/experimental/workspace_open_test.go @@ -67,7 +67,7 @@ func TestBuildWorkspaceURLFragmentBasedResources(t *testing.T) { func TestBuildWorkspaceURLUnknownResourceType(t *testing.T) { _, err := workspaceurls.BuildResourceURL("https://myworkspace.databricks.com", "unknown", "123", "") assert.ErrorContains(t, err, "unknown resource type \"unknown\"") - assert.ErrorContains(t, err, "alerts, apps, catalogs, cluster_policies, clusters, dashboards, database_catalogs, database_instances, experiments, genie_spaces, instance_pools, jobs, model_services, model_serving_endpoints, models, notebooks, pipelines, postgres_catalogs, postgres_synced_tables, quality_monitors, queries, registered_models, schemas, secrets, synced_database_tables, vector_search_endpoints, vector_search_indexes, volumes, warehouses") + assert.ErrorContains(t, err, "alerts, apps, catalogs, cluster_policies, clusters, dashboards, database_catalogs, database_instances, experiments, genie_spaces, instance_pools, jobs, mcp_services, model_services, model_serving_endpoints, models, notebooks, pipelines, postgres_catalogs, postgres_synced_tables, quality_monitors, queries, registered_models, schemas, secrets, synced_database_tables, vector_search_endpoints, vector_search_indexes, volumes, warehouses") } func TestBuildWorkspaceURLHostWithTrailingSlash(t *testing.T) { @@ -119,6 +119,7 @@ func TestWorkspaceOpenCommandCompletion(t *testing.T) { "genie_spaces", "instance_pools", "jobs", + "mcp_services", "model_services", "model_serving_endpoints", "models", @@ -150,7 +151,7 @@ func TestWorkspaceOpenCommandCompletionSecondArg(t *testing.T) { func TestWorkspaceOpenCommandHelpText(t *testing.T) { cmd := newWorkspaceOpenCommand() - assert.Contains(t, cmd.Long, "Supported resource types: alerts, apps, catalogs, cluster_policies, clusters, dashboards, database_catalogs, database_instances, experiments, genie_spaces, instance_pools, jobs, model_services, model_serving_endpoints, models, notebooks, pipelines, postgres_catalogs, postgres_synced_tables, quality_monitors, queries, registered_models, schemas, secrets, synced_database_tables, vector_search_endpoints, vector_search_indexes, volumes, warehouses.") + assert.Contains(t, cmd.Long, "Supported resource types: alerts, apps, catalogs, cluster_policies, clusters, dashboards, database_catalogs, database_instances, experiments, genie_spaces, instance_pools, jobs, mcp_services, model_services, model_serving_endpoints, models, notebooks, pipelines, postgres_catalogs, postgres_synced_tables, quality_monitors, queries, registered_models, schemas, secrets, synced_database_tables, vector_search_endpoints, vector_search_indexes, volumes, warehouses.") assert.Contains(t, cmd.Long, "databricks experimental open jobs 123456789") assert.Contains(t, cmd.Long, "databricks experimental open notebooks /Users/user@example.com/my-notebook") assert.Contains(t, cmd.Long, "databricks experimental open registered_models catalog.schema.my_model") diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 36ed8db3da2..7786e7b1492 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -205,6 +205,7 @@ type FakeWorkspace struct { ExternalLocations map[string]catalog.ExternalLocationInfo RegisteredModels map[string]catalog.RegisteredModelInfo ModelServices map[string]catalog.ModelService + McpServices map[string]catalog.McpService ServingEndpoints map[string]serving.ServingEndpointDetailed VectorSearchEndpoints map[string]vectorsearch.EndpointInfo VectorSearchIndexes map[string]fakeVectorSearchIndex @@ -481,6 +482,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { Schemas: map[string]catalog.SchemaInfo{}, RegisteredModels: map[string]catalog.RegisteredModelInfo{}, ModelServices: map[string]catalog.ModelService{}, + McpServices: map[string]catalog.McpService{}, Volumes: map[string]catalog.VolumeInfo{}, Dashboards: NewEventualMap[string, *fakeDashboard](strings.HasPrefix(token, EventualConsistencyTokenPrefix)), PublishedDashboards: map[string]dashboards.PublishedDashboard{}, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index 03607853be6..be085e098e9 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -648,6 +648,22 @@ func AddDefaultHandlers(server *Server) { return MapDelete(req.Workspace, req.Workspace.ModelServices, req.Vars["name"]) }) + server.Handle("POST", "/api/2.1/unity-catalog/mcp-services", func(req Request) any { + return req.Workspace.McpServicesCreate(req) + }) + + server.Handle("GET", "/api/2.1/unity-catalog/mcp-services/{name}", func(req Request) any { + return MapGet(req.Workspace, req.Workspace.McpServices, req.Vars["name"]) + }) + + server.Handle("PATCH", "/api/2.1/unity-catalog/mcp-services/{name}", func(req Request) any { + return req.Workspace.McpServicesUpdate(req, req.Vars["name"]) + }) + + server.Handle("DELETE", "/api/2.1/unity-catalog/mcp-services/{name}", func(req Request) any { + return MapDelete(req.Workspace, req.Workspace.McpServices, req.Vars["name"]) + }) + // Volumes: server.Handle("GET", "/api/2.1/unity-catalog/volumes/{full_name}", func(req Request) any { diff --git a/libs/testserver/mcp_services.go b/libs/testserver/mcp_services.go new file mode 100644 index 00000000000..41200d06951 --- /dev/null +++ b/libs/testserver/mcp_services.go @@ -0,0 +1,69 @@ +package testserver + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/databricks/databricks-sdk-go/service/catalog" +) + +// McpServicesCreate fakes POST /api/2.1/unity-catalog/mcp-services. See +// ModelServicesCreate for the shape: the McpService body is sent directly, +// parent and mcp_service_id are query parameters, and the map is keyed by the +// {catalog}.{schema}.{mcp_service} path segment. +func (s *FakeWorkspace) McpServicesCreate(req Request) Response { + defer s.LockUnlock()() + + var ms catalog.McpService + if err := json.Unmarshal(req.Body, &ms); err != nil { + return Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } + } + + schema := strings.TrimPrefix(req.URL.Query().Get("parent"), "schemas/") + key := schema + "." + req.URL.Query().Get("mcp_service_id") + + ms.Name = "mcp-services/" + key + ms.CreatedBy = s.CurrentUser().UserName + ms.UpdatedBy = s.CurrentUser().UserName + ms.EffectiveOwner = s.CurrentUser().UserName + ms.MetastoreId = nextUUID() + + s.McpServices[key] = ms + return Response{ + Body: ms, + } +} + +func (s *FakeWorkspace) McpServicesUpdate(req Request, name string) Response { + defer s.LockUnlock()() + + existing, ok := s.McpServices[name] + if !ok { + return Response{ + StatusCode: http.StatusNotFound, + Body: fmt.Sprintf("mcp service %s not found", name), + } + } + + var incoming catalog.McpService + if err := json.Unmarshal(req.Body, &incoming); err != nil { + return Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } + } + + existing.Comment = incoming.Comment + existing.Config = incoming.Config + existing.UpdatedBy = s.CurrentUser().UserName + + s.McpServices[name] = existing + return Response{ + Body: existing, + } +} diff --git a/libs/workspaceurls/urls.go b/libs/workspaceurls/urls.go index 1eb8dcd034f..3bbf1339de4 100644 --- a/libs/workspaceurls/urls.go +++ b/libs/workspaceurls/urls.go @@ -20,6 +20,7 @@ var resourceURLPatterns = map[string]string{ "experiments": "ml/experiments/%s", "genie_spaces": "genie/rooms/%s", "jobs": "jobs/%s", + "mcp_services": "explore/data/mcp-services/%s", "models": "ml/models/%s", "model_serving_endpoints": "ml/endpoints/%s", "model_services": "explore/data/model-services/%s", @@ -54,6 +55,7 @@ var resourceAliases = map[string]string{ // requires slash-separated segments. var dotSeparatedResources = map[string]bool{ "catalogs": true, + "mcp_services": true, "model_services": true, "postgres_synced_tables": true, "quality_monitors": true, diff --git a/python/databricks/bundles/core/__init__.py b/python/databricks/bundles/core/__init__.py index d3991c493de..d66f21260ed 100644 --- a/python/databricks/bundles/core/__init__.py +++ b/python/databricks/bundles/core/__init__.py @@ -28,6 +28,7 @@ "load_resources_from_module", "load_resources_from_modules", "load_resources_from_package_module", + "mcp_service_mutator", "mlflow_experiment_mutator", "mlflow_model_mutator", "model_service_mutator", @@ -64,6 +65,7 @@ instance_pool_mutator, job_mutator, job_run_mutator, + mcp_service_mutator, mlflow_experiment_mutator, mlflow_model_mutator, model_service_mutator, diff --git a/python/databricks/bundles/core/_generated/__init__.py b/python/databricks/bundles/core/_generated/__init__.py index f785073c1ce..7b3c3bc985e 100644 --- a/python/databricks/bundles/core/_generated/__init__.py +++ b/python/databricks/bundles/core/_generated/__init__.py @@ -37,6 +37,10 @@ job_run_mutator, ) from databricks.bundles.core._generated.jobs import _JobResources, job_mutator +from databricks.bundles.core._generated.mcp_services import ( + _McpServiceResources, + mcp_service_mutator, +) from databricks.bundles.core._generated.model_services import ( _ModelServiceResources, model_service_mutator, @@ -105,6 +109,7 @@ "instance_pool_mutator", "job_mutator", "job_run_mutator", + "mcp_service_mutator", "mlflow_experiment_mutator", "mlflow_model_mutator", "model_service_mutator", @@ -136,6 +141,7 @@ class _GeneratedResources( _InstancePoolResources, _JobRunResources, _JobResources, + _McpServiceResources, _ModelServiceResources, _ModelServingEndpointResources, _MlflowModelResources, @@ -168,6 +174,7 @@ def _all_resource_types() -> "tuple[_ResourceType, ...]": instance_pools, job_runs, jobs, + mcp_services, model_services, model_serving_endpoints, models, @@ -197,6 +204,7 @@ def _all_resource_types() -> "tuple[_ResourceType, ...]": instance_pools._resource_type(), job_runs._resource_type(), jobs._resource_type(), + mcp_services._resource_type(), model_services._resource_type(), model_serving_endpoints._resource_type(), models._resource_type(), diff --git a/python/databricks/bundles/core/_generated/mcp_services.py b/python/databricks/bundles/core/_generated/mcp_services.py new file mode 100644 index 00000000000..5e5bf32ae08 --- /dev/null +++ b/python/databricks/bundles/core/_generated/mcp_services.py @@ -0,0 +1,118 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from collections.abc import Callable +from typing import TYPE_CHECKING, Optional, overload + +from databricks.bundles.core._bundle import Bundle +from databricks.bundles.core._location import Location +from databricks.bundles.core._resource_mutator import ResourceMutator +from databricks.bundles.core._transform import _transform + +if TYPE_CHECKING: + from databricks.bundles.core._resource_type import _ResourceType + from databricks.bundles.mcp_services._models.mcp_service import ( + McpService, + McpServiceParam, + ) + + +def _resource_type() -> "_ResourceType": + from databricks.bundles.core._resource_type import _ResourceType + from databricks.bundles.mcp_services._models.mcp_service import McpService + + return _ResourceType( + resource_type=McpService, + singular_name="mcp_service", + plural_name="mcp_services", + ) + + +class _McpServiceResources: + """ + Generated mcp_service accessors, mixed into Resources. + """ + + # Provided by the Resources subclass; declared here so the generated methods + # below type-check. + _resources: dict[str, dict] + + if TYPE_CHECKING: + + def add_location(self, path: tuple[str, ...], location: Location) -> None: ... + + def add_diagnostic_error( + self, + msg: str, + *, + detail: Optional[str] = None, + path: Optional[tuple[str, ...]] = None, + location: Optional[Location] = None, + ) -> None: ... + + @property + def mcp_services(self) -> dict[str, "McpService"]: + return self._resources["mcp_services"] + + def add_mcp_service( + self, + resource_name: str, + mcp_service: "McpServiceParam", + *, + location: Optional[Location] = None, + ) -> None: + """ + Adds the resource mcp_service to the collection of resources. Resource name must be unique across all mcp_services. + + :param resource_name: unique identifier for the mcp_service + :param mcp_service: the mcp_service to add, can be McpService or dict + :param location: optional location of the mcp_service in the source code + """ + from databricks.bundles.mcp_services._models.mcp_service import McpService + + mcp_service = _transform(McpService, mcp_service) + path = ("resources", "mcp_services", resource_name) + location = location or Location.from_stack_frame(depth=1) + + if self._resources["mcp_services"].get(resource_name): + self.add_diagnostic_error( + msg=f"Duplicate resource name '{resource_name}' for resource 'mcp_service'. Resource names must be unique.", + location=location, + path=path, + ) + else: + if location: + self.add_location(path, location) + + self._resources["mcp_services"][resource_name] = mcp_service + + +@overload +def mcp_service_mutator( + function: Callable[[Bundle, "McpService"], "McpService"], +) -> ResourceMutator["McpService"]: ... + + +@overload +def mcp_service_mutator( + function: Callable[["McpService"], "McpService"], +) -> ResourceMutator["McpService"]: ... + + +def mcp_service_mutator(function: Callable) -> ResourceMutator["McpService"]: + """ + Decorator for defining mutator for mcp_services. Function should return a new instance of the mcp_service + with the desired changes, instead of mutating the input mcp_service. + + Example: + + .. code-block:: python + + @mcp_service_mutator + def my_mcp_service_mutator(bundle: Bundle, mcp_service: McpService) -> McpService: + return replace(mcp_service, ...) + + :param function: Function that mutates mcp_services. + """ + from databricks.bundles.mcp_services._models.mcp_service import McpService + + return ResourceMutator(resource_type=McpService, function=function) diff --git a/python/databricks/bundles/mcp_services/__init__.py b/python/databricks/bundles/mcp_services/__init__.py new file mode 100644 index 00000000000..b1aed03e279 --- /dev/null +++ b/python/databricks/bundles/mcp_services/__init__.py @@ -0,0 +1,58 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +__all__ = [ + "Lifecycle", + "LifecycleDict", + "LifecycleParam", + "McpService", + "McpServiceConfig", + "McpServiceConfigDict", + "McpServiceConfigParam", + "McpServiceConfigSourceConnection", + "McpServiceConfigSourceConnectionDict", + "McpServiceConfigSourceConnectionParam", + "McpServiceDict", + "McpServiceParam", + "RateLimit", + "RateLimitDict", + "RateLimitParam", + "RateLimitRateLimitKey", + "RateLimitRateLimitKeyParam", + "RateLimitRateLimitRenewalPeriod", + "RateLimitRateLimitRenewalPeriodParam", +] + + +from databricks.bundles.mcp_services._models.lifecycle import ( + Lifecycle, + LifecycleDict, + LifecycleParam, +) +from databricks.bundles.mcp_services._models.mcp_service import ( + McpService, + McpServiceDict, + McpServiceParam, +) +from databricks.bundles.mcp_services._models.mcp_service_config import ( + McpServiceConfig, + McpServiceConfigDict, + McpServiceConfigParam, +) +from databricks.bundles.mcp_services._models.mcp_service_config_source_connection import ( + McpServiceConfigSourceConnection, + McpServiceConfigSourceConnectionDict, + McpServiceConfigSourceConnectionParam, +) +from databricks.bundles.mcp_services._models.rate_limit import ( + RateLimit, + RateLimitDict, + RateLimitParam, +) +from databricks.bundles.mcp_services._models.rate_limit_rate_limit_key import ( + RateLimitRateLimitKey, + RateLimitRateLimitKeyParam, +) +from databricks.bundles.mcp_services._models.rate_limit_rate_limit_renewal_period import ( + RateLimitRateLimitRenewalPeriod, + RateLimitRateLimitRenewalPeriodParam, +) diff --git a/python/databricks/bundles/mcp_services/_models/lifecycle.py b/python/databricks/bundles/mcp_services/_models/lifecycle.py new file mode 100644 index 00000000000..697776a198e --- /dev/null +++ b/python/databricks/bundles/mcp_services/_models/lifecycle.py @@ -0,0 +1,40 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypedDict + +from databricks.bundles.core._transform import _transform +from databricks.bundles.core._transform_to_json import _transform_to_json_value +from databricks.bundles.core._variable import VariableOrOptional + +if TYPE_CHECKING: + from typing_extensions import Self + + +@dataclass(kw_only=True) +class Lifecycle: + """""" + + prevent_destroy: VariableOrOptional[bool] = None + """ + Lifecycle setting to prevent the resource from being destroyed. + """ + + @classmethod + def from_dict(cls, value: "LifecycleDict") -> "Self": + return _transform(cls, value) + + def as_dict(self) -> "LifecycleDict": + return _transform_to_json_value(self) # type:ignore + + +class LifecycleDict(TypedDict, total=False): + """""" + + prevent_destroy: VariableOrOptional[bool] + """ + Lifecycle setting to prevent the resource from being destroyed. + """ + + +LifecycleParam = LifecycleDict | Lifecycle diff --git a/python/databricks/bundles/mcp_services/_models/mcp_service.py b/python/databricks/bundles/mcp_services/_models/mcp_service.py new file mode 100644 index 00000000000..7dde9d68a7f --- /dev/null +++ b/python/databricks/bundles/mcp_services/_models/mcp_service.py @@ -0,0 +1,56 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypedDict + +from databricks.bundles.core._resource import Resource +from databricks.bundles.core._transform import _transform +from databricks.bundles.core._transform_to_json import _transform_to_json_value +from databricks.bundles.core._variable import VariableOr, VariableOrOptional +from databricks.bundles.mcp_services._models.lifecycle import Lifecycle, LifecycleParam +from databricks.bundles.mcp_services._models.mcp_service_config import ( + McpServiceConfig, + McpServiceConfigParam, +) + +if TYPE_CHECKING: + from typing_extensions import Self + + +@dataclass(kw_only=True) +class McpService(Resource): + """""" + + mcp_service_id: VariableOr[str] + + parent: VariableOr[str] + + comment: VariableOrOptional[str] = None + + config: VariableOrOptional[McpServiceConfig] = None + + lifecycle: VariableOrOptional[Lifecycle] = None + + @classmethod + def from_dict(cls, value: "McpServiceDict") -> "Self": + return _transform(cls, value) + + def as_dict(self) -> "McpServiceDict": + return _transform_to_json_value(self) # type:ignore + + +class McpServiceDict(TypedDict, total=False): + """""" + + mcp_service_id: VariableOr[str] + + parent: VariableOr[str] + + comment: VariableOrOptional[str] + + config: VariableOrOptional[McpServiceConfigParam] + + lifecycle: VariableOrOptional[LifecycleParam] + + +McpServiceParam = McpServiceDict | McpService diff --git a/python/databricks/bundles/mcp_services/_models/mcp_service_config.py b/python/databricks/bundles/mcp_services/_models/mcp_service_config.py new file mode 100644 index 00000000000..155550ba925 --- /dev/null +++ b/python/databricks/bundles/mcp_services/_models/mcp_service_config.py @@ -0,0 +1,79 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, TypedDict + +from databricks.bundles.core._transform import _transform +from databricks.bundles.core._transform_to_json import _transform_to_json_value +from databricks.bundles.core._variable import VariableOrList, VariableOrOptional +from databricks.bundles.mcp_services._models.mcp_service_config_source_connection import ( + McpServiceConfigSourceConnection, + McpServiceConfigSourceConnectionParam, +) +from databricks.bundles.mcp_services._models.rate_limit import RateLimit, RateLimitParam + +if TYPE_CHECKING: + from typing_extensions import Self + + +@dataclass(kw_only=True) +class McpServiceConfig: + """ + Operational configuration for an MCP service. Groups the source reference, + tool selectors, and rate limits -- the fields that configure how the MCP + service behaves at invocation time. + """ + + include_tool_selectors: VariableOrList[str] = field(default_factory=list) + """ + Tool names or prefix patterns to expose from the MCP server. Use exact + tool names or prefix patterns such as `read_*`. An empty list exposes all + tools. At most 1,024 selectors are allowed, and each selector can contain + at most 256 characters. + """ + + rate_limits: VariableOrList[RateLimit] = field(default_factory=list) + """ + Rate limits for tool invocations. Supported scopes are user, group, service + principal, the service as a whole, and each user by default. Request and + token limits are supported. Empty when no rate limit is configured. + """ + + source_connection: VariableOrOptional[McpServiceConfigSourceConnection] = None + """ + Unity Catalog connection referencing the MCP server. Required on Create. + """ + + @classmethod + def from_dict(cls, value: "McpServiceConfigDict") -> "Self": + return _transform(cls, value) + + def as_dict(self) -> "McpServiceConfigDict": + return _transform_to_json_value(self) # type:ignore + + +class McpServiceConfigDict(TypedDict, total=False): + """""" + + include_tool_selectors: VariableOrList[str] + """ + Tool names or prefix patterns to expose from the MCP server. Use exact + tool names or prefix patterns such as `read_*`. An empty list exposes all + tools. At most 1,024 selectors are allowed, and each selector can contain + at most 256 characters. + """ + + rate_limits: VariableOrList[RateLimitParam] + """ + Rate limits for tool invocations. Supported scopes are user, group, service + principal, the service as a whole, and each user by default. Request and + token limits are supported. Empty when no rate limit is configured. + """ + + source_connection: VariableOrOptional[McpServiceConfigSourceConnectionParam] + """ + Unity Catalog connection referencing the MCP server. Required on Create. + """ + + +McpServiceConfigParam = McpServiceConfigDict | McpServiceConfig diff --git a/python/databricks/bundles/mcp_services/_models/mcp_service_config_source_connection.py b/python/databricks/bundles/mcp_services/_models/mcp_service_config_source_connection.py new file mode 100644 index 00000000000..4da924fcd38 --- /dev/null +++ b/python/databricks/bundles/mcp_services/_models/mcp_service_config_source_connection.py @@ -0,0 +1,50 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypedDict + +from databricks.bundles.core._transform import _transform +from databricks.bundles.core._transform_to_json import _transform_to_json_value +from databricks.bundles.core._variable import VariableOr + +if TYPE_CHECKING: + from typing_extensions import Self + + +@dataclass(kw_only=True) +class McpServiceConfigSourceConnection: + """ + Unity Catalog connection that points to the MCP server. On Create, provide + `name` in the schema-scoped form + `connections/{catalog}.{schema}.{connection}`. On read, the service + populates the resolved connection metadata. If the connection is deleted, + its reference remains visible so you can identify the broken dependency. + """ + + name: VariableOr[str] + """ + Resource name of the Unity Catalog connection used to access the MCP + server, in the form `connections/{catalog}.{schema}.{connection}`. + """ + + @classmethod + def from_dict(cls, value: "McpServiceConfigSourceConnectionDict") -> "Self": + return _transform(cls, value) + + def as_dict(self) -> "McpServiceConfigSourceConnectionDict": + return _transform_to_json_value(self) # type:ignore + + +class McpServiceConfigSourceConnectionDict(TypedDict, total=False): + """""" + + name: VariableOr[str] + """ + Resource name of the Unity Catalog connection used to access the MCP + server, in the form `connections/{catalog}.{schema}.{connection}`. + """ + + +McpServiceConfigSourceConnectionParam = ( + McpServiceConfigSourceConnectionDict | McpServiceConfigSourceConnection +) diff --git a/python/databricks/bundles/mcp_services/_models/rate_limit.py b/python/databricks/bundles/mcp_services/_models/rate_limit.py new file mode 100644 index 00000000000..cba7fed7056 --- /dev/null +++ b/python/databricks/bundles/mcp_services/_models/rate_limit.py @@ -0,0 +1,102 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypedDict + +from databricks.bundles.core._transform import _transform +from databricks.bundles.core._transform_to_json import _transform_to_json_value +from databricks.bundles.core._variable import VariableOr, VariableOrOptional +from databricks.bundles.mcp_services._models.rate_limit_rate_limit_key import ( + RateLimitRateLimitKey, + RateLimitRateLimitKeyParam, +) +from databricks.bundles.mcp_services._models.rate_limit_rate_limit_renewal_period import ( + RateLimitRateLimitRenewalPeriod, + RateLimitRateLimitRenewalPeriodParam, +) + +if TYPE_CHECKING: + from typing_extensions import Self + + +@dataclass(kw_only=True) +class RateLimit: + """ + A rate limit applied to service requests. Leave `requests` or `tokens` + unset to impose no limit on that dimension; set a value to cap that dimension + within the renewal period. + """ + + key: VariableOr[RateLimitRateLimitKey] + """ + Scope of the rate limit. Depending on this value, the limit applies to a + principal, the service as a whole, or each user by default. + """ + + renewal_period: VariableOr[RateLimitRateLimitRenewalPeriod] + """ + Renewal period. + """ + + principal: VariableOrOptional[str] = None + """ + Principal this limit applies to: user email, group name, or service + principal application ID. Required when `key` applies to a user, group, or + service principal; otherwise it must be unset. + """ + + requests: VariableOrOptional[int] = None + """ + Maximum requests allowed in one renewal period. Leave unset for no request + limit. Set to `0` to deny all requests. + """ + + tokens: VariableOrOptional[int] = None + """ + Maximum tokens allowed in one renewal period. Leave unset for no token + limit. Set to `0` to deny all requests. + """ + + @classmethod + def from_dict(cls, value: "RateLimitDict") -> "Self": + return _transform(cls, value) + + def as_dict(self) -> "RateLimitDict": + return _transform_to_json_value(self) # type:ignore + + +class RateLimitDict(TypedDict, total=False): + """""" + + key: VariableOr[RateLimitRateLimitKeyParam] + """ + Scope of the rate limit. Depending on this value, the limit applies to a + principal, the service as a whole, or each user by default. + """ + + renewal_period: VariableOr[RateLimitRateLimitRenewalPeriodParam] + """ + Renewal period. + """ + + principal: VariableOrOptional[str] + """ + Principal this limit applies to: user email, group name, or service + principal application ID. Required when `key` applies to a user, group, or + service principal; otherwise it must be unset. + """ + + requests: VariableOrOptional[int] + """ + Maximum requests allowed in one renewal period. Leave unset for no request + limit. Set to `0` to deny all requests. + """ + + tokens: VariableOrOptional[int] + """ + Maximum tokens allowed in one renewal period. Leave unset for no token + limit. Set to `0` to deny all requests. + """ + + +RateLimitParam = RateLimitDict | RateLimit diff --git a/python/databricks/bundles/mcp_services/_models/rate_limit_rate_limit_key.py b/python/databricks/bundles/mcp_services/_models/rate_limit_rate_limit_key.py new file mode 100644 index 00000000000..fb907da810f --- /dev/null +++ b/python/databricks/bundles/mcp_services/_models/rate_limit_rate_limit_key.py @@ -0,0 +1,28 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from enum import Enum +from typing import Literal + + +class RateLimitRateLimitKey(Enum): + """ + Scope key for a rate limit. + """ + + RATE_LIMIT_KEY_USER = "RATE_LIMIT_KEY_USER" + RATE_LIMIT_KEY_USER_GROUP = "RATE_LIMIT_KEY_USER_GROUP" + RATE_LIMIT_KEY_SERVICE_PRINCIPAL = "RATE_LIMIT_KEY_SERVICE_PRINCIPAL" + RATE_LIMIT_KEY_SERVICE = "RATE_LIMIT_KEY_SERVICE" + RATE_LIMIT_KEY_USER_DEFAULT = "RATE_LIMIT_KEY_USER_DEFAULT" + + +RateLimitRateLimitKeyParam = ( + Literal[ + "RATE_LIMIT_KEY_USER", + "RATE_LIMIT_KEY_USER_GROUP", + "RATE_LIMIT_KEY_SERVICE_PRINCIPAL", + "RATE_LIMIT_KEY_SERVICE", + "RATE_LIMIT_KEY_USER_DEFAULT", + ] + | RateLimitRateLimitKey +) diff --git a/python/databricks/bundles/mcp_services/_models/rate_limit_rate_limit_renewal_period.py b/python/databricks/bundles/mcp_services/_models/rate_limit_rate_limit_renewal_period.py new file mode 100644 index 00000000000..58636c784c0 --- /dev/null +++ b/python/databricks/bundles/mcp_services/_models/rate_limit_rate_limit_renewal_period.py @@ -0,0 +1,19 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from enum import Enum +from typing import Literal + + +class RateLimitRateLimitRenewalPeriod(Enum): + """ + Renewal period for a rate limit. + """ + + RATE_LIMIT_RENEWAL_PERIOD_MINUTE = "RATE_LIMIT_RENEWAL_PERIOD_MINUTE" + RATE_LIMIT_RENEWAL_PERIOD_HOUR = "RATE_LIMIT_RENEWAL_PERIOD_HOUR" + + +RateLimitRateLimitRenewalPeriodParam = ( + Literal["RATE_LIMIT_RENEWAL_PERIOD_MINUTE", "RATE_LIMIT_RENEWAL_PERIOD_HOUR"] + | RateLimitRateLimitRenewalPeriod +) diff --git a/python/databricks_tests/core/_generated/__init__.py b/python/databricks_tests/core/_generated/__init__.py index 94627b5b0a0..4ab30185d16 100644 --- a/python/databricks_tests/core/_generated/__init__.py +++ b/python/databricks_tests/core/_generated/__init__.py @@ -12,6 +12,7 @@ instance_pools, job_runs, jobs, + mcp_services, model_services, model_serving_endpoints, models, @@ -43,6 +44,7 @@ instance_pools._test_case(), job_runs._test_case(), jobs._test_case(), + mcp_services._test_case(), model_services._test_case(), model_serving_endpoints._test_case(), models._test_case(), diff --git a/python/databricks_tests/core/_generated/mcp_services.py b/python/databricks_tests/core/_generated/mcp_services.py new file mode 100644 index 00000000000..7865a4b5ec1 --- /dev/null +++ b/python/databricks_tests/core/_generated/mcp_services.py @@ -0,0 +1,30 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from databricks.bundles.core import Resources, mcp_service_mutator +from databricks.bundles.core._generated.mcp_services import _resource_type +from databricks.bundles.mcp_services._models.lifecycle import Lifecycle +from databricks.bundles.mcp_services._models.mcp_service import McpService +from databricks.bundles.mcp_services._models.mcp_service_config import McpServiceConfig +from databricks_tests.core._resource_test_case import ResourceTestCase + + +def _test_case(): + return ( + ResourceTestCase( + add_resource=Resources.add_mcp_service, + dict_example={ + "config": {}, + "lifecycle": {}, + "mcp_service_id": "mcp_service_id", + "parent": "parent", + }, + dataclass_example=McpService( + config=McpServiceConfig(), + lifecycle=Lifecycle(), + mcp_service_id="mcp_service_id", + parent="parent", + ), + mutator=mcp_service_mutator, + ), + _resource_type(), + ) diff --git a/python/databricks_tests/core/public_api.txt b/python/databricks_tests/core/public_api.txt index 52a32c56ec8..5e6a64a1dcd 100644 --- a/python/databricks_tests/core/public_api.txt +++ b/python/databricks_tests/core/public_api.txt @@ -27,6 +27,7 @@ __all__ = [ load_resources_from_module, load_resources_from_modules, load_resources_from_package_module, + mcp_service_mutator, mlflow_experiment_mutator, mlflow_model_mutator, model_service_mutator, @@ -99,6 +100,7 @@ class Resources: def add_job(self, resource_name: str, job: JobParam, *, location: Union[Location, None] = None) -> None def add_job_run(self, resource_name: str, job_run: JobRunParam, *, location: Union[Location, None] = None) -> None def add_location(self, path: tuple[str, ...], location: Location) -> None + def add_mcp_service(self, resource_name: str, mcp_service: McpServiceParam, *, location: Union[Location, None] = None) -> None def add_mlflow_experiment(self, resource_name: str, mlflow_experiment: MlflowExperimentParam, *, location: Union[Location, None] = None) -> None def add_mlflow_model(self, resource_name: str, mlflow_model: MlflowModelParam, *, location: Union[Location, None] = None) -> None def add_model_service(self, resource_name: str, model_service: ModelServiceParam, *, location: Union[Location, None] = None) -> None @@ -129,6 +131,7 @@ class Resources: @property instance_pools -> dict[str, InstancePool] @property job_runs -> dict[str, JobRun] @property jobs -> dict[str, Job] + @property mcp_services -> dict[str, McpService] @property model_services -> dict[str, ModelService] @property model_serving_endpoints -> dict[str, ModelServingEndpoint] @property models -> dict[str, MlflowModel] @@ -210,6 +213,10 @@ def load_resources_from_modules(modules: Iterable[module]) -> Resources def load_resources_from_package_module(package_module: module) -> Resources +@overload def mcp_service_mutator(function: Callable[[Bundle, McpService], McpService]) -> ResourceMutator[McpService] +@overload def mcp_service_mutator(function: Callable[[McpService], McpService]) -> ResourceMutator[McpService] +def mcp_service_mutator(function: Callable) -> ResourceMutator[McpService] + @overload def mlflow_experiment_mutator(function: Callable[[Bundle, MlflowExperiment], MlflowExperiment]) -> ResourceMutator[MlflowExperiment] @overload def mlflow_experiment_mutator(function: Callable[[MlflowExperiment], MlflowExperiment]) -> ResourceMutator[MlflowExperiment] def mlflow_experiment_mutator(function: Callable) -> ResourceMutator[MlflowExperiment] @@ -287,6 +294,7 @@ singular_name=external_location plural_name=external_locations resource_type=Ext singular_name=instance_pool plural_name=instance_pools resource_type=InstancePool singular_name=job plural_name=jobs resource_type=Job singular_name=job_run plural_name=job_runs resource_type=JobRun +singular_name=mcp_service plural_name=mcp_services resource_type=McpService singular_name=mlflow_experiment plural_name=experiments resource_type=MlflowExperiment singular_name=mlflow_model plural_name=models resource_type=MlflowModel singular_name=model_service plural_name=model_services resource_type=ModelService