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.
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:
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
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 call | Better IAM API |
|---|---|
| authenticate | sts.getCallerIdentity |
| authorize, authorizeMany | authorize, authorizeMany |
| confirmations | the 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
| 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 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:readoncompute-pool/{poolId}; one that names a node needscompute:nodes:readoncompute-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:adminoncompute-fleet/global, which only resolves in the root tenant. list_command_batchesandget_command_batchare open to any authenticated caller; compute decidescompute:batches:readon the tenant. So ispropose_command_batch, where compute decidescompute:batches:createon the tenant, and the batch then needs people's approver keys.create_sandboxis visible to a caller withcompute:sandboxes:readoncompute-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_volumeis visible withcompute:pools:readon its pool;list_sandbox_volumes,resize_sandbox_volumeanddelete_sandbox_volumeare 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):
{
"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.
Limits worth knowing
exec_commandis off unless you passexecCommand: true, and even then it only runsinventory.refresh,logs.collectandnode.reboot.node.rebootrestarts the machine; under the quorum it needs a batch frompropose_command_batchthat 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 answersRATE_LIMITEDwithretryAfterMs: 2000(48d41a4, review 14 L5). With several MCP server instances, pass a sharedcallLimiterso the cap holds across them (76e4e37); the default counts per process. - The person approving a
create_instance,reserve_gpus,extend_reservationorcreate_sandboxreads compute's description of the request, and the approval admits only that request; see delegated confirmation. run_diagnosticrunsdiag.nvmlonly. It returns the queued command; the result arrives when the node finishes, andnode_statusor the command's own methods show it.reserve_gpusbooks withbinding: 'hold', orkuberneteswithkubernetes: 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_snapshotanddelete_sandbox_volumeare 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.listChangedisfalse. It negotiates protocol versions2025-06-18,2025-03-26and2024-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.
Better Compute is created by Sean Filimon
Last updated
CLI
Use the better-compute command line as a client - authenticate with a token, run a command for every API group, reserve GPUs, run sandboxes, plan the fleet.
Consolebeta
The Next.js console runs Better IAM and Better Compute on one origin, built on console-kit, framework-free stores with live updates and permission-aware actions.