Snowflake SPCS
Snowpark Container Services (SPCS) runs your MCP as a long-running container next to your data, with a public ingress that Snowflake gates with OAuth. Best for teams already on Snowflake who want the MCP inside the account boundary. Everything below uses the snow CLI against a single connection (-c <connection>).
SPCS ingress is not anonymous. The public URL is internet-routable but OAuth-gated: every request must carry a Snowflake token, and the calling user's role must be granted access to the endpoint. This shapes both the deploy and how the platform connects — see Authentication below.
Container
Build a standard linux/amd64 image listening on a fixed port (8080 here), with a health route for the SPCS readiness probe:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt
COPY server.py .
ENV PYTHONUNBUFFERED=1
ENV PORT=8080
EXPOSE 8080
CMD ["python", "server.py"]SPCS only runs linux/amd64 images. Build with --platform linux/amd64
even on Apple Silicon, or the container will fail to start.
Provision Snowflake objects
Create the database, schema, image repository, and a compute pool. The smallest CPU family is plenty for an MCP; --auto-suspend-secs releases the pool after it goes idle.
CONN=<your-connection>
snow sql -c $CONN -q "CREATE DATABASE IF NOT EXISTS MCP_DB;
CREATE SCHEMA IF NOT EXISTS MCP_DB.MCP_SCHEMA;"
snow spcs image-repository create MCP_REPO \
--database MCP_DB --schema MCP_SCHEMA --if-not-exists -c $CONN
snow spcs compute-pool create MCP_POOL \
--family CPU_X64_XS --min-nodes 1 --max-nodes 1 \
--auto-resume --auto-suspend-secs 3600 --if-not-exists -c $CONNPush the image
snow spcs image-registry login authenticates Docker to the Snowflake registry using your existing connection — no manually-created token, no docker login prompt.
REPO_URL=$(snow spcs image-repository url MCP_REPO \
--database MCP_DB --schema MCP_SCHEMA -c $CONN | tail -1)
snow spcs image-registry login -c $CONN
docker tag my-mcp:latest $REPO_URL/my-mcp:latest
docker push $REPO_URL/my-mcp:latestDeploy
Describe the service in service.yaml. The public: true endpoint is what creates the internet-routable ingress; readinessProbe points at your health path.
spec:
containers:
- name: my-mcp
image: /mcp_db/mcp_schema/mcp_repo/my-mcp:latest
env:
PORT: "8080"
readinessProbe:
port: 8080
path: /health
endpoints:
- name: mcp
port: 8080
public: truesnow spcs service create MY_MCP_SVC \
--compute-pool MCP_POOL \
--spec-path service.yaml \
--database MCP_DB --schema MCP_SCHEMA -c $CONNThe container reaches READY within a minute or so; the public URL takes a few minutes more to provision. Read it from SHOW ENDPOINTS:
snow sql -c $CONN -q "SELECT SYSTEM\$GET_SERVICE_STATUS('MCP_DB.MCP_SCHEMA.MY_MCP_SVC')"
snow sql -c $CONN --format json -q "SHOW ENDPOINTS IN SERVICE MCP_DB.MCP_SCHEMA.MY_MCP_SVC"The ingress_url plus the /mcp path is your endpoint — e.g. https://<ingress>.snowflakecomputing.app/mcp.
Authentication
SPCS gates the public endpoint with OAuth, so the platform has to present a Snowflake credential. The token acts as exactly one role, and that role must be granted access to the endpoint.
The SPCS ingress header is not Bearer. Sending Authorization: Bearer <token> gets a 302 redirect to the sign-in page (treated as
unauthenticated). The working header is Authorization: Snowflake Token="<token>". Confirm your MCP client / the agent registration can send
this exact header format before relying on the public endpoint.
The simplest credential is a Programmatic Access Token (PAT), pinned to a role with ROLE_RESTRICTION:
ALTER USER "<user>" ADD PROGRAMMATIC ACCESS TOKEN mcp_client_pat
ROLE_RESTRICTION = 'MCP_CLIENT_ROLE'
DAYS_TO_EXPIRY = 30;Don't ship an ACCOUNTADMIN PAT. Give the agent a dedicated, minimal role that can do only "reach this endpoint" — declare a service role on the service and grant it to that role:
# add to service.yaml, then re-create / ALTER the service
serviceRoles:
- name: mcp_user
endpoints:
- mcpCREATE ROLE IF NOT EXISTS MCP_CLIENT_ROLE;
GRANT SERVICE ROLE MCP_DB.MCP_SCHEMA.MY_MCP_SVC!mcp_user TO ROLE MCP_CLIENT_ROLE;
GRANT ROLE MCP_CLIENT_ROLE TO USER "<user>";The PAT pinned to MCP_CLIENT_ROLE can now reach the MCP endpoint and nothing else in the account. Rotate it on expiry and keep DAYS_TO_EXPIRY short.
If a PAT is returned with "Programmatic access tokens are not allowed for
users without a network policy", attach a network policy to the user first
(tighten the IP list for real use, don't ship 0.0.0.0/0).
Secrets
Never bake credentials into the image. Store them as Snowflake secrets and reference them from service.yaml, or rely on the session token SPCS injects at /snowflake/session/token for in-account calls. Egress is closed by default — to let the container reach the public internet you must attach an EXTERNAL ACCESS INTEGRATION with network rules.
Register with an agent
Register the ingress URL plus /mcp (e.g. https://<ingress>.snowflakecomputing.app/mcp) on your agent, with the Authorization: Snowflake Token="<PAT>" header. Make sure the Sema4.ai platform has network line of sight to the ingress and can send that header format. For external agent access this is the path — a role-restricted PAT over the public ingress.
Calling from inside the same account
If the consumer also runs in SPCS in this account — for example your own app or job calling the MCP — you don't need the public ingress or a PAT at all. Call the service over Snowflake's private container network by its internal DNS name:
http://<service>.<hash>.svc.spcs.internal:8080/mcpNo public internet, no token, no Authorization header — and the endpoint does not need to be public: true for this. Read the internal DNS name from SHOW SERVICES (the dns_name column).
Authorization here is a service-role grant, not a token: if both services share an owner role it's allowed by default; across owner roles, declare a service role on the MCP service and grant it to the consumer's owner role.
# service.yaml
serviceRoles:
- name: mcp_user
endpoints:
- mcpGRANT SERVICE ROLE MCP_DB.MCP_SCHEMA.MY_MCP_SVC!mcp_user
TO ROLE <consumer_service_owner_role>;The MCP service and its consumer can live in different compute pools in the same account — service-to-service networking is account/network-scoped, not pool-scoped, so the internal DNS name resolves across pools. This is a good pattern: independent scaling and suspend schedules per pool (e.g. a GPU pool for the app, a cheap CPU pool for the MCP).
Operating notes
- No scale-to-zero-and-wake. A running service keeps the pool
ACTIVEand bills continuously;AUTO_SUSPEND_SECSonly counts idle time, so a running service never auto-suspends. A suspended service has nothing behind the endpoint — requests fail until youALTER SERVICE … RESUME. Cold resume (poolSUSPENDED) is realistically 2–5 min; warm (poolIDLE, image cached) is tens of seconds. Don't expect Lambda-style behavior. - Stateful MCP + scaling caveat. Streamable-HTTP MCP is session-ful — the
mcp-session-idfrominitializeis bound to the instance that created it. Scaling out (MAX_INSTANCES> 1 andMAX_NODES> 1) requires session affinity or externalized session state, or sessions break. Stay single-instance unless you've verified routing. - Least privilege. The container gets its owner role's token; code can do anything that role can. Don't run prod services as
ACCOUNTADMIN. Setresources.requests/limitsso a runaway container can't starve neighbors. - Logs.
snow spcs service logs MY_MCP_SVC --container-name my-mcp --instance-id 0 --database MCP_DB --schema MCP_SCHEMA -c $CONN. - Pin the image tag rather than
:latest, so deployments are deliberate and reproducible.