Source: mcp/gateway/README.md

run_js MCP gateway

run_js_server example gateway

run_js_server is a stdio MCP server that runs bounded JavaScript and calls tools from configured downstream stdio or Streamable HTTP MCP servers. It exposes two tools:

  • run_js: run JavaScript with optional JSON input.
  • run_js_help: list authorized tools or inspect one tool's schema.

This file is the user setup guide. Current implementation status and follow-up work are in STATUS.md.

Inside JavaScript, call an authorized tool with await alias.tool(args) or await mcp.call("alias", "tool", args). Use help() or mcp.help() to inspect the authorized catalog.

Example: use the gateway from std_slop

This example uses the echo MCP server. Build the gateway, echo server, and std_slop; use absolute executable paths in the files below.

Create gateway.json:

{
  "runTimeoutMs": 30000,
  "traceLogPath": "/tmp/run_js_trace.log",
  "servers": [
    {
      "alias": "echo",
      "transport": "stdio",
      "command": "/absolute/path/to/bazel-bin/mcp/server/echo_server",
      "args": [],
      "allowTools": ["echo"]
    }
  ]
}

Register the gateway in the std_slop MCP registry, normally ~/.config/slop/mcp.ini:

[server.js]
transport=stdio
command=/absolute/path/to/bazel-bin/mcp/gateway/run_js_server
args_json=["--config","/absolute/path/to/gateway.json"]
enabled=true

Start std_slop with this registry and your usual model configuration. The registry alias js exposes the tools mcp_js_run_js and mcp_js_run_js_help. Ask the model to call mcp_js_run_js with this code:

const result = await echo.echo({text: "hello from std_slop"});
return result.structuredContent;

The returned structuredContent.result should be {"text":"hello from std_slop"}. A non-empty allowTools list restricts JavaScript to the listed tools. If allowTools is omitted or empty, all tools discovered from that downstream server are available.

This example enables a full trace log. In another tmux pane, follow it with:

tail -F /tmp/run_js_trace.log

The log contains the JavaScript source, run input, downstream tool calls and arguments, raw and JavaScript-visible results, and the final run result or error. Each record includes a timestamp and trace identifier.

To check the tools from JavaScript, call:

const schema = help("echo", "echo");
const echoed = await echo.echo({text: "hello"});
return {schema, result: echoed.structuredContent};

To check repeated calls and sequential awaits:

const messages = ["loop-1", "loop-2", "loop-3"];
const replies = [];
for (const text of messages) {
  const response = await echo.echo({text});
  replies.push(response.structuredContent);
}
return {
  count: replies.length,
  replies,
  allMatched: replies.every((reply, index) => reply.text === messages[index])
};

The result should report count: 3 and allMatched: true.

Configuration and limits

Optional gateway configuration options:

  • runTimeoutMs: Run deadline in milliseconds. The default is 30000 ms; the allowed range is 1–60000 ms.
  • traceLogPath: Absolute path for the trace log file. Omit this option to disable trace logging.

The gateway gives downstream MCP clients 10 seconds to initialize and 30 seconds per request. runTimeoutMs is a separate budget for each JavaScript run and also limits the time left for its downstream calls.

The gateway also enforces limits on code, QuickJS heap and stack, result size, tool-call count, catalog size, and queued calls. A stdio server uses an absolute command path and literal args array. An HTTP server uses endpointUrl with transport: "http"; the MCP client selects the protocol automatically. Set a non-empty allowTools list to restrict the exposed tools; omitting it or setting it to [] exposes every discovered tool from that server. Gateway configuration accepts references to authentication material, not access tokens, refresh tokens, or client secret values.

Example: connect to an HTTP MCP server

Add an HTTP server entry to gateway.json (keep any other entries you need):

{
  "servers": [
    {
      "alias": "openai_docs",
      "transport": "http",
      "endpointUrl": "https://developers.openai.com/mcp",
      "allowTools": []
    }
  ]
}

The gateway connects during startup and discovers the downstream tools. Inspect the available tools with help() before calling one from JavaScript. Configure allowTools with a non-empty list when you want to limit access.

Example: connect to the SondaHub MCP test server

