cd /news/developer-tools/use-mcp-js-to-control-modal-sandboxe… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-116851] src=r33drichards.github.io β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Use MCP-JS to control Modal Sandboxes

Modal's serverless GPU and sandbox platform can now be controlled from JavaScript inside an mcp-v8 isolate using the stock modal npm package unmodified, according to a tutorial from mcp-v8. The setup requires enabling four capabilities: external module imports, an HTTP/2 policy allowing *.modal.com, header injection of the Modal token, and WebAssembly (with heap persistence off). The tutorial demonstrates calling a deployed Modal Function from a sandbox, with credentials injected server-side so they never enter the isolate.

read12 min views1 publishedAug 31, 2026

In this tutorial you'll drive Modal β€” serverless containers, GPUs, and sandboxes β€” from JavaScript running inside an mcp-v8

isolate. By the end, code in the sandbox will call a deployed Modal Function and get a result back, using the stock modal npm package, unmodified.

The interesting part is how it works. Modal's SDK talks to api.modal.com

over gRPC, which rides HTTP/2 β€” a protocol the sandbox has no raw sockets for. It works anyway because mcp-v8

ships a policy-gated node:http2 transport, and the SDK's credentials are injected server-side so they never enter the isolate. You'll assemble those pieces one at a time and see why each is needed.

PrerequisitesΒΆ #

mcp-v8

installed (seeInstall).curl

andjq

.- A Modal account and an API token pair (a token id ak-…

and secretas-…

), created in your Modal workspace settings. Modal is a cloud platform β€” there is no offline mode; the calls really hitapi.modal.com

. A deployed Modal Function to call. The JS SDK invokes Functions that aredefined in Pythonand already deployed β€” it does not define them.

If you don't have a deployed Function, deploy this minimal example first (with the Python modal

CLI, pip install modal && modal setup

):

import modal

app = modal.App("my-app")

@app.function()
def my_fn(name: str) -> str:
    return f"hello {name}"
modal deploy echo.py

That publishes Function my-fn

in app my-app

β€” the names Step 3 looks up.

Why this needs four things turned onΒΆ #

Everything the SDK touches is a capability that mcp-v8

keeps off by default. Turning them on one at a time makes the failure modes legible:

External module importsβ€” toimport

themodal

package from esm.sh. Without it, the import throws immediately.Anβ€” every gRPC connection goes through the gated HTTP/2 transport. With no policy, the connect is refused.http2

policy allowing*.modal.com

Header injection of your Modal tokenβ€” gRPC metadata is just HTTP/2 headers, somcp-v8

can attach the token at the transport layer. Injection overwrites the same-named header the SDK sets, so the sandbox authenticates without ever holding the real secret (it passes a placeholder).WebAssemblyβ€” the SDK's dependency tree needs a liveWebAssembly

global. WebAssembly is present in the normal runtime, but a V8SnapshotCreator

isolate disables it β€” soheap persistence must be off(--heap-store none

, the default). If you need per-session state, usefilesystem persistenceinstead, which doesn't disable WebAssembly.

Step 1 β€” Write the policyΒΆ #

The HTTP/2 transport asks a policy before dialing anywhere. Scope it to Modal so the sandbox can reach Modal and nothing else. Match any *.modal.com

host, not just api.modal.com

: the SDK's control plane can hand back a separate input-plane host (also under modal.com

) for some function calls, and a policy pinned to api.modal.com

would deny that second connection. Save this as http2.rego

:

package mcp.http2

default allow = false

allow if {
    input.operation == "connect"
    endswith(input.url_parsed.host, ".modal.com")
}
allow if {
    input.operation == "connect"
    input.url_parsed.host == "modal.com"
}

allow if {
    input.operation == "request"
    endswith(input.authority, ".modal.com")
}
allow if {
    input.operation == "request"
    input.authority == "modal.com"
}

Step 2 β€” Start the server with the four capabilitiesΒΆ #

mcp-v8 \
  --http-port 8080 \
  --allow-external-modules \
  --heap-store none \
  --policies-json '{"http2":{"policies":[{"url":"file:///path/to/http2.rego"}]}}' \
  --fetch-header "host=*.modal.com,header=x-modal-token-id,value=ak-..." \
  --fetch-header "host=*.modal.com,header=x-modal-token-secret,value=as-..."

