Agents and MCP
Everything isb's web UI does is a tool on isb serve's
Model Context Protocol server: deploy an
app, read its logs, roll it back, make a sandbox and run commands in it,
manage secrets and backups. An agent such as Claude Code, Codex or Cursor
connects to it like any remote MCP server. The point is reach without risk:
an agent holding an org's token administers that org's apps and sandboxes and
nothing else, and the remote-spec
policy keeps even a careless
or compromised agent off the host.
Two endpoints#
| Endpoint | For | Every tool's org |
|---|---|---|
/orgs/ORG/mcp |
an agent working in one org: the usual choice | filled in; any other value is refused |
/mcp |
a caller that spans orgs (a platform admin's token, a superadmin) | an argument the caller passes |
Both serve the same tools, filtered to what the caller may use. The org-bound
endpoint is what an org token should point at: it cannot be talked into
another org, whatever the arguments say. It is scoped to the org even for a
superadmin or a platform admin, who act there as an admin of that org only:
the host, superadmin and platform tools are not listed and are refused, and
the remote-spec policy applies. A superadmin can therefore add /mcp and
/orgs/ORG/mcp connectors side by side, each behaving as its URL says. Each tool's refusal is an MCP tool
error with the reason, so the agent can explain it.
The transport is MCP's Streamable HTTP with plain JSON responses (no SSE
stream; GET /mcp is 405), stateless: tools/call works without a prior
initialize, and there is no session id. The same tools answer as REST at
POST /orgs/ORG/api/v1/tools/<tool> (HTTP API),
and MCP tools lists them all.
Connect an agent to an org#
Make a token for the org. In the web UI, open the org's MCP page (
/orgs/ORG/agents) and make a token there, or on the host:isb token create claude-code --org acme --expires 90dIt is shown once. Any member of the org can make one for it; it acts with that member's role, read on every request, so demoting or removing the member narrows or ends the token at once.
Give it to the client through an environment variable, never as a value in a file you commit. With the token in
ISB_TOKEN:export ISB_TOKEN=isb_tok_... claude mcp add --transport http --scope user isb-acme https://isb.example.com/orgs/acme/mcp \ --header "Authorization: Bearer $ISB_TOKEN"# ~/.codex/config.toml [mcp_servers.isb-acme] url = "https://isb.example.com/orgs/acme/mcp" bearer_token_env_var = "ISB_TOKEN"{ "mcpServers": { "isb-acme": { "url": "https://isb.example.com/orgs/acme/mcp", "headers": { "Authorization": "Bearer ${env:ISB_TOKEN}" } } } }The last is Cursor's
~/.cursor/mcp.json; other clients that take anmcpServersmap use the same shape. For a repository, Claude Code's.mcp.jsontakes the same map with"type": "http"and${ISB_TOKEN}references, so the file can be committed and the token stays outside it.Check it without the agent:
curl -sS https://isb.example.com/orgs/acme/mcp \ -H "Authorization: Bearer $ISB_TOKEN" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'A JSON answer with
toolsmeans the URL and the credentials work. 401: the token is missing, wrong, revoked or expired (the same call without theAuthorizationheader answers401withsign in: send an API token as Authorization: Bearer ...); 403 from Cloudflare: Access stopped it before isb.
The MCP page writes all of these for you: the org endpoint's URL on the origin
the page is open at, a form that makes the token (name, access, expiry) and
fills the export line with it, copyable snippets for Claude Code (claude mcp add and .mcp.json), Codex (codex mcp add and config.toml), Cursor
and other mcpServers clients, the curl test, and the tools the endpoint
lists (GET /api/v1/tools). Who may make a token there follows the server's
rules: members, viewers (whose token only reads), platform admins; not a
superadmin without an account, and not a session signed in with a narrowed
token.
Without a token: tailnet and Access identities#
An org's owners and admins can let an agent in by where it connects from, with a role in that org only (Agent identities). The MCP page's How the agent signs in shows three cards, each On or Off, and the snippets for the one selected:
| Card | On when | The agent |
|---|---|---|
| Org token | always | sends Authorization: Bearer (above) |
| Tailnet identity | the server has a tailnet --listen address and the org maps a tailnet login or tag |
connects to http://TAILNET-ADDRESS:PORT/orgs/ORG/mcp from a mapped node, with no credential |
| Access identity | Access guards a listener, and the org maps an Access service token or email, or has member users | sends the Access service token's CF-Access-Client-Id and CF-Access-Client-Secret; a person whose email is a member acts as that member |
An Off card says what is missing: a tailnet --listen address on the server,
or a mapping set in the org's Settings, Agent identities (or
agent_identity_set). The client must send Content-Type: application/json
and no foreign Origin, as MCP clients do; REST writes need X-Isb-Csrf: 1.
Behind Cloudflare Access#
When the daemon sits behind Access (Reach isb serve remotely), an agent also needs to get past Access:
- claude.ai and Claude Desktop connectors (and ChatGPT's) use Access
Managed OAuth: add
https://isb.example.com/mcp(or the org URL) as a connector and sign in through Access in the browser. No isb token is copied; the Access identity acts as the isb user with that email. - Command-line agents send an Access service token's headers as well,
CF-Access-Client-IdandCF-Access-Client-Secret, read fromCF_ACCESS_CLIENT_IDandCF_ACCESS_CLIENT_SECRET. The MCP page's Access switch adds them to every snippet; it is on by default for an address that is not localhost or the tailnet.
Narrow what a token may do#
A token with no scopes has its user's whole role in the org: an agent administers the org's apps as its people do. Scopes narrow it, and never widen a role:
| Scope | Allows |
|---|---|
read |
read-only tools, never secret material (as a viewer) |
deploy |
read, plus stack_deploy, stack_redeploy, stack_rollback, stack_scale, app_scale, app_restart, instance_restart, app_deploy, app_rollback, build_run |
admin |
everything the role allows (the same as no scopes) |
tool:GLOB |
tools whose name matches, e.g. tool:app_*, tool:stack_status |
isb token create ci --org acme --scope deploy
isb token create watcher --org acme --scope read --scope 'tool:app_deployment_log'
The MCP page and the Account page offer the same choice as Access: full,
deploy, read only, or only some tools. A scoped token without admin cannot
change accounts, tokens, invitations or members. Details: API tokens and
scopes.
The operator can also hide tools from every remote caller with
--allow-tools and --deny-tools (names or globs such as sandbox_*,
comma-separated; deny wins). A hidden tool is not listed and cannot be
called. The local socket always has every tool.
# ~/.config/isb/serve.env: remote callers never read secret values or exec
ISB_SERVE_DENY_TOOLS=secret_*,sandbox_exec,app_exec,instance_exec,instance_file_*
What an agent can and cannot do#
An org's member token reaches everything in the org: its apps, deployments, stacks, sandboxes (create, exec, remove), secrets (values included), databases, backups, volumes, jobs, notifications and, for owners and admins, the audit log. It reaches nothing in any other org, and no platform or host tool.
It can also look inside what runs, as kubectl does for pods:
instance_list and instance_get describe every instance of the org,
app_exec and instance_exec run a command in a replica (or any instance),
app_logs, app_top and app_events read an app's output, use and events,
app_restart, app_scale and instance_restart roll, resize and replace
replicas, and instance_file_read and instance_file_write copy small files
in and out. Viewers and read tokens only read what instance_list,
instance_get, app_logs, app_top and app_events show; running code,
restarting and files are for members and up. isb for kubectl
users maps each to the kubectl verb it replaces.
Whatever the role, a remote caller's specs are held to the remote-spec
policy: no privileged containers, raw config or devices, host bind mounts
outside the operator's --bind-root directories, ports published on
anything but loopback, or instances isb does not manage, unless the operator
allows each one. ${VAR} in a remote caller's compose file is filled from the
vars it sent, never from the daemon's environment. See
Security.
Every call that changes something, and every refusal, is in the audit
log with the token's name; a sandbox a remote caller
creates is labelled isb.owner=mcp:<identity>.
Agents in an org's workspace#
An org's workspace is its long-lived machine, where its people and agents work. It comes with its own credential, so an agent started inside needs no setup:
/run/isb/token(0400, owned by the workspace user) holds the workspace's token,isb_ws_..., with a role in the org (--token-role, admin by default). It is never shown by any tool, never logged, and rotated withisb workspace rotate-token.- Login shells get it as
$ISB_TOKEN, with$ISB_URL(where isb answers),$ISB_ORGand$ISB_WORKSPACE, from/etc/profile.d/isb.sh. $ISB_URLis the org bridge's gateway on port 8481 (--workspace-mcp-port), the bridge listener: it answers only the org's own subnet, serves only/orgs/<org>/...and/healthz, and takes only bearer tokens (the workspace's or an org API token; no cookies, Access assertions or superadmin tokens). See Reaching isb from inside.
So inside the workspace:
claude mcp add --transport http --scope user isb "$ISB_URL/orgs/$ISB_ORG/mcp" \
--header "Authorization: Bearer $ISB_TOKEN"
isb app ls # the isb CLI uses $ISB_URL and $ISB_TOKEN when there is no daemon socket
The workspace's Connect tab shows the same snippets, the token's role and last
use, and Rotate for admins (never the token itself). In the audit log and the
history the workspace acts as workspace.
The token is confined to its org: it acts as no user, reaches no other org, no platform or host tool, and cannot use the identity endpoints (no members, invitations, tokens or keys).
Sandboxes for heavy or risky work#
The workspace is shared and long-lived, so builds, test runs and anything
untrusted belong in sandboxes the agent makes through the same MCP, never in
nested incus. sandbox_create takes one compose service as the spec:
{"name": "sandbox_create", "arguments": {
"spec": {"container_name": "pr-412-tests", "image": "dev-base",
"cpus": 2, "mem_limit": "4g", "user": "dev"},
"expires": "6h", "idle_timeout": "1h"}}
then sandbox_exec runs argv in it (exit code, stdout and stderr each capped
at 256 KiB keeping the end, optional stdin text, a timeout of 10 minutes by
default), and sandbox_remove deletes it. A sandbox made this way is
labelled isb.owner=workspace and is short-lived by rule:
- it expires after the org's
sandbox_expiry(default 24 h, at most 30 days), or the call'sexpires; - it is deleted when idle for the org's
sandbox_idle(default 2 h), or the call'sidle_timeout, or never withnone. Idle means no exec or terminal through isb and under 2 % of a core of CPU.
sandbox_extend pushes the expiry out (by, default 24 h) or changes the idle
timeout; the sandbox's creator or the org's admins may. sandbox_list shows
each sandbox's creator, age, expiry and use, and whether the caller made it
(mine). The daemon's reaper deletes expired and idle sandboxes every minute
and records sandbox.reaped in the history. The workspace itself can never
be reached as a sandbox.
Superadmin MCP#
A superadmin has the unix socket's reach over HTTP: every tool, no
remote-spec policy, any instance on the host, and the host tools
(host_inventory, host_policy, superadmin_token_list,
superadmin_token_revoke). It is for agents that administer the host itself,
across orgs, and it is root on the host in all but name. It comes from exactly
three remote sources (Superadmins):
- a superadmin token,
isb token create NAME --superadmin [--expires 30d], minted only on the host (no HTTP caller can mint one), sent asAuthorization: Bearer isb_sa_...; - a tailnet identity on a tailnet
--listenaddress, listed in--superadmin-tailnet: the agent's machine signs it in, so no token is needed; - a Cloudflare Access identity listed in
--superadmin-access.
Point it at the unbound /mcp: on /orgs/ORG/mcp a superadmin is scoped
down to an admin of that org. A superadmin also sees this endpoint on the
MCP page, with a warning, and for each source whether it is on and its
snippets; the web never makes a superadmin token. Tailnet and Access
superadmins are ambient credentials, so their /mcp calls must be
Content-Type: application/json and a foreign Origin is refused.
Prefer an org token wherever one org is enough.
Locally, without the daemon's HTTP#
On the host itself an agent can use the isb CLI directly (it talks to the
daemon's unix socket, as the daemon's user, with every tool), or run sandboxes
from an isb.yaml with isb up (A dev environment per
worktree). The agent skill in the repository's
SKILL.md teaches an agent both.