From b1021ecb42016bee7dc601ab825055eda608cd2e Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 6 Jun 2026 15:49:40 -0600 Subject: [PATCH 1/2] Add mcp_server materialization for Snowflake MCP servers Adds an `mcp_server` materialization alongside `cortex_agent` for managing Snowflake-managed MCP servers via CREATE OR REPLACE MCP SERVER ... FROM SPECIFICATION. The model body is the raw MCP server YAML spec (a tools array), passed through verbatim (column-0 + trim, matching create_agent). MCP server DDL has no COMMENT/PROFILE clause, so the materialization takes no extra config. GET_DDL does not support MCP servers, so the integration test (mcp_server_test) verifies via DESCRIBE MCP SERVER captured through a pre-hook + result_scan, wired to the existing test_semantic_view and test search service. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 +++ README.md | 53 +++++++++++++++++++ integration_tests/models/mcp_server_test.sql | 29 ++++++++++ .../models/mcp_server_test_describe.sql | 16 ++++++ integration_tests/models/schema.yml | 9 ++++ .../tests/mcp_server_test_exists.sql | 8 +++ .../tests/mcp_server_test_spec_content.sql | 24 +++++++++ macros/materializations/mcp_server.sql | 35 ++++++++++++ .../mcp_server/create_mcp_server.sql | 15 ++++++ .../relations/mcp_server/drop_mcp_server.sql | 10 ++++ 10 files changed, 205 insertions(+) create mode 100644 integration_tests/models/mcp_server_test.sql create mode 100644 integration_tests/models/mcp_server_test_describe.sql create mode 100644 integration_tests/tests/mcp_server_test_exists.sql create mode 100644 integration_tests/tests/mcp_server_test_spec_content.sql create mode 100644 macros/materializations/mcp_server.sql create mode 100644 macros/relations/mcp_server/create_mcp_server.sql create mode 100644 macros/relations/mcp_server/drop_mcp_server.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 3653d6b..f05fcba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `mcp_server` materialization for Snowflake-managed MCP servers — model body is the raw MCP server YAML specification (a `tools:` array), sent verbatim to `CREATE OR REPLACE MCP SERVER ... FROM SPECIFICATION` +- `snowflake__create_mcp_server` macro for DDL generation +- `snowflake__get_drop_mcp_server_sql` macro for DROP DDL generation +- Integration tests for the MCP server materialization (`mcp_server_test`), verified via `DESCRIBE MCP SERVER` captured into a table, since `GET_DDL` does not support MCP servers + ## [0.4.2] - 2026-06-05 ### Added diff --git a/README.md b/README.md index 6a6250e..b67e48a 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,59 @@ By default the procedure runs with `EXECUTE AS CALLER`, so `current_user()` insi The procedure is recreated on every `dbt run`, so changes to the feedback table schema are picked up automatically. The feedback table uses `CREATE TABLE IF NOT EXISTS`, so existing data is never dropped. +## MCP Servers + +The package also ships an `mcp_server` materialization for deploying [Snowflake-managed MCP servers](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-agents-mcp). It works the same way as `cortex_agent`: the model body is the raw MCP server YAML specification (a `tools:` array), wrapped in `CREATE OR REPLACE MCP SERVER ... FROM SPECIFICATION $$ ... $$`. + +```sql +-- models/mcp/data_mcp_server.sql +{{ config(materialized='mcp_server') }} + +tools: + - name: "product_search" + type: "CORTEX_SEARCH_SERVICE_QUERY" + identifier: "{{ ref('product_search_service') }}" + title: "Product Search" + description: "Cortex Search service over product documentation." + + - name: "revenue_analyst" + type: "CORTEX_ANALYST_MESSAGE" + identifier: "{{ ref('revenue_semantic_view') }}" + title: "Revenue Analyst" + description: "Semantic view for revenue analysis." + + - name: "sql_exec_tool" + type: "SYSTEM_EXECUTE_SQL" + title: "SQL Execution" + description: "Execute read-only SQL against Snowflake." + config: + read_only: true + query_timeout: 120 + warehouse: "MY_WAREHOUSE" +``` + +Run it: + +```bash +dbt run --select data_mcp_server +``` + +Verify in Snowflake: + +```sql +SHOW MCP SERVERS IN SCHEMA my_db.my_schema; +DESCRIBE MCP SERVER my_db.my_schema.data_mcp_server; +``` + +Notes specific to MCP servers: + +- The `tools` array is required and must contain at least one tool. Supported `type` values include `CORTEX_SEARCH_SERVICE_QUERY`, `CORTEX_ANALYST_MESSAGE`, `CORTEX_AGENT_RUN`, `SYSTEM_EXECUTE_SQL`, and `GENERIC`. Each tool that wraps an existing object needs a fully-qualified `identifier` — use `{{ ref() }}` to wire it into the dbt DAG so the MCP server always runs after its upstream objects. +- Unlike agents, the MCP server DDL has **no `COMMENT` or `PROFILE` clause**, so the `mcp_server` materialization takes no extra config options — everything lives in the spec body. +- Like `cortex_agent`, every `dbt run` issues `CREATE OR REPLACE MCP SERVER`, so re-runs are fully idempotent, and Jinja (`{{ ref() }}`, `{{ var() }}`, etc.) works anywhere in the spec. +- To drop an MCP server explicitly, use the helper macro in a dbt operation or post-hook: `{% do run_query(dbt_cortex_agent.snowflake__get_drop_mcp_server_sql(this)) %}` (or run `DROP MCP SERVER IF EXISTS ..;` directly). + +Refer to the [Snowflake CREATE MCP SERVER docs](https://docs.snowflake.com/en/sql-reference/sql/create-mcp-server) for the full and up-to-date specification reference. + ## License Apache 2.0 diff --git a/integration_tests/models/mcp_server_test.sql b/integration_tests/models/mcp_server_test.sql new file mode 100644 index 0000000..6fdcccd --- /dev/null +++ b/integration_tests/models/mcp_server_test.sql @@ -0,0 +1,29 @@ +{{ + config( + materialized='mcp_server', + alias='mcp_server_test', + tags=['integration'] + ) +}} + +tools: + - name: "product_search" + type: "CORTEX_SEARCH_SERVICE_QUERY" + identifier: "{{ var('test_cortex_search_service') }}" + title: "Product Search" + description: "Cortex Search service over unstructured content." + + - name: "revenue_analyst" + type: "CORTEX_ANALYST_MESSAGE" + identifier: "{{ ref('test_semantic_view') }}" + title: "Revenue Analyst" + description: "Semantic view for structured revenue analysis." + + - name: "sql_exec_tool" + type: "SYSTEM_EXECUTE_SQL" + title: "SQL Execution" + description: "Execute read-only SQL against Snowflake." + config: + read_only: true + query_timeout: 120 + warehouse: "{{ target.warehouse }}" diff --git a/integration_tests/models/mcp_server_test_describe.sql b/integration_tests/models/mcp_server_test_describe.sql new file mode 100644 index 0000000..a3b0d47 --- /dev/null +++ b/integration_tests/models/mcp_server_test_describe.sql @@ -0,0 +1,16 @@ +-- MCP servers are not supported by GET_DDL, so we verify them via DESCRIBE MCP SERVER. +-- DESCRIBE can't be used as a subquery, so this model captures its output into a table: +-- the pre-hook runs DESCRIBE on the connection immediately before the SELECT, and +-- result_scan(last_query_id()) reads that DESCRIBE's result set. The {{ ref(...) }} in the +-- pre-hook also forces dbt to create the MCP server first. Singular tests assert on this table. +{{ + config( + materialized='table', + pre_hook="describe mcp server {{ ref('mcp_server_test') }}" + ) +}} + +select + "name" as name, + "server_spec" as server_spec +from table(result_scan(last_query_id())) diff --git a/integration_tests/models/schema.yml b/integration_tests/models/schema.yml index 53167fc..06b1fa3 100644 --- a/integration_tests/models/schema.yml +++ b/integration_tests/models/schema.yml @@ -15,3 +15,12 @@ models: profile: '{"display_name": "Full Test Agent", "avatar": "robot", "color": "blue"}' tags: ['integration'] agent_grants: ['dbt_demo_role'] + + - name: mcp_server_test + description: "Comprehensive MCP server exercising all three tool types — Cortex Search (via var), Cortex Analyst (via ref to test_semantic_view), and SQL execution." + config: + alias: mcp_server_test + tags: ['integration'] + + - name: mcp_server_test_describe + description: "Captures DESCRIBE MCP SERVER output for mcp_server_test so singular tests can assert on the spec (GET_DDL does not support MCP servers)." diff --git a/integration_tests/tests/mcp_server_test_exists.sql b/integration_tests/tests/mcp_server_test_exists.sql new file mode 100644 index 0000000..d1f0be6 --- /dev/null +++ b/integration_tests/tests/mcp_server_test_exists.sql @@ -0,0 +1,8 @@ +-- Fails if the MCP server's DESCRIBE capture returned no rows, i.e. the server +-- does not exist in Snowflake. +-- Returns 0 rows on success (standard dbt test contract). + +select 'mcp server does not exist' as error +where ( + select count(*) from {{ ref('mcp_server_test_describe') }} +) = 0 diff --git a/integration_tests/tests/mcp_server_test_spec_content.sql b/integration_tests/tests/mcp_server_test_spec_content.sql new file mode 100644 index 0000000..0b21adf --- /dev/null +++ b/integration_tests/tests/mcp_server_test_spec_content.sql @@ -0,0 +1,24 @@ +-- Validates that key tools are present in the MCP server spec, read from the +-- server_spec JSON returned by DESCRIBE MCP SERVER: +-- - all three tool types +-- - the tool names from the spec +-- Returns one row per failed assertion; 0 rows = all pass (standard dbt test contract). + +with spec as ( + select server_spec as content + from {{ ref('mcp_server_test_describe') }} +), + +assertions as ( + select 'cortex search tool type missing' as error from spec where not contains(content, 'CORTEX_SEARCH_SERVICE_QUERY') + union all + select 'cortex analyst tool type missing' as error from spec where not contains(content, 'CORTEX_ANALYST_MESSAGE') + union all + select 'sql exec tool type missing' as error from spec where not contains(content, 'SYSTEM_EXECUTE_SQL') + union all + select 'product_search tool missing' as error from spec where not contains(content, 'product_search') + union all + select 'revenue_analyst tool missing' as error from spec where not contains(content, 'revenue_analyst') +) + +select * from assertions diff --git a/macros/materializations/mcp_server.sql b/macros/materializations/mcp_server.sql new file mode 100644 index 0000000..db0fc03 --- /dev/null +++ b/macros/materializations/mcp_server.sql @@ -0,0 +1,35 @@ +-- Custom dbt materialization for Snowflake MCP Servers. +-- The model body must be a valid Snowflake MCP server YAML specification +-- (a `tools:` array). The materialization wraps it in +-- CREATE OR REPLACE MCP SERVER ... FROM SPECIFICATION $$ ... $$. +-- +-- Unlike Cortex Agents, MCP servers expose no COMMENT or PROFILE clause in their +-- DDL, so this materialization takes no extra config options beyond the spec body. + +{% materialization mcp_server, adapter='snowflake' %} + + -- dbt has no native 'mcp server' relation type. 'view' is used as a placeholder + -- for graph tracking only — the actual DDL is always CREATE OR REPLACE MCP SERVER. + -- + -- IMPORTANT for consumers: if you change a model away from mcp_server + -- materialization, dbt will attempt DROP VIEW IF EXISTS, which may silently no-op + -- rather than dropping the MCP server. Drop it manually before switching: + -- DROP MCP SERVER IF EXISTS ..; + {%- set target_relation = api.Relation.create( + identifier=this.identifier, + schema=this.schema, + database=this.database, + type='view' + ) -%} + + {{ run_hooks(pre_hooks) }} + + {% call statement('main') %} + {{ dbt_cortex_agent.snowflake__create_mcp_server(target_relation, sql) }} + {% endcall %} + + {{ run_hooks(post_hooks) }} + + {{ return({'relations': [target_relation]}) }} + +{% endmaterialization %} diff --git a/macros/relations/mcp_server/create_mcp_server.sql b/macros/relations/mcp_server/create_mcp_server.sql new file mode 100644 index 0000000..e7e5486 --- /dev/null +++ b/macros/relations/mcp_server/create_mcp_server.sql @@ -0,0 +1,15 @@ +-- Generates and executes CREATE OR REPLACE MCP SERVER DDL for a Snowflake MCP Server. +-- Called by the mcp_server materialization on every dbt run. +-- Args: +-- relation : the target relation object (database, schema, identifier) +-- specification : compiled YAML MCP server spec from the model body +{% macro snowflake__create_mcp_server(relation, specification) %} + + create or replace mcp server + {{ relation.database }}.{{ relation.schema }}.{{ relation.identifier }} + from specification + $$ +{{ specification | trim }} + $$ + +{% endmacro %} diff --git a/macros/relations/mcp_server/drop_mcp_server.sql b/macros/relations/mcp_server/drop_mcp_server.sql new file mode 100644 index 0000000..abcd6f8 --- /dev/null +++ b/macros/relations/mcp_server/drop_mcp_server.sql @@ -0,0 +1,10 @@ +-- Generates DROP MCP SERVER IF EXISTS DDL for a Snowflake MCP Server. +-- Use this in a dbt operation or post-hook when you need to explicitly remove an MCP server: +-- +-- {% do run_query(dbt_cortex_agent.snowflake__get_drop_mcp_server_sql(this)) %} +{% macro snowflake__get_drop_mcp_server_sql(relation) %} + + drop mcp server if exists + {{ relation.database }}.{{ relation.schema }}.{{ relation.identifier }} + +{% endmacro %} From 1d0bd3b735384e976f2b908e5808449500a2b2cf Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Sat, 6 Jun 2026 16:14:34 -0600 Subject: [PATCH 2/2] Remove ref --- integration_tests/models/mcp_server_test_describe.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration_tests/models/mcp_server_test_describe.sql b/integration_tests/models/mcp_server_test_describe.sql index a3b0d47..0177765 100644 --- a/integration_tests/models/mcp_server_test_describe.sql +++ b/integration_tests/models/mcp_server_test_describe.sql @@ -1,7 +1,7 @@ -- MCP servers are not supported by GET_DDL, so we verify them via DESCRIBE MCP SERVER. -- DESCRIBE can't be used as a subquery, so this model captures its output into a table: -- the pre-hook runs DESCRIBE on the connection immediately before the SELECT, and --- result_scan(last_query_id()) reads that DESCRIBE's result set. The {{ ref(...) }} in the +-- result_scan(last_query_id()) reads that DESCRIBE's result set. The ref(...) in the -- pre-hook also forces dbt to create the MCP server first. Singular tests assert on this table. {{ config(