Developers

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.

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.

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 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:

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.

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

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

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. Headless MCP is not possible.

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

SurfaceWhere the work runs
MCP, over OAuthA person's own client — claude.ai, Claude Code, ChatGPT, Cursor
REST API, on /api/v1CI, 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.

The three scopes

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

ScopeGrants
mcp:readView invoices, customers, products, recurring series and revenue recognition.
mcp:writeDraft and edit invoices, customers, products and series, register work, and commit a plan. Nothing is booked or emailed.
mcp:issueIssue invoices — book to Dinero and email your customers — and raise credit notes.

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

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

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:

scopeThe 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.
relatedThe 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.
allThe 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.

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

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

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

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.