# Auth and scopes

> OAuth is the only MCP credential: the flow, the three scopes, the five roles, whose work an answer is about, and how access ends.

Source: https://www.tabba.io/developers/mcp/auth

The MCP server takes one credential: an OAuth access token that Tabba's own
authorization server issued to your client, after you approved it in your
browser. There is nothing to paste and nothing to create by hand.

An API key is not a credential here. A `tba_` key sent to `/api/mcp` gets the
same `401` as no credential at all. Keys authenticate the REST API at
`https://www.tabba.io/api/v1`: see
[Authentication](/developers/api/authentication).

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

A conversation is held with someone. "Which customers do I have?" is a
question in the first person, and a credential that names only a company
cannot answer it. So every MCP request has an **acting user**: the person who
approved the client. That is what lets `me` resolve, what makes a read answer
from your own work, and what puts a person in the activity log behind every
commit.

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

Tabba is the resource server and its own authorization server, both on
`https://www.tabba.io`. A client discovers everything it needs. There is no
client id to register by hand.

1. The client calls `https://www.tabba.io/api/mcp` with no credential and
   gets a `401` that names the resource's metadata (RFC 9728).
2. It reads the discovery documents and registers itself at
   `POST /api/oauth/register` (RFC 7591). A public client gets no secret and
   uses PKCE.
3. It sends your browser to `GET /api/oauth/authorize`. Tabba signs you in if
   you are not, and shows the consent page.
4. You pick the company and approve. The client is sent back with an
   authorization code, good once and for sixty seconds.
5. It exchanges the code at `POST /api/oauth/token`, with its PKCE verifier,
   for an access token and a refresh token.

You can watch step 1 from a terminal:

```bash
curl -i -X POST https://www.tabba.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://www.tabba.io/.well-known/oauth-protected-resource/api/mcp"

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

That header tells the client where to read the metadata, and the metadata
names the authorization server:

```
https://www.tabba.io/.well-known/oauth-protected-resource/api/mcp
https://www.tabba.io/.well-known/oauth-authorization-server
https://www.tabba.io/.well-known/jwks.json
```

All three are public. Fetch them when a client will not connect. Between them
they name the authorization, token and registration endpoints, the key set
that access tokens are signed with, `S256` as the only code challenge method,
and the three scopes below.

### What the authorization server requires of a client [#what-the-authorization-server-requires-of-a-client]

For anyone writing a client, or working out why one is refused:

* **PKCE with `S256`**, on every authorization request.
* **`response_type=code`.** The grants are `authorization_code` and
  `refresh_token`. There is no client credentials grant.
* **A `resource` parameter** equal to `https://www.tabba.io/api/mcp`. A
  request without it, or with another value, is refused with
  `invalid_target`. A token is issued for that resource and is refused
  anywhere else.
* **A registered redirect address**, matched exactly. A registration takes one
  to ten: `https` anywhere, `http` on the loopback interface, or an app's own
  scheme, with no fragment.
* **Client authentication** at the token endpoint is `none` for a public
  client, or `client_secret_post`.

Registration is open and grants nothing by itself. It is limited to 100
registrations an hour per address, and a registration nobody has used for
ninety days is removed. A client that meets an unknown `client_id` registers
again.

> [!WARNING]
> **Both Accept types, or 406**
>
> Streamable HTTP has the client accept `application/json` and
> `text/event-stream`, though this server always answers in JSON. A request
> with a valid token and only one of them is answered `406`. MCP clients send
> both. A hand-written request needs the header.

## The consent page [#the-consent-page]

The page names the client, lists what it asked to be able to do, and asks
which company it reaches. Its answer is one of two buttons, **Approve** or
**Deny**.

* **You pick one company.** A connection reaches that company and no other.
  With several companies none is preselected. To reach another, connect the
  client again.
* **You are asked every time.** No approval is remembered, and no client
  skips the page.
* **The scopes are the client's request.** The page shows what the client
  asked for and takes one answer for all of it. It has no control for
  granting less than was asked. Where the request leaves something out, the
  page says so: "It cannot issue an invoice or a credit note."

Only a company that is connected to its accounting system, and that you can
open in Tabba, is offered.

## Token lifetimes [#token-lifetimes]

