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. The configuration your client needs is 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 24 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 scripting and agents 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 organisation 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. 9 scopes cover the current surface: audit:read, invites:read, invites:write, keys:read, keys:write, repos:read, repos:write, runs:read and runs:write.

toolscope it requiresread-only
customer_runs_listruns:readyes
customer_runs_getruns:readyes
customer_runs_cancelruns:writeno
customer_repos_listrepos: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_repos_grooming_candidatesrepos:readyes
customer_repos_grooming_documentrepos:readyes
customer_repos_grooming_dispatchrepos:writeno
customer_repos_order-previewrepos:readyes
customer_repos_ordering-previewrepos: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 reads the key at a prompt that does not echo and keeps it in 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.
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 24 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.cancel` 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, holding its credential in its own environment and speaking JSON-RPC over standard input and output. 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 organisation 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.