Skip to content
Docs menu

Tempa MCP server

Everything the admin UI can do is also available as MCP tools, so admins, team leads and auditors can run Tempa from their own Claude Code, Codex or Copilot (functional spec FA14, tech spec §10.1). This is not the local tempa mcp proxy that carries company context into coding sessions; this one manages Tempa itself.

  • Endpoint: https://tempa.devleaps.nl/mcp, streamable HTTP (stateless, JSON responses), listed in the discovery document as mcp_url.
  • Auth: MCP OAuth (TEMPA-092): a client connects with the URL alone and the person signs in with Tempa's logins (Connecting). A pasted bearer token still works too: a Tempa device token (from tempa login) or an Entra access token for the tempa-api app registration. Every request to /mcp is authenticated before the server handles it; without a valid token it answers 401 with WWW-Authenticate: Bearer resource_metadata="https://tempa.devleaps.nl/.well-known/oauth-protected-resource/mcp".
  • One service layer: every tool calls the same REST handler tempa-web uses, in-process and with the caller's own token, so a tool never sees or changes more than that person can in the browser.
  • Audit: actions through MCP land in the audit log with channel: mcp.
  • Tenant: a connection works in the tenant of its login, or in the one it names with the X-Tempa-Tenant header when the person belongs to it (TEMPA-066, auth.md; 403 otherwise). list_tenants shows your tenants and which one the connection works in. A device token is bound to its own tenant, and so is an OAuth connection (the tenant chosen at sign-in) until switch_tenant moves it (TEMPA-093, auth.md).

Tools

