From 8a5d0787ddb9d786b7580835505c6d734f0e8f71 Mon Sep 17 00:00:00 2001 From: Gregory Clunies Date: Thu, 16 Jul 2026 23:13:28 -0700 Subject: [PATCH 1/2] feat(resources): add MCP Server resource and MCP server grants Snowflake managed MCP servers (CREATE MCP SERVER ... FROM SPECIFICATION) previously had to be managed with hand-run SQL, where re-running CREATE OR REPLACE silently drops all USAGE grants on the server. Add a schema-scoped mcp_server resource (mcp_servers: YAML key) with a specification property. Because Snowflake has no ALTER MCP SERVER, the specification is canonicalized (sorted keys, version defaulted, JSON/YAML representation-independent, tools sorted by name) so an unchanged spec produces a no-op plan; only a real change emits a single CREATE OR REPLACE, and the plan warns that grants on the server will be dropped - on both the one-step apply and the plan-file two-step workflow. Renames raise a clear error instead of emitting invalid ALTER SQL. GRANT USAGE ON MCP SERVER works via on_mcp_server: and the multi-word on: string form with no grant.py changes; MCPServerPriv registers MODIFY/USAGE. fetch_mcp_server reads SHOW + DESC (server_spec) with an existence_only fast path; list_mcp_servers enables sync/export sweeps. Closes #26 --- docs/SUMMARY.md | 1 + docs/resources/grant.md | 8 ++ docs/resources/mcp_server.md | 65 +++++++++ snowcap/blueprint.py | 18 ++- snowcap/data_provider.py | 28 ++++ snowcap/enums.py | 1 + snowcap/lifecycle.py | 16 +++ snowcap/privs.py | 10 ++ snowcap/resources/__init__.py | 2 + snowcap/resources/mcp_server.py | 129 +++++++++++++++++ tests/fixtures/json/mcp_server.json | 5 + tests/fixtures/sql/mcp_server.sql | 7 + tests/test_blueprint.py | 92 ++++++++++++ tests/test_grant.py | 36 ++++- tests/test_lifecycle.py | 75 ++++++++++ tests/test_resource_types.py | 210 ++++++++++++++++++++++++++++ 16 files changed, 701 insertions(+), 2 deletions(-) create mode 100644 docs/resources/mcp_server.md create mode 100644 snowcap/resources/mcp_server.py create mode 100644 tests/fixtures/json/mcp_server.json create mode 100644 tests/fixtures/sql/mcp_server.sql diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 05dc8d8..f3ee800 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -35,6 +35,7 @@ * [InternalStage](resources/internal_stage.md) * [JSONFileFormat](resources/json_file_format.md) * [JavascriptUDF](resources/javascript_udf.md) +* [MCPServer](resources/mcp_server.md) * [MaskingPolicy](resources/masking-policy.md) * [MaterializedView](resources/materialized_view.md) * [NetworkPolicy](resources/network_policy.md) diff --git a/docs/resources/grant.md b/docs/resources/grant.md index 81dad6d..757df50 100644 --- a/docs/resources/grant.md +++ b/docs/resources/grant.md @@ -59,6 +59,11 @@ grants: - priv: MONITOR on: cortex search service somedb.someschema.someservice to: search_observability_role + + # AI: USAGE on an MCP Server so MCP clients can call its tools + - priv: USAGE + on: mcp server somedb.someschema.someserver + to: mcp_client_role ``` #### Future Grants @@ -115,6 +120,9 @@ grant = Grant(priv="CREATE TABLE", on_schema="foo", to="somerole") # Table Privileges: grant = Grant(priv=["SELECT", "INSERT", "DELETE"], on_table="sometable", to="somerole") + +# MCP Server Privileges: +grant = Grant(priv="USAGE", on_mcp_server="someserver", to="mcp_client_role") ``` #### Future Grants diff --git a/docs/resources/mcp_server.md b/docs/resources/mcp_server.md new file mode 100644 index 0000000..25ab5ff --- /dev/null +++ b/docs/resources/mcp_server.md @@ -0,0 +1,65 @@ +--- +description: >- + A Snowflake-managed MCP server exposing tools to MCP clients. +--- + +# MCPServer + +[Snowflake Documentation](https://docs.snowflake.com/en/sql-reference/sql/create-mcp-server) | Snowcap CLI label: `mcp_server` + +Represents a Snowflake-managed MCP (Model Context Protocol) server, which exposes a +set of tools, resources, and prompts to MCP clients over a Snowflake-hosted endpoint. +MCP servers are a generally available Snowflake feature. +Snowflake has no ALTER MCP SERVER command, so a change to specification is applied by +dropping and recreating the server with CREATE OR REPLACE. This drops any grants on the +MCP server; snowcap-managed grants are re-created on the next snowcap apply, but +externally-managed grants must be re-applied manually. The plan output warns when a +specification change will trigger this. Renaming an MCP server is not supported; +changing name creates a new resource instead of altering the existing one. +The specification is canonicalized (re-serialized as sorted YAML) before it is stored +or compared, so semantically identical specs written with different formatting, key +order, tool order, or JSON vs. YAML syntax normalize to the same value. Tools are +reordered by name. This uses YAML 1.1 scalar rules, so unquoted words like yes/no/on/off +are read as booleans; quote them (e.g. "yes") if you intend a string. Unquoted +date/timestamp-like scalars (e.g. 2024-01-01) are read as dates and re-serialized as +plain strings, matching how the same value round-trips through DESC MCP SERVER's JSON. +Comments in the input are not preserved by canonicalization. + + +## Examples + +### Python + +```python +mcp_server = MCPServer( + name="some_mcp_server", + specification=""" + tools: + - name: query_data + type: SYSTEM_EXECUTE_SQL + """, + owner="SYSADMIN", +) +``` + + +### YAML + +```yaml +mcp_servers: + - name: some_mcp_server + specification: | + tools: + - name: query_data + type: SYSTEM_EXECUTE_SQL + owner: SYSADMIN +``` + + +## Fields + +* `name` (string, required) - The name of the MCP server. +* `specification` (string, required) - A YAML or JSON document describing the MCP server's tools, resources, and prompts. Canonicalized to sorted YAML with tools sorted by name; an absent top-level version key defaults to 1. +* `owner` (string or [Role](role.md)) - The role that owns the MCP server. Defaults to "SYSADMIN". + + diff --git a/snowcap/blueprint.py b/snowcap/blueprint.py index 8f82dfb..02bf1a4 100644 --- a/snowcap/blueprint.py +++ b/snowcap/blueprint.py @@ -1149,6 +1149,19 @@ def _warning_for_nonconforming_plan(self, session_ctx: SessionContext, plan: Pla if change.after["role"] in SYSTEM_ROLES: role_grant_to_system = True + # MCP server specification changes are applied via CREATE OR REPLACE, which + # drops all grants on the server (see lifecycle.update_mcp_server). + if ( + isinstance(change, UpdateResource) + and change.urn.resource_type == ResourceType.MCP_SERVER + and "specification" in change.delta + ): + warnings.append( + f"MCP server {change.urn} specification change will be applied via CREATE OR REPLACE, which drops " + "all existing grants on the MCP server; snowcap-managed grants are re-created on the next apply, " + "externally-managed grants must be re-applied manually." + ) + if grant_to_system: warnings.append( "Grants to system role found. They will be always recreated since system roles are not managed by Snowcap" @@ -1762,8 +1775,12 @@ def process_commands(commands, roles, available_roles): # TODO: cursor setup, including query tag logger = logging.getLogger(__name__) + session_ctx = data_provider.fetch_session(session) if plan is None: + # self.plan() already emits the nonconforming-plan warning. plan = self.plan(session) + else: + self._warning_for_nonconforming_plan(session_ctx, plan) # Print plan details (includes summary counts) print_plan(plan) @@ -1771,7 +1788,6 @@ def process_commands(commands, roles, available_roles): if not plan: return - session_ctx = data_provider.fetch_session(session) _raise_if_plan_would_drop_session_user(session_ctx, plan) sql_commands_per_change, available_roles = compile_plan_to_sql(session_ctx, plan) diff --git a/snowcap/data_provider.py b/snowcap/data_provider.py index f556b0e..fd887cc 100644 --- a/snowcap/data_provider.py +++ b/snowcap/data_provider.py @@ -2040,6 +2040,30 @@ def fetch_materialized_view(session: SnowflakeConnection, fqn: FQN): } +def fetch_mcp_server(session: SnowflakeConnection, fqn: FQN, existence_only: bool = False): + show_result = _show_resources(session, "MCP SERVERS", fqn) + if len(show_result) == 0: + return None + if len(show_result) > 1: + raise Exception(f"Found multiple mcp servers matching {fqn}") + data = show_result[0] + + # For existence checks (reference validation), skip expensive DESC MCP SERVER + if existence_only: + return { + "name": _quote_snowflake_identifier(data["name"]), + "owner": _get_owner_identifier(data), + } + + desc_result = execute(session, f"DESC MCP SERVER {fqn}") + properties = desc_result[0] + return { + "name": _quote_snowflake_identifier(data["name"]), + "owner": _get_owner_identifier(data), + "specification": properties["server_spec"], + } + + def fetch_network_policy(session: SnowflakeConnection, fqn: FQN): policies = _show_resources(session, "NETWORK POLICIES", fqn) if len(policies) == 0: @@ -3744,6 +3768,10 @@ def list_masking_policies(session: SnowflakeConnection) -> list[FQN]: return list_schema_scoped_resource(session, "MASKING POLICIES") +def list_mcp_servers(session: SnowflakeConnection) -> list[FQN]: + return list_schema_scoped_resource(session, "MCP SERVERS") + + def list_network_policies(session: SnowflakeConnection) -> list[FQN]: return list_account_scoped_resource(session, "NETWORK POLICIES") diff --git a/snowcap/enums.py b/snowcap/enums.py index 3b12b9b..b7b428d 100644 --- a/snowcap/enums.py +++ b/snowcap/enums.py @@ -78,6 +78,7 @@ class ResourceType(ParseableEnum): INTEGRATION = "INTEGRATION" MASKING_POLICY = "MASKING POLICY" MATERIALIZED_VIEW = "MATERIALIZED VIEW" + MCP_SERVER = "MCP SERVER" NETWORK_POLICY = "NETWORK POLICY" NETWORK_RULE = "NETWORK RULE" NOTEBOOK = "NOTEBOOK" diff --git a/snowcap/lifecycle.py b/snowcap/lifecycle.py index 52c998b..5c83861 100644 --- a/snowcap/lifecycle.py +++ b/snowcap/lifecycle.py @@ -392,6 +392,22 @@ def update_masking_policy(urn: URN, data: dict, props: Props) -> str: return update__default(urn, {attr: new_value}, props) +def update_mcp_server(urn: URN, data: dict, props: Props) -> str: + # Snowflake has no ALTER MCP SERVER command. A rename is impossible regardless + # of what else changed, so it takes precedence over a specification change. + if "name" in data: + raise NotImplementedError( + "Snowflake does not support renaming MCP servers (no ALTER MCP SERVER command); " + "rename requires dropping and recreating the server" + ) + return tidy_sql( + "CREATE OR REPLACE", + urn.resource_type, + fqn_to_sql(urn.fqn), + props.render({"specification": data["specification"]}), + ) + + def update_account_parameter(urn: URN, data: dict, props: Props) -> str: return create_account_parameter(urn, data, props) diff --git a/snowcap/privs.py b/snowcap/privs.py index 4d6ca8c..499dae7 100644 --- a/snowcap/privs.py +++ b/snowcap/privs.py @@ -183,6 +183,13 @@ class MaterializedViewPriv(Priv): SELECT = "SELECT" +class MCPServerPriv(Priv): + ALL = "ALL" + MODIFY = "MODIFY" + OWNERSHIP = "OWNERSHIP" + USAGE = "USAGE" + + class NetworkPolicyPriv(Priv): OWNERSHIP = "OWNERSHIP" @@ -250,6 +257,7 @@ class SchemaPriv(Priv): CREATE_IMAGE_REPOSITORY = "CREATE IMAGE REPOSITORY" CREATE_MASKING_POLICY = "CREATE MASKING POLICY" CREATE_MATERIALIZED_VIEW = "CREATE MATERIALIZED VIEW" + CREATE_MCP_SERVER = "CREATE MCP SERVER" CREATE_MODEL = "CREATE MODEL" CREATE_NETWORK_RULE = "CREATE NETWORK RULE" CREATE_NOTEBOOK = "CREATE NOTEBOOK" @@ -411,6 +419,7 @@ class WarehousePriv(Priv): ResourceType.IMAGE_REPOSITORY: None, ResourceType.INTEGRATION: IntegrationPriv, ResourceType.MATERIALIZED_VIEW: MaterializedViewPriv, + ResourceType.MCP_SERVER: MCPServerPriv, ResourceType.NETWORK_POLICY: NetworkPolicyPriv, ResourceType.NETWORK_RULE: NetworkRulePriv, ResourceType.NOTEBOOK: NotebookPriv, @@ -463,6 +472,7 @@ class WarehousePriv(Priv): ResourceType.GIT_REPOSITORY: SchemaPriv.CREATE_GIT_REPOSITORY, # ResourceType.GRANT: AccountPriv.CREATE_GRANT, ResourceType.MATERIALIZED_VIEW: SchemaPriv.CREATE_MATERIALIZED_VIEW, + ResourceType.MCP_SERVER: SchemaPriv.CREATE_MCP_SERVER, ResourceType.NETWORK_POLICY: AccountPriv.CREATE_NETWORK_POLICY, ResourceType.NETWORK_RULE: SchemaPriv.CREATE_NETWORK_RULE, ResourceType.NOTEBOOK: SchemaPriv.CREATE_NOTEBOOK, diff --git a/snowcap/resources/__init__.py b/snowcap/resources/__init__.py index 2406891..4708327 100644 --- a/snowcap/resources/__init__.py +++ b/snowcap/resources/__init__.py @@ -27,6 +27,7 @@ from .image_repository import ImageRepository from .masking_policy import MaskingPolicy from .materialized_view import MaterializedView +from .mcp_server import MCPServer from .network_policy import NetworkPolicy from .network_rule import NetworkRule from .notebook import Notebook @@ -115,6 +116,7 @@ "JSONFileFormat", "MaskingPolicy", "MaterializedView", + "MCPServer", "NetworkPolicy", "NetworkRule", "Notebook", diff --git a/snowcap/resources/mcp_server.py b/snowcap/resources/mcp_server.py new file mode 100644 index 0000000..0f2be93 --- /dev/null +++ b/snowcap/resources/mcp_server.py @@ -0,0 +1,129 @@ +import json +from dataclasses import dataclass +from typing import Union + +import yaml + +from ..enums import ResourceType +from ..props import Props, StringProp +from ..resource_name import ResourceName +from ..role_ref import RoleRef +from ..scope import SchemaScope +from .resource import NamedResource, Resource, ResourceSpec + + +def normalize_mcp_specification(spec: Union[str, dict]) -> str: + if isinstance(spec, str): + if not spec.strip(): + raise ValueError("MCP server specification cannot be empty") + try: + data = yaml.safe_load(spec) + except yaml.YAMLError as err: + raise ValueError(f"Failed to parse MCP server specification: {err}") from err + else: + data = spec + + if not isinstance(data, dict): + raise ValueError(f"MCP server specification must be a mapping, got {type(data).__name__}: {data!r}") + + # Round-trip through JSON so YAML-1.1-only types (e.g. an unquoted date-like scalar) collapse + # to the same plain strings DESC MCP SERVER's JSON would produce, keeping the canonical form + # independent of whether the input was written as YAML or JSON. + data = json.loads(json.dumps(data, default=str)) + + data["version"] = data.get("version", 1) + if isinstance(data.get("tools"), list): + # Tool order isn't semantic; sort by name (required, unique) so reordering a user's + # config doesn't produce a diff. + data["tools"] = sorted(data["tools"], key=lambda tool: tool["name"]) + + return yaml.safe_dump(data, sort_keys=True, default_flow_style=False) + + +@dataclass(unsafe_hash=True) +class _MCPServer(ResourceSpec): + name: ResourceName + specification: str + owner: RoleRef = "SYSADMIN" + + def __post_init__(self): + super().__post_init__() + self.specification = normalize_mcp_specification(self.specification) + + +class MCPServer(NamedResource, Resource): + """ + Description: + Represents a Snowflake-managed MCP (Model Context Protocol) server, which exposes a + set of tools, resources, and prompts to MCP clients over a Snowflake-hosted endpoint. + MCP servers are a generally available Snowflake feature. + + Snowflake has no ALTER MCP SERVER command, so a change to specification is applied by + dropping and recreating the server with CREATE OR REPLACE. This drops any grants on the + MCP server; snowcap-managed grants are re-created on the next snowcap apply, but + externally-managed grants must be re-applied manually. The plan output warns when a + specification change will trigger this. Renaming an MCP server is not supported; + changing name creates a new resource instead of altering the existing one. + + The specification is canonicalized (re-serialized as sorted YAML) before it is stored + or compared, so semantically identical specs written with different formatting, key + order, tool order, or JSON vs. YAML syntax normalize to the same value. Tools are + reordered by name. This uses YAML 1.1 scalar rules, so unquoted words like yes/no/on/off + are read as booleans; quote them (e.g. "yes") if you intend a string. Unquoted + date/timestamp-like scalars (e.g. 2024-01-01) are read as dates and re-serialized as + plain strings, matching how the same value round-trips through DESC MCP SERVER's JSON. + Comments in the input are not preserved by canonicalization. + + Snowflake Docs: + https://docs.snowflake.com/en/sql-reference/sql/create-mcp-server + + Fields: + name (string, required): The name of the MCP server. + specification (string, required): A YAML or JSON document describing the MCP server's tools, resources, and prompts. Canonicalized to sorted YAML with tools sorted by name; an absent top-level version key defaults to 1. + owner (string or Role): The role that owns the MCP server. Defaults to "SYSADMIN". + + Python: + + ```python + mcp_server = MCPServer( + name="some_mcp_server", + specification=\"\"\" + tools: + - name: query_data + type: SYSTEM_EXECUTE_SQL + \"\"\", + owner="SYSADMIN", + ) + ``` + + Yaml: + + ```yaml + mcp_servers: + - name: some_mcp_server + specification: | + tools: + - name: query_data + type: SYSTEM_EXECUTE_SQL + owner: SYSADMIN + ``` + """ + + resource_type = ResourceType.MCP_SERVER + props = Props(specification=StringProp("FROM SPECIFICATION", eq=False)) + scope = SchemaScope() + spec = _MCPServer + + def __init__( + self, + name: str, + specification: str, + owner: str = "SYSADMIN", + **kwargs, + ): + super().__init__(name, **kwargs) + self._data: _MCPServer = _MCPServer( + name=self._name, + specification=specification, + owner=owner, + ) diff --git a/tests/fixtures/json/mcp_server.json b/tests/fixtures/json/mcp_server.json new file mode 100644 index 0000000..86a9ce2 --- /dev/null +++ b/tests/fixtures/json/mcp_server.json @@ -0,0 +1,5 @@ +{ + "name": "MCP_SERVER_EXAMPLE", + "owner": "SYSADMIN", + "specification": "tools:\n- identifier: run_sql\n name: run_sql\n type: SYSTEM_EXECUTE_SQL\nversion: 1\n" +} diff --git a/tests/fixtures/sql/mcp_server.sql b/tests/fixtures/sql/mcp_server.sql new file mode 100644 index 0000000..5ff9633 --- /dev/null +++ b/tests/fixtures/sql/mcp_server.sql @@ -0,0 +1,7 @@ +CREATE MCP SERVER my_mcp_server +FROM SPECIFICATION $$ +tools: + - name: run_sql + identifier: run_sql + type: SYSTEM_EXECUTE_SQL +$$; diff --git a/tests/test_blueprint.py b/tests/test_blueprint.py index 75c3045..04b87ee 100644 --- a/tests/test_blueprint.py +++ b/tests/test_blueprint.py @@ -1,4 +1,5 @@ import json +import logging import re from copy import deepcopy @@ -46,6 +47,7 @@ def flatten_sql_commands(sql_commands_result) -> list[str]: from snowcap.blueprint import ( Blueprint, CreateResource, + UpdateResource, _merge_pointers, compile_plan_to_sql, diff, @@ -1099,3 +1101,93 @@ def test_resource_type_needs_params(session_ctx): ) manifest = blueprint.generate_manifest(session_ctx) assert resource_type_needs_params(ResourceType.ROLE, manifest) is True + + +class TestWarningForNonconformingPlanMCPServer: + """ + Tests for the MCP server grant-drop warning surfaced by + Blueprint._warning_for_nonconforming_plan. + + Snowflake has no ALTER MCP SERVER command, so a specification change is applied + via CREATE OR REPLACE, which drops all grants on the server. This warning tells + the operator that up front, at plan time, rather than leaving them to discover + the dropped grants after apply. + """ + + def _mcp_server_urn(self, session_ctx): + return URN( + resource_type=ResourceType.MCP_SERVER, + fqn=FQN(ResourceName("MY_SERVER"), database=ResourceName("MY_DB"), schema=ResourceName("MY_SCHEMA")), + account_locator=session_ctx["account_locator"], + ) + + def test_specification_update_warns_about_dropped_grants(self, session_ctx, caplog): + urn = self._mcp_server_urn(session_ctx) + change = UpdateResource( + urn=urn, + resource_cls=res.MCPServer, + before={"specification": "old"}, + after={"specification": "new"}, + delta={"specification": "new"}, + ) + blueprint = Blueprint(resources=[]) + + with caplog.at_level(logging.WARNING, logger="snowcap"): + blueprint._warning_for_nonconforming_plan(session_ctx, [change]) + + assert str(urn) in caplog.text + assert "grant" in caplog.text.lower() + + def test_update_without_specification_in_delta_produces_no_warning(self, session_ctx, caplog): + urn = self._mcp_server_urn(session_ctx) + change = UpdateResource( + urn=urn, + resource_cls=res.MCPServer, + before={"owner": "SYSADMIN"}, + after={"owner": "OTHER_ROLE"}, + delta={"owner": "OTHER_ROLE"}, + ) + blueprint = Blueprint(resources=[]) + + with caplog.at_level(logging.WARNING, logger="snowcap"): + blueprint._warning_for_nonconforming_plan(session_ctx, [change]) + + assert caplog.text == "" + + def test_create_produces_no_warning(self, session_ctx, caplog): + urn = self._mcp_server_urn(session_ctx) + change = CreateResource( + urn=urn, + resource_cls=res.MCPServer, + container=None, + after={"specification": "new"}, + ) + blueprint = Blueprint(resources=[]) + + with caplog.at_level(logging.WARNING, logger="snowcap"): + blueprint._warning_for_nonconforming_plan(session_ctx, [change]) + + assert caplog.text == "" + + def test_apply_with_prebuilt_plan_warns_about_dropped_grants(self, monkeypatch, session_ctx, caplog): + """The two-step CLI workflow (`snowcap plan -o plan.json` then `snowcap apply + --plan plan.json`) hands apply() an already-built plan, so plan() never re-runs. + apply() must surface the warning itself in that case.""" + urn = self._mcp_server_urn(session_ctx) + change = UpdateResource( + urn=urn, + resource_cls=res.MCPServer, + before={"specification": "old"}, + after={"specification": "new"}, + delta={"specification": "new"}, + ) + blueprint = Blueprint(resources=[]) + + monkeypatch.setattr("snowcap.blueprint.data_provider.fetch_session", lambda session: session_ctx) + monkeypatch.setattr("snowcap.blueprint.compile_plan_to_sql", lambda session_ctx, plan: ([], [])) + + with caplog.at_level(logging.WARNING, logger="snowcap"): + blueprint.apply(session=None, plan=[change]) + + assert str(urn) in caplog.text + assert "grant" in caplog.text.lower() diff --git a/tests/test_grant.py b/tests/test_grant.py index a32961b..a6d92d0 100644 --- a/tests/test_grant.py +++ b/tests/test_grant.py @@ -2,7 +2,7 @@ from snowcap import resources as res from snowcap.enums import GrantType, ResourceType -from snowcap.privs import all_privs_for_resource_type +from snowcap.privs import GrantedPrivilege, all_privs_for_resource_type from snowcap.identifiers import URN from snowcap.resource_name import ResourceName from snowcap.resources.resource import ResourcePointer @@ -174,6 +174,40 @@ def test_grant_on_cortex_search_service(): assert "MONITOR ON CORTEX SEARCH SERVICE" in monitor_grant.create_sql() +def test_grant_on_mcp_server(): + """USAGE on an MCP SERVER parses and renders correctly via the on_mcp_server kwarg.""" + grant = res.Grant( + priv="USAGE", + on_mcp_server="somedb.sch.server1", + to="somerole", + ) + assert grant._data.on == "SOMEDB.SCH.SERVER1" + assert grant._data.on_type == ResourceType.MCP_SERVER + assert grant.create_sql() == "GRANT USAGE ON MCP SERVER SOMEDB.SCH.SERVER1 TO ROLE SOMEROLE" + + +def test_grant_on_mcp_server_string_form(): + """The multi-word 'mcp server ' string form resolves on_type to MCP_SERVER.""" + grant = res.Grant( + priv="USAGE", + on="mcp server somedb.sch.server1", + to="somerole", + ) + assert grant._data.on == "SOMEDB.SCH.SERVER1" + assert grant._data.on_type == ResourceType.MCP_SERVER + + +def test_grant_mcp_server_all_privs_expansion(): + """ALL on an MCP SERVER expands to exactly MODIFY and USAGE.""" + assert set(all_privs_for_resource_type(ResourceType.MCP_SERVER)) == {"MODIFY", "USAGE"} + + +def test_grant_mcp_server_invalid_priv_raises(): + """An invalid privilege (e.g. SELECT) for an MCP SERVER raises ValueError.""" + with pytest.raises(ValueError): + GrantedPrivilege.from_grant(privilege="SELECT", granted_on="MCP SERVER", name="somedb.sch.server1") + + def test_grant_database_role_to_database_role(): database = res.Database(name="somedb") parent = res.DatabaseRole(name="parent", database=database) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index ae84cb8..60d1be5 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -32,6 +32,7 @@ update__default, update_account_parameter, update_event_table, + update_mcp_server, update_procedure, update_role_grant, update_scanner_package, @@ -1385,3 +1386,77 @@ def test_update_dispatched_via_update_resource(self): assert "?" not in result, f"dispatcher produced malformed SQL: {result!r}" assert "UNSET MASKING POLICY" in result + + +class TestUpdateMCPServer: + """ + Tests for update_mcp_server. + + Snowflake has no ALTER MCP SERVER command, so a specification change is applied + by dropping and recreating the server with CREATE OR REPLACE. Renaming is not + supported at all: no command exists to change an MCP server's name in place. + """ + + def test_specification_change_uses_create_or_replace(self): + """A specification delta must produce CREATE OR REPLACE, never ALTER MCP SERVER.""" + urn = make_urn(ResourceType.MCP_SERVER, "MY_SERVER", database="MY_DB", schema="MY_SCHEMA") + data = {"specification": "tools:\n- name: query_data\n type: SYSTEM_EXECUTE_SQL\nversion: 1\n"} + result = update_mcp_server(urn, data, res.MCPServer.props) + + assert result.startswith("CREATE OR REPLACE MCP SERVER") + assert "FROM SPECIFICATION $$" in result + assert "ALTER MCP SERVER" not in result + + def test_rename_raises_targeted_error(self): + """Renaming an MCP server has no Snowflake command and must raise, never ALTER ... RENAME TO.""" + urn = make_urn(ResourceType.MCP_SERVER, "MY_SERVER", database="MY_DB", schema="MY_SCHEMA") + data = {"name": "NEW_SERVER"} + props = MockProps("") + + with pytest.raises(NotImplementedError, match="MCP server"): + update_mcp_server(urn, data, props) + + def test_rename_combined_with_specification_still_raises(self): + """Rename is impossible regardless of any other field in the delta, including specification.""" + urn = make_urn(ResourceType.MCP_SERVER, "MY_SERVER", database="MY_DB", schema="MY_SCHEMA") + data = {"name": "NEW_SERVER", "specification": "tools:\n- name: foo\n type: SYSTEM_EXECUTE_SQL\n"} + props = MockProps("") + + with pytest.raises(NotImplementedError, match="MCP server"): + update_mcp_server(urn, data, props) + + def test_update_resource_dispatches_to_mcp_server_handler(self): + """update_resource dispatcher must route MCP_SERVER to update_mcp_server, not update__default.""" + urn = make_urn(ResourceType.MCP_SERVER, "MY_SERVER", database="MY_DB", schema="MY_SCHEMA") + data = {"specification": "tools:\n- name: query_data\n type: SYSTEM_EXECUTE_SQL\nversion: 1\n"} + result = update_resource(urn, data, res.MCPServer.props) + + assert result.startswith("CREATE OR REPLACE MCP SERVER") + assert "ALTER MCP SERVER" not in result + + +class TestCreateMCPServer: + """create_resource needs no MCP server override; create__default already produces valid DDL.""" + + def test_create_uses_from_specification(self): + urn = make_urn(ResourceType.MCP_SERVER, "MY_SERVER", database="MY_DB", schema="MY_SCHEMA") + data = { + "name": "MY_SERVER", + "specification": "tools:\n- name: query_data\n type: SYSTEM_EXECUTE_SQL\nversion: 1\n", + "owner": "SYSADMIN", + } + result = create_resource(urn, data, res.MCPServer.props) + + assert result.startswith("CREATE MCP SERVER MY_DB.MY_SCHEMA.MY_SERVER") + assert "FROM SPECIFICATION $$" in result + assert "OR REPLACE" not in result + + +class TestDropMCPServer: + """drop_resource needs no MCP server override; drop__default already produces valid DDL.""" + + def test_drop_if_exists(self): + urn = make_urn(ResourceType.MCP_SERVER, "MY_SERVER", database="MY_DB", schema="MY_SCHEMA") + result = drop_resource(urn, {}, if_exists=True) + + assert result == "DROP MCP SERVER IF EXISTS MY_DB.MY_SCHEMA.MY_SERVER" diff --git a/tests/test_resource_types.py b/tests/test_resource_types.py index 1d120a1..c6343ee 100644 --- a/tests/test_resource_types.py +++ b/tests/test_resource_types.py @@ -10,6 +10,8 @@ 6. Property defaults """ +import json + import pytest from unittest.mock import MagicMock @@ -17,6 +19,7 @@ from snowcap.enums import ResourceType, WarehouseSize from snowcap.identifiers import FQN, URN from snowcap.resource_name import ResourceName +from snowcap.resources.mcp_server import normalize_mcp_specification class TestDatabase: @@ -612,6 +615,213 @@ def test_internal_stage_all_properties(self): assert stage._data.directory is not None +class TestMCPServer: + """Tests for MCPServer resource.""" + + SPEC = "tools:\n - name: run_sql\n identifier: run_sql\n type: SYSTEM_EXECUTE_SQL\n" + + def test_mcp_server_minimal(self): + """Test MCPServer with minimal required properties.""" + server = res.MCPServer(name="test_mcp_server", specification=self.SPEC) + assert server.fqn.name == "TEST_MCP_SERVER" + assert server.resource_type == ResourceType.MCP_SERVER + assert server._data.owner.name == "SYSADMIN" + + def test_mcp_server_create_sql(self): + """Test MCPServer create_sql produces the exact expected DDL.""" + server = res.MCPServer(name="test_mcp_server", specification=self.SPEC) + assert server.create_sql() == ( + "CREATE MCP SERVER TEST_MCP_SERVER FROM SPECIFICATION $$tools:\n" + "- identifier: run_sql\n" + " name: run_sql\n" + " type: SYSTEM_EXECUTE_SQL\n" + "version: 1\n" + "$$" + ) + + def test_normalize_desc_json_shape_matches_equivalent_yaml(self): + """The documented DESC JSON shape must normalize identically to equivalent user YAML. + + Guards against Perpetual Diff: a spec fetched back from Snowflake (JSON, with an + explicit version) must compare equal to the same spec as a user would write it in + YAML (no version key), or every plan would show a spurious update. + """ + desc_json = json.dumps( + { + "version": 1, + "tools": [ + { + "name": "search_docs", + "identifier": "search_docs", + "type": "CORTEX_SEARCH_SERVICE_QUERY", + } + ], + } + ) + user_yaml = """ + tools: + - name: search_docs + identifier: search_docs + type: CORTEX_SEARCH_SERVICE_QUERY + """ + assert normalize_mcp_specification(desc_json) == normalize_mcp_specification(user_yaml) + + def test_normalize_multi_tool_all_tool_types(self): + """Multi-tool specs covering all five tool types normalize identically regardless of + key order, optional config/title/description, or YAML vs. JSON source syntax.""" + tools = [ + { + "name": "search_docs", + "identifier": "search_docs", + "type": "CORTEX_SEARCH_SERVICE_QUERY", + "title": "Search Docs", + "description": "Search the docs", + "config": {"service": "my_search_svc"}, + }, + { + "name": "ask_analyst", + "identifier": "ask_analyst", + "type": "CORTEX_ANALYST_MESSAGE", + }, + { + "name": "run_sql", + "identifier": "run_sql", + "type": "SYSTEM_EXECUTE_SQL", + "description": "Run a SQL query", + }, + { + "name": "run_agent", + "identifier": "run_agent", + "type": "CORTEX_AGENT_RUN", + "config": {"agent": "my_agent"}, + }, + { + "name": "custom_tool", + "identifier": "custom_tool", + "type": "GENERIC", + "title": "Custom Tool", + }, + ] + spec_dict = {"tools": tools} + json_form = json.dumps(spec_dict) + expected = normalize_mcp_specification(spec_dict) + + # Same tools, different key order per tool, no explicit version -> same canonical form. + yaml_reordered = """ + tools: + - identifier: search_docs + type: CORTEX_SEARCH_SERVICE_QUERY + name: search_docs + config: + service: my_search_svc + description: Search the docs + title: Search Docs + - name: ask_analyst + identifier: ask_analyst + type: CORTEX_ANALYST_MESSAGE + - description: Run a SQL query + identifier: run_sql + name: run_sql + type: SYSTEM_EXECUTE_SQL + - config: + agent: my_agent + identifier: run_agent + name: run_agent + type: CORTEX_AGENT_RUN + - title: Custom Tool + identifier: custom_tool + name: custom_tool + type: GENERIC + """ + + assert normalize_mcp_specification(json_form) == expected + assert normalize_mcp_specification(yaml_reordered) == expected + + def test_normalize_yaml_1_1_boolean_scalars(self): + """YAML 1.1 boolean words normalize the same as `true`/`false`, but a quoted word stays + a string.""" + yaml_yes = """ + tools: + - name: run_sql + identifier: run_sql + type: SYSTEM_EXECUTE_SQL + read_only: yes + """ + yaml_true = """ + tools: + - name: run_sql + identifier: run_sql + type: SYSTEM_EXECUTE_SQL + read_only: true + """ + yaml_quoted_yes = """ + tools: + - name: run_sql + identifier: run_sql + type: SYSTEM_EXECUTE_SQL + read_only: "yes" + """ + assert normalize_mcp_specification(yaml_yes) == normalize_mcp_specification(yaml_true) + assert normalize_mcp_specification(yaml_yes) != normalize_mcp_specification(yaml_quoted_yes) + assert "read_only: true" in normalize_mcp_specification(yaml_yes) + assert "read_only: 'yes'" in normalize_mcp_specification(yaml_quoted_yes) + + def test_normalize_invalid_yaml_raises(self): + """Malformed YAML/JSON raises ValueError rather than propagating a YAMLError.""" + with pytest.raises(ValueError): + normalize_mcp_specification("tools: [unterminated") + + def test_normalize_non_mapping_top_level_raises(self): + """A specification whose top level is not a mapping (e.g. a bare list) raises ValueError.""" + with pytest.raises(ValueError): + normalize_mcp_specification("- just\n- a\n- list") + + def test_normalize_empty_string_raises(self): + """An empty (or whitespace-only) specification raises ValueError with a clear message.""" + with pytest.raises(ValueError, match="cannot be empty"): + normalize_mcp_specification("") + with pytest.raises(ValueError, match="cannot be empty"): + normalize_mcp_specification(" \n ") + + def test_normalize_date_like_scalar_matches_json_form(self): + """An unquoted date-like scalar in YAML (read as a `date` via YAML 1.1 implicit typing) + must normalize the same as the equivalent JSON, where the same value is a quoted string. + + Guards against Perpetual Diff: without coercing through a JSON-compatible intermediate, + the YAML side stays a `date` object while the JSON side is a `str`, and the two dump to + different scalar styles even though they're semantically identical. + """ + yaml_form = """ + tools: + - name: run_sql + identifier: run_sql + type: SYSTEM_EXECUTE_SQL + title: 2024-01-01 + """ + json_form = json.dumps( + { + "tools": [ + { + "name": "run_sql", + "identifier": "run_sql", + "type": "SYSTEM_EXECUTE_SQL", + "title": "2024-01-01", + } + ] + } + ) + assert normalize_mcp_specification(yaml_form) == normalize_mcp_specification(json_form) + assert "title: '2024-01-01'" in normalize_mcp_specification(yaml_form) + + def test_normalize_tool_order_not_significant(self): + """Two specs differing only in tool order normalize identically.""" + tool_a = {"name": "a_tool", "identifier": "a_tool", "type": "SYSTEM_EXECUTE_SQL"} + tool_b = {"name": "b_tool", "identifier": "b_tool", "type": "SYSTEM_EXECUTE_SQL"} + forward = {"tools": [tool_a, tool_b]} + reversed_ = {"tools": [tool_b, tool_a]} + assert normalize_mcp_specification(forward) == normalize_mcp_specification(reversed_) + + class TestNetworkPolicy: """Tests for NetworkPolicy resource.""" From da50360629a9cf441365b2a050052349c24457b1 Mon Sep 17 00:00:00 2001 From: Gregory Clunies Date: Sat, 18 Jul 2026 09:14:30 -0700 Subject: [PATCH 2/2] feat(mcp_server): re-create managed grants in the same apply as a spec replace CREATE OR REPLACE MCP SERVER drops all grants on the server, and COPY GRANTS is not supported for this object type (verified against a live account: syntax error in both grammatical positions). Previously snowcap-managed grants only came back on the NEXT apply, leaving a window where MCP clients lose access. diff() now detects a specification-change UpdateResource on an MCP server and appends CreateResource changes for every manifest object-grant targeting that server (skipping grants that already have a change in the plan). Grants execute after their target in the dependency order, so managed grants are restored in the same apply. The plan output shows the re-grants explicitly, and the grant-drop warning now only concerns externally-managed grants. --- docs/resources/mcp_server.md | 4 +- snowcap/blueprint.py | 34 +++++++++++++++- snowcap/resources/mcp_server.py | 4 +- tests/test_blueprint.py | 72 +++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 5 deletions(-) diff --git a/docs/resources/mcp_server.md b/docs/resources/mcp_server.md index 25ab5ff..8cf1971 100644 --- a/docs/resources/mcp_server.md +++ b/docs/resources/mcp_server.md @@ -12,8 +12,8 @@ set of tools, resources, and prompts to MCP clients over a Snowflake-hosted endp MCP servers are a generally available Snowflake feature. Snowflake has no ALTER MCP SERVER command, so a change to specification is applied by dropping and recreating the server with CREATE OR REPLACE. This drops any grants on the -MCP server; snowcap-managed grants are re-created on the next snowcap apply, but -externally-managed grants must be re-applied manually. The plan output warns when a +MCP server; grants managed by snowcap are automatically re-created in the same apply, +but externally-managed grants must be re-applied manually. The plan output warns when a specification change will trigger this. Renaming an MCP server is not supported; changing name creates a new resource instead of altering the existing one. The specification is canonicalized (re-serialized as sorted YAML) before it is stored diff --git a/snowcap/blueprint.py b/snowcap/blueprint.py index 02bf1a4..8e114b1 100644 --- a/snowcap/blueprint.py +++ b/snowcap/blueprint.py @@ -1158,7 +1158,7 @@ def _warning_for_nonconforming_plan(self, session_ctx: SessionContext, plan: Pla ): warnings.append( f"MCP server {change.urn} specification change will be applied via CREATE OR REPLACE, which drops " - "all existing grants on the MCP server; snowcap-managed grants are re-created on the next apply, " + "all existing grants on the MCP server; snowcap-managed grants are re-created in the same apply, " "externally-managed grants must be re-applied manually." ) @@ -2358,6 +2358,38 @@ def _diff_resource_data(lhs: dict, rhs: dict) -> dict: ) ) + # An MCP server specification change is applied via CREATE OR REPLACE (Snowflake has no + # ALTER MCP SERVER and COPY GRANTS is not supported), which drops all grants on the server. + # Re-create manifest grants targeting the replaced server in the same plan; grants execute + # after their target, so they are restored in the same apply. + replaced_mcp_servers = { + str(change.urn.fqn) + for change in changes + if isinstance(change, UpdateResource) + and change.urn.resource_type == ResourceType.MCP_SERVER + and "specification" in change.delta + } + if replaced_mcp_servers: + changed_urns = {change.urn for change in changes} + for urn in manifest_urns: + manifest_item = manifest[urn] + if ( + urn.resource_type == ResourceType.GRANT + and not isinstance(manifest_item, ResourcePointer) + and manifest_item.data["grant_type"] == GrantType.OBJECT.value + and manifest_item.data["on_type"] == ResourceType.MCP_SERVER.value + and manifest_item.data["on"] in replaced_mcp_servers + and urn not in changed_urns + ): + changes.append( + CreateResource( + urn, + manifest_item.resource_cls, + _container_descriptor(urn), + manifest_item.data, + ) + ) + return changes diff --git a/snowcap/resources/mcp_server.py b/snowcap/resources/mcp_server.py index 0f2be93..b2703cd 100644 --- a/snowcap/resources/mcp_server.py +++ b/snowcap/resources/mcp_server.py @@ -60,8 +60,8 @@ class MCPServer(NamedResource, Resource): Snowflake has no ALTER MCP SERVER command, so a change to specification is applied by dropping and recreating the server with CREATE OR REPLACE. This drops any grants on the - MCP server; snowcap-managed grants are re-created on the next snowcap apply, but - externally-managed grants must be re-applied manually. The plan output warns when a + MCP server; grants managed by snowcap are automatically re-created in the same apply, + but externally-managed grants must be re-applied manually. The plan output warns when a specification change will trigger this. Renaming an MCP server is not supported; changing name creates a new resource instead of altering the existing one. diff --git a/tests/test_blueprint.py b/tests/test_blueprint.py index 04b87ee..d91cba5 100644 --- a/tests/test_blueprint.py +++ b/tests/test_blueprint.py @@ -1191,3 +1191,75 @@ def test_apply_with_prebuilt_plan_warns_about_dropped_grants(self, monkeypatch, assert str(urn) in caplog.text assert "grant" in caplog.text.lower() + + +class TestMCPServerSpecChangeRegrantsManagedGrants: + """ + CREATE OR REPLACE MCP SERVER drops all grants on the server (Snowflake has no + ALTER MCP SERVER, and COPY GRANTS is not supported for this object type). To keep + snowcap-managed grants from silently disappearing until a later run, diff() must + re-create every manifest grant targeting a spec-changed MCP server in the same + plan — grants execute after their target, so they are restored in the same apply. + """ + + @staticmethod + def _diff_for(session_ctx, remote_spec: str, grant_in_remote: bool = True): + db = res.Database(name="DB") + schema = res.Schema(name="SCHEMA", database=db) + server = res.MCPServer( + name="SERVER", + specification="tools:\n- name: query-data\n type: SYSTEM_EXECUTE_SQL\n", + schema=schema, + ) + role = res.Role(name="SOME_ROLE") + grant = res.Grant(priv="USAGE", on_mcp_server="DB.SCHEMA.SERVER", to=role) + blueprint = Blueprint(name="blueprint", resources=[db, schema, server, role, grant]) + manifest = blueprint.generate_manifest(session_ctx) + + server_urn = next(u for u in manifest.urns if u.resource_type == ResourceType.MCP_SERVER) + grant_urn = next(u for u in manifest.urns if u.resource_type == ResourceType.GRANT) + + remote_state = {parse_URN("urn::ABCD123:account/ACCOUNT"): {}} + for urn in manifest.urns: + item = manifest[urn] + if isinstance(item, ResourcePointer): + continue + remote_state[urn] = dict(item.data) + remote_state[server_urn] = {**remote_state[server_urn], "specification": remote_spec} + if not grant_in_remote: + del remote_state[grant_urn] + + return diff(remote_state, manifest), server_urn, grant_urn + + def test_spec_change_recreates_managed_grants(self, session_ctx): + from snowcap.resources.mcp_server import normalize_mcp_specification + + plan, server_urn, grant_urn = self._diff_for(session_ctx, remote_spec=normalize_mcp_specification("tools: []")) + + server_change = find_change_by_urn(plan, server_urn) + assert isinstance(server_change, UpdateResource) + assert "specification" in server_change.delta + + grant_change = find_change_by_urn(plan, grant_urn) + assert isinstance(grant_change, CreateResource) + assert len(plan) == 2 + + def test_unchanged_spec_recreates_nothing(self, session_ctx): + from snowcap.resources.mcp_server import normalize_mcp_specification + + plan, _, _ = self._diff_for( + session_ctx, + remote_spec=normalize_mcp_specification("tools:\n- name: query-data\n type: SYSTEM_EXECUTE_SQL\n"), + ) + assert plan == [] + + def test_grant_already_in_plan_is_not_duplicated(self, session_ctx): + from snowcap.resources.mcp_server import normalize_mcp_specification + + plan, _, grant_urn = self._diff_for( + session_ctx, + remote_spec=normalize_mcp_specification("tools: []"), + grant_in_remote=False, + ) + grant_changes = [change for change in plan if change.urn == grant_urn] + assert len(grant_changes) == 1