| Token         | Lives      | Notes                                                                               |
| ------------- | ---------- | ----------------------------------------------------------------------------------- |
| Access token  | 10 minutes | Sent with each request. It names you, the company and the scope.                    |
| Refresh token | 30 days    | Spent by being used: each refresh issues a successor and retires the one presented. |

The client refreshes on its own. A client that has not refreshed for thirty
days holds no live token and asks you to approve it again.

> [!NOTE]
> **A refresh token is used once**
>
> A spent refresh token that is presented again ends the whole connection,
> for every holder of its tokens: it means a copy is in someone else's hands.
> Two refreshes sent at the same moment with one token are treated the same
> way. If a client keeps asking you to approve again, that is the first thing
> to suspect.

## There is no path without a browser [#there-is-no-path-without-a-browser]

The consent page takes a signed-in browser session. CI, a scheduled job, a
container and a server-side agent cannot complete it, and no grant skips it.

| Surface                                   | Where the work runs                                   |
| ----------------------------------------- | ----------------------------------------------------- |
| MCP, over OAuth                           | A person's own client                                 |
| [REST API](/developers/api), on `/api/v1` | CI, a scheduled job, a queue worker, your own backend |

The REST API reaches the same Sales data with the same error codes. It has no
consent step because it has no person: it authenticates with a key an admin
creates, bound to one company.

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

A token holds one of three scopes, each containing the one before it. The
labels are the ones the consent page shows.

| Scope       | On the consent page | What it allows                                                                                                                              |
| ----------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `mcp:read`  | Read                | See invoices, credit notes, customers, products, recurring series, registered work and earned revenue.                                      |
| `mcp:write` | Draft and register  | Draft and edit invoices, customers, products and recurring series, and register work. Nothing is booked, and nothing is sent to a customer. |
| `mcp:issue` | Issue               | Issue invoices and credit notes: they are booked in your accounting system and sent to your customers.                                      |

A client that names no scope in its request asks for what it registered for,
and a client that registered without naming scopes is registered for all
three. A scope Tabba does not know is dropped, not refused.

`mcp:write` covers every plan that only drafts, and `commit`. What takes
`mcp:issue` is decided by what a plan leads to, not by the tool that made it:
see [Plan and commit](/developers/mcp/propose-commit).

## Your role [#your-role]

You hold one of five roles in each company, and the role is per company: you
can be an admin in one and an auditor in another. On this server the five act
as three:

| Your role in the company              | Acts as         | Reads                                                | Writes                                |
| ------------------------------------- | --------------- | ---------------------------------------------------- | ------------------------------------- |
| Organisation admin, admin, bookkeeper | An admin        | The whole company                                    | Every plan                            |
| Auditor                               | An auditor      | The whole company, with every read tool an admin has | Nothing: no plan tool and no `commit` |
| Standard user                         | A standard user | Their own work                                       | Their own registered work             |

## Two gates decide the catalogue [#two-gates-decide-the-catalogue]

The tool list is built for each request. A tool is registered only when the
token holds its scope and your role admits it: a plan and `commit` are never
an auditor's, and a tool that is an admin's is offered to an admin, or, when
it only reads, to an auditor too. A tool that is not registered is not in
`tools/list` and cannot be called, so there is nothing for an agent to
attempt.

| Token scope | An admin is offered                                                                                                                                                                                         | An auditor is offered | A standard user is offered                                                             |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------- |
| `mcp:read`  | All 17 read tools                                                                                                                                                                                           | All 17 read tools     | 13 read tools: all but `list_products`, `get_product`, `list_users` and `get_accruals` |
| `mcp:write` | Also `commit`, `plan_register_work`, `plan_upsert_customer`, `plan_upsert_product`, `plan_create_invoice`, `plan_delete_invoice`, `plan_create_series`, `plan_cancel_series` and `plan_setup_from_contract` | Nothing more          | Also `plan_register_work` and `commit`                                                 |
| `mcp:issue` | Also `plan_issue_invoice`, `plan_issue_occurrence` and `plan_create_credit_note`                                                                                                                            | Nothing more          | Nothing more                                                                           |

Your role is not part of the token. It is read from Tabba on every request,
so a role that changed is in force on the next call, and the catalogue of two
people in one company differs. A client should hold a tool list against the
connection, not against the server.

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