SondaHub's MCP test server provides both a public HTTP endpoint and an OAuth-protected endpoint:

  • Public: https://api.sondahub.com/mcp (no OAuth).
  • OAuth: https://api.sondahub.com/mcp/secure.

Add either entry, or both with distinct aliases, to gateway.json:

{
  "servers": [
    {
      "alias": "sondahub_public",
      "transport": "http",
      "endpointUrl": "https://api.sondahub.com/mcp",
      "allowTools": []
    },
    {
      "alias": "sondahub",
      "transport": "http",
      "endpointUrl": "https://api.sondahub.com/mcp/secure",
      "allowTools": [],
      "auth": {
        "type": "oauth",
        "tokenFile": "/home/YOUR_USER/.config/slop/sondahub-token.json",
        "clientId": "sonda",
        "scopes": ["mcp:read", "mcp:write"]
      }
    }
  ]
}

The public endpoint needs no login. The OAuth example uses SondaHub's public test client ID, sonda; it does not need a client secret. Use an absolute path for tokenFile. The gateway discovers OAuth metadata from the endpoint. Its callback URI is http://127.0.0.1/callback; the OAuth client must allow that exact URI. Request only mcp:read if you do not need to test write tools.

Log in to the OAuth endpoint before starting the gateway:

bazel run //mcp/gateway:run_js_server -- auth login \
  --config /absolute/path/to/gateway.json \
  --server sondahub

After startup, inspect the catalog with help() and call tools through the configured aliases. For example:

const order = await sondahub.store_order_status({order_number: "SH-100042"});
const products = await sondahub_public.store_search_products({query: "headphones", limit: 2});
return {order: order.structuredContent, products: products.structuredContent};

SondaHub is a test server. Its simulated writes are not persisted unless a client carries the returned X-Sondahub-Session token into later requests.

Gateway-owned OAuth login and refresh

An HTTP OAuth profile normally needs only a registered client ID and an absolute token-file path. Gateway login, refresh, and startup discover the OAuth metadata from the MCP endpoint:

  1. The gateway sends an MCP initialize request and uses a resource_metadata URL from a 401 WWW-Authenticate challenge when present. Otherwise, it tries the protected-resource well-known URLs in MCP order: endpoint-path insertion, then the root URL.
  2. It fetches the protected-resource metadata and selects the advertised authorization server.
  3. It tries RFC 8414 Authorization Server Metadata first. MCP requires OpenID Connect Discovery fallbacks: with a path issuer, path insertion then path appending; without a path, the root OIDC URL. Each response is parsed as JSON and validated against the issuer, resource binding, endpoints, and S256 PKCE requirements. RFC 8414 places /.well-known/oauth-authorization-server between the host and issuer path.

Minimal configuration:

{
  "servers": [
    {
      "alias": "private_docs",
      "transport": "http",
      "endpointUrl": "https://mcp.example.com/mcp",
      "auth": {
        "type": "oauth",
        "tokenFile": "/home/user/.config/slop/private-docs-token.json",
        "clientId": "docs-client"
      }
    }
  ]
}

scopes and clientSecretEnv are optional. Add clientSecretEnv only for a confidential client; it is the name of an environment variable, not the secret. MCP well-known fallback discovery works without a WWW-Authenticate challenge. Set resourceMetadataUrl only as an explicit override for a non-standard resource server. If the server advertises multiple authorization servers, set issuer to select and pin one. For servers without usable metadata, you can set issuer, authorizationEndpoint, and tokenEndpoint manually; provide both endpoints or neither.

For a public client, omit clientSecretEnv. For a confidential client, set the named environment variable using a secure prompt or your secret manager. Use a different environment variable name for each client that has a different secret. For example, in Bash:

read -rs -p 'OAuth client secret: ' DOCS_OAUTH_CLIENT_SECRET
printf '\n'
export DOCS_OAUTH_CLIENT_SECRET

Do not put the secret in a command-line argument or in gateway.json. The OAuth client must allow the redirect URI http://127.0.0.1/callback.

Run login, finish authorization in a browser, then paste the full redirect URL when prompted:

bazel run //mcp/gateway:run_js_server -- auth login --config /absolute/path/to/gateway.json --server private_docs

The command uses PKCE and validates the callback state and issuer before exchanging the code. The token file is written with the shared secure token-store format. Refresh it with:

bazel run //mcp/gateway:run_js_server -- auth refresh --config /absolute/path/to/gateway.json --server private_docs

Both commands use gateway.json and the configured client-secret environment variable, if present; they do not start the gateway MCP stdio service or read mcp.ini. Gateway startup repeats metadata discovery and validates the token file's issuer/resource binding before use. Restart the gateway after a manual refresh. Tokens and client secrets are not printed by these commands. Live token refresh is not supported.

Example: connect to the Decision API server

Add the Decision API server as a downstream stdio server in gateway.json (keep other server entries you need):

{
  "runTimeoutMs": 30000,
  "servers": [
    {
      "alias": "decisions",
      "transport": "stdio",
      "command": "/absolute/path/to/bazel-bin/mcp/decision_api/decision_api_server",
      "args": ["--config", "/absolute/path/to/.config/slop/decision-api.json"],
      "allowTools": ["decision_help", "decision_models", "decide"]
    }
  ]
}

The decision-api.json file holds the API key and model for the configured Decision API service (OpenRouter is the default); do not put credentials in gateway.json or JavaScript. See the Decision API setup guide for its format and permission requirements. In ~/.config/slop/mcp.ini, register the gateway using the registry alias run_js:

[server.run_js]
transport=stdio
command=/absolute/path/to/bazel-bin/mcp/gateway/run_js_server
args_json=["--config","/absolute/path/to/gateway.json"]
enabled=true

The agent calls mcp_run_js_run_js with JavaScript in its code argument. The JavaScript alias is decisions:

const guide = await decisions.decision_help({topic: "overview"});
const models = await decisions.decision_models({});
const result = await decisions.decide({
  state: {message: "The invoice was paid yesterday."},
  questions: {
    contains_keyword: {
      type: "noul",
      instructions: "Does the message contain the exact word invoice?",
      criteria: {
        true: "The message contains the word invoice.",
        false: "The message does not contain the word invoice."
      }
    }
  }
});
return {
  help: guide.structuredContent,
  modelCount: models.structuredContent.total_count,
  decision: result.structuredContent
};

Use allowTools to expose only the tools required by the script. traceLogPath is optional. When enabled, the gateway logs code, arguments, and results, including decision state; disable tracing or use non-sensitive data for tests. To also call the server directly, register it separately under [server.decisions]; the direct tool names then start with mcp_decisions_. See the Decision API setup guide.

Every run_js call runs in a fresh run_js_server --worker-fd child. The parent and worker exchange length-prefixed JSON frames over a private Unix socketpair, with a 4 MiB frame limit. The child receives code, input, the authorized public catalog, limits, and tool results. The parent validates worker requests and performs all downstream MCP calls. Calls to one downstream client run serially; calls to different clients may run concurrently. The remaining run deadline is passed to downstream calls.

Testing without an LLM

Build the example servers and run the subprocess integration test:

bazel build //mcp/gateway:run_js_server //mcp/server:echo_server
bazel test //mcp/gateway:gateway_subprocess_test --test_output=all

The test composes concurrent echo calls through the worker and verifies that a 100 ms run timeout stops an infinite loop. Run bazel test //... for the full repository suite.

Security considerations

An empty or omitted allowTools list grants JavaScript access to every tool advertised by that downstream server. Use a non-empty list when you need least-privilege access. Configured downstream commands run with the gateway account's operating-system permissions.

Trace logging is opt-in. When traceLogPath is set, the gateway logs code, run input, tool arguments, and raw and normalized tool results. Source code is preserved except that terminal control bytes are escaped for safe display. These values may contain secrets. The log file is created with mode 0600, but it grows as runs are recorded; protect, rotate, and delete it as needed. Logging is for debugging, not an audit trail.

Each run uses a worker process, but this is not an OS sandbox. A QuickJS or native-library exploit could compromise the worker and access resources available to the gateway account. Do not pass secrets to the gateway unless you accept that they may appear in configured trace logs. The gateway does not execute application handlers or provide reverse calls into the host agent. To expose application capabilities, use a separate MCP server that preserves their existing authorization checks and configure it as a downstream server.

Content is generated from the repository sources listed above. See the source files for the complete reference.