{"slug": "use-mcp-js-to-control-modal-sandboxes", "title": "Use MCP-JS to control Modal Sandboxes", "summary": "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.", "body_md": "# Call Modal (serverless GPUs and sandboxes)[¶](#call-modal-serverless-gpus-and-sandboxes)\n\nIn this tutorial you'll drive [Modal](https://modal.com) — serverless\ncontainers, GPUs, and sandboxes — from JavaScript running inside an `mcp-v8`\n\nisolate. By the end, code in the sandbox will call a deployed Modal Function\nand get a result back, using the **stock modal npm package, unmodified**.\n\nThe interesting part is *how* it works. Modal's SDK talks to `api.modal.com`\n\nover gRPC, which rides HTTP/2 — a protocol the sandbox has no raw sockets for.\nIt works anyway because `mcp-v8`\n\nships a policy-gated\n[ node:http2](../../how-to/http2/) transport, and the SDK's credentials are\ninjected server-side so they never enter the isolate. You'll assemble those\npieces one at a time and see why each is needed.\n\n## Prerequisites[¶](#prerequisites)\n\n`mcp-v8`\n\ninstalled (see[Install](../../install/overview/)).`curl`\n\nand`jq`\n\n.- A Modal account and an API token pair (a token id\n`ak-…`\n\nand secret`as-…`\n\n), created in your Modal workspace settings. Modal is a cloud platform — there is no offline mode; the calls really hit`api.modal.com`\n\n. **A deployed Modal Function to call.** The JS SDK invokes Functions that are*defined in Python*and already deployed — it does not define them.\n\nIf you don't have a deployed Function, deploy this minimal example first (with\nthe Python `modal`\n\nCLI, `pip install modal && modal setup`\n\n):\n\n``` python\n# echo.py\nimport modal\n\napp = modal.App(\"my-app\")\n\n@app.function()\ndef my_fn(name: str) -> str:\n    return f\"hello {name}\"\nmodal deploy echo.py\n```\n\nThat publishes Function `my-fn`\n\nin app `my-app`\n\n— the names Step 3 looks up.\n\n## Why this needs four things turned on[¶](#why-this-needs-four-things-turned-on)\n\nEverything the SDK touches is a capability that `mcp-v8`\n\nkeeps **off by\ndefault**. Turning them on one at a time makes the failure modes legible:\n\n**External module imports**— to`import`\n\nthe`modal`\n\npackage 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`\n\npolicy allowing`*.modal.com`\n\n**Header injection of your Modal token**— gRPC metadata is just HTTP/2 headers, so`mcp-v8`\n\ncan 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`\n\nglobal. WebAssembly is present in the normal runtime, but a V8`SnapshotCreator`\n\nisolate disables it — so**heap persistence must be off**(`--heap-store none`\n\n, the default). If you need per-session state, use[filesystem persistence](../../how-to/fs-snapshots/)instead, which doesn't disable WebAssembly.\n\n## Step 1 — Write the policy[¶](#step-1-write-the-policy)\n\nThe HTTP/2 transport asks a policy before dialing anywhere. Scope it to Modal so\nthe sandbox can reach Modal and nothing else. Match any `*.modal.com`\n\nhost, not\njust `api.modal.com`\n\n: the SDK's control plane can hand back a separate\ninput-plane host (also under `modal.com`\n\n) for some function calls, and a policy\npinned to `api.modal.com`\n\nwould deny that second connection. Save this as\n`http2.rego`\n\n:\n\n```\npackage mcp.http2\n\ndefault allow = false\n\n# Which authorities may be dialed (api.modal.com plus any input-plane host).\nallow if {\n    input.operation == \"connect\"\n    endswith(input.url_parsed.host, \".modal.com\")\n}\nallow if {\n    input.operation == \"connect\"\n    input.url_parsed.host == \"modal.com\"\n}\n\n# Which streams (per-RPC) may open on an allowed session.\nallow if {\n    input.operation == \"request\"\n    endswith(input.authority, \".modal.com\")\n}\nallow if {\n    input.operation == \"request\"\n    input.authority == \"modal.com\"\n}\n```\n\n## Step 2 — Start the server with the four capabilities[¶](#step-2-start-the-server-with-the-four-capabilities)\n\n```\nmcp-v8 \\\n  --http-port 8080 \\\n  --allow-external-modules \\\n  --heap-store none \\\n  --policies-json '{\"http2\":{\"policies\":[{\"url\":\"file:///path/to/http2.rego\"}]}}' \\\n  --fetch-header \"host=*.modal.com,header=x-modal-token-id,value=ak-...\" \\\n  --fetch-header \"host=*.modal.com,header=x-modal-token-secret,value=as-...\"\n```\n\n`--http-port 8080`\n\nis required: with no port flag mcp-v8 serves the stdio\ntransport, and Step 3's `curl http://localhost:8080/...`\n\nwould get connection\nrefused. The two `--fetch-header`\n\nrules are the trick that keeps the secret out\nof the isolate: they're **host-scoped** to `*.modal.com`\n\n(matching the policy\nabove), so the token only ever travels to Modal, and there is no request-header\nread-back API — sandboxed code can authenticate but can never read the injected\nvalues. Injection **overwrites** the same-named header the SDK sets, so the\nplaceholder the script passes (Step 3) is replaced by the real token before the\nrequest leaves the host.\n\nFor a container or Kubernetes deployment, the same settings are environment variables (the JSON must be a single line):\n\n```\nMCP_V8_HTTP_PORT=8080\nMCP_V8_ALLOW_EXTERNAL_MODULES=true\nMCP_V8_HEAP_STORE=none\nMCP_V8_POLICIES_JSON={\"http2\":{\"policies\":[{\"url\":\"file:///path/to/http2.rego\"}]}}\nMCP_V8_FETCH_HEADER_CONFIG=[{\"host\":\"*.modal.com\",\"headers\":{\"x-modal-token-id\":\"ak-...\",\"x-modal-token-secret\":\"as-...\"}}]\n```\n\n## Step 3 — Call Modal from the sandbox[¶](#step-3-call-modal-from-the-sandbox)\n\nHere's the script. Note it constructs the client with a **placeholder secret** —\nthe real one is injected server-side, and header injection replaces the\nsame-named header the SDK sets, so the placeholder never reaches Modal.\n\n``` js\nimport { ModalClient } from 'npm:modal?target=node';\nimport { Buffer } from 'node:buffer';\nimport process from 'node:process';\n\n// Packages built for Node expect these as globals.\nglobalThis.Buffer = Buffer;\nglobalThis.process = process;\n\nconst modal = new ModalClient({\n  tokenId: 'ak-...',            // your public token id\n  tokenSecret: 'placeholder',   // overridden server-side by header injection\n});\n\n// Call the deployed Function and print its result:\nconst fn = await modal.functions.fromName('my-app', 'my-fn');\nconsole.log(JSON.stringify(await fn.remote(['world'])));\n```\n\nThe `?target=node`\n\nsuffix matters: it selects the SDK's Node build, which\nimports the `node:*`\n\nbuiltins `mcp-v8`\n\nserves, rather than the browser build.\n\nRun it through the sandbox. `/api/exec`\n\nis **asynchronous**: it returns `202`\n\nwith an `execution_id`\n\n, and you read the result from the execution's output\nendpoint — there is no synchronous `.output`\n\nfield.\n\n```\n# Save the script above as modal-call.js, then submit it.\nEXEC_ID=$(curl -sX POST http://localhost:8080/api/exec \\\n  -H 'Content-Type: application/javascript' \\\n  --data-binary @modal-call.js | jq -r '.execution_id')\n\n# Poll until the execution reaches a terminal state.\nwhile :; do\n  STATUS=$(curl -s \"http://localhost:8080/api/executions/$EXEC_ID\" | jq -r '.status')\n  case \"$STATUS\" in\n    Completed) break ;;\n    Failed|TimedOut|Cancelled) echo \"execution $STATUS\"; break ;;\n    *) sleep 1 ;;\n  esac\ndone\n\n# Read the console output (your Function's return value is here).\ncurl -s \"http://localhost:8080/api/executions/$EXEC_ID/output\" | jq -r '.data'\n```\n\nYou should see your Function's return value (`\"hello world\"`\n\n). That round trip — JS in the\nisolate → `node:http2`\n\n→ gRPC → `api.modal.com`\n\n→ back — is the whole point:\nan unmodified cloud SDK, talking to its backend over a protocol the sandbox\nimplements through host-side ops, authenticated by a credential the isolate\nnever saw.\n\n## Going further: Sandboxes[¶](#going-further-sandboxes)\n\nThe same setup drives Modal Sandboxes — spin up a container, stream to its stdin, read its stdout:\n\n``` js\nconst app = await modal.apps.fromName('sandbox-app', { createIfMissing: true });\nconst image = modal.images.fromRegistry('alpine:3.21');\nconst sb = await modal.sandboxes.create(app, image, { command: ['cat'] });\nawait sb.stdin.writeText('hi there'); await sb.stdin.close();\nconsole.log(await sb.stdout.readText());\nawait sb.terminate();\n```\n\nSandbox creation takes many more options (`secrets`\n\n, `timeoutMs`\n\n, `cpu`\n\n,\n`memoryMiB`\n\n, GPUs, volumes, tunnels). The JS SDK's scope is creating and\ndriving Sandboxes and calling deployed Functions/Classes — Functions themselves\nare defined in Python. The\n[Modal JS examples](https://github.com/modal-labs/modal-client/tree/main/js/examples)\ncover each of these.\n\n## Deploy it to Railway[¶](#deploy-it-to-railway)\n\nRunning this on [Railway](https://railway.com) gives you a hosted, always-on\nsandbox that an agent elsewhere can call. The fastest start is the one-click\n[ Deploy on Railway template](https://railway.com/deploy/mcp-js), which\nprovisions the server with a volume and the standard variables; the\n\n[in the repo documents every variable. Then apply two Modal-specific changes.](https://github.com/r33drichards/mcp-js/blob/main/RAILWAY.md)\n\n`RAILWAY.md`\n\nguideFirst, the policy file has no place on an ephemeral container, so write it from\nthe **start command** (Settings → Deploy → Custom Start Command) before the\nserver launches. Write it to `/tmp`\n\n— the image runs as a non-root user that\ncan't create files under `/`\n\n, and the OS sandbox still grants read access to a\n`file://`\n\npolicy path:\n\n```\nsh -c 'printf %s \"package mcp.http2\ndefault allow = false\nallow if { input.operation == \\\"connect\\\"; endswith(input.url_parsed.host, \\\".modal.com\\\") }\nallow if { input.operation == \\\"request\\\"; endswith(input.authority, \\\".modal.com\\\") }\n\" > /tmp/http2.rego\nexec mcp-v8'\n```\n\nSecond, set the Modal variables alongside the standard ones — and note\n`MCP_V8_HEAP_STORE=none`\n\n(WebAssembly), which replaces the `dir`\n\nvalue the base\nguide uses; keep `MCP_V8_FS_STORE=dir`\n\nfor per-session filesystem state:\n\n```\nMCP_V8_HEAP_STORE=none\nMCP_V8_FS_STORE=dir\nMCP_V8_FS_DIR=/data/fs\nMCP_V8_ALLOW_EXTERNAL_MODULES=true\nMCP_V8_POLICIES_JSON={\"http2\":{\"policies\":[{\"url\":\"file:///tmp/http2.rego\"}]}}\nMCP_V8_FETCH_HEADER_CONFIG=[{\"host\":\"*.modal.com\",\"headers\":{\"x-modal-token-id\":\"ak-...\",\"x-modal-token-secret\":\"as-...\"}}]\nMCP_V8_ALLOWED_HOSTS=${{RAILWAY_PUBLIC_DOMAIN}},${{RAILWAY_PRIVATE_DOMAIN}}\n```\n\nKeep both domains in `MCP_V8_ALLOWED_HOSTS`\n\n: the public one for external agents,\nthe private one so other Railway services can reach it over the internal\nnetwork. **Generate the public domain first** (Settings → Networking → target\nport `8080`\n\n) — until it exists, `${{RAILWAY_PUBLIC_DOMAIN}}`\n\nexpands empty and\nmcp-v8 falls back to loopback-only, 403-ing every request; redeploy after\ngenerating it if you set the variable first. The server is then reachable at\n`https://<your-domain>/mcp`\n\n, and your Modal token lives only in a Railway\nvariable — never in the JavaScript an agent submits.\n\n## Provision an identity provider (Keycloak on Railway)[¶](#provision-an-identity-provider-keycloak-on-railway)\n\nA public `/mcp`\n\nendpoint that runs arbitrary JavaScript **must** be\nauthenticated. mcp-v8 verifies JWT bearer tokens against a JWKS endpoint, so you\nneed something that issues signed tokens. This repo ships a ready-made Keycloak\nrealm — `keycloak/mcp-realm.json`\n\n, realm `mcp`\n\nwith a confidential client\n`mcp-client`\n\n— so you can stand up an issuer in one more service instead of\nwiring an IdP by hand.\n\nAdd a second Railway service for Keycloak. The realm is **declarative**:\nimporting it on every boot recreates the client and its secret identically, so\nKeycloak's dev mode (ephemeral H2 storage) is enough here — a redeploy re-imports\nthe same realm, and while its **signing keys rotate on each redeploy** (so tokens\nmust be re-minted after one), the client id and secret stay stable.\n\nThe official Keycloak image has `curl`\n\nand package managers **removed**, so you\ncan't fetch the realm from a start command. Instead deploy from a tiny\nDockerfile that bakes the realm in at build time (Docker's `ADD`\n\nfetches the URL\nduring the build, where the network is available):\n\n```\nFROM quay.io/keycloak/keycloak:26.4\nADD --chmod=444 \\\n  https://raw.githubusercontent.com/r33drichards/mcp-js/main/keycloak/mcp-realm.json \\\n  /opt/keycloak/data/import/mcp-realm.json\nCMD [\"start-dev\", \"--import-realm\", \"--http-port=8080\"]\n```\n\nPut 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:\n\n```\nKC_PROXY_HEADERS=xforwarded\nKC_HTTP_ENABLED=true\nKC_HOSTNAME_STRICT=false\nKC_BOOTSTRAP_ADMIN_USERNAME=admin\nKC_BOOTSTRAP_ADMIN_PASSWORD=<pick-a-strong-password>\n```\n\nGenerate a public domain for the service (target port `8080`\n\n). Keycloak is now\nserving the realm's JWKS at\n`https://<keycloak-domain>/realms/mcp/protocol/openid-connect/certs`\n\n.\n\nNot 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), rotate`mcp-client`\n\n's secret, and lengthen or shorten the access-token lifespan to taste. The declarative realm is the starting point, not the final config.\n\n## Require auth on the sandbox and connect Claude Code[¶](#require-auth-on-the-sandbox-and-connect-claude-code)\n\nPoint mcp-v8 at Keycloak's key set by adding one variable to the **mcp-js**\nservice (from the [Deploy it to Railway](#deploy-it-to-railway) step):\n\n```\nJWKS_URL=https://<keycloak-domain>/realms/mcp/protocol/openid-connect/certs\n```\n\nBring 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`\n\nshould return a JSON key set. Set`JWKS_URL`\n\n(and redeploy mcp-js) only after that succeeds.\n\nWith that set, mcp-v8 **enforces** auth: every request to `/mcp`\n\n*and* the HTTP\nAPI (`/api/exec`\n\n, `/api/fs/*`\n\n) must carry a valid `Authorization: Bearer <jwt>`\n\n,\nor it is rejected with `401`\n\n. (Without `JWKS_URL`\n\nthe server requires no token —\nso don't expose it publicly until this is set.) Mint a token with the\nclient-credentials grant — no browser, no user, just the client id and secret\nfrom the realm:\n\n```\nTOKEN=$(curl -s \\\n  -X POST https://<keycloak-domain>/realms/mcp/protocol/openid-connect/token \\\n  -d grant_type=client_credentials \\\n  -d client_id=mcp-client \\\n  -d client_secret=mcp-client-secret \\\n  | jq -r .access_token)\n```\n\nRegister the deployment with Claude Code, passing that token as a header (see\n[Authentication](../../how-to/authentication/) for other ways to present it):\n\n```\nclaude mcp add --transport http modal-sandbox \\\n  https://<your-domain>/mcp \\\n  --header \"Authorization: Bearer ${TOKEN}\"\n```\n\nClaude Code now sees `run_js`\n\n(and any [upstream MCP tools](../../how-to/mcp-client/)\nyou've bridged) as callable tools, and can drive Modal through the sandbox on\nyour behalf — GPUs, Sandboxes, and deployed Functions — with the credential\nboundary intact end to end: the agent holds a short-lived JWT to reach the\nsandbox, and the sandbox holds nothing; the Modal token is injected host-side\nand never crosses into the isolate or back to the agent.\n\nAccess tokens are short-lived (five minutes by default), so a header captured\nonce will expire — re-run `claude mcp add`\n\nwith a fresh `${TOKEN}`\n\nwhen it\nlapses. Raising the lifespan in the Keycloak admin console **won't stick** in the\ndev-mode setup above: the next redeploy re-imports the declarative realm and\nresets it. To change it durably, set `accessTokenLifespan`\n\nin the realm JSON, or\nrun Keycloak in production mode against a database.\n\n## When something goes wrong[¶](#when-something-goes-wrong)\n\n| Symptom | Cause |\n|---|---|\n`Unknown node builtin module: 'crypto'` |\nOld build without `node:crypto` ; update mcp-v8. |\n`WebAssembly is not an object` |\nHeap persistence is on. Set `--heap-store none` . |\ngRPC `connect` rejected / capability disabled |\nNo `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. |\nA call to `api.modal.com` works but another gRPC connect is denied |\nThe policy (and the `--fetch-header` host) is pinned to `api.modal.com` ; widen both to `*.modal.com` for the input-plane host. |\n`UNAUTHENTICATED` from Modal |\nHeader-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` ). |\n| Import fails | `--allow-external-modules` not set, or egress to esm.sh blocked by the OS sandbox or network policy. |\n`401` /`403` from `/mcp` or `/api/*` |\nMissing or expired bearer token, or `JWKS_URL` doesn't point at the realm's `.../protocol/openid-connect/certs` . Mint a fresh token. |\nNative-module load error importing `modal` |\nThe 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. |\nKeycloak token request returns `invalid_client` |\nWrong `client_id` /`client_secret` , or the realm didn't import — check the service logs for the `Imported realm mcp` line. |\n\n## See also[¶](#see-also)\n\n[HTTP/2 sessions (node:http2)](../../how-to/http2/)— the transport, per-stream policy, and header injection in depth.[Running stock @grpc/grpc-js](../../how-to/http2/#run-a-stock-grpc-client)— the same mechanism for any gRPC SDK.[ES module imports](../../how-to/module-imports/)and[Security policies](../../how-to/policies/).", "url": "https://wpnews.pro/news/use-mcp-js-to-control-modal-sandboxes", "canonical_source": "https://r33drichards.github.io/mcp-js/tutorials/modal/", "published_at": "2026-08-31 17:03:52+00:00", "updated_at": "2026-08-31 17:22:30.082917+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["Modal", "mcp-v8", "npm", "V8", "WebAssembly"], "alternates": {"html": "https://wpnews.pro/news/use-mcp-js-to-control-modal-sandboxes", "markdown": "https://wpnews.pro/news/use-mcp-js-to-control-modal-sandboxes.md", "text": "https://wpnews.pro/news/use-mcp-js-to-control-modal-sandboxes.txt", "jsonld": "https://wpnews.pro/news/use-mcp-js-to-control-modal-sandboxes.jsonld"}}