The scope of the token says what a connection may do. A second thing, the
**view**, says whose work a read answers from. It is asked with an argument
that is also called `scope`, on `get_my_work` and on the four lists whose
rows can be related to a person: `list_customers`, `list_invoices`,
`list_series` and `list_credit_notes`.

| `scope`              | On a list                                                                                                                                  | On `get_my_work`                                        |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
| `mine` (the default) | The rows your own places reach: the invoices you hold a place on a line of, the recurring series you are on, and the customers those bill. | Your own open lines.                                    |
| `related`            | The same rows as `mine`.                                                                                                                   | Also the colleagues on the work you share.              |
| `all`                | The whole company. An admin's and an auditor's.                                                                                            | Every person's open lines. An admin's and an auditor's. |

`mine` is the default because a question asked in the first person is a
question about your own work. `get_my_work` answers that question in one
call: the lines you hold a place on, on invoices that still ask for
something, grouped by customer or recurring series and ranked by what needs
attention. Each line carries the `line_id` a work registration takes.

The `scope` enum a tool advertises is built from your role: `mine` and
`related` for a standard user, all three for an admin and an auditor. A request for a view
your role does not reach is refused with `SCOPE_NOT_PERMITTED`.

Every scoped answer opens with a line that says which view produced it. An
admin and an auditor are also told how many rows it left out:

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

A standard user is told that there is no wider list, and is given no count of
the rest:

```
Scope "mine", reached through your own places: 12 customers in scope, all shown. This is the widest list you may ask for; a company-wide answer needs an admin.
```

> [!NOTE]
> **Read that line**
>
> A narrowed answer that does not say it was narrowed is worse than a broad
> one, because nothing signals what is missing. The line is written for the
> agent to relay. If yours summarises the rows and drops it, you are reading
> a narrowed answer with nothing to say so.

### What a standard user reads [#what-a-standard-user-reads]

A standard user reads within their own work on every tool, not only on the
lists:

* A single record outside it is answered `NOT_FOUND`, whether it was named by
  id, by `ext:` or by name, and it is never offered as a candidate. A record
  that does not exist is answered in the same words, so the answer does not
  say which of the company's records exist.
* `get_recognition` for an invoice within their work is theirs to read. For
  a whole customer it is answered `ADMIN_REQUIRED`.
* `get_user` answers for themselves. Naming anyone else needs an admin.
* The product catalog, the list of people and the month-end accrual entries
  are an admin's, and those tools are not in a standard user's list.

### Roles and writes [#roles-and-writes]

A standard user's only plan tool is `plan_register_work`, and it registers
their own work: naming a colleague is refused with
`REGISTER_FOR_OTHERS_FORBIDDEN`. An admin may name a colleague in `user`.
Every other plan is an admin's, and the role is asked again when the plan is
committed. An auditor makes no plan, and a person made an auditor since their
plan was made cannot commit it: `commit` is no longer offered to them, and
where it is reached it answers `READ_ONLY_ROLE`. `me` is a valid reference
wherever a person is named.

## The company of a connection [#the-company-of-a-connection]

A token carries one company. No tool takes a company as an argument, so
nothing an agent passes can move a request to another one. If you work in
several companies, that is one connection each.

## Revoking access [#revoking-access]

Your connections are listed in Tabba under the heading **Claude and other AI
clients**: on your profile settings, and for an admin also under Settings,
Developers. Each row is one client acting as you on one company.

**Revoke access** ends every connection of yours at once. There is no control
that ends a single one, and an admin cannot see or end a colleague's. After a
revoke:

* A client is refused at its next refresh. A client that is working at that
  moment can go on for up to 10 minutes, until its access token runs out.
* To use a client again, connect it and approve it again.
* The company's API keys are untouched. Those are the company's, and an
  admin's to revoke.

Removing the server from your client does not revoke anything in Tabba. Do
both.

A connection also ends without you:

| What happened                                           | When it takes effect                                                                   |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Your role in the company changed                        | The next request runs with the new role.                                               |
| You lost access to the company, or were removed from it | The next request is refused with `401`.                                                |
| The company was disconnected from its accounting system | The next request is refused. The company's connections end, and its plans are deleted. |
| A spent refresh token was presented again               | The connection ends for every holder.                                                  |
