Building on Descant

Connect an agent

Descant speaks the Model Context Protocol over HTTP. An agent running anywhere — your laptop, a CI job, a server you operate — connects an MCP client to one endpoint, presents a tenant API key, and drives the same operations the API reference publishes, as typed tools.

There is no software to install for this. A client that supports OAuth sign-in needs only the URL, and signs you in in the browser (see signing in from a remote client). Any other client needs a URL and a bearer token, and both are below.

The endpoint

POST https://app.descant.run/api/v1/mcp

One mount, many tools. Everywhere else in this API, a method and a path together name exactly one operation — one request shape, one response shape, one thing it does. This one does not. The same POST to the same path carries a JSON-RPC envelope naming a tool, and each of the 72 tools a tenant key can reach maps to a different operation behind it. So a client is configured once, against this address, and gains the whole surface rather than one call.

The tools themselves — what each takes, the dry-run field, and the refusal vocabulary to branch on — are on the MCP page. This page is only about reaching them.

Requests are stateless. Each one is served independently, nothing is held between them, and there is no session to establish or keep alive.

Mint a key

The credential is an ordinary tenant API key, created in the dashboard and shown once; the tenant is taken from the key, never from the request. Keys are minted under Settings → API keys.

Minting a key is an owner's action. If you are a member of the organization rather than an owner, that page will decline — ask an owner to mint the key with the scopes below and send it to you. Nothing else about this page changes: the key is what the client presents, and who created it makes no difference to what it can do.

Tick two things, not one:

  1. mcp:write — this is what opens the transport at all.
  2. The scope of every tool the agent will actually call, from the table below.

mcp:write grants the transport, not any write. It is the envelope, not the contents. The contract states it directly, and this is its own wording rather than a summary of it:

The Model Context Protocol endpoint. An agent connects an MCP client here and drives the operations in this document as typed tools, with this key's own credential. THE SCOPE THIS REQUIRES IS THE ENVELOPE, NOT THE CONTENTS: holding it means a key may open the transport, and every tool call inside is still checked against the scope its own operation requires — so a key with only read scopes can open this and still cannot write through it. Requests are stateless: each one is served independently and nothing is held between them.

So a key holding mcp:write and read scopes only can connect, list the tools, and call the reads — and every write inside the envelope is still refused. Granting the transport is not granting what travels through it.

The scope each tool needs

Derived from the same registry the tools are built from, so this cannot promise a grant the endpoint would then refuse. 22 scopes cover the current surface: analytics:read, audit:read, billing:read, billing:write, capabilities:read, capabilities:write, invites:read, invites:write, keys:read, keys:write, notifications:read, notifications:write, repos:read, repos:write, runs:read, runs:write, security:read, settings:read, settings:write, sso:read, team:read and team:write.

