# Connect an AI agent to Atlas Labs

> To connect an AI agent to Atlas Labs, point an MCP client at `https://automationatlas.io/labs/mcp` over Streamable HTTP and call the `create_sandbox` tool, after which the session holds both keys. An agent that cannot speak MCP can read `/labs/llms-full.txt` and call the REST API directly.

Canonical: `https://automationatlas.io/labs/docs/tools/ai-agents/` · Checked: `2026-10-19`

Atlas Labs is a fictional company's API; every figure in every response is simulation data.

*Reference table — the Atlas Labs MCP server, not part of the fictional simulation. From the API source and docs/MCP.md, checked `19 Oct 2026`.*

| At a glance | Value |
| --- | --- |
| Endpoint | `https://automationatlas.io/labs/mcp`, Streamable HTTP, one JSON-RPC message per `POST` |
| Accept header | `Accept: application/json, text/event-stream` on every `POST`, or the server answers `-32000 Not Acceptable`; the optional `GET` stream opens for `Accept: text/event-stream`, `*/*` or no `Accept` |
| Keys | Call `create_sandbox` once and the session holds both keys, or send `Authorization: Bearer lab_off_YOUR_KEY` (or a `lab_fac_` key) on any request |
| Session | `Mcp-Session-Id`, issued by `initialize`; sessions idle for `30` minutes expire |
| Tools | `18` task-level tools, plus the resources `labs://guide` and `labs://openapi` and the prompt `run-the-factory` (the `starter` missions, played from Head Office) |
| Without MCP | `/labs/llms-full.txt`, `GET /labs/api/v1/` and the OpenAPI documents describe the same REST API |

