# MCP (/docs/guides/clients/mcp)

> The Better Compute MCP server gives AI agents tools to find capacity, reserve GPUs and check nodes, as the agent, behind Better IAM's gate with delegated confirmation.



`@better-compute/mcp` turns the compute API into [Model Context Protocol](https://modelcontextprotocol.io) tools, so
an AI agent can find GPU capacity, reserve and release GPUs, create, stop, start and delete VPS instances, create,
extend and delete [sandboxes](/docs/guides/vps/sandboxes), look at a node and run a diagnostic. It is built for
the v0.3 goal "an agent reserves via MCP with human confirmation, retrying with the same key", and the end-to-end
test `tests/mcp-e2e-confirmation.test.ts` runs exactly that against a real server and an embedded Better IAM.

The design has one rule: &#x2A;*every tool calls the compute API as the caller.** A tool forwards the agent's own
`Authorization` header, so compute and Better IAM make the real decision on each call, and a tool can never do more
than the agent's credential allows. The server holds no credential of its own.

## How it fits [#how-it-fits]

<Mermaid
  chart="sequenceDiagram
  participant A as Agent (MCP client)
  participant G as Better IAM gate
  participant M as createComputeMcp
  participant C as compute API
  participant I as Better IAM
  A->>G: POST tools/call reserve_gpus (Bearer agent token)
  G->>I: authenticate; compute:pools:read on the pool?
  G->>M: forwarded request, caller
  M->>M: validate arguments against the tool's schema
  M->>C: reservations.create (same Bearer token)
  C->>I: decide compute:reservations:create on the draft
  C-->>M: reservation, or CONFIRMATION_REQUIRED
  M-->>A: tool result (structuredContent)"
/>

The server is a small, stateless Streamable HTTP endpoint that speaks JSON-RPC 2.0 for `initialize`, `ping`,
`tools/list` and `tools/call`, and accepts notifications. It has no MCP SDK dependency; its only runtime dependency
is `@better-compute/client`. `@better-iam/mcp`, which provides the gate, is an optional peer.

> **Mounted when you ask for it.** 
  `serve` and `dev` mount the endpoint when the configuration's host sets `host.mcp` (below); nothing is mounted
  otherwise. You can also host the handler yourself in any runtime with the Fetch API, with Better IAM embedded in
  the same process or reached over HTTP ([remote IAM](#remote-iam)).

## Mount it with serve [#mount-it-with-serve]

`computeMcpEndpoint({ compute, gate, delegations?, ...options })` builds the endpoint for a control plane in the same
process: the gate first, then the tools, each calling `compute.handler` in-process with the caller's own
`Authorization` header, so compute decides every call exactly as it would over HTTP. With `delegations` (Better IAM's
delegations API) the tools wait inside the call for a person to approve a held-back action. Hand its `handler` to the
host:

```ts title="better-compute.config.ts"
import { createMcpGate } from '@better-iam/mcp';
import { defineHost } from 'better-compute/cli';
import { computeMcpEndpoint, gateTools } from 'better-compute/mcp';

export const host = defineHost(({ compute }) => ({
  mcp: {
    path: '/mcp', // the default
    handler: computeMcpEndpoint({
      compute,
      gate: createMcpGate({ iam, tenantId: platformTenantId, tools: gateTools(), unlisted: 'deny', confirmations: false }),
      delegations: iam.api.delegations,
    }).handler,
  },
}));
```

`serve` and `dev` then answer `POST`, `GET` and `DELETE` on the path, and `GET
/.well-known/oauth-protected-resource{path}` for the gate's RFC 9728 metadata. A path that is not absolute, or a
missing handler, is `INVALID_CONFIG`. The gate is required. Give it a fixed `tenantId` (the organization that runs the
endpoint), or choose one per caller deliberately: deciding in each caller's own tenant would let any organization
grant itself access. Build it with `confirmations: false`: the gate only checks visibility, and confirmations belong
to compute's own decisions, which carry the whole request. With the gate's default, a wildcard in a delegation's
confirm list would also match the gate's read actions and file confirmation requests built from unvalidated tool
arguments (daf58eb, security review 14 M2). The compose audit test mounts it through a real `serve`: no token is
401, and an administrator lists the tools.

`better-compute doctor` checks that the mounted handler is gated (b491aed, review 14 L6). It sends the handler one
unauthenticated JSON-RPC `tools/list` (read-only, given up after 5 seconds). A gated endpoint answers 401 with its
OAuth challenge; any 2xx is the error finding `host.mcp.ungated` ("host.mcp answers MCP at /mcp without a
credential"), since a bare `createComputeMcp().handler`, or one with a fixed client, serves MCP to anyone or makes
every caller act as one credential. A probe that fails or gets a non-2xx answer reports nothing.

## Mount the endpoint yourself [#mount-the-endpoint-yourself]

```ts title="mcp.ts"
import { createMcpGate } from '@better-iam/mcp';
import { createComputeMcp, gateTools, iamConfirmations } from 'better-compute/mcp';
import { iam } from './iam';

const mcp = createComputeMcp({
  baseURL: 'https://compute.example.com',
  // Optional: wait inside the tool call for the person to approve (see below).
  confirmations: iamConfirmations(iam.api.delegations),
});

const gate = createMcpGate({ iam, tenantId, tools: gateTools(), unlisted: 'deny', confirmations: false });

export default {
  fetch: (request: Request) => gate(request, (forwarded, caller) => mcp.handler(forwarded, caller)),
};
```

With `baseURL`, each request gets a fresh compute client that forwards the caller's `Authorization` header and a
request id. The gate authenticates the caller, applies the visibility rules below, and hands the handler the
caller's `tenantId`, which the tools act in.

<TypeTable
  type="{
  baseURL: { type: 'string', description: 'The compute API origin. Needed unless client is given.' },
  basePath: { type: 'string', description: &#x22;Where the API is mounted.&#x22;, default: &#x22;'/api/compute'&#x22; },
  client: {
    type: 'ComputeApi | ((request, caller?) => ComputeApi)',
    description: 'How tools reach compute instead: a fixed client, or one per request. Anything with $request fits, such as createComputeClient().',
  },
  tenantId: { type: 'string | ((caller?) => string | undefined)', description: &#x22;The tenant tools act in. Default: the caller's tenantId. Without one, every tool answers ACCESS_DENIED.&#x22; },
  execCommand: { type: 'boolean', description: 'Build exec_command. Off: running node commands through a model is a deliberate choice.', default: 'false' },
  fleet: { type: 'boolean', description: 'Build the read-only fleet_export and fleet_plan tools (platform administrators only).', default: 'false' },
  confirmations: {
    type: 'ConfirmationPolling | ((request, caller?) => ConfirmationPolling | undefined)',
    description: 'Wait for people to approve held-back actions inside the tool call. iamConfirmations() builds one per request.',
  },
  serverInfo: { type: '{ name: string; version: string }', description: 'Reported at initialize.', default: &#x22;{ name: 'better-compute', version: '0.1.0' }&#x22; },
  instructions: { type: 'string', description: 'Shown to the model at initialize. The default explains the list, preview, reserve and retry flow.' },
  maxBodyBytes: { type: 'number', description: 'Largest JSON-RPC request body.', default: '1048576 (1 MiB)' },
  maxConcurrentCallsPerCaller: { type: 'number', description: &#x22;Tool calls one caller may have in flight at once, counted per caller identity (tenant and identity), else per Authorization header. Above it a call answers the tool error RATE_LIMITED with retryAfterMs 2000 (48d41a4). Counted by callLimiter.&#x22;, default: '4' },
  callLimiter: { type: 'McpCallLimiter', description: &#x22;Where the per-caller count lives (76e4e37): { acquire(key, max) } returns a release, or undefined to refuse. The default, memoryCallLimiter(), counts in this process, so with several MCP instances the cap multiplies; pass a shared one (Redis, your database) to hold it across them. A shared limiter must expire slots it never saw released, for example after 2 minutes. A limiter that throws refuses the call with UNAVAILABLE and retryAfterMs 2000, never lets it through uncounted. Keys never carry credentials: a caller known only by its Authorization header counts under the header's SHA-256.&#x22;, default: 'memoryCallLimiter()' },
  newKey: { type: '() => string', description: 'Idempotency keys for tools that create things.', default: 'mcp- and 24 random hex characters' },
}"
/>

### Remote IAM [#remote-iam]

When Better IAM runs in another process, give the gate `remoteMcpIam()` instead of a `betterIam()` instance (v0.4).
It wraps a `createIamClient<BetterIam>()` from `@better-iam/client` and answers the gate's calls over Better IAM's
HTTP API, each with the caller's own credential, so every decision is still made and audited as the caller. Only
`authorization`, `user-agent` and `x-request-id` are forwarded; since 7890821 (review 14 L3) neither cookies nor the
client's own `x-forwarded-for`, which behind an appending proxy would let the caller choose `request.sourceIp` for
policy conditions:

| Gate call                | Better IAM API                                                                   |
| ------------------------ | -------------------------------------------------------------------------------- |
| authenticate             | `sts.getCallerIdentity`                                                          |
| authorize, authorizeMany | `authorize`, `authorizeMany`                                                     |
| confirmations            | the caller's delegation: `delegations.get` and `delegations.requestConfirmation` |

```ts
import { createIamClient } from '@better-iam/client';
import type { BetterIam } from '@better-iam/server';
import { remoteMcpIam } from 'better-compute/mcp';

const gate = createMcpGate({
  iam: remoteMcpIam(createIamClient<BetterIam>({ baseURL: 'https://iam.example.com' })),
  tenantId,
  tools: gateTools(),
  unlisted: 'deny',
  confirmations: false,
});
```

`iamConfirmations()` reads confirmations through the server API's shape (`getConfirmation(credential, input)`), so a
remote host that wants tools to wait for approval passes an equivalent reader over its IAM client.
`tests/mcp-e2e-confirmation.test.ts` runs the whole confirmation flow through both the embedded and the remote gate,
including refusing another organization's owner.

`createComputeMcp()` returns the `tools`, `listTools()` (the `tools/list` entries) and `callTool(name, args, request,
caller?)` besides `handler`, so you can run a tool without the JSON-RPC wrapper, for example from your own agent
framework.

## The tools [#the-tools]

| Tool                                               | Calls                                                                                                                      | Gate rule                                | Hints                                   |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | --------------------------------------- |
| `list_pools`                                       | `pools.list`                                                                                                               | any authenticated caller                 | read-only                               |
| `capacity_summary`                                 | `capacity.summary`, or `capacity.byPool` with `poolId`                                                                     | any authenticated caller                 | read-only                               |
| `preview_reservation`                              | `capacity.preview`                                                                                                         | `compute:pools:read` on the pool         | read-only                               |
| `reserve_gpus`                                     | `reservations.create` (`binding: 'hold'`, or `'kubernetes'`)                                                               | `compute:pools:read` on the pool         | idempotent                              |
| `release_reservation`                              | `reservations.release`                                                                                                     | any authenticated caller                 | destructive, idempotent                 |
| `list_reservations`                                | `reservations.list`: the caller's own by default, `scope: 'all'`, filters `state` and `poolId`, `limit` 1 to 200, `cursor` | any authenticated caller                 | read-only                               |
| `get_reservation`                                  | `reservations.get`                                                                                                         | any authenticated caller                 | read-only                               |
| `extend_reservation`                               | `reservations.extend` by a duration, confirmation-aware                                                                    | any authenticated caller                 | idempotent                              |
| `node_status`                                      | `nodes.get`                                                                                                                | `compute:nodes:read` on the node         | read-only                               |
| `run_diagnostic`                                   | `commands.run` with `diag.nvml`                                                                                            | `compute:nodes:read` on the node         | idempotent                              |
| `create_instance`                                  | `instances.create`                                                                                                         | `compute:pools:read` on the pool         | idempotent                              |
| `list_instances`                                   | `instances.list`: the caller's own by default, `scope: 'all'`, filter `poolId`, `limit` 1 to 200, `cursor`                 | any authenticated caller                 | read-only                               |
| `get_instance`                                     | `instances.get`                                                                                                            | any authenticated caller                 | read-only                               |
| `start_instance`                                   | `instances.start`                                                                                                          | any authenticated caller                 | idempotent                              |
| `stop_instance`                                    | `instances.stop`                                                                                                           | any authenticated caller                 | destructive, idempotent                 |
| `delete_instance`                                  | `instances.delete`                                                                                                         | any authenticated caller                 | destructive, idempotent                 |
| `list_sandbox_templates`                           | `sandboxTemplates.list`, without each template's `userData` and `env`                                                      | any authenticated caller                 | read-only                               |
| `create_sandbox`                                   | `sandboxes.create`, confirmation-aware                                                                                     | `compute:sandboxes:read` on the template | idempotent                              |
| `list_sandboxes`                                   | `sandboxes.list`: the caller's own by default, `scope: 'all'`, filter `templateId`, `limit` 1 to 200, `cursor`             | any authenticated caller                 | read-only                               |
| `get_sandbox`                                      | `sandboxes.get`                                                                                                            | any authenticated caller                 | read-only                               |
| `extend_sandbox`                                   | `sandboxes.extend` by a duration                                                                                           | any authenticated caller                 | not idempotent: each call extends again |
| `delete_sandbox`                                   | `sandboxes.delete`                                                                                                         | any authenticated caller                 | destructive, idempotent                 |
| `list_sandbox_snapshots`                           | `sandboxSnapshots.list`: the caller's own by default, `scope: 'all'`, filter `sandboxId`, `limit` 1 to 200, `cursor`       | any authenticated caller                 | read-only                               |
| `delete_sandbox_snapshot`                          | `sandboxSnapshots.delete`                                                                                                  | any authenticated caller                 | destructive, idempotent                 |
| `list_sandbox_volumes`                             | `sandboxVolumes.list` (v1.1), filter `poolId`, `scope` (`mine` by default, or `all`)                                       | any authenticated caller                 | read-only                               |
| `create_sandbox_volume`                            | `sandboxVolumes.create`, confirmation-aware                                                                                | `compute:pools:read` on the pool         | idempotent                              |
| `resize_sandbox_volume`                            | `sandboxVolumes.resize`                                                                                                    | any authenticated caller                 | idempotent                              |
| `delete_sandbox_volume`                            | `sandboxVolumes.delete`                                                                                                    | any authenticated caller                 | destructive, idempotent                 |
| `list_command_batches`                             | `commandBatches.list` (v1.0): filter `state`, `limit` 1 to 200, `cursor`                                                   | any authenticated caller                 | read-only                               |
| `get_command_batch`                                | `commandBatches.get` with a `batchId` (`batch_…`)                                                                          | any authenticated caller                 | read-only                               |
| `exec_command` (with `execCommand: true`)          | `commands.run`: `inventory.refresh`, `logs.collect` or `node.reboot`, optionally with a `batchId`                          | `compute:nodes:read` on the node         | destructive                             |
| `propose_command_batch` (with `execCommand: true`) | `commandBatches.create` (v1.0): a `node.reboot` batch over the given nodes                                                 | any authenticated caller                 | idempotent                              |
| `fleet_export` (with `fleet: true`)                | `fleet.export`                                                                                                             | `compute:fleet:admin` on the fleet       | read-only                               |
| `fleet_plan` (with `fleet: true`)                  | `fleet.plan`                                                                                                               | `compute:fleet:admin` on the fleet       | read-only                               |

The GPU tools share one argument shape: `poolId`, `gpus` (1 to 64 whole GPUs), `duration` (such as `30m`, `8h`,
`3d`), and optionally `gpuModel` (`H100`), `gpuProduct` (`H100-SXM5-80GB`), `nvlink` (all GPUs in one NVLink
domain) and `selector` (up to 32 `{ key, op, values }` expressions with `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`,
`Gte`, `Lt` or `Lte`). The tool turns them into one device request of class `gpu`. Since 17f600b both also take
`vcpu` (0 to 4096) and `memoryGiB` (0 to 65,536), the host CPUs and memory to book with the GPUs (sent as
`resources`; without them the pool's per-GPU share), and `kubernetes: true`, which asks for `binding: 'kubernetes'`
on a pool whose machines are [Kubernetes workers](/docs/guides/capacity/kubernetes) instead of a hold; the preview books
the same host resources as the create would. `reserve_gpus` also takes an
`idempotencyKey` and `waitSeconds` (0 to 30, default 20), which it sends as `wait: { timeoutMs }`, so on a pool-lease
pool the call returns once the reservation is placed or the wait runs out; see
[waiting for placement](/docs/guides/capacity/reservations#waiting-for-placement).

`create_instance` takes `poolId`, `flavor` and `imageId`, and optionally an `idempotencyKey` and
`waitSeconds` (0 to 30, default 20). It takes no `sshKeys` (7890821, review 14 H1): an agent under prompt injection
could install an attacker's key, so people add keys themselves and the agent reaches the VM through tunnels. The wait
is sent as `wait: { timeoutMs }`: the call returns once the instance is running or has
failed, or the wait runs out, so the agent does not have to poll `get_instance`; see
[waiting for the instance](/docs/guides/vps/instances#waiting-for-the-instance).

The instance tools take one `instanceId` (`inst_…`), except `list_instances`. They pass it to compute as is, with the
caller's tenant: `stop_instance` is a graceful shutdown that keeps the disk and addresses, `start_instance` runs a
stopped instance again, and `delete_instance` removes the instance and its disk for good. Unlike `create_instance`,
they do not turn `CONFIRMATION_REQUIRED` into a result; it comes back as a tool error.

The sandbox tools act on the caller's tenant. `create_sandbox` takes a `templateId` (`sbxt_…`, from
`list_sandbox_templates`) and optionally `ttlSeconds` (60 s to 30 days, at most the template's maximum), an
`idempotencyKey` and `waitSeconds` (0 to 30, default 20), sent as `wait: { timeoutMs }`: the call returns once the
sandbox is ready or failed, or the wait runs out. It answers `{ idempotencyKey, sandbox }` and, like
`create_instance`, turns `CONFIRMATION_REQUIRED` into a result to retry with the same key. `extend_sandbox` takes a
`sandboxId` and `by` (such as `30m`), and `get_sandbox` and `delete_sandbox` a `sandboxId`; `delete_sandbox` removes
the sandbox and its disk before its TTL ends. `list_sandbox_snapshots` lists snapshots (kept ones of deleted sandboxes
too), and `delete_sandbox_snapshot` deletes one (`snap_…`) for good. No tool takes a snapshot or calls
`sandboxes.exec`.

**Volume tools are agent memory** (e6759df). [Sandbox volumes](/docs/guides/vps/sandboxes#volumes) are persistent
disks that outlive sandboxes, which a template mounts in every sandbox it creates. `list_sandbox_volumes` lists the
caller's own volumes, or with `scope: 'all'` the tenant's (e68aba7; the API's default is `mine` since 77bf9f5),
optionally of one `poolId`. `create_sandbox_volume` takes a `poolId`, a `name` unique in the tenant
(`^[a-z0-9][a-z0-9._-]{0,62}$`, such as `agent-notes`), `sizeGiB` (1 to 16,384, the API's own limit, so a larger size is
refused by the schema before compute is called; f181f43) and optionally an `idempotencyKey`; like `create_sandbox` it answers
`{ idempotencyKey, volume }` and turns `CONFIRMATION_REQUIRED` into a result to retry with the same key. The volume
lives on one host and is billed until deleted. `resize_sandbox_volume` grows a volume (`vol_…`) to `sizeGiB`, never
shrinks it (the same 1 to 16,384), and sandboxes see the new size at their next boot. `delete_sandbox_volume` deletes a volume and wipes its
data; a volume a sandbox still mounts is refused. No tool attaches a volume to a template: a person does that with
`sandboxTemplates.update`, the CLI's `sandbox-template update --volumes` or a FleetConfig template.

Every argument is checked against the tool's JSON Schema before anything is called: ids must match their prefix
(`pool_…`, `node_…`, `rsv_…`, `inst_…`, `sbxt_…`, `sbx_…`, `snap_…`, `vol_…`, `batch_…`), and unknown arguments are refused. A bad argument is a tool error `INVALID_INPUT`
with the path of the first problem. The check reads own properties only (`constructor`, `__proto__` and the like are
never arguments) and enforces `minItems` and `uniqueItems`, and the list tools put the caller's `tenantId` after
the arguments, so an argument can never replace it (7890821, review 14 L1).

**Batch tools read and propose, never approve.** `list_command_batches` and `get_command_batch` show the tenant's
[destructive-batch](/docs/operations/pki#the-destructive-batch-quorum-v10) quorum: a batch's reason, size, the
approvals it has and needs, and its commands by state. With `execCommand: true`, `propose_command_batch` (171ad2f)
creates a batch for operators to approve: `nodeIds` (1 to 1,000 distinct `node_…` ids), `reason` (1 to 500
characters, what the approvers read), optionally `kind` (only `node.reboot`, the default), `notAfterSeconds` (60 to
259,200, how long approvals and commands are accepted; default 24 h) and an `idempotencyKey`. Every leaf's subject is
a node. Nothing runs until people approve it with their approver keys (`better-compute batch approve`); the agent then
passes the `batchId` to `exec_command` for each node, and the command waits, held, until the batch is approved. With
approver keys configured, `exec_command` with `node.reboot` and no `batchId` is refused `INVALID_STATE`, reason
`batch-required`. There is no approve tool: approving is a person's signature with an approver key held off the
control plane.

**Fleet tools plan but never apply.** `fleet_plan` returns the changes and the `planHash`, with the list of calls
replaced by `callCount`. There is deliberately no apply tool: a person reviews the plan and runs
`better-compute fleet apply FILE --plan-hash HASH`. Pass `fleet: true` to both `createComputeMcp` and `gateTools`.

## The gate checks visibility only [#the-gate-checks-visibility-only]

`gateTools()` returns the rules for `@better-iam/mcp`'s `createMcpGate({ tools, unlisted: 'deny' })`. They are
deliberately light:

* A tool that names a pool is visible to a caller with `compute:pools:read` on `compute-pool/{poolId}`; one that
  names a node needs `compute:nodes:read` on `compute-node/{nodeId}`.
* `list_pools`, `capacity_summary`, the reservation tools (`release_reservation`, `list_reservations`,
  `get_reservation`, `extend_reservation`) and the instance tools (`list_instances`, `get_instance`,
  `start_instance`, `stop_instance`, `delete_instance`) are open to any authenticated caller, because the compute API
  filters and decides every call anyway, on the reservation or instance itself (`OwnReservations`, `BoundedExtend`,
  a delegation's confirm list).
* The fleet tools need `compute:fleet:admin` on `compute-fleet/global`, which only resolves in the root tenant.
* `list_command_batches` and `get_command_batch` are open to any authenticated caller; compute decides
  `compute:batches:read` on the tenant. So is `propose_command_batch`, where compute decides
  `compute:batches:create` on the tenant, and the batch then needs people's approver keys.
* `create_sandbox` is visible to a caller with `compute:sandboxes:read` on `compute-sandbox-template/{templateId}`:
  creating a sandbox starts with seeing its template. The other sandbox tools (`list_sandbox_templates`,
  `list_sandboxes`, `get_sandbox`, `extend_sandbox`, `delete_sandbox`, `list_sandbox_snapshots`,
  `delete_sandbox_snapshot`) are open to any authenticated caller, since compute decides each call on the tenant,
  the sandbox, the snapshot or the draft.
* `create_sandbox_volume` is visible with `compute:pools:read` on its pool; `list_sandbox_volumes`,
  `resize_sandbox_volume` and `delete_sandbox_volume` are open to any authenticated caller, and compute decides each
  call on the tenant or the volume (e6759df).

The rules accept only well-formed ids: a `poolId` of letters, digits and `._:-` (at most 128 characters), a
`node_…` or a `sbxt_…` id. Anything else throws, so the gate denies the call (7890821, review 14 M2): the gate runs
before compute validates anything, and unvalidated text must never reach a confirmation a person reads.

Every tool the server can list has a rule: a test builds a server with every option on and requires a gate rule for
each of its tools, so a gate with `unlisted: 'deny'` never hides a tool by omission.

The gate never checks the action a tool performs, such as `compute:reservations:create`. That decision happens once,
in compute, on the reservation draft with its full attributes. Actions a delegation holds back for confirmation are
never gate actions, so a person is never asked twice about two different resources for one request. Pass
`typePrefix` to `gateTools` if your deployment uses a resource type prefix other than `compute-`.

## Results and errors [#results-and-errors]

A tool result carries the compute response twice: as pretty-printed JSON text in `content`, and as
`structuredContent` (an object; other values are wrapped as `{ result }`). `reserve_gpus` answers
`{ idempotencyKey, reservation }`, `create_instance` `{ idempotencyKey, instance }` and `create_sandbox`
`{ idempotencyKey, sandbox }`, so the model always has the key to retry with.

A compute error becomes a tool error with `isError: true`, the text `CODE: message`, and
`structuredContent.error` holding `code`, `message`, and `retryAfterMs` and `details` when compute sent them. Any
other failure is reported as `INTERNAL_ERROR` with a generic message, never internals. JSON-RPC problems use the
standard codes: `-32700` for unparsable JSON (HTTP 400) or a non-JSON content type (HTTP 415), `-32600` for an
invalid request or a batch (HTTP 400) or a body over `maxBodyBytes` (HTTP 413), `-32601` for an unknown method and
`-32602` for a missing `params.name`. Methods other than POST get 405. Notifications get 202 with no body.

## Delegated confirmation [#delegated-confirmation]

An agent usually acts through a Better IAM **delegation**: a person lets the agent use some of their permissions,
and can list actions that the person must approve each time. When the agent calls one of those,
compute answers `CONFIRMATION_REQUIRED` and Better IAM opens a confirmation request. See
[delegated confirmation](/docs/guides/iam/delegated-confirmation) for the whole mechanism.

`reserve_gpus`, `extend_reservation`, `create_instance` and `create_sandbox` turn that error into a normal result the
model can act on (`extend_reservation` answers `{ idempotencyKey, extension }` once it goes through):

```json title="reserve_gpus, waiting for a person"
{
  "status": "confirmation-required",
  "confirmationId": "cnf_…",
  "idempotencyKey": "mcp-3f9c0a…",
  "message": "A person must approve this action. Once they have, call the tool again with the same idempotencyKey."
}
```

The model calls the tool again with the same arguments and that `idempotencyKey` once the person has approved. The
retry reaches the same reservation draft, Better IAM finds the approval, and the reservation is placed. The default
server instructions tell the model to do exactly this.

**Waiting inside the call.** With `confirmations`, the tool polls instead of returning at once: it asks for the
confirmation's state every `intervalMs` (default 2 s) for up to `timeoutMs` (default 25 s). On `approved` it
retries once with the same key and returns the result; on `denied` or `expired` it returns
`{ status: 'confirmation-denied' | 'confirmation-expired', confirmationId, idempotencyKey }`; on a timeout it returns
`confirmation-required` as above. If checking the approval fails, it still returns that pending result with its
`idempotencyKey` and a note to retry with the same key (7890821, review 14 L4), so the agent never files a second
draft and a second request for the same person.

`iamConfirmations(iam.api.delegations, { timeoutMs?, intervalMs?, now? })` builds that poller per request from
Better IAM's `delegations.getConfirmation`, called with the caller's own bearer token in the caller's tenant, so an
agent only ever sees its own requests. It maps `rejected` to `denied`, and a `pending` request past its `expiresAt`
to `expired`. Without a bearer token or a tenant it returns nothing and the tool answers `confirmation-required`.

> **Stop on a rejection.** 
  A retry after the person rejected is decided again, denied again, and opens a new confirmation request. Tell your
  agent to stop on `confirmation-denied`, and to start over with a new request (and key) after
  `confirmation-expired`.

## Limits worth knowing [#limits-worth-knowing]

* `exec_command` is off unless you pass `execCommand: true`, and even then it only runs `inventory.refresh`,
  `logs.collect` and `node.reboot`. `node.reboot` restarts the machine; under the quorum it needs a batch from
  `propose_command_batch` that people approved.
* One caller has at most `maxConcurrentCallsPerCaller` (default 4) tool calls in flight. A call can wait about
  85 seconds for an approval or an instance to start, so an agent looping on retries would otherwise pile up held
  requests; above the cap a call answers `RATE_LIMITED` with `retryAfterMs: 2000` (48d41a4, review 14 L5). With
  several MCP server instances, pass a shared `callLimiter` so the cap holds across them (76e4e37); the default
  counts per process.
* The person approving a `create_instance`, `reserve_gpus`, `extend_reservation` or `create_sandbox` reads compute's
  description of the request, and the approval admits only that request; see
  [delegated confirmation](/docs/guides/iam/delegated-confirmation#what-the-person-sees).
* `run_diagnostic` runs `diag.nvml` only. It returns the queued command; the result arrives when the node finishes,
  and `node_status` or the command's own methods show it.
* `reserve_gpus` books with `binding: 'hold'`, or `kubernetes` with `kubernetes: true`; whole-node leases, spot, gangs
  and calendar starts are not exposed as tool arguments.
* `release_reservation`, `stop_instance`, `delete_instance`, `delete_sandbox`, `delete_sandbox_snapshot` and
  `delete_sandbox_volume` are marked destructive: many MCP hosts ask the user before calling such a tool.
* The sandbox tools cannot take a snapshot or run code in a sandbox; code runs through the CLI's `sandbox exec`.
* The server is stateless, with JSON responses only: it does not open SSE streams or send server notifications, and
  `tools.listChanged` is `false`. It negotiates protocol versions `2025-06-18`, `2025-03-26` and `2024-11-05`,
  answering with the newest when the client asks for another.

Related: [reservations](/docs/guides/capacity/reservations), [policies](/docs/guides/iam/policies) (the
`agent-guardrails` example policy), and the [TypeScript client](/docs/guides/clients/typescript-client) the tools
are built on.
