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. Call Modal serverless GPUs and sandboxes ¶ call-modal-serverless-gpus-and-sandboxes In this tutorial you'll drive Modal https://modal.com — 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 ../../how-to/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 ¶ prerequisites mcp-v8 installed see Install ../../install/overview/ . curl and jq .- A Modal account and an API token pair a token id ak-… and secret as-… , created in your Modal workspace settings. Modal is a cloud platform — there is no offline mode; the calls really hit api.modal.com . A deployed Modal Function to call. The JS SDK invokes Functions that are defined in Python and 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 : python echo.py 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 ¶ 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 — to import the modal 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, so mcp-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 live WebAssembly global. WebAssembly is present in the normal runtime, but a V8 SnapshotCreator isolate disables it — so heap persistence must be off --heap-store none , the default . If you need per-session state, use filesystem persistence ../../how-to/fs-snapshots/ instead, which doesn't disable WebAssembly. Step 1 — Write the policy ¶ 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 Which authorities may be dialed api.modal.com plus any input-plane host . allow if { input.operation == "connect" endswith input.url parsed.host, ".modal.com" } allow if { input.operation == "connect" input.url parsed.host == "modal.com" } Which streams per-RPC may open on an allowed session. 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 ¶ 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 ¶ 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. js 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. Save the script above as modal-call.js, then submit it. EXEC ID=$ curl -sX POST http://localhost:8080/api/exec \ -H 'Content-Type: application/javascript' \ --data-binary @modal-call.js | jq -r '.execution id' Poll until the execution reaches a terminal state. 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 Read the console output your Function's return value is here . 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 ¶ going-further-sandboxes The same setup drives Modal Sandboxes — spin up a container, stream to its stdin, read its stdout: js 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 https://github.com/modal-labs/modal-client/tree/main/js/examples cover each of these. Deploy it to Railway ¶ deploy-it-to-railway Running this on Railway https://railway.com gives you a hosted, always-on sandbox that an agent elsewhere can call. The fastest start is the one-click Deploy on Railway template https://railway.com/deploy/mcp-js , which provisions the server with a volume and the standard variables; the in the repo documents every variable. Then apply two Modal-specific changes. https://github.com/r33drichards/mcp-js/blob/main/RAILWAY.md 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://