toolscope it requiresread-only
customer_runs_listruns:readyes
customer_runs_startruns:writeno
customer_runs_getruns:readyes
customer_runs_activityruns:readyes
customer_runs_cancelruns:writeno
customer_runs_restartruns:writeno
customer_runs_split-scorecard-getruns:readyes
customer_repos_listrepos:readyes
customer_repos_connectrepos:writeno
customer_repos_activaterepos:writeno
customer_repos_disconnectrepos:writeno
customer_repos_reconnectrepos:writeno
customer_repos_validaterepos:writeno
customer_repos_getrepos:readyes
customer_repos_updaterepos:writeno
customer_repos_settings-auditrepos:readyes
customer_repos_poller-statusrepos:readyes
customer_repos_configrepos:readyes
customer_repos_issues_listrepos:readyes
customer_repos_labels_listrepos:readyes
customer_repos_poller-diagnosticsrepos:readyes
customer_audit_listaudit:readyes
customer_keys_listkeys:readyes
customer_keys_createkeys:writeno
customer_keys_revokekeys:writeno
customer_keys_rotatekeys:writeno
customer_invites_listinvites:readyes
customer_invites_createinvites:writeno
customer_invites_revokeinvites:writeno
customer_invites_sendinvites:writeno
customer_repos_grooming_candidatesrepos:readyes
customer_repos_grooming_next-candidatesrepos:readyes
customer_repos_grooming_documentrepos:readyes
customer_repos_grooming_dispatchrepos:writeno
customer_repos_order-previewrepos:readyes
customer_repos_ordering-previewrepos:readyes
customer_settings_engine-tier-getsettings:readyes
customer_settings_engine-tier-setsettings:writeno
customer_billing_credit-pack-listbilling:readyes
customer_billing_credit-pack-buybilling:writeno
customer_billing_auto-reload-getbilling:readyes
customer_billing_auto-reload-setbilling:writeno
customer_settings_repo-defaults-getsettings:readyes
customer_settings_repo-defaults-setsettings:writeno
customer_settings_profile-getsettings:readyes
customer_settings_usage-getsettings:readyes
customer_settings_usage-setsettings:writeno
customer_team_members-listteam:readyes
customer_team_organization-getteam:readyes
customer_team_organization-renameteam:writeno
customer_team_invitations-listteam:readyes
customer_team_invitations-createteam:writeno
customer_team_invitations-revoketeam:writeno
customer_team_members-removeteam:writeno
customer_analytics_cost-by-label-getanalytics:readyes
customer_analytics_cost-getanalytics:readyes
customer_analytics_session-insights-getanalytics:readyes
customer_analytics_throughput-getanalytics:readyes
customer_runs_overview-getruns:readyes
customer_billing_balance-getbilling:readyes
customer_billing_spend-cap-setbilling:writeno
customer_billing_ledger-listbilling:readyes
customer_billing_run-cost-listbilling:readyes
customer_notifications_listnotifications:readyes
customer_notifications_mark-readnotifications:writeno
customer_notifications_email-setnotifications:writeno
customer_settings_privacy-getsettings:readyes
customer_settings_privacy-setsettings:writeno
customer_sso_getsso:readyes
customer_capabilities_listcapabilities:readyes
customer_capabilities_decidecapabilities:writeno
customer_security_getsecurity:readyes

Grant the narrowest set that does the agent's job. A key can be revoked in the dashboard at any time, and a narrower replacement minted, without touching the client's configuration beyond the token.

Point a client at it

MCP clients disagree about where server configuration lives and what the fields inside it are called, and those names change. Rather than describe one client's file, here are the two values every client asks for. Take the field names from your client's own documentation.

what the client asks forthe value
The server URL, for a remote or HTTP serverhttps://app.descant.run/api/v1/mcp
An HTTP header to send with every requestauthorization: Bearer <your key>

Two things are worth knowing while you look for those fields.

  • Pick the shape your client offers. Most clients offer two ways to add a server: launching a process on your machine, configured with a command and its arguments, or connecting to a URL. This section is the second. A client that only offers the first is not shut out — the other transport is that shape, and serves the same tools.
  • Many clients group servers under an mcpServers object, keyed by a name you choose. The name is yours and affects nothing here; the field names inside the entry are your client's, and are the part worth reading their documentation for.

Check it before you wire it

This asks the endpoint for its tool list — the same request a client makes first, so it exercises the address, the credential and the transport scope in one go. -i prints the status line and the response headers, which is where the answer below is read from.

The export in that block is for this one check, not for keeping the key. An exported variable is inherited by every process the shell starts afterwards and lasts the rest of the session, so run unset DESCANT_API_KEY when you are done with the check — whether it passed or not. A failed check is the case that matters: it is the one where you stay in that shell trying things, which is exactly how a key meant for a single command outlives the session it was typed into. The key's long-term home is the header field in your client's own configuration, in the table above.

export DESCANT_API_KEY=dsc_…

curl -sSi -X POST https://app.descant.run/api/v1/mcp \
  -H "authorization: Bearer $DESCANT_API_KEY" \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

If you also drive Descant with the descant binary, use descant login rather than exporting the variable there. It opens your browser, where you check the code it prints, choose the organization and what this terminal may do, and approve; with --key, or when stdin is not a terminal, it reads a key at a prompt that does not echo instead. Either way the key goes into the machine's own credential store, so it is not inherited by child processes, not in shell history and not in the process table. That is the documented path for the CLI, on the CLI reference. It does nothing for the request above: curl and your MCP client read no credential store, so each still needs the key handed to it directly.

What each answer means.

answerwhat it tells you
200The address is right, the key is accepted, and it holds mcp:write.
400The request body failed validation.
401No valid credential was presented. The key was not accepted at all — check it was copied whole, and that it has not been revoked.
403The credential is valid but may not do this. For this endpoint that means the key does not hold mcp:write. The refusal is the gate's generic one and does not name the scope, so there is nothing in the body to read — mint a key with the transport scope ticked.
429Too many requests for this credential or source. The limit on the source you called from, which runs BEFORE the credential is looked at — so this says nothing about whether the key is valid, and a revoked key answers identically. Honour Retry-After, run it again, and read the answer you get then.
500The request failed for a reason the caller cannot fix. The service, or something it depends on, failed. This request sends nothing and changes nothing, so running it again is safe.

