Webinar: Better Agents, Easier than Ever — Thursday, June 18th at 9am PT / 12pm ET. Register Now
Version 2.5
First-time setup
Snowflake SPCS

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 $CONN

Push 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:latest

Deploy

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: true
snow spcs service create MY_MCP_SVC \
  --compute-pool MCP_POOL \
  --spec-path service.yaml \
  --database MCP_DB --schema MCP_SCHEMA -c $CONN

The 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:
      - mcp
CREATE 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/mcp

No 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:
      - mcp
GRANT 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 ACTIVE and bills continuously; AUTO_SUSPEND_SECS only counts idle time, so a running service never auto-suspends. A suspended service has nothing behind the endpoint — requests fail until you ALTER SERVICE … RESUME. Cold resume (pool SUSPENDED) is realistically 2–5 min; warm (pool IDLE, 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-id from initialize is bound to the instance that created it. Scaling out (MAX_INSTANCES > 1 and MAX_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. Set resources.requests/limits so 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.