snowflake-sandbox-python 0.2.1a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snowflake/cli_sandbox/__init__.py +13 -0
- snowflake/cli_sandbox/_adapter.py +170 -0
- snowflake/cli_sandbox/_common.py +77 -0
- snowflake/cli_sandbox/_egress_flags.py +121 -0
- snowflake/cli_sandbox/_get_command.py +109 -0
- snowflake/cli_sandbox/_run_command.py +1091 -0
- snowflake/cli_sandbox/_shell_command.py +666 -0
- snowflake/cli_sandbox/_upload_plan.py +187 -0
- snowflake/cli_sandbox/commands.py +556 -0
- snowflake/cli_sandbox/plugin_spec.py +28 -0
- snowflake/cli_sandbox/py.typed +0 -0
- snowflake/sandbox/__init__.py +317 -0
- snowflake/sandbox/__main__.py +225 -0
- snowflake/sandbox/_ansi.py +206 -0
- snowflake/sandbox/_args.py +208 -0
- snowflake/sandbox/_assemble.py +256 -0
- snowflake/sandbox/_bundle.py +240 -0
- snowflake/sandbox/_connection_resolve.py +328 -0
- snowflake/sandbox/_deploy_spec.py +56 -0
- snowflake/sandbox/_diagnostics.py +501 -0
- snowflake/sandbox/_env.py +143 -0
- snowflake/sandbox/_files_mixin.py +280 -0
- snowflake/sandbox/_fs_ops.py +304 -0
- snowflake/sandbox/_globs.py +176 -0
- snowflake/sandbox/_hosts.py +110 -0
- snowflake/sandbox/_mcp_discovery.py +288 -0
- snowflake/sandbox/_mcp_status.py +183 -0
- snowflake/sandbox/_retry.py +94 -0
- snowflake/sandbox/_runtime/__init__.py +42 -0
- snowflake/sandbox/_runtime/_fs_helper.py +93 -0
- snowflake/sandbox/_runtime/_job_runner.py +111 -0
- snowflake/sandbox/_runtime/_protocol.py +53 -0
- snowflake/sandbox/_runtime/_shims.py +267 -0
- snowflake/sandbox/_sandbox_state.py +303 -0
- snowflake/sandbox/_session_registry.py +222 -0
- snowflake/sandbox/_sse.py +160 -0
- snowflake/sandbox/_stage.py +270 -0
- snowflake/sandbox/_sync_files_mixin.py +272 -0
- snowflake/sandbox/_sync_fs_ops.py +185 -0
- snowflake/sandbox/_sync_transport.py +737 -0
- snowflake/sandbox/_sync_watch.py +99 -0
- snowflake/sandbox/_transport.py +1366 -0
- snowflake/sandbox/_transport_errors.py +270 -0
- snowflake/sandbox/_upload_plan.py +497 -0
- snowflake/sandbox/_version.py +37 -0
- snowflake/sandbox/_watch.py +164 -0
- snowflake/sandbox/_wire.py +348 -0
- snowflake/sandbox/app.py +256 -0
- snowflake/sandbox/client.py +2356 -0
- snowflake/sandbox/config.py +1133 -0
- snowflake/sandbox/connect.py +288 -0
- snowflake/sandbox/deploy.py +499 -0
- snowflake/sandbox/egress.py +388 -0
- snowflake/sandbox/exceptions.py +253 -0
- snowflake/sandbox/exec_stream.py +264 -0
- snowflake/sandbox/files.py +547 -0
- snowflake/sandbox/function.py +567 -0
- snowflake/sandbox/image.py +46 -0
- snowflake/sandbox/jobs.py +649 -0
- snowflake/sandbox/lifecycle.py +67 -0
- snowflake/sandbox/log_stream.py +219 -0
- snowflake/sandbox/mcp.py +480 -0
- snowflake/sandbox/mount.py +161 -0
- snowflake/sandbox/py.typed +0 -0
- snowflake/sandbox/secret.py +244 -0
- snowflake/sandbox/session_app.py +244 -0
- snowflake/sandbox/shell.py +556 -0
- snowflake/sandbox/sync_client.py +2245 -0
- snowflake/sandbox/sync_exec_stream.py +238 -0
- snowflake/sandbox/sync_files.py +377 -0
- snowflake/sandbox/sync_log_stream.py +142 -0
- snowflake/sandbox/sync_shell.py +413 -0
- snowflake/sandbox/types.py +193 -0
- snowflake/sandbox/warm_session.py +700 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
- snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
snowflake/sandbox/mcp.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""External MCP servers exposed to the sandbox.
|
|
2
|
+
|
|
3
|
+
An **EXTERNAL MCP SERVER** is a Snowflake schema-level object (``DB.SCHEMA.NAME``)
|
|
4
|
+
that points at a remote MCP endpoint through an API INTEGRATION whose
|
|
5
|
+
``api_provider`` is ``external_mcp``. Declaring one on a sandbox is addressing
|
|
6
|
+
only — you name the object, and Snowflake resolves everything else:
|
|
7
|
+
|
|
8
|
+
from snowflake.sandbox import Sandbox
|
|
9
|
+
|
|
10
|
+
with Sandbox.create(mcp_servers=["MYDB.MYSCHEMA.GITHUB_MCP_SERVER"]) as sb:
|
|
11
|
+
sb.exec(["python", "agent.py"])
|
|
12
|
+
|
|
13
|
+
That is the same shape every other Snowflake surface takes — Cortex Agents accept
|
|
14
|
+
``mcp_servers`` as a list of FQN strings and Snowflake resolves them server-side, per
|
|
15
|
+
invoking user. Pass `McpServer` instead of a bare string only to override the
|
|
16
|
+
client-facing ``name`` or the transport ``profile``:
|
|
17
|
+
|
|
18
|
+
from snowflake.sandbox import McpServer
|
|
19
|
+
|
|
20
|
+
gh = McpServer.external("MYDB.MYSCHEMA.GITHUB_MCP_SERVER", name="github")
|
|
21
|
+
|
|
22
|
+
**What the SDK sends, and what it must never send.** The wire entry has seven
|
|
23
|
+
fields, and the SDK populates three of them (``kind``, ``name``, ``fqn``) plus
|
|
24
|
+
``profile``. The other three — ``url``, ``api_integration``, ``secret_entity_id``
|
|
25
|
+
— are filled in by Snowflake *after* it has resolved the server under the calling user's
|
|
26
|
+
own identity. They are not caller input: accepting them would let one user point
|
|
27
|
+
a sandbox at another user's OAuth secret, so the SDK rejects them up front and
|
|
28
|
+
the platform rejects them again with a 400. That double rejection is the security
|
|
29
|
+
boundary of this feature, and `validate_mcp_servers` is the client half of it.
|
|
30
|
+
|
|
31
|
+
The per-user OAuth token itself never enters the container: the platform injects a
|
|
32
|
+
dummy into ``SANDBOX_MCP_TOKEN_<NAME>`` (see `mcp_token_env_var`) and the egress
|
|
33
|
+
proxy swaps in the real token on the wire, the same mechanism `Secret` uses.
|
|
34
|
+
|
|
35
|
+
Use `list_mcp_servers` to see what this account has and whether *you* have
|
|
36
|
+
authorized it — connecting is a one-time per-user OAuth flow done in Snowsight,
|
|
37
|
+
and a server you have not authorized is silently omitted from the sandbox rather
|
|
38
|
+
than handed over with an empty token.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import re
|
|
44
|
+
from collections.abc import Mapping, Sequence
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from typing import Literal, NoReturn, cast, get_args
|
|
47
|
+
|
|
48
|
+
from snowflake.sandbox._mcp_discovery import ( # noqa: F401
|
|
49
|
+
_auth_statuses,
|
|
50
|
+
_auth_statuses_call,
|
|
51
|
+
_column_index,
|
|
52
|
+
_is_privilege_error,
|
|
53
|
+
_mcp_discovery_error,
|
|
54
|
+
_server_row,
|
|
55
|
+
list_mcp_servers,
|
|
56
|
+
)
|
|
57
|
+
from snowflake.sandbox._mcp_status import ( # noqa: F401
|
|
58
|
+
MCP_STATUS_LIVE,
|
|
59
|
+
MCP_STATUS_PATH,
|
|
60
|
+
MCP_STATUS_RECONNECT_REQUIRED,
|
|
61
|
+
MCP_STATUS_UNKNOWN,
|
|
62
|
+
McpConnectionStatus,
|
|
63
|
+
_load_status_doc,
|
|
64
|
+
_status_entry,
|
|
65
|
+
mcp_server_status,
|
|
66
|
+
)
|
|
67
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
68
|
+
|
|
69
|
+
# `list_mcp_servers` and its SQL helpers now live in `_mcp_discovery`; the
|
|
70
|
+
# in-sandbox status surface (`mcp_server_status`, `McpConnectionStatus`, the
|
|
71
|
+
# ``MCP_STATUS_*`` vocabulary) lives in `_mcp_status`. Both are re-exported above
|
|
72
|
+
# so ``from snowflake.sandbox.mcp import ...`` and __init__'s ``_LAZY`` map keep
|
|
73
|
+
# resolving them here unchanged. The re-export edge is one-way — those modules
|
|
74
|
+
# reach back for mcp's value objects only inside function bodies — which keeps
|
|
75
|
+
# the package's import graph acyclic (see test_module_layout).
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"McpServer",
|
|
79
|
+
"McpServerInfo",
|
|
80
|
+
"McpConnectionStatus",
|
|
81
|
+
"MCP_SERVER_KIND",
|
|
82
|
+
"DEFAULT_MCP_PROFILE",
|
|
83
|
+
"MCP_STATUS_PATH",
|
|
84
|
+
"MCP_STATUS_LIVE",
|
|
85
|
+
"MCP_STATUS_RECONNECT_REQUIRED",
|
|
86
|
+
"MCP_STATUS_UNKNOWN",
|
|
87
|
+
"list_mcp_servers",
|
|
88
|
+
"mcp_server_status",
|
|
89
|
+
"mcp_token_env_var",
|
|
90
|
+
"validate_mcp_servers",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# The only kind on the wire. There is no "nova" kind: Nova is at most a provider
|
|
95
|
+
# behind an API INTEGRATION, never the abstraction a caller addresses.
|
|
96
|
+
MCP_SERVER_KIND: Literal["external_mcp"] = "external_mcp"
|
|
97
|
+
|
|
98
|
+
# Transport profile the runtime renders into ``mcp.json``. Streamable HTTP is what
|
|
99
|
+
# the shipped runtime speaks; the field exists so a future transport does not need
|
|
100
|
+
# a new SDK release.
|
|
101
|
+
DEFAULT_MCP_PROFILE = "streamablehttp"
|
|
102
|
+
|
|
103
|
+
# Prefix for the dummy-token env var, per the SANDBOX_MCP_SERVERS contract. The
|
|
104
|
+
# SANDBOX_ prefix is server-reserved on the create body's `env` MAP and permitted
|
|
105
|
+
# only on `sandbox_env`, which is where the server delivers these — see
|
|
106
|
+
# `_env._PLATFORM_ENV_PREFIXES`.
|
|
107
|
+
_MCP_TOKEN_ENV_PREFIX = "SANDBOX_MCP_TOKEN_"
|
|
108
|
+
|
|
109
|
+
# Fields Snowflake resolves per user, server-side. A caller cannot know them (the OAuth
|
|
110
|
+
# SECRET is an invisible entity in a parentless per-user schema with a
|
|
111
|
+
# deliberately meaningless name) and must not be able to assert them.
|
|
112
|
+
_GS_RESOLVED_FIELDS: tuple[str, ...] = ("url", "api_integration", "secret_entity_id")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# One FQN part: an unquoted Snowflake identifier or a "quoted identifier".
|
|
116
|
+
_FQN_PART = re.compile(r'^(?:[A-Za-z_][A-Za-z0-9_$]*|"[^"]+")$')
|
|
117
|
+
|
|
118
|
+
# Client-facing name: what mcp.json keys on and what the dummy env var is derived
|
|
119
|
+
# from. Kept to a charset that survives that derivation legibly.
|
|
120
|
+
_MCP_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.\-]*$")
|
|
121
|
+
|
|
122
|
+
# Transport profile: a lowercase token, no punctuation beyond _ and -.
|
|
123
|
+
_MCP_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_\-]*$")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def mcp_token_env_var(name: str) -> str:
|
|
127
|
+
"""The environment variable an MCP server's dummy token lands in.
|
|
128
|
+
|
|
129
|
+
Non-alphanumeric characters become underscores and the name is uppercased, so
|
|
130
|
+
a server named ``github`` gets ``SANDBOX_MCP_TOKEN_GITHUB`` and one named
|
|
131
|
+
``jira-cloud`` gets ``SANDBOX_MCP_TOKEN_JIRA_CLOUD``. The value in the
|
|
132
|
+
container is a dummy; the egress proxy substitutes the real per-user OAuth
|
|
133
|
+
token on requests to that server's host.
|
|
134
|
+
"""
|
|
135
|
+
return _MCP_TOKEN_ENV_PREFIX + re.sub(r"[^A-Za-z0-9]", "_", name).upper()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _reject_removed(where: str, keys: Sequence[str]) -> None:
|
|
139
|
+
"""Raise for any Snowflake-resolved field the caller must not assert.
|
|
140
|
+
|
|
141
|
+
``url`` / ``api_integration`` / ``secret_entity_id`` are filled in server-side from
|
|
142
|
+
the EXTERNAL MCP SERVER named by ``fqn``, under the calling user's own OAuth
|
|
143
|
+
authorization — accepting them from a caller would let one user point a sandbox at
|
|
144
|
+
another user's secret. Every other unrecognized keyword is caught by the strict
|
|
145
|
+
constructor / mapping validators (a plain "unexpected keyword" / "unknown field"),
|
|
146
|
+
so superseded spellings like ``secret_fqn=`` / ``connector=`` now fail there.
|
|
147
|
+
"""
|
|
148
|
+
for key in keys:
|
|
149
|
+
if key in _GS_RESOLVED_FIELDS:
|
|
150
|
+
raise SandboxError(
|
|
151
|
+
f"{where}: {key!r} is resolved by Snowflake, not by the caller. "
|
|
152
|
+
f"url, api_integration, and secret_entity_id are filled in "
|
|
153
|
+
f"server-side from the EXTERNAL MCP SERVER named by fqn, under the "
|
|
154
|
+
f"calling user's own OAuth authorization — accepting them from a "
|
|
155
|
+
f"caller would let one user point a sandbox at another user's "
|
|
156
|
+
f"secret, so the platform rejects them with a 400 as well. Pass only "
|
|
157
|
+
f"fqn (and optionally name / profile)."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validate_fqn(fqn: str) -> str:
|
|
162
|
+
"""Return *fqn* if it is a three-part Snowflake object name, else raise."""
|
|
163
|
+
raw = fqn.strip()
|
|
164
|
+
if not raw:
|
|
165
|
+
raise SandboxError("MCP server fqn must not be empty")
|
|
166
|
+
parts = raw.split(".")
|
|
167
|
+
if len(parts) != 3 or not all(_FQN_PART.match(p) for p in parts):
|
|
168
|
+
raise SandboxError(
|
|
169
|
+
f"MCP server fqn {fqn!r} is not a fully-qualified Snowflake object name: "
|
|
170
|
+
"expected DB.SCHEMA.SERVER (letters, digits, '_' or '$' per part; a "
|
|
171
|
+
'double-quoted "identifier" is also accepted). Run '
|
|
172
|
+
"snowflake.sandbox.list_mcp_servers() to see the FQNs this account has."
|
|
173
|
+
)
|
|
174
|
+
return raw
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _name_from_fqn(fqn: str) -> str:
|
|
178
|
+
"""Derive a client-facing name from an FQN's last part.
|
|
179
|
+
|
|
180
|
+
Unquoted, lowercased, and squashed to the name charset — a quoted identifier
|
|
181
|
+
can hold spaces and punctuation that an explicitly-passed name is not allowed
|
|
182
|
+
to, and a derived name failing validation would report a name the caller never
|
|
183
|
+
typed. Pass ``name=`` to choose your own.
|
|
184
|
+
"""
|
|
185
|
+
last = fqn.split(".")[-1]
|
|
186
|
+
if last.startswith('"') and last.endswith('"'):
|
|
187
|
+
last = last[1:-1]
|
|
188
|
+
squashed = re.sub(r"[^A-Za-z0-9_.\-]", "_", last.lower()).lstrip("_.-")
|
|
189
|
+
return squashed or "mcp"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True, init=False)
|
|
193
|
+
class McpServer:
|
|
194
|
+
"""An EXTERNAL MCP SERVER to expose to the sandbox, referenced by FQN.
|
|
195
|
+
|
|
196
|
+
A bare FQN string is the common case and needs none of this —
|
|
197
|
+
``mcp_servers=["MYDB.MYSCHEMA.GITHUB_MCP_SERVER"]`` is equivalent to
|
|
198
|
+
``mcp_servers=[McpServer.external("MYDB.MYSCHEMA.GITHUB_MCP_SERVER")]``.
|
|
199
|
+
Construct one explicitly to override the client-facing `name` (which
|
|
200
|
+
``mcp.json`` keys on, and from which the dummy-token env var is derived) or the
|
|
201
|
+
transport `profile`.
|
|
202
|
+
|
|
203
|
+
Example:
|
|
204
|
+
gh = McpServer.external(
|
|
205
|
+
"MYDB.MYSCHEMA.GITHUB_MCP_SERVER",
|
|
206
|
+
name="github", # -> SANDBOX_MCP_TOKEN_GITHUB
|
|
207
|
+
)
|
|
208
|
+
with Sandbox.create(mcp_servers=[gh]) as sb:
|
|
209
|
+
sb.exec(["python", "agent.py"])
|
|
210
|
+
|
|
211
|
+
`url`, `api_integration`, and `secret_entity_id` are deliberately absent: Snowflake
|
|
212
|
+
resolves them per calling user and the SDK is not allowed to assert them.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
fqn: str
|
|
216
|
+
name: str | None = None
|
|
217
|
+
profile: str = DEFAULT_MCP_PROFILE
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
fqn: str,
|
|
222
|
+
name: str | None = None,
|
|
223
|
+
profile: str = DEFAULT_MCP_PROFILE,
|
|
224
|
+
**removed: object,
|
|
225
|
+
) -> None:
|
|
226
|
+
# Hand-written so a superseded or Snowflake-resolved keyword raises the message
|
|
227
|
+
# that names its replacement, rather than a bare TypeError from the
|
|
228
|
+
# generated __init__ (or, worse, being accepted and dropped).
|
|
229
|
+
_reject_removed("McpServer", list(removed))
|
|
230
|
+
if removed:
|
|
231
|
+
raise SandboxError(
|
|
232
|
+
f"McpServer got unexpected keyword(s) {sorted(removed)!r}: it takes "
|
|
233
|
+
"fqn, name, and profile only."
|
|
234
|
+
)
|
|
235
|
+
object.__setattr__(self, "fqn", _validate_fqn(fqn))
|
|
236
|
+
object.__setattr__(self, "name", name)
|
|
237
|
+
object.__setattr__(self, "profile", profile)
|
|
238
|
+
self._validate()
|
|
239
|
+
|
|
240
|
+
def _validate(self) -> None:
|
|
241
|
+
name = self.name if self.name is not None else _name_from_fqn(self.fqn)
|
|
242
|
+
if not _MCP_NAME_RE.match(name):
|
|
243
|
+
raise SandboxError(
|
|
244
|
+
f"MCP server name {name!r} is not usable: expected letters, digits, "
|
|
245
|
+
"'_', '.' or '-', starting with a letter or digit. The name keys "
|
|
246
|
+
"mcp.json and is uppercased into the dummy-token env var "
|
|
247
|
+
f"({_MCP_TOKEN_ENV_PREFIX}<NAME>)."
|
|
248
|
+
)
|
|
249
|
+
object.__setattr__(self, "name", name)
|
|
250
|
+
if not _MCP_PROFILE_RE.match(self.profile):
|
|
251
|
+
raise SandboxError(
|
|
252
|
+
f"MCP server profile {self.profile!r} is not a transport profile: "
|
|
253
|
+
f'expected a lowercase token such as "{DEFAULT_MCP_PROFILE}".'
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def kind(self) -> Literal["external_mcp"]:
|
|
258
|
+
"""The wire kind — always ``external_mcp``."""
|
|
259
|
+
return MCP_SERVER_KIND
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def token_env_var(self) -> str:
|
|
263
|
+
"""The env var this server's dummy token lands in inside the container."""
|
|
264
|
+
return mcp_token_env_var(str(self.name))
|
|
265
|
+
|
|
266
|
+
@staticmethod
|
|
267
|
+
def external(
|
|
268
|
+
fqn: str,
|
|
269
|
+
*,
|
|
270
|
+
name: str | None = None,
|
|
271
|
+
profile: str = DEFAULT_MCP_PROFILE,
|
|
272
|
+
**removed: object,
|
|
273
|
+
) -> McpServer:
|
|
274
|
+
"""Declare an EXTERNAL MCP SERVER by fully-qualified name.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
fqn:
|
|
279
|
+
``DB.SCHEMA.SERVER`` — the EXTERNAL MCP SERVER object. This is the only
|
|
280
|
+
required field, and the only addressing the platform accepts; the URL,
|
|
281
|
+
API integration, and per-user OAuth secret behind it are resolved
|
|
282
|
+
server-side under the calling user's identity.
|
|
283
|
+
name:
|
|
284
|
+
Client-facing name: the key in ``mcp.json`` and the source of the
|
|
285
|
+
dummy-token env var. Defaults to the FQN's last part, lowercased.
|
|
286
|
+
profile:
|
|
287
|
+
Transport profile. Defaults to ``"streamablehttp"``.
|
|
288
|
+
"""
|
|
289
|
+
_reject_removed("McpServer.external", list(removed))
|
|
290
|
+
if removed:
|
|
291
|
+
raise SandboxError(
|
|
292
|
+
f"McpServer.external got unexpected keyword(s) "
|
|
293
|
+
f"{sorted(removed)!r}: it takes fqn, name, and profile only."
|
|
294
|
+
)
|
|
295
|
+
return McpServer(fqn=fqn, name=name, profile=profile)
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def nova(*_args: object, **_kwargs: object) -> NoReturn:
|
|
299
|
+
"""Removed — raises `SandboxError` naming the replacement."""
|
|
300
|
+
raise SandboxError(
|
|
301
|
+
"McpServer.nova() has been removed: 'nova' was never the object a caller "
|
|
302
|
+
"addresses (it is at most a provider behind an API INTEGRATION), and its "
|
|
303
|
+
"secret_fqn= argument was impossible to populate. Use "
|
|
304
|
+
'McpServer.external("DB.SCHEMA.SERVER") — or just pass the FQN string: '
|
|
305
|
+
'mcp_servers=["DB.SCHEMA.SERVER"].'
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def account(*_args: object, **_kwargs: object) -> NoReturn:
|
|
310
|
+
"""Removed — raises `SandboxError` naming the replacement."""
|
|
311
|
+
raise SandboxError(
|
|
312
|
+
"McpServer.account() has been removed: its database/schema/server triple "
|
|
313
|
+
'is the one FQN this surface takes. Use McpServer.external("DB.SCHEMA.'
|
|
314
|
+
'SERVER") — or just pass the FQN string: mcp_servers=["DB.SCHEMA.SERVER"].'
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def to_wire(self) -> dict[str, str]:
|
|
318
|
+
"""Serialize to the caller-supplied half of the ``SANDBOX_MCP_SERVERS`` entry.
|
|
319
|
+
|
|
320
|
+
Exactly four keys: ``kind``, ``name``, ``fqn``, ``profile``. Snowflake adds
|
|
321
|
+
``url``, ``api_integration``, and ``secret_entity_id`` after resolving the
|
|
322
|
+
server for the calling user; the SDK never sends those.
|
|
323
|
+
"""
|
|
324
|
+
return {
|
|
325
|
+
"kind": MCP_SERVER_KIND,
|
|
326
|
+
"name": str(self.name),
|
|
327
|
+
"fqn": self.fqn,
|
|
328
|
+
"profile": self.profile,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _server_from_mapping(srv: Mapping[str, object]) -> McpServer:
|
|
333
|
+
"""Build an `McpServer` from one mapping entry of ``mcp_servers=``.
|
|
334
|
+
|
|
335
|
+
Only ``kind``/``fqn``/``name``/``profile`` are accepted, and ``kind`` only as
|
|
336
|
+
the single supported value: a mapping is the form a caller is most likely to
|
|
337
|
+
hand-assemble (or paste from another surface), so an unrecognised key is an
|
|
338
|
+
error rather than something silently dropped.
|
|
339
|
+
"""
|
|
340
|
+
keys = [str(k) for k in srv]
|
|
341
|
+
_reject_removed("mcp_servers entry", keys)
|
|
342
|
+
unknown = [k for k in keys if k not in ("kind", "fqn", "name", "profile")]
|
|
343
|
+
if unknown:
|
|
344
|
+
raise SandboxError(
|
|
345
|
+
f"mcp_servers entry has unknown field(s) {sorted(unknown)!r}: an "
|
|
346
|
+
"entry carries fqn (required) and optionally name and profile."
|
|
347
|
+
)
|
|
348
|
+
kind = srv.get("kind", MCP_SERVER_KIND)
|
|
349
|
+
if kind != MCP_SERVER_KIND:
|
|
350
|
+
raise SandboxError(
|
|
351
|
+
f"mcp_servers entry kind {kind!r} is not supported: the only kind "
|
|
352
|
+
f"is {MCP_SERVER_KIND!r} (an EXTERNAL MCP SERVER named by fqn)."
|
|
353
|
+
)
|
|
354
|
+
fqn = srv.get("fqn")
|
|
355
|
+
if not isinstance(fqn, str) or not fqn:
|
|
356
|
+
raise SandboxError(
|
|
357
|
+
"mcp_servers entry requires fqn='DB.SCHEMA.SERVER' (the EXTERNAL "
|
|
358
|
+
"MCP SERVER object; everything else is resolved server-side)."
|
|
359
|
+
)
|
|
360
|
+
raw_name = srv.get("name")
|
|
361
|
+
raw_profile = srv.get("profile", DEFAULT_MCP_PROFILE)
|
|
362
|
+
return McpServer(
|
|
363
|
+
fqn=fqn,
|
|
364
|
+
name=str(raw_name) if raw_name is not None else None,
|
|
365
|
+
profile=str(raw_profile),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def validate_mcp_servers(
|
|
370
|
+
servers: Sequence[str | McpServer | Mapping[str, object]],
|
|
371
|
+
) -> list[dict[str, str]]:
|
|
372
|
+
"""Validate ``mcp_servers=`` and convert it to wire entries.
|
|
373
|
+
|
|
374
|
+
Accepts a bare FQN string (the common case), an `McpServer`, or a mapping with
|
|
375
|
+
``fqn`` and optionally ``name`` / ``profile``. Raises `SandboxError` for a
|
|
376
|
+
field the caller cannot legitimately supply — the Snowflake-resolved ``url`` /
|
|
377
|
+
``api_integration`` / ``secret_entity_id`` — and for any other unrecognized
|
|
378
|
+
keyword (a plain "unexpected keyword" / "unknown field"), which is how the
|
|
379
|
+
superseded ``secret_fqn=`` / ``connector=`` / ``database=`` spellings now fail.
|
|
380
|
+
|
|
381
|
+
Duplicate names, duplicate FQNs, and two names that collapse to the same dummy
|
|
382
|
+
env var are all rejected: each would be a last-one-wins overwrite in the
|
|
383
|
+
container rather than an error.
|
|
384
|
+
"""
|
|
385
|
+
entries: list[dict[str, str]] = []
|
|
386
|
+
names_seen: set[str] = set()
|
|
387
|
+
fqns_seen: set[str] = set()
|
|
388
|
+
env_vars_seen: dict[str, str] = {}
|
|
389
|
+
|
|
390
|
+
for srv in servers:
|
|
391
|
+
if isinstance(srv, McpServer):
|
|
392
|
+
entry = srv.to_wire()
|
|
393
|
+
elif isinstance(srv, str):
|
|
394
|
+
entry = McpServer(fqn=srv).to_wire()
|
|
395
|
+
elif isinstance(srv, Mapping):
|
|
396
|
+
entry = _server_from_mapping(srv).to_wire()
|
|
397
|
+
else:
|
|
398
|
+
raise SandboxError(
|
|
399
|
+
f"mcp_servers entry must be an FQN string, McpServer, or mapping, got "
|
|
400
|
+
f"{type(srv).__name__}"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
name = entry["name"]
|
|
404
|
+
if name in names_seen:
|
|
405
|
+
raise SandboxError(f"duplicate MCP server name: {name!r}")
|
|
406
|
+
names_seen.add(name)
|
|
407
|
+
if entry["fqn"] in fqns_seen:
|
|
408
|
+
raise SandboxError(f"duplicate MCP server fqn: {entry['fqn']!r}")
|
|
409
|
+
fqns_seen.add(entry["fqn"])
|
|
410
|
+
env_var = mcp_token_env_var(name)
|
|
411
|
+
clash = env_vars_seen.get(env_var)
|
|
412
|
+
if clash is not None:
|
|
413
|
+
raise SandboxError(
|
|
414
|
+
f"MCP server names {clash!r} and {name!r} both derive the dummy-token "
|
|
415
|
+
f"env var {env_var}, so one token would overwrite the other in the "
|
|
416
|
+
"container. Give them names that differ by more than punctuation."
|
|
417
|
+
)
|
|
418
|
+
env_vars_seen[env_var] = name
|
|
419
|
+
|
|
420
|
+
entries.append(entry)
|
|
421
|
+
|
|
422
|
+
return entries
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# --------------------------------------------------------------------------- #
|
|
426
|
+
# Discovery
|
|
427
|
+
# --------------------------------------------------------------------------- #
|
|
428
|
+
|
|
429
|
+
# Statuses SYSTEM$GET_USER_INTEGRATION_AUTHORIZATIONS returns, plus the SDK's own
|
|
430
|
+
# "could not determine" value.
|
|
431
|
+
MCP_AUTH_CONNECTED: Literal["CONNECTED"] = "CONNECTED"
|
|
432
|
+
MCP_AUTH_NOT_CONNECTED: Literal["NOT_CONNECTED"] = "NOT_CONNECTED"
|
|
433
|
+
MCP_AUTH_NEEDS_REAUTH: Literal["NEEDS_REAUTH"] = "NEEDS_REAUTH"
|
|
434
|
+
MCP_AUTH_INTEGRATION_NOT_FOUND: Literal["INTEGRATION_NOT_FOUND"] = "INTEGRATION_NOT_FOUND"
|
|
435
|
+
MCP_AUTH_UNKNOWN: Literal["UNKNOWN"] = "UNKNOWN"
|
|
436
|
+
|
|
437
|
+
McpAuthStatus = Literal[
|
|
438
|
+
"CONNECTED", "NOT_CONNECTED", "NEEDS_REAUTH", "INTEGRATION_NOT_FOUND", "UNKNOWN"
|
|
439
|
+
]
|
|
440
|
+
"""Per-user authorization status of an EXTERNAL MCP SERVER (`McpServerInfo.status`)."""
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _coerce_auth_status(raw: object) -> McpAuthStatus:
|
|
444
|
+
"""Clamp a wire authorization value to the known set; unknown -> ``UNKNOWN``."""
|
|
445
|
+
return cast("McpAuthStatus", raw) if raw in get_args(McpAuthStatus) else MCP_AUTH_UNKNOWN
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@dataclass(frozen=True)
|
|
449
|
+
class McpServerInfo:
|
|
450
|
+
"""One EXTERNAL MCP SERVER in the account, with *your* authorization status.
|
|
451
|
+
|
|
452
|
+
`status` is per calling user, because the OAuth authorization is: the same
|
|
453
|
+
server reads ``CONNECTED`` for one user and ``NOT_CONNECTED`` for the next.
|
|
454
|
+
Values are ``CONNECTED``, ``NOT_CONNECTED``, ``NEEDS_REAUTH``,
|
|
455
|
+
``INTEGRATION_NOT_FOUND``, or ``UNKNOWN`` when the status could not be read
|
|
456
|
+
(no API integration on the server, or the account does not expose the
|
|
457
|
+
``api_integration`` column of ``SHOW EXTERNAL MCP SERVERS``).
|
|
458
|
+
|
|
459
|
+
This is the *account authorization* view, and it can lag reality: a grant
|
|
460
|
+
revoked on the provider side still reads ``CONNECTED`` here until Snowflake
|
|
461
|
+
next tries to use the token. From inside a sandbox, `mcp_server_status` reads
|
|
462
|
+
the hostagent's own most-recent fetch result instead, which is the signal
|
|
463
|
+
that actually tells you whether a tool call will work right now.
|
|
464
|
+
"""
|
|
465
|
+
|
|
466
|
+
name: str
|
|
467
|
+
fqn: str
|
|
468
|
+
api_integration: str | None
|
|
469
|
+
status: McpAuthStatus
|
|
470
|
+
enabled: bool | None = None
|
|
471
|
+
comment: str | None = None
|
|
472
|
+
|
|
473
|
+
@property
|
|
474
|
+
def connected(self) -> bool:
|
|
475
|
+
"""True when this user has a live OAuth authorization for the server."""
|
|
476
|
+
return self.status == MCP_AUTH_CONNECTED
|
|
477
|
+
|
|
478
|
+
def as_mcp_server(self, *, name: str | None = None) -> McpServer:
|
|
479
|
+
"""The `McpServer` to pass to ``mcp_servers=`` for this entry."""
|
|
480
|
+
return McpServer.external(self.fqn, name=name)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""FUSE stage/workspace mounts.
|
|
2
|
+
|
|
3
|
+
``StageMount`` is a Snowflake stage or workspace mounted into the container's
|
|
4
|
+
filesystem. Its counterpart in the other direction — ``Bundle``, the local
|
|
5
|
+
project tree shipped *up* to a stage — used to live here as well; it now sits in
|
|
6
|
+
``_assemble`` next to the code that packages it.
|
|
7
|
+
|
|
8
|
+
from snowflake.sandbox import StageMount
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from snowflake.sandbox.exceptions import SandboxError
|
|
17
|
+
|
|
18
|
+
__all__ = ["StageMount", "validate_stage_mounts"]
|
|
19
|
+
|
|
20
|
+
# Container paths a stage must not be mounted over: a writable FUSE mount here
|
|
21
|
+
# would shadow the TLS trust store / CA bundle (defeating egress verification),
|
|
22
|
+
# the connector config + Snowflake credential dir, or core system binaries — i.e.
|
|
23
|
+
# turn a stage mount into a container-integrity / privilege-escalation primitive.
|
|
24
|
+
# The platform already rejects "/" and ".." for *secret file* mounts, but stage
|
|
25
|
+
# mounts have no such guard server-side. Reject the root itself and any path
|
|
26
|
+
# beneath it.
|
|
27
|
+
_DENIED_MOUNT_ROOTS: tuple[str, ...] = (
|
|
28
|
+
"/etc",
|
|
29
|
+
"/usr",
|
|
30
|
+
"/bin",
|
|
31
|
+
"/sbin",
|
|
32
|
+
"/lib",
|
|
33
|
+
"/lib64",
|
|
34
|
+
"/proc",
|
|
35
|
+
"/sys",
|
|
36
|
+
"/dev",
|
|
37
|
+
"/boot",
|
|
38
|
+
"/snowflake",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _normalize_mount_path(mount_path: str) -> str:
|
|
43
|
+
"""Validate a stage ``mount_path`` and return its normalized form.
|
|
44
|
+
|
|
45
|
+
Requires an absolute POSIX path with no ``..`` component and no NUL/newline,
|
|
46
|
+
and refuses the filesystem root and the system roots in
|
|
47
|
+
`_DENIED_MOUNT_ROOTS` (and anything beneath them). Raises `SandboxError`.
|
|
48
|
+
Callers pass only a non-empty path (empty means "let the platform default").
|
|
49
|
+
"""
|
|
50
|
+
from posixpath import normpath
|
|
51
|
+
|
|
52
|
+
if any(c in mount_path for c in "\x00\n\r"):
|
|
53
|
+
raise SandboxError("stage mount_path must not contain NUL or newline characters")
|
|
54
|
+
if not mount_path.startswith("/"):
|
|
55
|
+
raise SandboxError(
|
|
56
|
+
f"stage mount_path {mount_path!r} must be an absolute path (start with '/')"
|
|
57
|
+
)
|
|
58
|
+
if ".." in mount_path.split("/"):
|
|
59
|
+
raise SandboxError(f"stage mount_path {mount_path!r} must not contain a '..' component")
|
|
60
|
+
# posixpath.normpath preserves exactly two leading slashes ('//etc' stays
|
|
61
|
+
# '//etc'), which Linux resolves to '/etc' — so collapse leading slashes before
|
|
62
|
+
# the root/denylist checks, or '//etc' / '//' would slip past them.
|
|
63
|
+
norm = "/" + normpath(mount_path).lstrip("/")
|
|
64
|
+
if norm == "/":
|
|
65
|
+
raise SandboxError("stage mount_path must not be the filesystem root '/'")
|
|
66
|
+
lowered = norm.lower()
|
|
67
|
+
for root in _DENIED_MOUNT_ROOTS:
|
|
68
|
+
if lowered == root or lowered.startswith(root + "/"):
|
|
69
|
+
raise SandboxError(
|
|
70
|
+
f"stage mount_path {mount_path!r} is under the reserved system path "
|
|
71
|
+
f"{root!r}; mounting a stage there would shadow system files "
|
|
72
|
+
"(TLS trust store, credentials, or binaries). Choose a path like "
|
|
73
|
+
"'/workspace' or '/data'."
|
|
74
|
+
)
|
|
75
|
+
return norm
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_stage_mounts(mounts: Sequence[StageMount]) -> None:
|
|
79
|
+
"""Reject two mounts that target the same ``mount_path``.
|
|
80
|
+
|
|
81
|
+
Each `StageMount` already validates its own path at construction; this is the
|
|
82
|
+
cross-mount check (two stages mounted at the same point is a silent
|
|
83
|
+
last-one-wins on the platform). Raises `SandboxError`.
|
|
84
|
+
"""
|
|
85
|
+
seen: dict[str, str] = {}
|
|
86
|
+
for m in mounts:
|
|
87
|
+
if not m.mount_path:
|
|
88
|
+
continue
|
|
89
|
+
norm = _normalize_mount_path(m.mount_path)
|
|
90
|
+
if norm in seen:
|
|
91
|
+
raise SandboxError(
|
|
92
|
+
f"stage mounts collide on mount_path {norm!r}: {seen[norm]!r} and "
|
|
93
|
+
f"{m.stage_name!r} would mount at the same point"
|
|
94
|
+
)
|
|
95
|
+
seen[norm] = m.stage_name
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class StageMount:
|
|
100
|
+
"""A Snowflake stage or workspace FUSE-mounted into the container.
|
|
101
|
+
|
|
102
|
+
The mount is fully ready before the container command starts.
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
mount = StageMount.from_workspace("MY_WS", mount_path="/workspace")
|
|
106
|
+
data = StageMount.from_stage("MY_STAGE", mount_path="/data", readonly=True)
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
stage_name: str
|
|
110
|
+
mount_path: str = ""
|
|
111
|
+
readonly: bool = False
|
|
112
|
+
# Internal fields — set by named constructors, not exposed to users.
|
|
113
|
+
_is_vstage: bool = field(default=False, repr=False)
|
|
114
|
+
_vstage_namespace: str = field(default="", repr=False)
|
|
115
|
+
_vstage_version: str = field(default="live", repr=False)
|
|
116
|
+
|
|
117
|
+
def __post_init__(self) -> None:
|
|
118
|
+
# A non-empty mount_path is validated at construction so an unsafe target
|
|
119
|
+
# (system root, ``..``, non-absolute) fails at author time rather than
|
|
120
|
+
# mounting a writable stage over the TLS store / credentials / binaries.
|
|
121
|
+
# An empty mount_path is left for the platform to default.
|
|
122
|
+
if self.mount_path:
|
|
123
|
+
_normalize_mount_path(self.mount_path)
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def from_stage(name: str, *, mount_path: str = "", readonly: bool = False) -> StageMount:
|
|
127
|
+
"""Mount a regular Snowflake stage."""
|
|
128
|
+
return StageMount(stage_name=name, mount_path=mount_path, readonly=readonly)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def from_workspace(name: str, *, mount_path: str = "", readonly: bool = False) -> StageMount:
|
|
132
|
+
"""Mount a Snowflake Workspace.
|
|
133
|
+
|
|
134
|
+
``readonly=False`` (default) ensures a writable live version exists
|
|
135
|
+
before mounting. ``readonly=True`` mounts the head version read-only.
|
|
136
|
+
"""
|
|
137
|
+
return StageMount(
|
|
138
|
+
stage_name=name,
|
|
139
|
+
mount_path=mount_path,
|
|
140
|
+
readonly=readonly,
|
|
141
|
+
_is_vstage=True,
|
|
142
|
+
_vstage_namespace="workspace",
|
|
143
|
+
_vstage_version="head" if readonly else "live",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def to_api_dict(self) -> dict[str, object]:
|
|
147
|
+
"""Serialize to the REST request shape."""
|
|
148
|
+
d: dict[str, object] = {"stage_name": self.stage_name}
|
|
149
|
+
if self._is_vstage:
|
|
150
|
+
d["is_vstage"] = True
|
|
151
|
+
d["vstage_namespace"] = self._vstage_namespace
|
|
152
|
+
d["vstage_version"] = self._vstage_version
|
|
153
|
+
if self.mount_path:
|
|
154
|
+
d["mount_path"] = self.mount_path
|
|
155
|
+
if self.readonly:
|
|
156
|
+
# Omitted when False so the request shape is unchanged for the common
|
|
157
|
+
# case. Sending it when True is load-bearing: the flag was once accepted
|
|
158
|
+
# here and never emitted, so a mount asked to be read-only was mounted
|
|
159
|
+
# writable.
|
|
160
|
+
d["readonly"] = True
|
|
161
|
+
return d
|
|
File without changes
|