A successful list does not mean the per-tool scopes are right, and it is worth knowing why. The catalogue is registered when the server is built, and the scope check runs when a tool is called — so tools/list answers with all 72 tools whatever else the key holds. A key with mcp:write alone lists everything and can call nothing.

The scopes are proved by calling a tool. A call the key may not make is refused inside the envelope, and that refusal — unlike the 403 above — names both the operation and the scope it wanted:

`customer.runs.start` requires the `runs:write` scope, which this credential does not hold. NOTHING WAS SENT — the call was refused here, before any request reached the API, so this is not an estate failure and retrying it unchanged will fail identically. The credential's scopes are fixed when it is minted: a caller cannot widen its own grant, so this needs the key's owner to issue one that carries `runs:write`.

What is checked, and when

Worth knowing before you decide what to grant: authorization runs per tool call, not once at the door.

  1. Opening the envelope. The key is checked for mcp:write, exactly as any other route checks its own operation's scope. Without it the request is refused and no tool runs.
  2. Each tool call inside it. The tool is resolved to the operation it maps to, and the key is checked against that operation's scope. A key that can open the envelope therefore cannot drive every tool inside it.

Both checks run the same derivation over the same registry, so the two verdicts cannot drift into disagreeing about one credential. A refused tool call comes back as a refusal naming the operation and the scope it wanted, inside the envelope — the connection stays up, nothing is sent to the API, and the other tools keep working.

Listing is not a third check. The tool catalogue is registered when the server is built and the scope check runs on invocation, so a key sees every tool it could ever be granted and is refused on the ones it was not. A tool appearing in the list is therefore not a statement that this key may call it — which is why the check above proves the transport scope and nothing about the rest.

The transport serves the customer audience and nothing else. That is fixed where the handler is mounted rather than being a setting, so no key and no client configuration can reach the operator surface through it.

The other transport

The MCP server also speaks stdio — a server process on your own machine, finding its own credential and speaking JSON-RPC over standard input and output. It presents the key descant login stored there, else one exported in its environment; with neither, the first tool call that needs a key opens your browser to sign you in. That is the shape a client launches and supervises itself, configured with a command rather than a URL.

Which one to pick. Choose stdio when the agent runs on the same machine you do: there is no key in a configuration file and no endpoint to configure. Choose HTTP when the agent does not run on your machine — a hosted assistant, a shared runner, anything you cannot install a binary on. The tool surface is identical on both, so nothing you write against changes if you switch.

Stdio moves the endpoint out of your configuration, not out of the picture. The process the client launches still calls this API over the network for every tool, so the machine running it needs to reach Descant — which is the first thing to check if tools fail on a host that cannot. It prints the base URL it resolved on startup, so you can see which one it is using.

The descant binary carries the stdio server as descant mcp, from cli-v0.1.1 onwards. Check your copy before you wire it: descant mcp --help answers with help on a binary that has it, and no such command on one cut before it — reinstall if you see the second.

{
  "mcpServers": {
    "descant": {
      "command": "descant",
      "args": [
        "mcp"
      ]
    }
  }
}

There is no key in that block, and that is the point. The server finds its own credential — the key you gave descant login, which lives in the machine's credential store. So there is nothing to paste into a configuration file, and nothing to leak when that file is committed. The server name is yours to change; the field names inside are your client's, and are the part worth reading their documentation for.

With no key stored, the server signs you in, from cli-v0.1.8 onwards. The first tool call that needs a key opens your browser, you pick the organization and approve there, and the key is stored where descant login would have put it. Nothing is shown to copy and nothing is pasted. A tool call made while you are still approving answers with the link and the code and asks the agent to call again — so you can wire the block first and sign in when something needs it, and you can open that link on another machine if this one has no browser.

On an older binary the subcommand is there but the sign-in is not: it refuses the call and names descant login instead. Reinstall if you want the keyless setup.

To drive Descant from a shell or a script rather than from an agent, the same binary is the surface: install it in one line and read scripting and agents for its JSON output, exit codes and refusals. Every tool above is a command there.