--http-port 8080

is required: with no port flag mcp-v8 serves the stdio transport, and Step 3's curl http://localhost:8080/...

would get connection refused. The two --fetch-header

rules are the trick that keeps the secret out of the isolate: they're host-scoped to *.modal.com

(matching the policy above), so the token only ever travels to Modal, and there is no request-header read-back API β€” sandboxed code can authenticate but can never read the injected values. Injection overwrites the same-named header the SDK sets, so the placeholder the script passes (Step 3) is replaced by the real token before the request leaves the host.

For a container or Kubernetes deployment, the same settings are environment variables (the JSON must be a single line):

MCP_V8_HTTP_PORT=8080
MCP_V8_ALLOW_EXTERNAL_MODULES=true
MCP_V8_HEAP_STORE=none
MCP_V8_POLICIES_JSON={"http2":{"policies":[{"url":"file:///path/to/http2.rego"}]}}
MCP_V8_FETCH_HEADER_CONFIG=[{"host":"*.modal.com","headers":{"x-modal-token-id":"ak-...","x-modal-token-secret":"as-..."}}]

Step 3 β€” Call Modal from the sandboxΒΆ #

Here's the script. Note it constructs the client with a placeholder secret β€” the real one is injected server-side, and header injection replaces the same-named header the SDK sets, so the placeholder never reaches Modal.

import { ModalClient } from 'npm:modal?target=node';
import { Buffer } from 'node:buffer';
import process from 'node:process';

// Packages built for Node expect these as globals.
globalThis.Buffer = Buffer;
globalThis.process = process;

const modal = new ModalClient({
  tokenId: 'ak-...',            // your public token id
  tokenSecret: 'placeholder',   // overridden server-side by header injection
});

// Call the deployed Function and print its result:
const fn = await modal.functions.fromName('my-app', 'my-fn');
console.log(JSON.stringify(await fn.remote(['world'])));

The ?target=node

suffix matters: it selects the SDK's Node build, which imports the node:*

builtins mcp-v8

serves, rather than the browser build.

Run it through the sandbox. /api/exec

is asynchronous: it returns 202

with an execution_id

, and you read the result from the execution's output endpoint β€” there is no synchronous .output

field.

EXEC_ID=$(curl -sX POST http://localhost:8080/api/exec \
  -H 'Content-Type: application/javascript' \
  --data-binary @modal-call.js | jq -r '.execution_id')

while :; do
  STATUS=$(curl -s "http://localhost:8080/api/executions/$EXEC_ID" | jq -r '.status')
  case "$STATUS" in
    Completed) break ;;
    Failed|TimedOut|Cancelled) echo "execution $STATUS"; break ;;
    *) sleep 1 ;;
  esac
done

curl -s "http://localhost:8080/api/executions/$EXEC_ID/output" | jq -r '.data'

You should see your Function's return value ("hello world"

). That round trip β€” JS in the isolate β†’ node:http2

β†’ gRPC β†’ api.modal.com

β†’ back β€” is the whole point: an unmodified cloud SDK, talking to its backend over a protocol the sandbox implements through host-side ops, authenticated by a credential the isolate never saw.

Going further: SandboxesΒΆ #

The same setup drives Modal Sandboxes β€” spin up a container, stream to its stdin, read its stdout:

const app = await modal.apps.fromName('sandbox-app', { createIfMissing: true });
const image = modal.images.fromRegistry('alpine:3.21');
const sb = await modal.sandboxes.create(app, image, { command: ['cat'] });
await sb.stdin.writeText('hi there'); await sb.stdin.close();
console.log(await sb.stdout.readText());
await sb.terminate();

Sandbox creation takes many more options (secrets

, timeoutMs

, cpu

, memoryMiB

, GPUs, volumes, tunnels). The JS SDK's scope is creating and driving Sandboxes and calling deployed Functions/Classes β€” Functions themselves are defined in Python. The Modal JS examples cover each of these.

Deploy it to RailwayΒΆ #

Running this on Railway gives you a hosted, always-on sandbox that an agent elsewhere can call. The fastest start is the one-click Deploy on Railway template, which provisions the server with a volume and the standard variables; the