ToolDoesNeeds
list_tenantsthe tenants you belong to, your roles and status in each, which one this connection works in (current)anyone signed in
switch_tenant(tenant) (destructive)move this OAuth connection to another of your tenants (TEMPA-093): a member there, the tenant active, its login policy allowing the login you connected with; the current token stops and the client renews it for the new tenant. With a pasted token the answer says what to do instead (X-Tempa-Tenant, tempa login --tenant)anyone signed in
list_mcp_connectionsthe MCP clients connected as you with Tempa's sign-in in this tenant: client name (its own claim), where it returned to, scopes, login, connected, last used, ends (TEMPA-099; tempa-web's Connected apps)anyone signed in, own connections only
disconnect_mcp_connection(connection_id) (destructive)end one of your connections: the client signs in again to use Tempa (mcp.token_revoke, disconnected by the person)same
list_tenant_mcp_connectionsevery member's MCP connections in this tenant, as list_mcp_connections plus the person each acts as (e-mail, name); tempa-web's Settings → Connected appsplatform owners of the whole tenant
disconnect_tenant_mcp_connection(connection_id) (destructive)end any member's connection in this tenant: that client signs in again (mcp.token_revoke, disconnected by a platform owner)same
list_my_mcp_sign_insthe MCP servers you sign in to yourself (auth: per-user, TEMPA-283) in your profile and every sign-in you have, with status, since when and last used; never a token (tempa-web's Me, MCP servers; connecting is a browser step)anyone signed in, own sign-ins only
disconnect_mcp_sign_in(endpoint) (destructive)end your sign-in to a per-user MCP server, revoked at the server where it allows that (mcp.sign_in_disconnect)same
list_tenant_mcp_sign_insper per-user MCP server, how many people are connected and how many need to reconnect; counts onlyplatform owners of the whole tenant
list_nodes(counts?), get_node, list_reposthe tree and repo registry in your scope; with counts, per node the people whose default team is in its subtree, their devices, the enrolled ones and the share (null where you may not read KPIs)tree.read (kpi.read for the counts)
get_effective_profile(node, user?)a node's effective profile with the why per item, the chain per item (chains: every level that added, removed, overrode or locked it, removed items included) and the level that set each permission rule (rule_sources)profile.read
explain_decision(node, command | tool and input, user?)replay a shell command or a tool call against a node's live profile: decision, policy ids, bundles and the trace of how the decision comes aboutprofile.read
list_view_as(node)the people whose view of a node you may open: a role on the node or on an ancestor in your part of the tree, or the node as their default team; each with via and default_nodeprofile.read and member.read
get_kpi(kpi, node?, start?, end?, harness?)enrolment, account_kinds, active_use, policy_decisions, harness_use (adoption, Tempa's decisions and cost per harness), team level; harness narrows the telemetry KPIs as on the dashboard (kpis.md)kpi.read
list_devices(include_revoked?, node?)fleet health; with node, only devices whose owner's default team is in that subtree (403 outside yours)device.read
list_my_devicesyour own devices in this tenant: name, OS, harness, CLI version with update_available and cli_status, last seen, stale, and config (in_sync, behind, unknown) (TEMPA-277; tempa-web's My devices)anyone signed in, own devices only
revoke_my_device(device_id) (destructive)revoke one of your own devices: its tokens stop at once and it logs in again with tempa login (device.revoke_own); another person's device is a 404same
list_my_decisions(days?)your own recent denies and asks from Tempa's policies (TEMPA-278): tool, decision, policy ids, node, harness, whether an exception could waive it and your request for it; no commandanyone signed in, own decisions only
list_my_exception_requestsyour own exception requests with status, decline reason and draftsame
request_exception(decision, at, reason, command?)ask for an exception from one of your own decisions (exception.request); asking again for the same rule and node returns the open request; never put a secret in the reason or commandsame; another person's decision is a 404
withdraw_exception_request(request_id) (destructive)take back your own open request (exception.withdraw)the requester
list_exception_requests(status?)the exception requests you may decide, open (default) or all, with how many are openconfig.approve on the requester's node; empty otherwise
draft_from_exception_request(request_id, node?, command?, waives?)a config draft with a policy_exceptions entry linked to the request (exception.draft, config.draft_create); it then goes through the normal approval and publishconfig.approve on the requester's node and on node
decline_exception_request(request_id, reason) (destructive)decline with a reason the requester sees (exception.decline)config.approve on the requester's node
get_my_notificationsyour e-mail notification settings in this tenant: per kind whether your roles get it, its default and whether it is on, your digest day, and mail_configured (TEMPA-279; tempa-web's Notifications)anyone signed in, own settings only
set_my_notifications(kinds?, digest_day?, reset_digest_day?)turn kinds of mail on (true), off (false) or back to your roles' default (null), or choose the digest day (notification.settings_change); a kind your roles do not get is refusedsame
get_audit(action?, actor?, node?, start?, end?, limit?)the audit logaudit.read
list_config_versionsconfig versions and validation errorsconfig.read
list_kill_listassets withdrawn from every profileconfig.read
rollback_config(sha) (destructive)make an earlier version liveplatform_owner
list_drafts(status?), get_draft(draft_id)config drafts (TEMPA-084, config.md): revision, issues, base, whether each can be published and what to do next; the tenant's config_modeconfig.read
get_draft_file(draft_id, path)one file of a draft (text in content, else base64)config.read
diff_draft(draft_id, against?)files added, removed and changed with unified diffs, and what changes: nodes, settings per node, assetsconfig.read
preview_draft(draft_id, node, user?, tool?, input?)a node's (or a user's) effective profile with the draft next to live, and the decision for a sample action on bothconfig.read, profile.read on the node
create_draft(title?, base?), put_draft_file(draft_id, path, content?, content_base64?, executable?, revision?), delete_draft_file(draft_id, path, revision?), rename_draft_file(draft_id, path, to, revision?), rebase_draft(draft_id, resolutions?, revision?)change a draft; each change is validated at once; nothing is live until it is publishedplatform_owner (config.edit)
upload_config(archive_base64, filename?, title?)a config package (TEMPA-082, config.md): a .zip or .tar.gz of the whole config tree, base64, at most 10 MiB (the API takes 50 MiB); it becomes an open draft on live, validated at once, then diff_draft, preview_draft, publish_draft as any draft. A refused package (traversal, links, bombs, caps) answers 422 with the reasonplatform_owner (config.edit)
get_settings_catalogue()every setting (TEMPA-085, config.md): shape (list, object, value), allowed modes, the JSON Schema of its value, default, docs link, and per mode of the level above what a lower level may still doconfig.read
get_draft_tree(draft_id)the draft's tree as data: every node with kind, name, parent, path, repos, group bindings, the settings its own file sets and the validator's issues in its file; node files that do not load; the asset names list settings can useconfig.read
draft_add_node(draft_id, id, kind, parent, name?, revision?), draft_update_node(draft_id, node, name?, repos?, groups?, revision?), draft_move_node(draft_id, node, parent, revision?), draft_delete_node(draft_id, node, force?, revision?), draft_set_setting(draft_id, node, key, mode?, value?, add?, remove?, allow?, ask?, deny?, unset?, revision?)change the tree of a draft without YAML (config.md): written as YAML into the draft and validated at once; the answer is the draft with applied and the new tree. Refused: a kind under a kind it must sit above, a repo another node has, deleting a node with children or repos without force, a field the setting's shape does not take (draft_set_setting checks that against the catalogue before calling)platform_owner (config.edit)
draft_list_assets(draft_id, kind?), draft_get_asset(draft_id, kind, name)a draft's standards, skills, commands, subagents (agent) and MCP endpoints (TEMPA-086, config.md): per asset its file(s), the library pack it comes from (read-only), description, used_by (the nodes whose settings add or remove it) and issues; per kind its layout, required frontmatter and the MCP endpoint form's JSON Schema. One asset: its frontmatter fields and body, a skill's other files, an MCP endpoint's endpointconfig.read
draft_put_asset(draft_id, kind, name, create?, frontmatter?, body?, endpoint?, files?, revision?), draft_rename_asset(draft_id, kind, name, to, revision?), draft_delete_asset(draft_id, kind, name, force?, revision?)create or change an asset in its kind's layout (frontmatter fields and the Markdown body as given; endpoint {url, auth, tools, description} for an MCP endpoint; a skill's other files, a path → text or null to remove), rename it (every node setting that names it follows in the same revision), delete it. Refused: a bad name or one in use (also by a library pack), a skill or subagent without a description, an endpoint the schema refuses (draft_put_asset checks the form before calling), deleting an asset a node still names without force, any library pack asset (422, action: subscription)platform_owner (config.edit)
draft_list_policy_bundles(draft_id), draft_get_policy_file(draft_id, bundle, file)a draft's policy bundles (TEMPA-087, policies.md): per bundle its files (rule files, *_test.rego unit tests, samples.yaml), the library pack it comes from (read-only), used_by (the nodes whose policies setting names it) and the issues in its folder; one file's content with its issues by lineconfig.read
draft_put_policy_file(draft_id, bundle, file, content?, create?, revision?), draft_rename_policy_bundle(draft_id, name, to, revision?), draft_delete_policy_bundle(draft_id, name, force?, revision?)add or replace a rule file, unit test or samples.yaml (content null on a new file writes a starter; a rule file in a new bundle creates the bundle), rename a bundle (folder, package, data.<name> imports and every node's policies setting follow), delete it. Refused: a name that is not a Rego package or is in use, a file that is not *.rego or samples.yaml, deleting a bundle a node still names without force, any library pack bundle (422, action: subscription). Compile errors are not refusals: they come back as the draft's issues on their lineplatform_owner (config.edit)
draft_test_policies(draft_id, bundle?, node?, samples?, unit_tests?, saved_samples?, trace?)the policy test runner: the bundle's Rego unit tests through opa test (skipped without opa) and sample events ({name, command} or {name, tool, kind, type, input} with expect, reason, guidance, policy_ids) with the real engine against a node's profile, on the draft next to live, plus the bundle's samples.yaml; trace explains each decision. Read-onlyconfig.read (and profile.read on the nodes)
publish_draft(draft_id, revision?, canary?) (destructive)the draft becomes the live config, an mcp version (an upload version for an uploaded package; managed tenants; a git tenant gets 409); with canary node ids it is staged on those nodes and their subtrees first (TEMPA-091, config.md)platform_owner (config.publish)
get_rollout()per node which version it gets and why (live, canary, last_good, minimal), the staged rollout, the engaged kill switchesconfig.read
promote_rollout() (destructive)the staged version becomes live on every nodeplatform_owner (config.publish)
abort_rollout(reason?) (destructive)end the staged rollout; the canary nodes get live againplatform_owner (config.publish)
engage_kill_switch(mode, reason, node?, version?) (destructive)the tenant or a node and its subtree get the last known good version (last_good) or the minimal profile (minimal) from their next syncplatform_owner, node_admin in their subtree (config.kill_switch)
release_kill_switch(node?) (destructive)release itplatform_owner, node_admin in their subtree (config.kill_switch)
discard_draft(draft_id, revision?) (destructive)close a draft for goodplatform_owner (config.edit)
approve_draft(draft_id, revision?, comment?)approve the draft's current revision (TEMPA-088, config.md); never your own draftconfig.approve on the draft's level or above: platform owners, node admins, team leads, standards owners
withdraw_draft_approval(draft_id, revision?) (destructive)withdraw your approvalconfig.approve
open_draft_pull_request(draft_id, revision?, title?)a Git tenant's draft as a new branch of its config repo (never the config branch) and its pull request: opened through the GitHub App, else compare_url (TEMPA-089, config.md); off until config_pull_requests is onplatform_owner (config.publish)
revoke_device(device_id) (destructive)revoke a devicedevice.revoke
get_tenant_settingsyour tenant's settings, where each value comes from, no secrets (config.md), and the plan with usage and a notice per limit it is over (plans.md)platform_owner of the tenant
get_onboardingthe tenant's first-run checklist (TEMPA-106): connect a harness (the install commands per harness), log in the CLI, a starter pack or first standard, the first guardrail decision; each step derived from devices, config versions and decide callsplatform_owner of the tenant
dismiss_onboardingput the first-run checklist away for the tenant (dismissed: false brings it back); not destructive, idempotent, audited tenant.onboarding_changeplatform_owner of the tenant
update_tenant_settings(changes) (destructive)change platform keys: a value sets, null clears, an absent key stays; incl. the login policy (auth.md) and the config sourceplatform_owner of the tenant
get_config_sourcethe config source as it syncs: credentials metadata (a deploy key's public key and where to add it), webhook URL, pinned SSH host keys, last sync, newest version (config.md)platform_owner of the tenant
set_git_source(url, branch?, path?, auth?, secret?, username?, sync_interval_seconds?) (destructive)point the config at a Git repo on any server (TEMPA-094, config.md): auth defaults to ssh_key for an ssh URL (after generate_deploy_key) and none for https; replaces the current source. A GitHub repo through GitHub's API is update_tenant_settings with config_sourceplatform_owner of the tenant
sync_config_now (destructive)sync the config source now (a new head goes live when valid)platform_owner of the tenant
test_config_source(config_source)test a source before saving it (TEMPA-272, config.md): reads its branch head with its stored credentials and saves nothing; ok with the head and first-seen SSH host keys, or the error and a reasonplatform_owner of the tenant
get_config_export(version?)a version's whole tree (live by default), every file with its content (TEMPA-090, config.md)config.read
export_config(dry_run?, branch?, message?)diff the config repo against live (the default), or push live as a new branch (never the config branch) with the pull request's compare_urlplatform_owner of the tenant
import_config(dry_run?) (destructive)diff the config branch's head against live (the default), or import it as an import version and switch to managedplatform_owner of the tenant
switch_config_mode(mode, dry_run?, force?) (destructive)switch the source of truth (git, managed); to git refused while the head differs from live unless forceplatform_owner of the tenant
get_statusthe installation's public status (TEMPA-131): components, overall status and the incidents of the last week, as /status shows themanyone
get_tenant_usage(start?, end?)your tenant's metered usage per day (UTC; default this month): active developers, telemetry events, decide calls, totals and each month's distinct active developers so far (metering.md, TEMPA-072); counts onlyplatform_owner of the tenant
get_tenant_planyour tenant's plan: price, what it includes, each entitlement with your usage and whether it is over, and billing_portal_url (the Billing card in tempa-web once the tenant has billing, else null; TEMPA-073) (plans.md, TEMPA-096); read-only. Operators pass tenant for any tenant's (below)platform_owner of the tenant
list_plansthe public plan catalogue: plans with prices and what they include, what each entitlement means, and whether the tiers are still a proposal (plans.md)anyone signed in
get_billingyour tenant's Stripe subscription as Tempa last saw it, Stripe Tax, and the plans to buy (billing.md, TEMPA-073); 503 while billing is offplatform_owner of the tenant
list_invoices(limit?, after?)your tenant's invoices from Stripe, newest first, with links to Stripe's page and PDFplatform_owner of the tenant
start_checkout(plan, interval?)a Stripe Checkout link for a plan (month or year) for the person to open; Tempa never sees a card; 409 with a live subscriptionplatform_owner of the tenant
open_billing_portala Stripe customer portal link (payment details, invoices, cancellation) for the person to openplatform_owner of the tenant
sync_billingread the subscription from Stripe again (after a checkout); the plan follows itplatform_owner of the tenant
change_plan(plan, interval?) (destructive)move the subscription to another plan or interval (TEMPA-074): an upgrade at once (prorated), a downgrade at the period end with what would be over listed; ask the person firstplatform_owner of the tenant
withdraw_plan_change (destructive)withdraw a downgrade that waits for the period endplatform_owner of the tenant
cancel_subscription, resume_subscription (destructive)cancel at the period end, or keep it; the plan stays for its grace period after the endplatform_owner of the tenant
list_config_syncs(limit?)the last config syncs: sync now, push webhook deliveries (ignored ones too) and catch-ups that made a version or failed, with the outcome and error (config.md, TEMPA-081)platform_owner of the tenant
get_github_app, start_github_app_install, bind_github_app(installation_id, state), unbind_github_app (destructive)the GitHub App (config.md, TEMPA-080): whether it is configured and the tenant's installation; the install URL (open it in a browser); bind the installation GitHub sent back; forget itplatform_owner of the tenant
list_members(node)who has which role on the node, each with its source (config, manual, demo), pending invites, the roles you may hand out theremember.read
invite_member(node, email, role)invite an address to a node with a role; without a mail transport the answer holds the single-use accept_url to share (auth.md)may assign the role (rule)
grant_role(node, email, role)grant a role managed in Tempa to someone who has signed in before; idempotentmay assign the role
revoke_invite(invitation_id) (destructive)withdraw a pending invitemay assign its role
revoke_role(node, user, role, source?) (destructive)revoke a role granted in Tempa (user: e-mail or user id); source="partner" for a role your tenant's partner gave one of its people (TEMPA-168); config roles answer 409may assign the role
get_tenant_partnerwhich partner manages your tenant, if any, and the roles its people hold here (auth.md)member.read on the tenant node
list_partner_clients, get_partner_client(client_id)your partner tenant's clients: status, counts, directories and requests; one client with your people's roles in itpartner.manage (partner admin, platform owner) in a partner tenant
get_partner_billing, set_client_billing(client_id, billed_to) (destructive)your subscription and seats and who pays for each client; bill a client to you (partner, wholesale) or to itself (client) (TEMPA-170, billing.md)partner.manage in a partner tenant
get_client_plan(client_id), set_client_plan(client_id, plan) (destructive)one of your clients' plan with its usage (counts) and assignable_plans (every plan of the catalogue); put it on one of them, or plan: null for the default plan; limits only, never billing; 400 unknown plan, 404 not your client, 409 being deleted; audited in the client and your tenant (TEMPA-209, plans.md)partner.manage in a partner tenant
create_partner_client(client_id, name, platform_owner?, entra_tenant_ids?)create a client; the owner gets an invite (single-use link in the answer without mail); directories become requests for the operatorssame
suspend_partner_client(client_id) (destructive), reactivate_partner_client(client_id)suspend a client, or reactivate one you suspendedsame
export_partner_client(client_id)the client's whole export (audited in both tenants)same
grant_partner_access(client_id, email, role, node?)give one of your own people a role in a client (never partner_admin)same
revoke_partner_access(client_id, user, role, node?) (destructive)take a role you gave away; user is the e-mail or the user id in the clientsame
request_partner_client_directory(client_id, directory)ask the operators to assign an Entra directory to a clientsame
list_partner_packsyour partner tenant's own library packs with every version, and its content settings (TEMPA-169, library.md)same
publish_partner_pack(archive_base64, visibility?, forbid?)publish a version of your pack for your clients: a .zip/.tar.gz, base64, at most 10 MiB, publisher: your tenant id; partner_clients (new packs) or private, never public; a new version becomes an update proposal in the clients that subscribesame
yank_partner_pack(pack, version, reason) (destructive)mark a version of your pack not installablesame
set_partner_pack_visibility(pack, visibility) (destructive)partner_clients or privatesame
set_default_client_packs(packs, auto_publish_initial?, forbid?) (destructive)the packs a new client starts with ({pack, version, node, adopt_defaults}), whether that initial setup is published at once, the words your packs must never contain; a setting left out keeps its valuesame
list_client_pack_status(client_id?)per client your packs it runs: pinned version, status, an open update proposal, whether a newer version is out; facts onlysame
propose_partner_pack(pack, clients, version?)propose a version of one of your packs (default the newest installable one) to several clients: one update proposal per client that subscribes, and per client proposed, draft_open, published or failed with the reason (TEMPA-281, library.md)same
get_partner_overview(needs_attention?)every client in one row: status, plan, live config valid or invalid and its last publish, the share of developers enrolled and active in the last 7 days (per team, added up), your packs' open proposals and updates not proposed, and the reasons it needs attention (TEMPA-281, library.md)same
list_library_packsthe starter library's packs your tenant may see, with their versions and latest (library.md)any signed-in user
get_library_pack(pack, version?)a pack with its latest installable version in full; with version (or latest) that version and its file listany signed-in user
read_library_file(pack, version, path)one text file of a pack version (content)any signed-in user
list_subscriptions(status?)the tenant's library pack subscriptions (current or all): node, pinned version, status, the subscribing or unsubscribing draft, the open update proposal, latest and a warning for a yanked pin (library.md)config.read
subscribe_pack(pack, node, version?, adopt_defaults?, proposals?, mcp_values?)subscribe a node to a pack your tenant may see: a draft with the pack under packs/<id>/ and its assets in the node's settings; review it with diff_draft/preview_draft, then publish_draft (a git tenant commits its files, see git in the answer)platform_owner (config.edit)
unsubscribe_pack(subscription_id) (destructive)end a subscription: a pending one at once (its draft discarded), an active one through a draft that removes the packsame
set_pack_proposals(subscription_id, proposals)whether newer versions come in as update proposalssame
propose_pack_updates(pack?)look for newer versions now: one open draft "Update <pack> to <version>" per subscription that is behind; never published, never a duplicate, never a yanked versionsame
get_draft_archive(draft_id)a draft's files with their content, for a git tenant to commit (the web offers the tree as a zip)config.read

| list_domains | your tenant's e-mail domains: status, the TXT record to publish, join policy, default node (auth.md) | platform_owner of the tenant (domain.manage) | | add_domain(domain) | claim a domain; public mail domains are refused, one another tenant verified answers 409 | same | | verify_domain(domain) (destructive, open-world) | look the TXT record up now: match verifies; a verified domain whose record is gone lapses | same | | set_domain_policy(domain, join_policy?, default_node?) (destructive) | off, join (developer on default_node) or approval (a join request) | same | | remove_domain(domain) (destructive) | remove a domain; roles people got through it stay | same | | list_tenant_secrets | your tenant's secrets, metadata only (never a value), and whether the store is on (auth.md) | platform_owner of the tenant | | set_tenant_secret(name, value?, headers?) (destructive) | store or replace a secret (write-only); the value passes through the conversation, so prefer the API for long-lived credentials | same | | generate_deploy_key(name?) (destructive) | a new Ed25519 key pair for a git_ssh_key (default name); returns only the public key. Refuses webhook_secret (TEMPA-081): generate that in tempa-web or with the API, so its value never reaches a model | same | | delete_tenant_secret(name) (destructive) | delete a secret | same | | export_audit_log(after?, limit?, action?, actor?, target?, start?, end?) | a page of the tenant's audit log in chain order with prev_hash and hash, next_after and the chain head; audited audit.export (audit.md) | platform_owner of the tenant (tenant.export) | | verify_audit_log | recompute the tenant's audit hash chain now | audit.read on the tenant | | get_audit_forwarding | where the audit log is forwarded, what waits, the last error (audit.md) | platform_owner of the tenant (tenant.settings) | | set_audit_forwarding(kind, url, enabled?, start?) (destructive) | forward the audit log to a webhook (HMAC) or an otlp logs endpoint; the secret is a tenant secret, set it first | same | | remove_audit_forwarding (destructive) | stop forwarding and forget the destination | same | | get_evidence_report(start?, end?) | the evidence report on AI use and governance for a period: guardrails, decisions, coverage, onboarding, changes with approvals, incidents, the audit chain; evidence support, not legal advice; audited evidence.report (evidence-report.md) | platform_owner of the tenant (tenant.export) | | list_join_requests | pending requests to join through a verified domain that you may decide | member.write | | approve_join_request(request_id) | approve: developer on the request's node | may assign developer there | | deny_join_request(request_id) (destructive) | deny: no role, and the person is not asked about again | may assign developer there |

invite_member, grant_role, add_domain and approve_join_request are writes that only add something: annotated not read-only and not destructive (invite_member open-world, as it may send a mail). The draft tools that change a draft (also the draft_* tree tools, draft_delete_node included), and upload_config (which makes one), are annotated not read-only and not destructive: a draft is not live, and every change can be changed back; publish_draft and discard_draft are destructive. verify_domain is destructive and open-world: it asks DNS, and may lapse a domain. Accepting an invite, and joining through a domain, have no tool: they are part of a person's own sign-in.

explain_decision is also a REST endpoint: POST /v1/nodes/{id}/explain with {"command": "terraform apply", "user": null} or {"tool": "Edit", "input": {"file_path": "/workspace/a.py"}} (exactly one of the two). It decides with the same decide as POST /v1/decide, on the node's live profile.

Operator tools

The installation's operators (auth.md: TEMPA_OPERATORS, with an Entra access token from the Devleaps directory, never a device token) have the operator console as tools (TEMPA-057, operations.md). Everyone else gets 403 from each, and so does an operator whose sign-in did not prove MFA where that is required; delete_tenant and export_tenant may also need a step-up sign-in (auth.md, TEMPA-173).

ToolDoesAnnotation
list_all_tenantsevery tenant on the installation (list_tenants lists only yours): status, counts, live and newest config, config sync and 429s on this replicaread-only
get_tenant(tenant)one tenant with its operator settings and every rate limit (default, override)read-only
create_tenant(tenant, name, entra_tenant_ids?, platform_owner?)create a tenant; without mail the answer holds the owner's single-use invite linknot destructive, open-world
suspend_tenant(tenant, reason?)suspend: every request of the tenant is refused until reactivateddestructive
reactivate_tenant(tenant)reactivate a suspended tenantnot destructive
export_tenant(tenant)the whole export as one JSON document (large for a large tenant; tempa-web downloads it as a file)read-only
delete_tenant(tenant, confirm)delete a suspended tenant for good; confirm is the id againdestructive, not idempotent
set_tenant_operator_settings(tenant, changes)entra_tenant_ids and limits: a value sets, null clears, an absent key staysdestructive
tenant_support_view(tenant)read-only support view: tree, config versions, fleet summary, recent audit (recorded in the tenant's audit log)read-only
tenant_effective_profile(tenant, node, user?)a tenant's node's effective profile with the why (recorded likewise)read-only
list_telemetry_deletions(pending?)deleted tenants' ClickHouse deletionsread-only
run_telemetry_deletionsretry the pending ones (needs the ClickHouse writer)destructive
installation_healthversion, migration, RLS, ClickHouse, config sync per tenant, mail, secret store, telemetry, 429sread-only
list_mcp_clientsthe MCP clients that registered with Tempa's OAuth: redirect URIs, last use, active connectionsread-only
list_usage(start?, end?)every tenant's metered usage per day (TEMPA-072, metering.md): active developers, telemetry events, decide calls, totals and each month's distinct active developers; counts onlyread-only
list_tenant_costs(start?, end?)every tenant's estimated infrastructure cost in the period (TEMPA-076, metering.md): base, developers, telemetry and decide parts in EUR, the cost model used, and revenue (paid Stripe invoices excluding tax, TEMPA-077) and margin, null while billing is offread-only
get_tenant_billing(tenant)a tenant's billing as its platform owners see it, with its Stripe customer and subscription ids, its dunning, and the dunning e-mail drafts with their recipients for you to send (TEMPA-073, TEMPA-077, billing.md); 503 while billing is offread-only
get_tenant_plan(tenant)with tenant: that tenant's plan status (what GET /v1/operator/tenants/{id}/plan answers) and assignable_plans, every plan of the catalogue, public or not, with default_plan (TEMPA-209, plans.md); without it, your own tenant's plan as aboveread-only
set_tenant_plan(tenant, plan)put a tenant on a plan of the catalogue (also one that is not public), or plan: null for the default plan; limits only, never billing (a paying subscription's plan comes back when billing next syncs); changed false when it was on it; 400 unknown plan, 404, 409 being deleted; audited tenant.plan_change in the tenant and the home tenant (TEMPA-209)destructive, idempotent
run_seat_report(month?, tenant?)report a closed month's seats (default last month) to Stripe now; a month reported before is already; auditeddestructive, open-world
get_funnel(start?, end?)the self-serve funnel per day and per ISO week (TEMPA-108, operations.md): visits, signups started and completed, CLIs installed, first decisions; counts onlyread-only
get_funnel_stuck(start?, end?)per tenant step (signup completed, CLI installed) the tenants that reached it in the period and not the next one, oldest first, with age and owner contact (TEMPA-282, operations.md)read-only
operator_search(query)tenants (id, name), people (e-mail) and devices (name, start of the id) across every tenant, 2 to 200 characters, at most 20 per kind, each with its tenant (TEMPA-282, operations.md); every search is audited (operator.search)read-only
list_incidentsthe status page's newest incidents with their public timeline and who opened them (TEMPA-131, operations/incidents.md)read-only
create_incident(title, severity, components, message, status?)open an incident on the public status page (sev1 to sev4; components api, web, telemetry, mcp); public at once, auditednot destructive, open-world
post_incident_update(incident_id, message, status?, severity?, components?)a public update; resolved closes it; auditednot destructive, open-world
draft_incident_notification(incident_id, tenants?)the customer e-mail to the platform owners (BCC), drafted only: Tempa never sends itread-only
run_usage_metering(start?, end?, tenant?)meter a range again from the sources (at most 400 days); replaces the stored days, never adds to them; audited usage.meterdestructive, idempotent
set_tenant_partner(tenant, kind?, partner_id?, detach?)partner relationships (TEMPA-168): make a tenant a partner (or standard again), attach it to a partner, or detach=true (every partner role in it ends)destructive
list_directory_requests(status?)Entra directories partners asked for their clients (pending by default)read-only
approve_directory_request(request_id)assign the directory to the clientnot destructive
deny_directory_request(request_id, reason?)deny; the reason is shown to the partnerdestructive
yank_library_version(pack, version, reason)mark an installation library pack version not installable (it stays readable); publishing is an upload, tempa library publish (library.md)destructive

Resources and prompts (TEMPA-097)

Next to the tools the server offers resources, context a client can read (or attach to a conversation), and a prompt, a guided walk-through the client offers its user.

ResourceContentSame as
tempa://docs/mcp-clientsMarkdown: connecting each MCP client (mcp-clients.md)the docs page
tempa://docs/configMarkdown: the config format (config.md)the docs page
tempa://library/packsJSON: the library packs your tenant may seelist_library_packs
tempa://library/packs/{pack} (template)JSON: one pack, its versions and its latest version in fullget_library_pack(pack)
tempa://nodes/{node}/effective-profile (template)JSON: a node's effective profile with the why and coverage per harnessget_effective_profile(node)
  • Same scope as the tools. A resource that reads Tempa's data goes through the same REST handler as its tool, in-process with the caller's own token: a node outside your part of the tree is refused (403), and the catalogue is what your tenant may see. Reading one needs a token like any request to /mcp.
  • The docs pages ship with tempa-api as copies in api/tempa_api/mcp_docs/ (the image has no docs/); tests/test_mcp_resources.py fails when a copy differs from its page, so copy the page over after editing it.

Prompt setup_organisation (organisation and starter_pack, both optional; the pack defaults to tempa-baseline): walks a new platform owner through six steps: where things stand, the tree, a starter pack, the config source, people, and the first guardrail. It names only existing tools, reads first, and tells the model to ask for an explicit yes before every call that changes something (create_draft, the draft_* tree and policy edits, subscribe_pack, generate_deploy_key, set_git_source, sync_config_now, start_github_app_install, invite_member, add_domain, verify_domain, publish_draft), to show diff_draft and preview_draft before publishing, and never to delete, discard, revoke or roll back anything. In Claude Code it is /mcp__tempa-admin__setup_organisation.

Connecting

Give the client the URL, nothing else. It finds the rest itself (MCP authorization spec): the 401 names the protected resource metadata, which names tempa-api as the authorisation server; the client registers itself, opens your browser on Tempa's sign-in, and gets its tokens.

Claude Code:

claude mcp add --transport http tempa-admin https://tempa.devleaps.nl/mcp

Then /mcp in Claude Code, pick tempa-admin and Authenticate: the browser opens.

claude.ai (and Claude Desktop): Settings → Connectors → Add custom connector, name Tempa, URL https://tempa.devleaps.nl/mcp, no client id or secret; then Connect.

Other clients (Codex, Gemini CLI, Copilot, Cursor): add a remote (streamable HTTP) MCP server with the URL; mcp-clients.md has the tested setup per client, with sign-in and with a pasted token. A client must support the MCP authorization spec with dynamic client registration and a redirect URI that is https or loopback http (custom schemes such as cursor:// are refused).

In the browser:

  1. "<client> wants to connect to Tempa": sign in with Microsoft, Google, GitHub or an e-mail link, as on tempa-web (auth.md).
  2. When you belong to several tenants, choose the one this connection works in (TEMPA-113's choice page).
  3. The consent page names the client (as it calls itself), your account, the tenant, what it may do, and where the browser goes back to. Allow sends you back to the client; Deny tells the client you refused.

The connection works as you, with your roles in that tenant: a tool never does more than you can in tempa-web. It lasts 30 days (TEMPA_MCP_SESSION_DAYS), renewing itself silently, then you sign in again; a tenant with a login policy ends it after the policy's session_hours, and a login the policy does not allow (or without the MFA it requires) cannot connect, and stops working when the policy changes (auth.md). A suspended tenant refuses it (403) until it is reactivated. Disconnecting the client in its own settings revokes it at Tempa (RFC 7009) where the client supports that; Connected apps in tempa-web (/me/connections) lists your connections and disconnects any of them. Operators see the registered clients under Operator console → MCP clients, and platform owners every member's connections under Settings → Connected apps (/settings/connections).

On dev use https://tempa.dev.devleaps.nl/mcp (a separate installation: its own sign-in and tenants).

What a client sees

Endpoint
GET /.well-known/oauth-protected-resource/mcp (also without /mcp)RFC 9728: resource https://<host>/mcp, authorization_servers ["https://<host>"], scopes_supported mcp, offline_access
GET /.well-known/oauth-authorization-serverRFC 8414: the endpoints below, code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], authorization_response_iss_parameter_supported
POST /v1/oauth/registerRFC 7591: public clients only (no secret), redirect_uris https or loopback http, at most 10
GET /v1/oauth/authorizecode flow; code_challenge with S256 required, resource (RFC 8707) must be the /mcp URL when given, scope mcp and/or offline_access (none: both)
POST /v1/oauth/tokenauthorization_code (with code_verifier, redirect_uri, client_id) and refresh_token (rotating)
POST /v1/oauth/revokeRFC 7009: a refresh or access token; the connection ends

Details, token format and security notes: auth.md.

With a pasted token

Without OAuth (scripts, or a client that does not support it), pass a bearer token:

claude mcp add --transport http tempa-admin https://tempa.devleaps.nl/mcp \
  --header "Authorization: Bearer $(jq -r .access_token ~/.tempa/credentials.json)"

Then ask, for example: "Why does a session in the bridge repo block terraform apply?"

To work in another of your tenants with an Entra token, add --header "X-Tempa-Tenant: <tenant id>". With a device token, use that tenant's login instead (tempa login --tenant <id>; its token is under .tenants["<id>"].access_token in ~/.tempa/credentials.json).

Parity

api/tests/test_mcp_server.py reads tempa-web's typed API client (web/src/lib/api.ts) and fails when the UI calls an API path that has no MCP tool, the operator console's calls included, reads and writes alike. Add the tool in api/tempa_api/mcp_server.py (and the mapping in the test) whenever the UI gains a capability. api/tests/test_mcp_resources.py does the same for the resources: each one that reads Tempa's data maps onto a call tempa-web makes, and answers what that call answers.

api/tests/test_mcp_write_audit.py (TEMPA-098) runs every write tool once through /mcp and fails unless it records its audit action with channel mcp; a new write tool fails the test until it has a scenario there. Draft edits are audited too (config.draft_edit, audit.md).