Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions docs/resources/grant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions docs/resources/mcp_server.md
Original file line number Diff line number Diff line change
@@ -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; 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
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".


50 changes: 49 additions & 1 deletion snowcap/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 in the same 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"
Expand Down Expand Up @@ -1762,16 +1775,19 @@ 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)

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)
Expand Down Expand Up @@ -2342,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


Expand Down
28 changes: 28 additions & 0 deletions snowcap/data_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")

Expand Down
1 change: 1 addition & 0 deletions snowcap/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions snowcap/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions snowcap/privs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions snowcap/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -115,6 +116,7 @@
"JSONFileFormat",
"MaskingPolicy",
"MaterializedView",
"MCPServer",
"NetworkPolicy",
"NetworkRule",
"Notebook",
Expand Down
Loading
Loading