in the repo documents every variable. Then apply two Modal-specific changes.

RAILWAY.md

guideFirst, the policy file has no place on an ephemeral container, so write it from the start command (Settings β†’ Deploy β†’ Custom Start Command) before the server launches. Write it to /tmp

β€” the image runs as a non-root user that can't create files under /

, and the OS sandbox still grants read access to a file://

policy path:

sh -c 'printf %s "package mcp.http2
default allow = false
allow if { input.operation == \"connect\"; endswith(input.url_parsed.host, \".modal.com\") }
allow if { input.operation == \"request\"; endswith(input.authority, \".modal.com\") }
" > /tmp/http2.rego
exec mcp-v8'

Second, set the Modal variables alongside the standard ones β€” and note MCP_V8_HEAP_STORE=none

(WebAssembly), which replaces the dir

value the base guide uses; keep MCP_V8_FS_STORE=dir

for per-session filesystem state:

MCP_V8_HEAP_STORE=none
MCP_V8_FS_STORE=dir
MCP_V8_FS_DIR=/data/fs
MCP_V8_ALLOW_EXTERNAL_MODULES=true
MCP_V8_POLICIES_JSON={"http2":{"policies":[{"url":"file:///tmp/http2.rego"}]}}
MCP_V8_FETCH_HEADER_CONFIG=[{"host":"*.modal.com","headers":{"x-modal-token-id":"ak-...","x-modal-token-secret":"as-..."}}]
MCP_V8_ALLOWED_HOSTS=${{RAILWAY_PUBLIC_DOMAIN}},${{RAILWAY_PRIVATE_DOMAIN}}

Keep both domains in MCP_V8_ALLOWED_HOSTS

: the public one for external agents, the private one so other Railway services can reach it over the internal network. Generate the public domain first (Settings β†’ Networking β†’ target port 8080

) β€” until it exists, ${{RAILWAY_PUBLIC_DOMAIN}}

expands empty and mcp-v8 falls back to loopback-only, 403-ing every request; redeploy after generating it if you set the variable first. The server is then reachable at https://<your-domain>/mcp

, and your Modal token lives only in a Railway variable β€” never in the JavaScript an agent submits.

Provision an identity provider (Keycloak on Railway)ΒΆ #

A public /mcp

endpoint that runs arbitrary JavaScript must be authenticated. mcp-v8 verifies JWT bearer tokens against a JWKS endpoint, so you need something that issues signed tokens. This repo ships a ready-made Keycloak realm β€” keycloak/mcp-realm.json

, realm mcp

with a confidential client mcp-client

β€” so you can stand up an issuer in one more service instead of wiring an IdP by hand.

Add a second Railway service for Keycloak. The realm is declarative: importing it on every boot recreates the client and its secret identically, so Keycloak's dev mode (ephemeral H2 storage) is enough here β€” a redeploy re-imports the same realm, and while its signing keys rotate on each redeploy (so tokens must be re-minted after one), the client id and secret stay stable.

The official Keycloak image has curl

and package managers removed, so you can't fetch the realm from a start command. Instead deploy from a tiny Dockerfile that bakes the realm in at build time (Docker's ADD

fetches the URL during the build, where the network is available):

FROM quay.io/keycloak/keycloak:26.4
ADD --chmod=444 \
  https://raw.githubusercontent.com/r33drichards/mcp-js/main/keycloak/mcp-realm.json \
  /opt/keycloak/data/import/mcp-realm.json
CMD ["start-dev", "--import-realm", "--http-port=8080"]

Put that Dockerfile in a repo (or a subdirectory) and point the Railway service at it. Set these variables so Keycloak trusts Railway's TLS-terminating proxy and can bootstrap an admin user:

KC_PROXY_HEADERS=xforwarded
KC_HTTP_ENABLED=true
KC_HOSTNAME_STRICT=false
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=<pick-a-strong-password>

Generate a public domain for the service (target port 8080

). Keycloak is now serving the realm's JWKS at https://<keycloak-domain>/realms/mcp/protocol/openid-connect/certs

.

Not production-hardened as written.Dev mode stores nothing durably and the client secret is public in the repo. For real use, run Keycloak in production mode against a Postgres database (Railway provisions one in a click), rotatemcp-client

's secret, and lengthen or shorten the access-token lifespan to taste. The declarative realm is the starting point, not the final config.

Require auth on the sandbox and connect Claude CodeΒΆ #

Point mcp-v8 at Keycloak's key set by adding one variable to the mcp-js service (from the Deploy it to Railway step):

JWKS_URL=https://<keycloak-domain>/realms/mcp/protocol/openid-connect/certs

Bring Keycloak up first.mcp-v8 fetches the JWKS at startup andexits if the endpoint is unreachable, so confirm Keycloak is serving before you set this. A quick check:curl -sf https://<keycloak-domain>/realms/mcp/protocol/openid-connect/certs

should return a JSON key set. SetJWKS_URL

(and redeploy mcp-js) only after that succeeds.

With that set, mcp-v8 enforces auth: every request to /mcp

and the HTTP API (/api/exec

, /api/fs/*

) must carry a valid Authorization: Bearer <jwt>

, or it is rejected with 401

. (Without JWKS_URL

the server requires no token β€” so don't expose it publicly until this is set.) Mint a token with the client-credentials grant β€” no browser, no user, just the client id and secret from the realm:

TOKEN=$(curl -s \
  -X POST https://<keycloak-domain>/realms/mcp/protocol/openid-connect/token \
  -d grant_type=client_credentials \
  -d client_id=mcp-client \
  -d client_secret=mcp-client-secret \
  | jq -r .access_token)

Register the deployment with Claude Code, passing that token as a header (see Authentication for other ways to present it):

claude mcp add --transport http modal-sandbox \
  https://<your-domain>/mcp \
  --header "Authorization: Bearer ${TOKEN}"

Claude Code now sees run_js

(and any upstream MCP tools you've bridged) as callable tools, and can drive Modal through the sandbox on your behalf β€” GPUs, Sandboxes, and deployed Functions β€” with the credential boundary intact end to end: the agent holds a short-lived JWT to reach the sandbox, and the sandbox holds nothing; the Modal token is injected host-side and never crosses into the isolate or back to the agent.

Access tokens are short-lived (five minutes by default), so a header captured once will expire β€” re-run claude mcp add

with a fresh ${TOKEN}

when it lapses. Raising the lifespan in the Keycloak admin console won't stick in the dev-mode setup above: the next redeploy re-imports the declarative realm and resets it. To change it durably, set accessTokenLifespan

in the realm JSON, or run Keycloak in production mode against a database.

When something goes wrongΒΆ #

Symptom Cause
Unknown node builtin module: 'crypto'
Old build without node:crypto ; update mcp-v8.
WebAssembly is not an object
Heap persistence is on. Set --heap-store none .
gRPC connect rejected / capability disabled
No http2 policy, or it doesn't allow the host. Scope it to *.modal.com , not just api.modal.com β€” some calls dial a separate input-plane host.
A call to api.modal.com works but another gRPC connect is denied
The policy (and the --fetch-header host) is pinned to api.modal.com ; widen both to *.modal.com for the input-plane host.
UNAUTHENTICATED from Modal
Header-injection rule missing/misspelled, or token invalid. The header names must be exactly x-modal-token-id / x-modal-token-secret , and the rule host must match the request (*.modal.com ).
Import fails --allow-external-modules not set, or egress to esm.sh blocked by the OS sandbox or network policy.
401 /403 from /mcp or /api/*
Missing or expired bearer token, or JWKS_URL doesn't point at the realm's .../protocol/openid-connect/certs . Mint a fresh token.
Native-module load error importing modal
The SDK's transitive deps include a native (N-API) addon; smoke-test the import (console.log(typeof ModalClient) ) before a real call, and confirm esm.sh egress is allowed.
Keycloak token request returns invalid_client
Wrong client_id /client_secret , or the realm didn't import β€” check the service logs for the Imported realm mcp line.

See alsoΒΆ #

HTTP/2 sessions (node:http2)β€” the transport, per-stream policy, and header injection in depth.Running stock @grpc/grpc-jsβ€” the same mechanism for any gRPC SDK.ES module importsandSecurity policies.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @modal 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/use-mcp-js-to-contro…] indexed:0 read:12min 2026-08-31 Β· β€”