Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <db>.<schema>.<name>;` 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
29 changes: 29 additions & 0 deletions integration_tests/models/mcp_server_test.sql
Original file line number Diff line number Diff line change
@@ -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 }}"
16 changes: 16 additions & 0 deletions integration_tests/models/mcp_server_test_describe.sql
Original file line number Diff line number Diff line change
@@ -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()))
9 changes: 9 additions & 0 deletions integration_tests/models/schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
8 changes: 8 additions & 0 deletions integration_tests/tests/mcp_server_test_exists.sql
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions integration_tests/tests/mcp_server_test_spec_content.sql
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions macros/materializations/mcp_server.sql
Original file line number Diff line number Diff line change
@@ -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 <database>.<schema>.<name>;
{%- 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 %}
15 changes: 15 additions & 0 deletions macros/relations/mcp_server/create_mcp_server.sql
Original file line number Diff line number Diff line change
@@ -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 %}
10 changes: 10 additions & 0 deletions macros/relations/mcp_server/drop_mcp_server.sql
Original file line number Diff line number Diff line change
@@ -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 %}
Loading