> **Start a sandbox and your keys are filled in for you.** An agent can create its own sandbox with `create_sandbox`. If you already started one on the [hub](https://automationatlas.io/labs/#start-sandbox-heading), the Connect-it panel's MCP tab shows the command with your Head Office key.

## How do I connect Claude, Cursor or another AI agent to Atlas Labs?

Atlas Labs runs a [Model Context Protocol](https://modelcontextprotocol.io/) server over Streamable HTTP, for Claude Code, Cursor, ChatGPT connectors and any other MCP client. It drives the same fictional Atlas Print Works & Co. sandbox as the REST API, calling the same service layer, schemas, limits and idempotency handling, so a failed tool call returns the same `error.code` and `hint` a REST client would see for the equivalent request.

*Shell · `snippets/08-mcp-claude-code.sh` · Claude Code · command form from `docs/MCP.md`; the `--header` variant was not run*

```bash
# Claude Code: add the server (no key needed; call the create_sandbox tool, or send a key header)
claude mcp add --transport http atlas-labs https://automationatlas.io/labs/mcp
# With an existing sandbox's Head Office key:
claude mcp add --transport http atlas-labs https://automationatlas.io/labs/mcp \
  --header "Authorization: Bearer $ATLAS_OFFICE_KEY"
```

*JSON · `snippets/08-mcp-client-config.json` · Cursor and other clients that take a JSON server list · Claude Desktop remote-URL support not verified*

```json
{
  "mcpServers": {
    "atlas-labs": {
      "url": "https://automationatlas.io/labs/mcp"
    }
  }
}
```

> **Claude Desktop is not confirmed.** We could not verify that Claude Desktop reads a remote `url` from its config file. The Claude Code command and the TypeScript and Python SDK clients below were run against the endpoint; for Claude Desktop, check your client's own documentation for adding a remote MCP server.

*JavaScript · `snippets/08-mcp-connect.mjs` · ran against the live endpoint, keyed and keyless*

```javascript
// Connect an MCP client to Atlas Labs (Node 20+).  npm install @modelcontextprotocol/sdk
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const url = new URL(process.env.ATLAS_MCP ?? "https://automationatlas.io/labs/mcp");
// Optional: reuse a sandbox you already have by sending a key; otherwise call create_sandbox.
const key = process.env.ATLAS_OFFICE_KEY;
const transport = new StreamableHTTPClientTransport(url, {
  requestInit: key ? { headers: { Authorization: `Bearer ${key}` } } : undefined,
});

const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(transport);

const { tools } = await client.listTools();
console.log(tools.map((t) => t.name).join(", "));

if (!key) {
  // The session now holds both keys; later tool calls need no header.
  const created = await client.callTool({ name: "create_sandbox", arguments: {} });
  const result = JSON.parse(created.content[0].text);
  if (created.isError) throw new Error(`${result.error.code}: ${result.error.hint ?? result.error.message}`); // {"error": {code, message, retryable, hint}}
  console.log(result.public_name);
}
const status = await client.callTool({ name: "sandbox_status", arguments: {} });
console.log(status.content[0].text.slice(0, 200));
await client.close();
```

*Python · `snippets/08-mcp-connect.py` · ran against the live endpoint, keyed and keyless (`mcp<2`; the 2.x SDK renamed the transport helper)*

```python
"""Connect an MCP client to Atlas Labs.  pip install mcp"""
import asyncio
import json
import os

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

URL = os.environ.get("ATLAS_MCP", "https://automationatlas.io/labs/mcp")
KEY = os.environ.get("ATLAS_OFFICE_KEY")  # optional: reuse a sandbox; otherwise call create_sandbox


async def main():
    headers = {"Authorization": f"Bearer {KEY}"} if KEY else None
    async with streamablehttp_client(URL, headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            print(", ".join(t.name for t in tools.tools))
            if not KEY:
                # The session now holds both keys; later tool calls need no header.
                created = await session.call_tool("create_sandbox", {})
                result = json.loads(created.content[0].text)
                if created.isError:  # a typed error: {"error": {code, message, retryable, hint, ...}}
                    err = result["error"]
                    raise SystemExit(f"{err['code']}: {err.get('hint') or err['message']}")
                print(result["public_name"])
            status = await session.call_tool("sandbox_status", {})
            print(status.content[0].text[:200])


asyncio.run(main())
```

*curl · `snippets/08-mcp-connect.sh` · ran against the live endpoint, keyed and keyless*

```bash
#!/usr/bin/env bash
# Talk to the Atlas Labs MCP server with plain curl (Streamable HTTP, one JSON-RPC message per POST).
# The Accept header MUST list both application/json and text/event-stream, or you get -32000 Not Acceptable.
set -euo pipefail
MCP="${ATLAS_MCP:-https://automationatlas.io/labs/mcp}"
H=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream")

# 1. initialize: the response carries an Mcp-Session-Id header
SESSION=$(curl -sS -D - -o /dev/null -X POST "$MCP" "${H[@]}" -d '{
  "jsonrpc":"2.0","id":1,"method":"initialize",
  "params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \
  | awk 'tolower($1)=="mcp-session-id:" {print $2}' | tr -d '\r')

# 2. list the tools (no key needed)
curl -sS -X POST "$MCP" "${H[@]}" -H "Mcp-Session-Id: $SESSION" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq -r '.result.tools[].name'

# 3. create a sandbox: the session stores both keys, so later calls need no Authorization header
curl -sS -X POST "$MCP" "${H[@]}" -H "Mcp-Session-Id: $SESSION" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"create_sandbox","arguments":{}}}' \
  | jq '.result.content[0].text | fromjson | {sandbox_id, public_name}'

# Stateless alternative: send  -H "Authorization: Bearer $ATLAS_OFFICE_KEY"  on any call instead.
```

Atlas Labs plans to submit this server to the official MCP Registry after launch, so it can also be discovered without pasting the URL by hand.

## What is the MCP endpoint and transport?

- `POST` one JSON-RPC message per request. Batches are refused with `400 mcp_batch_not_supported`.
- `DELETE` ends a session.
- `GET` with the session's `Mcp-Session-Id` and `Accept: text/event-stream` opens its server-to-client stream (`200 text/event-stream`). The server sends no messages of its own, so the stream carries only a keep-alive comment every `15` seconds. It is optional: every response comes back on its `POST`.
- `POST` responses are JSON only. Sessions use `Mcp-Session-Id`, issued on `initialize`.
- No cookies and no CORS. Responses are `Cache-Control: no-store` and `X-Robots-Tag: noindex`, like the rest of `/labs/api/*`.

## Do I need a key, or does the agent create its own sandbox?

A tool call needs a Factory (`lab_fac_…`) or Head Office (`lab_off_…`) key, supplied in one of two ways:

1. **Stateless.** Send `Authorization: Bearer <key>` on the HTTP request to `/labs/mcp`, exactly as for REST. It works with or without a session and always wins over the session. A key that authenticates a call also binds that key to the session.
2. **Session-bound.** Call `create_sandbox` once. The session is bound to the new keys, which are shown only once, in that result. Later calls in the same session need no header.

Every call re-checks the keys it is bound to. If a key is revoked, or the sandbox is deleted, goes bankrupt or expires, the next call fails with `unauthorized`, `not_found`, `sandbox_bankrupt` or `sandbox_expired`. Each tool resolves the system it needs; the wrong key returns a typed `wrong_system` error whose `hint` names the right one, and no key at all returns `unauthorized`. If Atlas Labs is full, `create_sandbox` returns `{"queued": true, "ticket_id": …, "position": …, "eta_s": …, "poll": …}` instead of keys; the ticket is polled over REST, as on [Waiting for a sandbox](https://automationatlas.io/labs/docs/queue/).

## What tools does it expose?

Tools are task-level, not a one-to-one wrapper over REST. The key column says which key a tool accepts.

*Reference table — the Atlas Labs MCP tools, not part of the fictional simulation.*

| Tool | Key | What it does |
| --- | --- | --- |
| `world_status` | none | The public fictional world summary: factories open now, queue length, atlases shipped in the last 24h, all-time bankruptcies and the current Major Order. |
| `create_sandbox` | none | Creates a 30-minute sandbox and stores both keys in the MCP session. |
| `sandbox_status` | either | Both systems at a glance: cash, morale, inventory, press state, `expires_in_s` and suggested `next_actions`. |
| `extend_sandbox` | either | Adds 30 minutes when 10 minutes or fewer remain, up to twice; the second is refused while anyone is waiting for a sandbox. Takes no arguments. |
| `get_missions` | either | Ordered missions, detected from events and never self-reported. |
| `list_events` | either | Cursor feed of the sandbox's events, `seq`-ascending after `after_seq`. |
| `office_list_sales_orders` | Head Office | Sales orders, newest first. |
| `office_decide_sales_order` | Head Office | Accepts or declines a received sales order; `reason` is required to decline. |
| `office_request_quotes` | Head Office | Requests one or more supplier quotes in a single call. |
| `office_create_purchase_order` | Head Office | Creates a purchase order from a quote; `approve: true` also approves it when the total needs approval. |
| `office_pay_supplier_invoice` | Head Office | Pays a supplier invoice after the 3-way match; `override: true` pays anyway (logged). |
| `office_decide_contract_lot` | Head Office | Accepts or declines a continental contract lot offer. |
| `office_run_payroll` | Head Office | Pays the oldest outstanding payroll run, computed from bridged timesheets (HR module). |
| `factory_create_work_order` | Factory | Bridges an accepted sales order into a Factory work order. |
| `factory_start_print_run` | Factory | Starts a print run consuming paper and ink. |
| `factory_receive_goods` | Factory | Records a goods receipt against a dock delivery. |
| `factory_ship` | Factory | Ships a ready work order, or delivers atlases against an accepted contract lot. |
| `bridge_record` | by `kind` | A typed union that posts one record across the Factory/Head Office boundary. `work_order` needs a Factory key; `requisition`, `goods_receipt`, `shipment` and `timesheets` need a Head Office key. |

Writes that move money or stock take an optional `idempotency_key` argument. Omit it and a retry of the exact same call (same sandbox, tool and arguments) within `60` seconds never double-executes, even from a new session after a reconnect; after that the same call is a deliberate repeat and runs again. One exception: if a call's write went through but its result was lost, the same call is refused with `idempotency_committed`, even after the window, so read the result back and pass a new key to repeat it. To repeat a call on purpose inside the window, pass a new key. A replayed result carries `"replayed": true`. Keys starting `~mcp:` are reserved.

## What resources and prompts are there?

- `labs://guide` — the canonical agent loop, as markdown.
- `labs://openapi` — the same OpenAPI `3.1` document as `GET /labs/api/v1/openapi.json`.
- Prompt `run-the-factory` — a plan for the six `starter` missions: the agent runs Head Office while the Factory autopilot prints and ships. The two webhook missions need a subscription made over REST, because no MCP tool makes one.

## What are the limits?

- Each HTTP request is one call against the same per-key budget as REST, with the same `429` answer: wait for `Retry-After` or `retry_at` before the next call. Tool calls made in parallel share that budget, so keep them to one or two at a time.
- `initialize` is limited to `10` per minute per IP group.
- At most `5` sessions per IP group, `3` per sandbox and `2,000` in total. A full server evicts a session with no sandbox first, then the least recently used.
- Sessions idle for `30` minutes expire. A request on an evicted or expired session gets `404` with `mcp_session_evicted` or `mcp_session_not_found`; send a new `initialize`.
- One `GET` stream per session (a second gets `409 mcp_stream_open`), at most `5` per IP group, counted against the network that sent the session's `initialize` (`429 sse_limit`), and `500` on the server (`503 sse_unavailable`). A stream ends after `30` minutes, or when its session is closed or expires; open another if your client wants one. It never keeps a session alive. The server may take minutes to notice a stream whose connection dropped without closing, and until then a new `GET` on that session gets the `409`: carry on with `POST`, or `DELETE` the session (which ends the stream at once) and initialise a new one.
- If the operator has switched on proof-of-work, `create_sandbox` fails with a `pow_required` tool error. Attach `Atlas-PoW: <challenge>.<nonce>` to the HTTP request that carries the tool call; there is no JSON-RPC field for it.

## Questions people ask

**Why do I get “Not Acceptable” from my MCP client?** The request must send `Accept: application/json, text/event-stream`. Without both values the server answers `-32000 Not Acceptable: Client must accept both application/json and text/event-stream`. The TypeScript and Python SDK clients send it for you.

**Do I need a key, or does the agent create its own sandbox?** Either works. Call `create_sandbox` and the session holds both keys, or send a key you already have as `Authorization: Bearer`. A key in the header always wins over the session.

**What if my agent cannot use MCP?** Give it `/labs/llms-full.txt` and the REST API. The OpenAPI documents are at `/labs/api/v1/openapi.json`, `openapi-3.0.json` and `swagger-2.0.json`, and `GET /labs/api/v1/` describes the API without a key.

**Does Claude Desktop work?** We have not verified that Claude Desktop reads a remote `url` from its config file, so we do not claim it. Claude Code, Cursor and the SDK clients are the ones we ran or documented.

Sources: Atlas Labs API source (`mcp/route.ts`, `mcp/http-guard.ts`, `mcp/constants.ts`) and `docs/MCP.md`, observed live on `15 Oct 2026`. Snippets: `snippets/08`.

[Integrations](https://automationatlas.io/labs/docs/tools/) · [Webhooks](https://automationatlas.io/labs/docs/webhooks/)
