better/compute
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 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, 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: 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

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).

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:

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

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.

Prop

Type

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 callBetter IAM API
authenticatests.getCallerIdentity
authorize, authorizeManyauthorize, authorizeMany
confirmationsthe caller's delegation: delegations.get and delegations.requestConfirmation
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

ToolCallsGate ruleHints
list_poolspools.listany authenticated callerread-only
capacity_summarycapacity.summary, or capacity.byPool with poolIdany authenticated callerread-only
preview_reservationcapacity.previewcompute:pools:read on the poolread-only
reserve_gpusreservations.create (binding: 'hold', or 'kubernetes')compute:pools:read on the poolidempotent
release_reservationreservations.releaseany authenticated callerdestructive, idempotent
list_reservationsreservations.list: the caller's own by default, scope: 'all', filters state and poolId, limit 1 to 200, cursorany authenticated callerread-only
get_reservationreservations.getany authenticated callerread-only
extend_reservationreservations.extend by a duration, confirmation-awareany authenticated calleridempotent
node_statusnodes.getcompute:nodes:read on the noderead-only
run_diagnosticcommands.run with diag.nvmlcompute:nodes:read on the nodeidempotent
create_instanceinstances.createcompute:pools:read on the poolidempotent
list_instancesinstances.list: the caller's own by default, scope: 'all', filter poolId, limit 1 to 200, cursorany authenticated callerread-only
get_instanceinstances.getany authenticated callerread-only
start_instanceinstances.startany authenticated calleridempotent
stop_instanceinstances.stopany authenticated callerdestructive, idempotent
delete_instanceinstances.deleteany authenticated callerdestructive, idempotent
list_sandbox_templatessandboxTemplates.list, without each template's userData and envany authenticated callerread-only
create_sandboxsandboxes.create, confirmation-awarecompute:sandboxes:read on the templateidempotent
list_sandboxessandboxes.list: the caller's own by default, scope: 'all', filter templateId, limit 1 to 200, cursorany authenticated callerread-only
get_sandboxsandboxes.getany authenticated callerread-only
extend_sandboxsandboxes.extend by a durationany authenticated callernot idempotent: each call extends again
delete_sandboxsandboxes.deleteany authenticated callerdestructive, idempotent
list_sandbox_snapshotssandboxSnapshots.list: the caller's own by default, scope: 'all', filter sandboxId, limit 1 to 200, cursorany authenticated callerread-only
delete_sandbox_snapshotsandboxSnapshots.deleteany authenticated callerdestructive, idempotent
list_sandbox_volumessandboxVolumes.list (v1.1), filter poolId, scope (mine by default, or all)any authenticated callerread-only
create_sandbox_volumesandboxVolumes.create, confirmation-awarecompute:pools:read on the poolidempotent
resize_sandbox_volumesandboxVolumes.resizeany authenticated calleridempotent
delete_sandbox_volumesandboxVolumes.deleteany authenticated callerdestructive, idempotent
list_command_batchescommandBatches.list (v1.0): filter state, limit 1 to 200, cursorany authenticated callerread-only
get_command_batchcommandBatches.get with a batchId (batch_…)any authenticated callerread-only
exec_command (with execCommand: true)commands.run: inventory.refresh, logs.collect or node.reboot, optionally with a batchIdcompute:nodes:read on the nodedestructive
propose_command_batch (with execCommand: true)commandBatches.create (v1.0): a node.reboot batch over the given nodesany authenticated calleridempotent
fleet_export (with fleet: true)fleet.exportcompute:fleet:admin on the fleetread-only
fleet_plan (with fleet: true)fleet.plancompute:fleet:admin on the fleetread-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 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.

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.

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 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 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

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

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

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 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):

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

  • 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.
  • 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, policies (the agent-guardrails example policy), and the TypeScript client the tools are built on.

Was this page helpful?

Better Compute is created by Sean Filimon

Last updated

On this page