# MCP server

Drive infrawrench from Claude Desktop, Cursor, or any other Model Context Protocol client.

Infrawrench exposes a hosted [Model Context Protocol](https://modelcontextprotocol.io/) server at `/api/mcp`. Point an MCP client (Claude Desktop, Cursor, the MCP CLI, your own LLM agent) at it and the model can list, search, inspect, create, and edit resources in your organization — using your provider credentials, with every mutation written to the [audit log](../team-and-billing/audit-log.md).

## Endpoint

```
POST https://app.infrawrench.com/api/mcp
```

The transport is **Streamable HTTP** — no WebSocket or stdio bridge required. The server speaks both current protocol generations and answers each request in whichever one the client uses:

- **2026-07-28** (the current revision) — stateless per-request negotiation, `server/discover`, and the per-request `_meta` envelope. New clients need no handshake at all.
- **2025-11-25 and earlier** — the classic `initialize` handshake, served statelessly with plain JSON response bodies (`enableJsonResponse: true`), exactly as before. Existing clients keep working unchanged.

## Authentication

Authentication is OAuth via WorkOS AuthKit. The flow is standards-compliant ([RFC 9728 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728)) so any OAuth-capable MCP client just works:

1. Client makes an unauthenticated request to `/api/mcp`.
2. Server replies `401 Unauthorized` with `WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"`.
3. Client fetches the metadata, discovers the WorkOS authorization server, identifies itself (see below), and prompts the user to sign in.
4. Client retries with `Authorization: Bearer <token>`.

The bearer token is verified against WorkOS JWKS on every request, then mapped to a user + organization — so each MCP connection only ever sees orgs the signed-in user is a member of.

### How clients identify themselves

The 2026-07-28 spec deprecates OAuth Dynamic Client Registration in favour of **Client ID Metadata Documents**. All three registration mechanisms work against this server — clients pick whichever the authorization server advertises, preferring CIMD:

- **Client ID Metadata Documents** (the current standard) — the client's `client_id` is an HTTPS URL hosting its own metadata document; the authorization server fetches and validates it. No registration round-trip, and the same `client_id` is portable across servers.
- **Dynamic Client Registration** (deprecated by the spec, still fully supported) — the client registers itself at the authorization server's `registration_endpoint` before the first sign-in. Pre-2026 clients keep working unchanged.
- **Manual pre-registration** — register an OAuth client in the WorkOS Dashboard yourself and paste its client ID into the MCP client's connector settings.

### Choosing an organization

MCP clients have no organization picker, and the OAuth token AuthKit issues them does not necessarily carry an `org_id` claim. So the server resolves the org itself:

- If the token carries an `org_id`, it is used — after checking the caller is a member of it.
- Otherwise the **oldest organization you belong to** becomes the default for the connection.

To work with a different org, every tool takes an optional **`org_id`**. Call `list_organizations` to see what you can address:

```json
[
  { "org_id": "org_01H…", "name": "Acme", "role": "owner", "default": true },
  { "org_id": "org_01J…", "name": "Side project", "role": "member", "default": false }
]
```

Then pass it on any call — `list_resources { "org_id": "org_01J…" }`. An `org_id` you are not a member of is refused before the tool runs, so this can never reach beyond your own memberships.

`org_id` and `list_organizations` are **MCP-only**. The [AI chat](./ai-chat.md) already knows its organization from your session and has no org switcher, so neither appears there.

## What the model can do

The MCP server registers the full shared tool registry — the same tools the [AI chat](./ai-chat.md) uses — plus `list_organizations` (see [Choosing an organization](#choosing-an-organization)), which is MCP-only:

- **Discover** — `list_plugins`, `list_resource_types`, `list_accounts`, `list_resource_sidecars` (which peer plugins a resource exposes — e.g. `kubernetes` on a managed cluster, `postgres` on a managed database).
- **Read** — `search_resources`, `list_resources`, `get_resource`, `get_resource_inputs`, `get_resource_outputs`, `get_resource_stats`, `get_resource_metrics`, `describe_resource`.
- **Impact** — `get_blast_radius` (what breaks if a resource is deleted: transitive dependants, the dashboards, custom graphs, probes, status pages, firing metric alerts, leases, schedules, saved log queries and workflows that name it, what measurably talks to it, and its owner). Its `unchecked` field says what the report could not look at — an empty dependant list next to a non-empty `unchecked` is not a clean bill of health, and a client should say so rather than call the resource safe. See [Blast radius](./blast-radius.md).
- **Mutate** — `create_resource`, `delete_resource`, `invoke_action`. Reach for `get_blast_radius` before proposing a deletion.
- **Manifests** — `get_manifest`, `apply_manifest` (see [Manifest editor](./manifest-editor.md)).
- **Connections** — `sql_query`, `sql_execute`, `introspect_sql_schema`, `kv_command`, `docker_command`, `ssh_exec`, and the storage tools (`list_storage_objects`, `make_storage_folder`, `delete_storage_object`).
- **Costs & budgets** — `query_costs` (aggregate spend series with grouping, filters, previous-period comparison, and forecasts), `list_cost_dimension_values`, `get_cost_status`, `list_cost_anomalies` and `acknowledge_cost_anomaly` (record what a detected spike actually was — the explanation is published as a dated note on every cost chart covering that day; see [Cost anomaly alerts](./cost-anomaly-alerts.md#explaining-an-anomaly)), `list_budgets`, `get_budget`, `create_budget`, `update_budget`, `delete_budget`, plus the [tag governance](./tag-policy-and-showback.md) reads `get_tag_compliance`, `query_untagged_spend`, and `query_showback`, and `list_billing_rules` — the organization's own adjustments to collected spend, and the reason an adjusted total may not match an invoice. `query_costs` and `query_showback` both take `adjusted`; the tools quote collected spend unless asked otherwise and always state both figures when they report an adjusted one. See [Cloud costs](./cloud-costs.md) and [Billing rules](./billing-rules.md).
- **Managed accounts & invoices** — `list_managed_accounts`, `get_managed_account`, `list_invoices`, `get_invoice`. Customers you bill for infrastructure you run on their behalf, and the invoices raised against them. All read-only on `invoices:read`, a family of its own rather than `costs:*` because a managed account holds a customer's contact details and the price they were quoted. `get_invoice` returns the full derivation — collected, what the billing rules added, the rate and the day it was read — so "why is this customer being billed this" is answerable. A draft's totals are `null` in the list, not zero: drafts recompute on read and the list does not. See [Managed accounts & invoices](./managed-accounts.md).
- **Cost reports** — `list_cost_reports`, `get_cost_report`, `run_cost_report` (takes only an id — the report _is_ the query), `create_cost_report`, `update_cost_report`, `delete_cost_report`. Named saved cost graphs, so "run the monthly spend report" needs no filters restated. See [Cost reports](./cost-reports.md).
- **Sleep schedules** — `list_schedules` (windows, next transitions, last outcomes, projected savings) and `create_schedule` (attach an off-at/on-at weekly window to a stoppable resource; audit-logged). See [Sleep/wake schedules](./sleep-schedules.md).
- **Moment view** — `what_changed` (everything that happened around a timestamp, merged chronologically across the change timeline, provider incidents, cost anomalies, workflow runs, deployments, audit entries, freezes and alert deliveries). Invoking it requires `resources:read`; feeds the caller can't read are reported as **omitted**, while a feed whose query fails is reported as **errored** without losing the rest — so a client can tell authorization gaps from transient feed failures. See [Investigate a moment](./moment.md).
- **Ownership** — `list_resource_ownership` (who owns each resource, what it is for, its ticket link; filter by `ownerUserId` or `unownedOnly`). A resource absent from the list is unowned, which is what makes this worth asking before proposing a deletion. See [Resource ownership](../core-concepts/resource-ownership.md).
- **Status pages** — `list_status_pages` (what monitoring is published, whether each page is live, and its slug). Treat a `published: true` page as externally visible — anyone with its link can read it without signing in. See [Public status pages](./status-pages.md).
- **Environment diff** — `diff_environments` (two accounts of one provider compared: resource types present in one and not the other, per-type count deltas, and the fields on which corresponding resources disagree). Takes the accounts by id or exact name. The tool to reach for when someone asks why one environment behaves differently from another. See [Environment diff](./environment-diff.md).
- **Right-sizing** — `list_oversized_resources` (machines whose 14-day p95 utilisation sits well under their size, with the recommended smaller size and monthly saving; read-only — applying a resize is done from the app). See [Right-sizing](./right-sizing.md).
- **Workflows** — `list_workflows`, `get_workflow`, `get_workflow_typings` (returned in parts for large organizations — the global scope first, then named interfaces on request), `check_workflow_source`, `write_workflow`, `run_workflow`, `delete_workflow`, plus `list_workflow_secrets`, `write_workflow_secret`, and `delete_workflow_secret`. Listing or exposing assigned secret metadata/typings requires `secrets:read`; writing or deleting secret metadata requires `secrets:write`. `write_workflow` assigns reusable secret ids and generated typings expose only those names under `infra.secrets`. See [Writing workflows with an AI client](./workflows.md#writing-workflows-with-an-ai-client).
- **Custom graphs** — `list_custom_graphs`, `get_custom_graph`, `get_custom_graph_typings`, `write_custom_graph` (type-checked before saving), `render_custom_graph`, `delete_custom_graph`. Script-defined dashboard charts with their own controls and refresh policy; paid plan only. See [Custom graphs](./custom-graphs.md).
- **Deployments** — `list_deployments`, `get_deployment`, `list_deployable_repos`, `plan_deployment` (runs a repo's `plan()` and renders the Dockerfile, building and shipping nothing), `rollback_deployment`. There is deliberately no tool that deploys — a human starts that from the app or the CLI. See [Infrafile](./infrafile.md).
- **SSH keys** — `list_ssh_keys`, `create_ssh_key` (generates an Ed25519 keypair; the private key stays encrypted server-side and is usable by id with `ssh_exec` and tunnels — it is never returned through a tool), `import_ssh_key` (public key only), `delete_ssh_key`. See [SSH keys](../team-and-billing/ssh-keys.md).
- **SSH host trust** — `list_trusted_ssh_hosts`, `trust_ssh_host`, `remove_ssh_host_trust`. When `ssh_exec` hits an untrusted host it fails with the presented fingerprint; verify it out-of-band, then `trust_ssh_host { host, port, fingerprint }` and retry. See [Trusted SSH hosts](../team-and-billing/ssh-host-keys.md).
- **Linux applications** — drive a graphical application on a resource's host without a browser: `list_apps_on_host`, `launch_app` (returns a `windowId`), `screenshot_app_window` (a PNG the model can see), `read_app_accessibility_tree` (what a screen reader reads — every element's role, name, text, state and on-screen bounds, each bounded node carrying a `center` you can click), `click_app_window`, `type_in_app_window`, `press_keys_in_app_window`, `scroll_app_window`, `list_app_windows`, and `close_app_window`. The screenshot pixels, a click's coordinates, and the accessibility tree's bounds are all one space, so read the tree (cheaper and exact) or a screenshot to find a target, then act on it by pixel. The host runs the applications; Infrawrench brings the display. A VM resource needs an `sshKeyId` (see `list_ssh_keys`), the same key the terminal uses; a host whose plugin supplies SSH natively (Fly, Hetzner) needs none. All carry `resources:execute` and are audit-logged. See [Linux applications](./linux-apps.md).

It also registers **per-plugin create tools** at server build time. For every resource type that supports creation, you get a typed tool like `digitalocean_create_droplet` or `aws_create_s3_bucket` with a Zod schema generated from the plugin's field definitions — so the model can discover what to set without first round-tripping `list_resource_types`.

Create tools for VM types that install an SSH key at create time (DigitalOcean Droplets, Hetzner servers, EC2 instances, GCE instances, Scaleway instances) additionally accept **`sshKeyId`** — a stored [org SSH key](../team-and-billing/ssh-keys.md) id (see `list_ssh_keys`) whose public key is injected into the provider's SSH-key field server-side. The same parameter works on the generic `create_resource`. Only the public key is used; a raw OpenSSH public key can be passed instead.

Most resource tools take an optional `parentResourceId` to target a **sidecar** — the peer plugin a managed resource exposes through its outputs. "What's running in my DOKS cluster?" is `list_resource_sidecars` on the cluster, then `list_resources { pluginId: "kubernetes", resourceTypeId: "k8s-deployment", parentResourceId: <cluster id> }` — the kubeconfig is resolved server-side from the cluster's outputs, and the same pattern drives `describe_resource`, `invoke_action`, `apply_manifest`, and the per-plugin create tools inside the cluster or database.

The cost, budget, workflow, deployment, and SSH-key tools enforce the same [role permissions](../team-and-billing/roles-and-permissions.md) as the web dashboard (`costs:read`, `budgets:read`, `budgets:write`, `dashboards:read`, `dashboards:write`, `workflows:read`, `workflows:write`, `secrets:read`, `secrets:write`, `deployments:read`, `deployments:plan`, `deployments:write`, `ssh-keys:read`, `ssh-keys:write`) — a member whose role can't see spend or secret metadata in the UI can't read it through MCP either. Deleting another member's SSH key additionally requires `team:role:write`, matching the HTTP API.

## Audit and safety

Every mutating tool call (`create_resource`, `delete_resource`, `invoke_action`, `apply_manifest`, the budget tools, the workflow tools, `plan_deployment` and `rollback_deployment`, the per-plugin create tools) writes a row to the [audit log](../team-and-billing/audit-log.md) with `source: "mcp"`. You can filter the audit log by source to see exactly what the model has done in your org.

There is no destructive-action confirmation step inside the protocol — that responsibility lives in the client. Claude Desktop and Cursor surface a permission prompt before each tool call by default; we recommend leaving those prompts on for the mutating tools at a minimum.

`write_workflow_secret` is deliberately metadata-only over MCP: its schema has no value argument, so a password cannot leak into model context or client logs. It creates or updates the named slot; set its value through Infrawrench's workflow editor or secure in-chat password prompt, then use the returned id with `write_workflow`.

## Connecting from Claude Desktop

Add an entry to your `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "infrawrench": {
      "url": "https://app.infrawrench.com/api/mcp"
    }
  }
}
```

Restart Claude Desktop. The first request will trigger the OAuth browser flow.

## Tips & limits

- Each request is stateless — a fresh `McpServer` per call, whichever protocol revision the client speaks. There are no protocol-level sessions (the 2026-07-28 model), so long-running tools should return quickly and stream progress via subsequent calls rather than holding a request open.
- Self-hosted infrawrench: set `PUBLIC_BASE_URL` to your public origin so the OAuth resource metadata advertises the correct URL (it falls back to `APP_URL`), and `WORKOS_AUTHKIT_DOMAIN` (or `WORKOS_ISSUER`) so clients discover the right authorization server. `WORKOS_AUTHKIT_DOMAIN` must be your AuthKit domain — `https://api.workos.com/user_management` does **not** serve OAuth authorization-server metadata, so discovery fails against it.
- Self-hosted infrawrench: under **Connect → Configuration** in the WorkOS Dashboard, enable **Client ID Metadata Document** (the current MCP standard for client registration) and — for clients that haven't adopted it yet — **Dynamic Client Registration** (deprecated by the 2026-07-28 spec, still fully supported). With neither enabled, clients without pre-registered credentials cannot connect: CIMD-capable clients find no `client_id_metadata_document_supported` flag in the authorization server metadata, and DCR clients get `dynamic_client_registration_disabled` from `/oauth2/register`. The server probes the authorization server's metadata whenever OAuth discovery is served and logs a `[mcp-auth]` warning when neither mechanism is advertised — check the web pod's logs when clients report they couldn't register. As an alternative, register an OAuth client in WorkOS manually and paste its client ID into the MCP client's connector settings.
