The MCP server — 148 tools, your credential, your tier.
The IG1 MCP server (ig1-mcp 0.9.4, MCP SDK 2.0.0, spec 2026-07-28
stateless) exposes cloud operations as Model Context Protocol tools over streamable
HTTP at https://mcp.cloud.ig1.com/mcp. It holds
no identity of its own: every request carries the caller's Bearer token,
which is forwarded to the backing service on every tool call — the IG1 API resolves
project + tier server-side, exactly as if you called REST directly. The tool count in
the title is the size of the server's tool registry; the catalog below lists every one.
Auth model
- Every request needs
Authorization: Bearer <token>— the ASGI guard answers 401 JSON when it is absent (/healthexempt). - The Bearer is the credential's PAT (personal access token, shown once at create — paste and it works until revocation/expiry) or a
client_credentialsmint at https://zitadel.cloud.ig1.com/oauth/v2/token from the credential's pair — never cached server-side, per-request only. - The tier contract is enforced by the API per call (reads tier 0, writes tier 1, deletes tier 2) and its 403 tier message is forwarded verbatim as the tool result — a tool never rewrites or softens it.
- Machine-readable card for agentic directories: /server.json on this hub.
Tool catalog
Each tool with its minimum tier (0 read, 1 operate, 2 destructive). The tier noted is the effective one — the API gates by HTTP method, so reading a console log (a POST in Nova) is tier 1 even though it feels like a read. The one enumerated exception: detaching a volume (an HTTP DELETE upstream) is tier 1, because a detach is reversible and data-preserving — symmetric with attach.
Compute
| Tool | Tier | Purpose |
|---|---|---|
list_vms | 0 | list virtual machines for the caller's project |
create_vm | 1 | create a VM (name, flavor, image, network) |
vm_action | 1 | power actions: start | stop | reboot (SOFT) |
resize_vm | 1 | resize to a new flavor, then confirm or revert |
confirm_resize | 1 | commit a pending resize |
revert_resize | 1 | roll a pending resize back to the prior flavor |
get_console_log | 1 | read the serial console log (a POST in Nova → tier 1) |
snapshot_vm | 1 | snapshot a VM to a bootable Glance image — crash-consistent; stop first if the workload cannot tolerate that |
shelve_vm | 1 | power off AND free the host resources (disk snapshotted to Glance; reversible) |
unshelve_vm | 1 | bring a shelved VM back |
delete_vm | 2 | delete a VM |
Keypairs
| Tool | Tier | Purpose |
|---|---|---|
list_keypairs | 0 | list SSH keypairs |
import_keypair | 1 | import a public key — private-key generation is not exposed (see exclusions) |
delete_keypair | 2 | delete a keypair |
Volumes & snapshots
| Tool | Tier | Purpose |
|---|---|---|
list_volumes | 0 | list block-storage volumes |
create_volume | 1 | create a volume (name, size in GB) |
attach_volume | 1 | attach a volume to a server |
detach_volume | 1 | detach a volume (a DELETE upstream, but an enumerated tier-1 exception: reversible and data-preserving) |
extend_volume | 1 | grow a volume — Cinder never shrinks; the guest still grows the filesystem itself |
delete_volume | 2 | delete a volume |
list_volume_snapshots | 0 | list volume snapshots |
create_volume_snapshot | 1 | snapshot a volume |
delete_volume_snapshot | 2 | delete a volume snapshot |
create_volume_from_snapshot | 1 | new volume from a snapshot |
list_volume_backups | 0 | list volume backups — the OFF-cluster copies (platform object store, phase 58) |
create_volume_backup | 1 | back up a volume off the block layer — the DR copy a snapshot is not |
delete_volume_backup | 2 | delete a volume backup |
restore_volume_backup | 1 | restore a backup into a NEW volume — never over the source |
Network
| Tool | Tier | Purpose |
|---|---|---|
list_networks | 0 | list Neutron networks |
create_network | 1 | create a network |
delete_network | 2 | delete a network |
create_subnet | 1 | create a subnet — the CIDR is validated strictly before Neutron sees it (Neutron silently "fixes" a typo'd host octet) |
delete_subnet | 2 | delete a subnet |
list_routers | 0 | list routers |
create_router | 1 | create a router — no gateway parameter exists (see exclusions) |
add_router_interface | 1 | attach a subnet to a router |
remove_router_interface | 1 | detach a subnet (a PUT in Neutron — the reversible half) |
delete_router | 2 | delete a router — refuses a gateway-bearing one rather than clearing the gateway first |
list_ports | 0 | list ports |
list_floating_ips | 0 | list floating IPs |
allocate_floating_ip | 1 | allocate a FIP (default: first router:external network) |
associate_floating_ip | 1 | attach a FIP to a server |
disassociate_floating_ip | 1 | detach a FIP, keep the address (deliberately not tier 2: the reversible half) |
release_floating_ip | 2 | release a FIP back to the pool (a DELETE at the API → tier 2) |
list_nat_gateways | 0 | list this project's NAT gateways (0 or 1). The platform provides outbound internet access (SNAT) for every tenant router — one per tenant by design |
create_nat_gateway | 1 | create or converge a NAT gateway for this project. Idempotent: if a gateway already exists, the existing record is returned |
delete_nat_gateway | 2 | delete a NAT gateway — disables SNAT on the tenant's router. The router's external gateway network survives so floating IPs continue to work |
Security groups
| Tool | Tier | Purpose |
|---|---|---|
list_security_groups | 0 | list security groups |
create_security_group | 1 | create a group — default-deny: with just a name you get an empty group |
list_security_group_rules | 0 | list a group's rules |
add_security_group_rule | 1 | add one rule — the remote is never implicit, same posture as create |
remove_security_group_rule | 2 | remove a rule — revoking access can cut a running workload off (a DELETE → tier 2) |
delete_security_group | 2 | delete a group |
0.0.0.0/0, which meant "make me a VM" published SSH to the internet as a
side effect. ingress_cidr re-adds the convenience pair (ICMP + SSH/22)
from that range only; extra_rules requires remote_ip_prefix
on every rule; and any rule opening TCP/22 to the whole internet is refused unless
allow_ssh_from_anywhere=true — before the group is created, so nothing
half-open is left behind.
Identity
| Tool | Tier | Purpose |
|---|---|---|
whoami | 0 | who this token is — person, role, account (name, alias, number), project and tier. Call it first: every other tool acts on « the caller's project », and the same token is a sandbox in one context and a production account in another. A cross-tenant credential says so in capitals |
get_effective_org_policy | 0 | the organization guardrails in force for this credential, and which policy produced each one. Call it when a write is refused and the tier looks right: an account can attach deny-only policies to itself or to an OU, they inherit downwards and compose by minimum, and they can lower the tier a credential effectively holds. Read-only by design — an agent that could delete a policy could lift its own ceiling |
list_org_units | 0 | the account's organizational units and which projects sit in each. The path is the parentage (prod.eu-west is a child of prod), and two projects in different OUs can be subject to entirely different ceilings |
list_projects | 0 | the projects this account holds and how many more its tier allows. A project is a real isolation boundary — its own quota, network, object-storage namespace and Kubernetes namespace — so two of them may each hold a cluster called prod. Marks the one in effect, and whether each is addressable by this credential (an account can own a project a given token cannot reach) |
create_project | 2 | add another isolated project to this account, provisioned at the account's tier with its own quota, network and OpenStack credential. Billed capacity, refused at the tier's ceiling, and reserved to the Owner/Administrator rungs. There is deliberately no delete verb — removing a project destroys everything in it and stays an operator action |
list_workload_identities | 0 | list this project's workload identity configurations. One config per cluster; shows which clusters can exchange Kubernetes service-account tokens for IG1 credentials |
create_workload_identity | 1 | enable workload identity (IRSA) for a Kubernetes cluster. Registers the cluster's OIDC issuer with the IG1 identity plane so that pod service-account tokens can be exchanged for short-lived IG1 credentials |
delete_workload_identity | 2 | delete a workload identity configuration. Pods in the cluster will no longer be able to exchange service-account tokens for IG1 credentials |
exchange_workload_token | 1 | exchange a Kubernetes service-account JWT for an IG1 credential. The JWT must be signed by a cluster whose OIDC issuer is registered with IG1. The returned credential is scoped to the tenant's project and expires in 1 hour |
Images & flavors
| Tool | Tier | Purpose |
|---|---|---|
list_images | 0 | list Glance images visible to the project |
get_image | 0 | one image's detail |
import_image | 1 | web-download import — SSRF-guarded: private/link-local/loopback targets are refused before any image record is created |
delete_image | 2 | delete an image |
list_flavors | 0 | list Nova flavors (VM sizes) |
get_quotas | 0 | the project's quota tier, limits and current use — check before creating; nothing on this surface changes a tier |
get_tiers | 0 | the tier catalogue — every tier's name and headline ceilings (the menu; what the project holds is get_quotas). Never the platform's capacity internals, and the change itself stays an operator decision |
Kubernetes (via the KaaS factory)
| Tool | Tier | Purpose |
|---|---|---|
list_clusters | 0 | list tenant clusters (phase, version, workers, endpoint) |
get_kubernetes_cluster | 0 | one cluster's detail, incl. its LoadBalancer services with addresses (phase 53) |
create_cluster | 1 | provision a cluster (async — poll list_clusters for ready) |
scale_cluster | 1 | scale the worker plane — refused (409) while autoscaling is enabled: one desired count, one writer |
set_cluster_autoscaling | 1 | hand the worker count to the cluster-autoscaler within [min, max], or take manual control back — max is what quota admission charges |
upgrade_cluster | 1 | rolling upgrade — Kubernetes has no downgrade, so this is write-ahead audited despite being tier 1 |
set_cluster_protection | 1 | set the deletion-protection flag |
list_cluster_versions | 0 | available upgrade targets |
delete_cluster | 2 | deprovision a cluster |
get_cluster_kubeconfig | 0 | the admin kubeconfig — sensitive: returned to the caller only, never logged or audited |
VM autoscaling groups (via the KaaS factory)
| Tool | Tier | Purpose |
|---|---|---|
list_asgs | 0 | list autoscaling groups — current next to desired, so drift is visible at a glance |
create_asg | 1 | create a group (flavor, image, min/max/desired, optional tcp health check, LB pool binding, alarm bindings, cooldown) — quota admission runs before anything is stored |
get_asg | 0 | full detail: members with their drain/health standing, the last scaling decision, and degraded — every reason the engine could not act |
update_asg | 1 | move min/max/desired manually — manual beats alarm, and a changed desired starts the cooldown clock |
delete_asg | 2 | tear down the group and every member VM — drain-first: pool removal, drain window, then deletion |
ok → alarm transition of a bound alarm moves capacity by one;
insufficient_data never scales in either direction, a stale alarm state
reads as insufficient_data, and both directions firing at once resolves to
scale-out (availability over cost). These tools ride the factory service
(/v1/asgs), like the cluster tools — not the API gateway.
Object storage
| Tool | Tier | Purpose |
|---|---|---|
get_project_contents | 0 | what deleting this project would destroy. The counterpart to the delete verb that is deliberately absent — an agent can inventory a project so a human decides with the list in front of them |
list_buckets | 0 | list S3 buckets |
create_bucket | 1 | create a bucket |
list_bucket_objects | 0 | one page of a bucket's keys (S3's own continuation-token paging). Keys, never object bytes |
presign_bucket_object | 0 | a temporary download link for one object — treat the result as a credential with a short life, not a path: anyone it is passed to can fetch that object until it expires |
get_bucket_versioning | 0 | is versioning on? Worth checking before any bulk delete: on a versioned bucket a delete writes a marker and keeps — and bills for — every version |
set_bucket_versioning | 1 | turn versioning on, or suspend it. S3 offers no way back to "never versioned": suspended stops keeping NEW versions and leaves every stored version in place, still billed |
delete_bucket | 2 | delete a bucket |
get_s3_credentials | 0 | the project's RGW access/secret pair — a live secret enters the transcript; reading does not rotate (see guardrails) |
rotate_s3_credentials | 1 | issue a new S3 keypair — RGW rotation adds; the old pair stays valid until retired separately |
Databases
| Tool | Tier | Purpose |
|---|---|---|
list_databases | 0 | list managed databases |
create_database | 1 | provision a managed database (async — poll get_database for ready) |
get_database | 0 | one database's detail and status |
resize_database | 1 | grow storage — grow-only; storage never shrinks |
restore_database | 1 | restore a backup-enabled instance into a NEW database — never in place (phase 58) |
get_database_credentials | 0 | the connection secret — a live secret enters the transcript; reading does not rotate (see guardrails) |
delete_database | 2 | delete a database |
DNS (Designate)
| Tool | Tier | Purpose |
|---|---|---|
list_dns_zones | 0 | list DNS zones |
get_dns_zone | 0 | one zone's name, TTL, status and SOA serial — the serial is how you tell whether an edit has reached the servers that answer for it |
create_dns_zone | 1 | create a zone for a domain you control — refused inside IG1's own namespace, and above the tenant's zone ceiling |
delete_dns_zone | 2 | delete a zone AND every record in it — the whole namespace stops resolving; write-ahead audited |
get_dns_delegation | 0 | is the domain actually delegated here? Creating a zone is half the job — the registrar still has to point the NS records at us, and until it does the zone holds records nothing on the internet asks for. The tool to reach for when "I created the record and it still does not resolve": usually the record is fine and the delegation was never published |
import_dns_records | 1 | create many recordsets from a zone file. Previews by default — the plan a dry run returns is the same shape the commit returns, the same call rather than a second code path that can disagree. Re-running a commit is safe: an entry already present with the same values comes back identical, not as an error |
list_dns_records | 0 | list a zone's recordsets |
create_dns_record | 1 | create a recordset — a short name is qualified against its zone by the gateway |
update_dns_record | 1 | change what a record points at, in place — delete+create would take the name out of resolution in between |
delete_dns_record | 2 | delete a recordset — blackholes a name other people's clients are resolving; write-ahead audited |
Designate may not be deployed in a given environment; these tools then forward the
gateway's 404 verbatim rather than reporting an empty zone list —
"no zones" and "no DNS service" are different facts.
Load balancing (Octavia)
| Tool | Tier | Purpose |
|---|---|---|
list_load_balancers | 0 | list load balancers, each with its public facet — hostname + pending/active when internet-facing, internal only otherwise |
get_load_balancer | 0 | one LB's detail including member_health — every member's operating status and the pool's monitor id, so a dead member is found by reading, not by bisecting backends — and the same public facet |
create_load_balancer | 1 | one composite call builds VIP + listener + pool + health monitor + members, or rolls back what it made, newest first; internet_facing=true (default false) then publishes the VIP through the .75 edge — a floating IP on the VIP port, then an exposure targeting it — and answers with the hostname and its honest pending/active status; manage_security_group (default true) is what makes the listener answer at all — see the callout below |
delete_load_balancer | 2 | cascade delete — every live connection through the VIP drops, and a replacement gets a different address; an internet-facing LB's exposure and floating IP are released first (exposure → FIP → LB), and if they cannot be found the answer says they may remain; the managed listener group goes last, once the cascade has released the VIP port |
ACTIVE, its members read ONLINE, and the listener answered
nothing — the worst failure shape an agent can hand a human, because there is no field
to notice. So create_load_balancer now also creates the group
{name}-lb, opens each distinct listener port on it (protocol following the
listener), and attaches it to the VIP port beside whatever is already there —
the port is read first, never clobbered — and reports
security_group_id on create, get and list.
0.0.0.0/0 is the honest default: the boundary is the network, not this
rule (the VIP is reachable only where the tenant already permits — east-west, or
through a floating IP or edge exposure someone asked for), and a narrower default would
recreate this exact bug the moment internet_facing=true is passed. Narrow
the group afterwards with the security-group tools if the workload deserves it.
manage_security_group=false opts out — nothing created, nothing attached —
and the answer then says the VIP refuses traffic on the listener port
until a group allowing it is attached; it is never silent about that.
delete_load_balancer deletes only a group this platform created: named
exactly {name}-lb and attached to that VIP port. A group a human
attached is never touched, and a group that could not be deleted is named as possibly
remaining. If the whole hop fails, the load balancer is kept and the answer
says the listener is not reachable yet — it never claims a working listener it did not
open.
TCP, UDP → UDP-CONNECT, the only two the OVN driver
implements), every 5 s with a 3 s timeout; 3 consecutive failures mark a member
ERROR and stop new flows to it. health_check=false opts out,
with the consequence stated in the answer. Two things to design around:
L4 only (TCP/UDP passed through — no TLS termination at the LB, no L7
rules; put the certificate on the backends) and internal by default
(the VIP lives on a tenant subnet and creates no north-south exposure, so an
unpublished one that is unreachable from the office is behaving as designed).
internet_facing=true is the opt-in: the tool composes a floating IP on the
VIP port and an edge exposure targeting it, and answers with
{name}-{tenant-slug}.10.57.8.75.nip.io and its status —
pending until the edge syncs, never asserted active. TCP
listeners only, and the members must serve TLS on the member port — the
edge re-encrypts to the VIP, so a plain-HTTP backend fails the handshake and leaves a
hostname that resolves and never answers (a self-signed certificate is fine; the edge
does not verify it). A UDP listener plus
internet_facing is refused with the reason before the first call. If
publishing fails the load balancer is kept and the answer names what exists
and how to finish by hand — it never claims internet-facing on a floating IP alone.
The certificate on the generated hostname is the internal-CA wildcard; a customer domain
attached to the exposure gets a public one (see the edge callout below).
Edge exposures (the .75 tenant ingress)
| Tool | Tier | Purpose |
|---|---|---|
list_edge_exposures | 0 | the caller's published hostnames with derived status — pending until the edge acknowledges the config, never a green light nothing has wired |
create_edge_exposure | 1 | publish {name}-{tenant-slug}.10.57.8.75.nip.io → an IP the project owns — ownership verified server-side against Neutron under the caller's own credential, fail-closed |
get_edge_exposure | 0 | one exposure with its derived status and domain state — follow a single change instead of re-listing every exposure |
delete_edge_exposure | 2 | un-publish — takes a public hostname down on the edge's next sync; write-ahead audited |
claim_edge_domain | 1 | claim a domain you own (app.example.com) on an exposure — reserved, not routed; the answer carries the exact _ig1-challenge TXT record to publish. Claims are unique across the whole platform, first claim wins |
verify_edge_domain | 1 | read that TXT record back — this is the step that starts routing. Idempotent; 400 means the record is not published yet (it shows what was found), 503 means our resolver could not answer and says so |
release_edge_domain | 2 | stop serving the custom domain — the exposure and its .nip.io hostname survive, but the name returns to the global pool and another tenant may claim it; write-ahead audited |
10.57.8.75, the name-based HTTPS door that costs
zero public IPv4, used directly with
create_edge_exposure, or on your behalf by
create_load_balancer(internet_facing=true), which is these tools composed
and cleaned up by delete_load_balancer. Read "internet-facing" with its
bound: it publishes the VIP through the platform edge at 10.57.8.75 under a
generated .nip.io hostname, and that name resolves to a private
address — fabric/VPN reach, not the internet. TLS terminates at the edge with an
internal-CA wildcard certificate for *.10.57.8.75.nip.io; clients install the
lab CA, exactly as they already do for the portal, and that will not change, because a
public CA cannot validate a name that resolves into private space. The public path is a
domain the customer owns, attached with claim_edge_domain +
verify_edge_domain: since 2026-08-25 the edge answers on
185.255.84.178, cloud.ig1.com resolves worldwide, and a verified
custom domain is served with a publicly-trusted Let's Encrypt certificate ordered
automatically (networking guide §6.1). Targets must belong to the caller's project: a
foreign or fabric address is refused with the reason, and an unverifiable one is a 503,
never a grant.
Observability
| Tool | Tier | Purpose |
|---|---|---|
list_events | 0 | the project's own event stream — provisioning, quota, billing and agent.actions rows |
list_event_topics | 0 | the topics a webhook can subscribe to. Without it an agent creating a webhook had to guess the names — and a subscription to a topic that does not exist is accepted and then silently never fires |
get_audit_log | 0 | the audit trail: who did what, when, through which surface — including this agent's own calls; nothing on this surface writes or prunes it |
get_instance_metrics | 0 | CPU / memory / disk / network for one instance — the hypervisor's view, not the guest's |
get_instance_metrics_history | 0 | CPU / memory history over a window — what its load looked like at 03:00, not just now; Prometheus-backed, so history ends at retention |
get_status | 0 | platform component health and open incidents — check first when several unrelated things fail at once |
list_webhooks | 0 | list event webhooks |
create_webhook | 1 | subscribe an HTTPS endpoint to the project's event stream — the subscription outlives the session (see guardrails) |
list_webhook_deliveries | 0 | recent delivery attempts and their outcomes — the diagnostic for "my webhook is not firing", because it separates the three cases that look identical from outside: nothing sent, sent and refused, or sent and accepted with the problem downstream |
delete_webhook | 2 | remove a webhook — deliveries stop immediately |
Billing & budgets
| Tool | Tier | Purpose |
|---|---|---|
get_usage | 0 | rated usage (EUR) for the project over a billing period |
get_cost_breakdown | 0 | costs broken down by service/resource |
get_cost_forecast | 0 | projected spend for the period |
list_invoices | 0 | list invoices |
list_budgets | 0 | list budgets |
create_budget | 1 | create a spend budget |
delete_budget | 2 | remove a spend guardrail — a silent change nobody notices until the invoice, so it is write-ahead audited |
Secrets (Barbican)
| Tool | Tier | Purpose |
|---|---|---|
list_secrets | 0 | the project's stored secrets — metadata only: names, ids, status, type, expiry; never a value |
get_secret_metadata | 0 | one secret's metadata — never the payload (see the callout) |
create_secret | 1 | store a secret — write-only from here: the caller supplies the value (it already holds it), the tool returns the id and never echoes the value |
delete_secret | 2 | delete a secret, payload included — no undo, and (because this surface cannot read payloads) no copy an agent could have kept; write-ahead audited |
IAM
| Tool | Tier | Purpose |
|---|---|---|
list_credentials | 0 | list the project's API credentials (never their secrets) |
get_credentials_usage | 0 | when each credential was last used. list_credentials says what exists; this says what anything is still using, which is what makes a clean-up safe — the untouched one is safe to revoke, an old one in daily use is not |
create_credential | 1 | mint a scoped credential — tier ≤ 1, expiry mandatory and ≤ 7 days, defaults tier 0 / 7 days (see guardrails) |
revoke_credential | 2 | instant and total: kills the PAT, the client secret and every token already minted from it |
Workflow
| Tool | Tier | Purpose |
|---|---|---|
run_workflow | 1 | an ordered step list from a fixed op allowlist, one audited result — tier 1 to create, tier 2 required to enable rollback |
run_workflow — the sandbox
Steps come from a fixed op allowlist: create_vm, create_volume,
attach_volume, allocate_floating_ip,
associate_floating_ip — no free-form code, no shell. A step may reference a
previous step's resource as $step<N>.id (1-based), so "create a VM,
create a volume, attach it, allocate an address, attach the address" is one call with
one aggregated, audited result. The associate step exists because the canonical workflow
without it ends with an address attached to nothing — allocated, billed, status DOWN.
rollback=true (the default)
refuses to start unless the caller's credential is tier ≥ 2. A tier-1 caller
used to get halfway, fail, and have every rollback DELETE 403 — leaving real VMs,
volumes and floating IPs orphaned, billed, against quota. Either bring a tier-2
credential, or pass rollback=false to run anyway and own the cleanup
deliberately — a failed run's result names every resource that survived.
Guardrailed, not banned
Four capabilities on this surface create or expose things that outlive the session — a minted credential has no parent in the store, so revoking the token that made it does not cascade. They used to be absent; the operator decided the surface needs them, and the argument against them was not wrong, so it is encoded as limits. The limits are pinned by tests, not by convention:
| Tool | The limit, and why |
|---|---|
create_credential | Tier 2 is not mintable — destructive authority stays a human decision. Expiry is mandatory, capped at 7 days (tightened from 30 on 2026-08-13): the API accepts a no-expiry credential; this surface does not, because "forever" is what makes persistence permanent, and a week is long enough for any task an agent is plausibly running yet short enough that a forgotten key dies before anyone would have noticed it to revoke it. Defaults are tier 0 / 7 days: a caller that does not say what it needs gets the least that could work. The gateway independently enforces "cannot outrank creator". |
create_webhook | HTTPS-only, here as well as at the gateway — and the tool says plainly that the subscription outlives the session, the conversation and the token, and that a URL which came from anywhere but the operator is a reason to stop and ask. |
get_s3_credentials, get_database_credentials | Each returns a live, long-lived secret into model context and the transcript. Reading does not rotate it — the value stays valid wherever a copy has come to rest — and the tools say so and name the rotate path. |
rotate_s3_credentials | Says the quiet part: RGW rotation adds a keypair, it does not invalidate the old one. Retiring the previous pair is a separate deliberate act. |
The writes among these (create_credential, create_webhook) sit
in the destructive-audit set: they are write-ahead audited and refuse to act when the
caller cannot be attributed — an unattributable credential mint is precisely the event
the trail exists for. The audit records parameter names only, so a minted
secret or PAT never reaches the bus.
Excluded by decision
What an agent cannot do through this server, on purpose. None of these are gaps — each is a decision, and each has one reason. If your integration needs one of them, that is what the portal, the CLI and a human are for.
| Capability | Why it is not a tool |
|---|---|
| Database password rotation | Rotation breaks every application still holding the old password the moment it reconnects — a disruptive, timing-sensitive act on somebody else's running workload, with no undo. The portal and the CLI carry it, behind a typed confirm that spells out the reconnect behaviour. |
Tenant tiers & onboarding (set_tenant_tier, create_tenant, approve_signup, org-user creation) | Re-tiering and onboarding spend finite platform capacity across every tenant — an operator act. get_quotas reads; nothing on this surface writes. |
| Router external gateway | create_router has no gateway parameter at all — refusal by schema, because a parameter that does not exist cannot be argued into existence by a persuasive prompt. External addresses are platform-governed: your tenant's one uplinked router is provisioned by IG1 (before 2026-08-15 a second gateway-bearing router blackholed every tenant's data plane — two real outages taught this; the substrate is fixed since, gotcha 151, and every tenant router carries its own gateway). delete_router likewise refuses a gateway-bearing router rather than clearing the gateway first — that router is your uplink. |
| Private-key generation | import_keypair accepts the public half only; Nova's server-side key generation is unreachable. A private key generated into model context lives in the transcript forever. |
| Secret payload read | Barbican serves GET …/secrets/{id}/payload and no tool here calls it — a value read into model context outlives every revocation, exactly the reasoning that once kept S3 keys and database passwords off this surface. A stored secret differs from those in one decisive way: its consumers fetch it themselves at deploy time, so an agent can do its whole job — create, wire up, rotate, delete — without ever holding the value. Humans use the portal/CLI reveal-once flow. Writing a payload in (create_secret) is fine: the caller already holds it, and the tool never echoes it back. |
| Tier-2 or non-expiring credentials | create_credential cannot mint destructive authority and cannot mint "forever" — the two properties that turn one leaked token into a durable fleet of them. |
Agent quickstart
Five minutes from zero to your agent listing your VMs:
# 1. mint an agent credential (secret prints ONCE) — start read-only
ig1 credential create --kind mcp --tier 0 --label claude-laptop
# 2. print the MCP client JSON for this context
ig1 mcp config
# 3. install the IG1 skill + AGENTS.md into your project
ig1 agent init
The ig1 mcp config output drops straight into your runtime:
{
"mcpServers": {
"ig1": {
"type": "http",
"url": "https://mcp.cloud.ig1.com/mcp",
"headers": { "Authorization": "Bearer ${IG1_TOKEN}" }
}
},
"ig1": {
"issuer": "https://zitadel.cloud.ig1.com",
"token_endpoint": "https://zitadel.cloud.ig1.com/oauth/v2/token",
"scope": "openid urn:zitadel:iam:org:project:roles urn:zitadel:iam:org:project:id:385253743135817916:aud",
"cacert": "ig1-internal-ca.pem",
"client_id": "<from ig1 credential list>"
}
}
- Tidet / Claude Code —
~/.tidet/mcp.jsonor the project's.tidet/mcp.json. - Cursor — same shape in
~/.cursor/mcp.json. IG1_TOKENis the credential's PAT — shown once in the create dialog/response, paste it once and it works until the credential is revoked or expires. Headless alternative: mint a ~12 h token withclient_credentialsat the token endpoint above; the lab CA applies to both endpoints.
ig1 agent init writes AGENTS.md (read-only-first norms, tiers,
error semantics) and the full skill to .tidet/skills/ig1/SKILL.md and
.claude/skills/ig1/SKILL.md — the same documents that live in-repo at
docs/agent/ (ig1-skill/SKILL.md + AGENTS.md), which
remain the source of truth.
Audit & error semantics
Every tool call emits an agent.actions audit event — tool name, parameter
names (never values), started/ok/error — carrying the real
actor and project resolved from the caller's token, visible in the portal
(Security → activity) and through get_audit_log. For read/write tools the
audit is best-effort: a bus outage never fails the call. For destructive tools it is
write-ahead: the event is recorded before the action, and the
tool refuses to act if it cannot be — an unaudited delete is worse than a failed one.
Membership tracks disruption, not the HTTP verb: upgrade_cluster (a tier-1
POST with no downgrade) and delete_budget (a silent guardrail removal) are
both in. Kubeconfig content, tokens and secrets are never logged. What to do with
failures:
| Result | Meaning → action |
|---|---|
| 401 | token/credential dead (expired or revoked) → re-issue the credential; stop, do not retry in a loop |
| 403 insufficient credential tier | tier too low for the action → report the required tier; never retry |
| 403 no tenant assigned | the credential's store entry is gone → re-issue in the Security page |
| 404 on an id | unknown OR cross-tenant — existence is never leaked by design |
| 404 from the DNS tools | Designate is not deployed in this environment — forwarded verbatim, never softened into "no zones" |
| 502 from the load-balancing tools | Octavia is not answering (it may not be deployed) — a 404 from these paths is Octavia itself saying the id is not this tenant's |
| 429 | rate limited → back off per Retry-After, then retry once |