Spens logoSpens

Spens with MCP servers

Give a sandboxed agent MCP tools for one session, with the access token proxied by spens

This example injects an MCP (Model Context Protocol) server into a Claude Code session without baking anything into the image. The agent gets GitHub tools for the run; the GitHub token never enters the agent container — spens substitutes it in the interceptor.

There are three pieces:

  1. An mcp.json file in the standard mcpServers shape
  2. A spens config that proxies the GitHub token and allows the server's host
  3. A session run with --mcp-config

Prerequisites

  • A GitHub personal access token with the scopes the MCP tools need, exported on the host as GITHUB_TOKEN
  • The built-in claude agent template, which declares MCP support, and a node-24 environment

1. The MCP servers file

Create mcp.json in your workspace. This is the same file you would give Claude Desktop, Cursor or pi, so it is not spens-specific:

{
  "mcpServers": {
    "github": {
      "type": "http",
      "url": "https://api.githubcopilot.com/mcp/",
      "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }
    }
  }
}

Spens leaves ${GITHUB_TOKEN} untouched: the converted config the agent receives contains the reference, not the token. The agent expands it from its own environment at runtime, and what it finds there is decided by the next step.

2. The spens config

Add two things to .spens.config.json:

  • an inject_headers rule that puts a placeholder in the agent's environment instead of the real token, and authorizes substitution only on the MCP server's host;
  • a domain_rules entry that lets the agent reach that host.
{
  "domain_rules": [
    {"pattern": "*api.anthropic.com*", "allow": ["GET", "POST", "PUT", "DELETE", "OPTIONS"]},
    {"pattern": "*api.githubcopilot.com*", "allow": ["GET", "POST", "DELETE"]}
  ],
  "inject_headers": [
    {
      "placeholder": "GITHUB_TOKEN",
      "env_var": "GITHUB_TOKEN",
      "for_domains": ["*api.githubcopilot.com*"]
    }
  ]
}

At runtime:

  • the agent container gets GITHUB_TOKEN=GITHUB_TOKEN (the literal placeholder, not the secret);
  • the interceptor container gets the real token from your host;
  • requests to api.githubcopilot.com have the placeholder swapped for the real token;
  • requests to any other host leave the placeholder untouched.

The domain_rules entry is required: without it the MCP host is denied (fail closed), and spens emits an mcp_host_blocked warning. Streamable HTTP needs POST, GET and DELETE; the documentation for each server lists the methods it uses.

3. Run the session

export GITHUB_TOKEN=github_pat_...
spens node-24 claude . "Using the github MCP server, list the open pull requests in this repository and summarize each one." \
  --mcp-config ./mcp.json --accept-changes

Spens reads and validates the file on the host, converts it to Claude's native mcpServers shape, writes it under the session's mcp/ directory, and passes it to Claude with --mcp-config. The converted file is mounted read-only at /spens/mcp/config.json; nothing is baked into the image, so changing mcp.json never triggers a rebuild.

4. Verify what was injected

Every session records the MCP plan next to its events:

cat .spens/sessions/<session-id>/mcp/resolution.json

You will see the server's transport and source, any network warnings, and how each variable was resolved:

{
  "mode": "merge",
  "format": "mcp-json",
  "delivery": {"type": "arg"},
  "servers": {
    "github": {
      "transport": "http",
      "source": "cli",
      "url": "https://api.githubcopilot.com/mcp/",
      "warnings": []
    }
  },
  "variables": {
    "GITHUB_TOKEN": {"status": "proxied", "for_domains": ["*api.githubcopilot.com*"]}
  }
}

The mcp/config.json beside it is exactly what the agent received — references and placeholders only, never the token. The log viewer surfaces the MCP warnings at the top of the session.

Make it the default

To stop passing --mcp-config on every run, point the config at the file:

"mcp": {
  "servers": "./mcp.json",
  "mode": "merge"
}

servers is relative to the workspace. Servers from the config are loaded first; servers from --mcp-config win on a name collision, so the flag stays useful for one-off servers.

Hermetic sessions

By default (merge) the injected servers are layered on top of the servers the agent already knows about (a project .mcp.json, the user's own config). Use replace when you want the agent to see only what spens injects:

spens node-24 claude . "..." --mcp-config ./mcp.json --mcp-mode replace --accept-changes

or set "mode": "replace" in the config's mcp section. replace is a per-session choice, so the same workspace can run merged interactively and hermetic in CI.

Local stdio servers

An MCP server that runs as a local process uses command/args instead of url. The process runs inside the sandbox with the agent's permissions, and spens checks that it is on PATH before the agent starts (emitting an mcp_server_missing warning if not):

{
  "mcpServers": {
    "fetch": {
      "command": "uvx",
      "args": ["mcp-server-fetch"]
    }
  }
}

Two things to know:

  • Spens does not install MCP servers. The command must already exist in the image — add it to the environment template's packages, or to the agent's dependencies.
  • uvx / npx -y download the server at startup. That works, but it needs the package registry in domain_rules and re-downloads every session. Preinstalling the server into the image is faster and reproducible.

Why the token stays out

The MCP file carries no policy: a ${VAR} is only ever a reference. Whether it is proxied, forwarded or unresolved is decided entirely by .spens.config.json, and spens never widens network permissions or inlines a real value into a config file or command line. If you reference a variable with no rule, the session still starts, but spens emits an mcp_var_unresolved warning and the server will most likely fail to authenticate.

On this page