# Auth and scopes

> OAuth is the only MCP credential — the flow, the three scopes, whose work an answer is about, and why there is no headless path.

Source: https://invoia.io/developers/mcp/auth

The MCP server takes one credential: an **OAuth access token**, obtained by
your client through a consent screen in your browser. There is nothing to
paste and nothing to mint.

That is a change from earlier versions of this page, which described a second
path. An `ivk_…` key is no longer a credential here — presented at
`/api/mcp` it gets the same `401` as no credential at all. It is untouched on
the REST API, and if that is where your integration lives, nothing below
applies to you: skip to [What did not change](#what-did-not-change).

## Why the server insists on a person [#why-the-server-insists-on-a-person]

Because a conversation is held with someone. A consultant who asks "what
clients do I have?" is not being vague — the first person *is* the
specification — and a credential that authenticates an organisation cannot
answer it. So every MCP request now names an **acting user**: the person who
signed in at the consent screen. That is what lets `me` resolve, what lets
reads default to your own work, and what puts a human rather than a secret in
the audit trail behind every write.

## The OAuth flow [#the-oauth-flow]

The server is an OAuth 2.1 resource server *and* its own authorization
server, both on `https://invoia.io`. Clients discover everything they
need; there is no client id to register by hand.

1. The client calls `https://invoia.io/api/mcp` unauthenticated and gets
   a `401` naming the protected resource (RFC 9728).
2. It reads the discovery documents and registers itself dynamically at
   `/api/oauth/register` (RFC 7591) — public clients use PKCE and get no
   secret.
3. It sends you to invoia.io to sign in and approve scopes.
4. It receives a token whose audience is bound to
   `https://invoia.io/api/mcp`, so the token cannot be replayed against
   anything else.

You can watch step 1 happen from a terminal:

```bash
curl -i -X POST https://invoia.io/api/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

```
HTTP/2 401
www-authenticate: Bearer resource_metadata="https://invoia.io/.well-known/oauth-protected-resource/api/mcp"

{"error":{"code":"UNAUTHORIZED","message":"Missing or invalid bearer credential for the MCP resource."}}
```

That header is the whole handshake: it tells the client where to read the
metadata, and the metadata names the authorization server.

```
https://invoia.io/.well-known/oauth-protected-resource/api/mcp
https://invoia.io/.well-known/oauth-authorization-server
```

Both are public. Fetch them if you are debugging a client that will not
connect — between them they name the authorization, token and
registration endpoints, the JWKS, `S256` as the only challenge method,
and the three scopes below.

> [!WARNING]
> **Both Accept types or 406**
>
> Streamable HTTP requires the client to accept `application/json` **and**
> `text/event-stream`, even though this server always answers with JSON.
> Omit either and the transport returns `406` before your credential is
> ever looked at. Real MCP clients send both; curl needs telling.

## Token lifetimes [#token-lifetimes]

Access tokens live **10 minutes**; refresh tokens live **30 days** and
are exchanged transparently by the client. A connection you have not
touched in a month asks you to sign in again — that is the refresh token
expiring, not a fault.

## There is no headless path [#there-is-no-headless-path]

Step 3 above is a consent page, and it needs a live browser session. CI, a
scheduled job, a container, a server-side agent — none of them can complete
it, and there is no flag, no client-credentials grant and no long-lived token
that skips it. &#x2A;*Headless MCP is not possible.**

This is a real loss and it is not worked around. Plan for it:

| Surface                                   | Where the work runs                                             |
| ----------------------------------------- | --------------------------------------------------------------- |
| MCP, over OAuth                           | A person's own client — claude.ai, Claude Code, ChatGPT, Cursor |
| [REST API](/developers/api), on `/api/v1` | CI, cron, a queue worker, your own backend                      |

The REST API reaches the same product with the same error vocabulary. It has
no consent step because it has no person: it authenticates with a Bearer key
your admin mints, and any write that must be attributed to someone names them
explicitly. Start at [Getting started](/developers/api).

## The three scopes [#the-three-scopes]

Granted at the consent screen, and layered — granting one grants those
beneath it.

| Scope       | Grants                                                                                                                   |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| `mcp:read`  | View invoices, customers, products, recurring series and revenue recognition.                                            |
| `mcp:write` | Draft and edit invoices, customers, products and series, register work, and commit a plan. Nothing is booked or emailed. |
| `mcp:issue` | Issue invoices — book to Dinero and email your customers — and raise credit notes.                                       |

> [!NOTE]
> **Withholding issue is the point**
>
> The split exists so you can hand an agent the whole drafting workflow
> while keeping the irreversible act for yourself. A token without
> `mcp:issue` does not merely fail on those tools — it never sees them.

## Scope gating is by construction [#scope-gating-is-by-construction]

The tool catalogue is assembled per request from the token's scopes. A
`mcp:read` connection's `tools/list` contains sixteen read tools and nothing
else, so there is nothing for an agent to attempt and no error path to
mishandle. Adding `mcp:write` brings the eight drafting `plan_*` tools and
`commit`; `mcp:issue` brings the remaining three — `plan_issue_invoice`,
`plan_issue_occurrence` and `plan_create_credit_note` — for twenty-eight in
all.

## Whose work an answer is about [#whose-work-an-answer-is-about]

The scopes above say what a *credential* may do. A second, unrelated thing
decides what a read is *about*, and it is worth knowing before your first
call: reads answer from **your own work**, not from the whole account.

The four rosters that can be related to a person — `list_customers`,
`list_invoices`, `list_series`, `list_credit_notes` — take a `scope`
argument:

| `scope`          | The rows                                                                                                                                                                                                                                    |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mine` (default) | Everything your own line assignments reach — the invoices carrying a line of yours, the customers those invoices bill. A recurring series is reached through its *own* lines instead, so a draft series with no invoice yet is still yours. |
| `related`        | The same rows. What widens is who on them counts as your work, one level below anything a list renders — so a `related` list returns exactly what `mine` did.                                                                               |
| `all`            | The whole account.                                                                                                                                                                                                                          |

`mine` is the default because a question asked in the first person is a
question about your own work. There is also `get_my_work`, which answers the
whole intent in one call: your assigned lines on open invoices, grouped by
customer or retainer, ranked by what needs attention, each carrying the line
id that work registration targets. It takes no arguments — you are the
subject, not a filter — and it holds only unissued invoices, because an
issued one is frozen and its lines are not something to register against.

**Widening is explicit, and bounded by your role in the app.** A standard
user may ask for `mine` and `related`; only an admin may ask for `all`. The
enum an agent reads from `tools/list` is built from your role, so it cannot
formulate a request that would be refused — which also means the catalogue
differs between two people on the same account. Cache it against the token,
not against the server.

Every scoped result opens by saying which view produced it and both counts —
one line on the wire, wrapped here:

```
Scope "mine" — reached through your own assignments:
12 customers in scope, all shown; 228 beyond this scope.
Widen with scope:"all" for the whole account.
```

That last sentence is an admin's. A standard user is told the opposite, so
that an empty result never reads as something a wider view might fill:

```
… This is the widest list you may ask for; an account-wide answer
needs an admin.
```

> [!NOTE]
> **That line is load-bearing**
>
> A filtered answer that does not admit it was filtered is a worse failure than
> an over-broad one, because nothing signals the omission. It is written for the
> agent to relay verbatim — if yours summarises the rows and drops the line, you
> are reading a narrowed answer with nothing to say it was narrowed.

Role also governs writes, but not through scope: `plan_register_work` targets
one person, so an admin widens by *naming* them (`user: "Jane"`), and a
standard user registering for anyone but themselves is refused with the
reason. `me` is a valid reference anywhere a user is accepted.

If you connected an agent before this shipped and relied on account-wide
reads, this is the one behaviour that changed: the same call now answers from
your own work. The line above is how you will notice.

## The bound account [#the-bound-account]

A credential carries one account and one connected accounting
integration. The MCP surface never takes an account or integration
parameter — your credential *is* the tenant. If you operate several
entities, that is one connection each.

## Revoking access [#revoking-access]

Remove the connector in your client, and revoke the grant in **Settings →
Developers** in the app. Revocation is immediate: the next call the
credential makes gets the `401` above. A role change lands just as fast —
it is read from your profile on every request, so nobody has to reconnect
when their permissions change.

## What did not change [#what-did-not-change]

Nothing on `/api/v1`. Keys are minted the same way, carry the same scopes,
authenticate the same endpoints and return the same bodies — a shipped REST
integration needs no action, and none of the scope behaviour above reaches
it. See [Authentication](/developers/api/authentication).
