freshbooks-mcp
# freshbooks-mcp
MCP server for [FreshBooks](https://www.freshbooks.com) — invoices, clients, estimates and
payments, exposed to Claude as typed tools.
> This project was developed and is maintained by AI (Claude Code). Use at your own discretion.
## Install
```sh
npm install -g @chrischall/freshbooks-mcp
```
## Setup
FreshBooks is **OAuth2 only** — there is no API key and no personal access token, so a
one-time browser authorization is required.
1. Register an app at <https://my.freshbooks.com/#/developer>. The redirect URI must be
**HTTPS with no query string**; `https://localhost` works and never needs to resolve.
2. Note the **Client ID** and **Client Secret**.
3. Obtain a refresh token, either way:
- **From the server itself** (no script): set `FRESHBOOKS_CLIENT_ID` and
`FRESHBOOKS_CLIENT_SECRET`, start it, then call `freshbooks_auth_url`, open
the URL it returns, approve, and pass the URL you land on to
`freshbooks_auth_exchange`. Those two tools need no refresh token — minting
one is what they are for. This is also the path mcp-host's `authFlow`
drives, so a hosted connector can do it without you pasting anything.
- **From the script**, if you prefer it outside the server — see
[`skills/freshbooks-curl`](skills/freshbooks-curl/SKILL.md).
4. Configure:
```sh
FRESHBOOKS_CLIENT_ID=...
FRESHBOOKS_CLIENT_SECRET=...
FRESHBOOKS_REFRESH_TOKEN=... # from the bootstrap
FRESHBOOKS_REDIRECT_URI=https://localhost # optional; must match what you registered
FRESHBOOKS_TOKEN_STORE=~/.freshbooks-mcp/session.json # optional
FRESHBOOKS_BUSINESS_ID=... # optional; required for writes if you belong to several businesses
FRESHBOOKS_ACCOUNT_ID=... # optional; with FRESHBOOKS_BUSINESS_ID, confirms its accountId when FreshBooks omits it
```
In Claude Desktop, both are optional fields in the extension's settings. If FreshBooks
returns no `account_id` for the chosen business, invoice/expense writes are refused
(projects and time entries still work) until you set both IDs yourself.
### ⚠️ Refresh tokens rotate
FreshBooks issues a **new refresh token on every refresh and immediately invalidates the
old one**. This server persists each rotation to `FRESHBOOKS_TOKEN_STORE` (mode `0600`)
before the refresh is considered complete, and prefers the stored token over the
environment value — the stored one has rotated past it.
Two consequences worth knowing:
- **Do not point two tools at the same store.** The MCP server and the `freshbooks-curl`
skill keep separate state files on purpose; sharing one makes them spend each other's
tokens and locks both out.
- **If the store is lost, re-run the bootstrap.** A spent refresh token cannot be
recovered.
Changing `FRESHBOOKS_REFRESH_TOKEN` to a freshly bootstrapped value is detected and
adopted, so re-bootstrapping is the supported recovery path.
## Tools
| Tool | Purpose |
| --- | --- |
| `freshbooks_get_identity` | Resolve accountId / businessId / businessUuid |
| `freshbooks_auth_url` | Get the consent URL to authorise this connection |
| `freshbooks_auth_exchange` | Exchange the authorization code (or pasted redirect URL) for a refresh token |
| `freshbooks_healthcheck` | Verify the OAuth credential and FreshBooks reachability; distinguishes "no credential" from "rejected" from "FreshBooks is down" |
| `freshbooks_list_invoices` / `freshbooks_get_invoice` | Browse and fetch invoices |
| `freshbooks_list_clients` / `freshbooks_get_client` | Browse and fetch clients |
| `freshbooks_list_estimates` / `freshbooks_get_estimate` | Browse and fetch estimates |
| `freshbooks_list_payments` / `freshbooks_get_payment` | Browse and fetch payments |
| `freshbooks_list_items` / `freshbooks_get_item` | Browse and fetch catalogue items |
| `freshbooks_create_client` | Create a client — confirmation-gated |
| `freshbooks_create_invoice` | Create an invoice — confirmation-gated |
| `freshbooks_update_invoice` | Update an invoice — confirmation-gated |
| `freshbooks_record_payment` | Record a payment against an invoice — confirmation-gated |
| `freshbooks_accept_estimate` | Accept an estimate (`action_accept`) — confirmation-gated, idempotent |
| `freshbooks_update_estimate` | Update an estimate's lines, notes, terms, presentation — confirmation-gated |
| `freshbooks_send_estimate` | Email an estimate to the client (`action_email`) — confirmation-gated |
| `freshbooks_decline_estimate` | Always fails: FreshBooks has no decline. Answers with the alternatives |
| `freshbooks_list_expenses` / `freshbooks_get_expense` | Browse and fetch expenses |
| `freshbooks_list_expense_categories` | Categories supplying `categoryid` for new expenses |
| `freshbooks_create_expense` | Record an expense — confirmation-gated |
| `freshbooks_list_projects` / `freshbooks_get_project` | Projects (businessId-keyed) |
| `freshbooks_create_project` | Create a project — confirmation-gated |
| `freshbooks_list_time_entries` | Tracked time, with `total_logged` / `total_unbilled` |
| `freshbooks_create_time_entry` | Log time in seconds — confirmation-gated |
| `freshbooks_list_services` | Billable work types for projects and time entries |
| `freshbooks_list_records` / `freshbooks_get_record` | Generic accessor for the accounting long tail (taxes, credit notes, invoice profiles, tasks, staff, gateways, bills, bill vendors, bill payments, other income) |
**Confirmation-gated** means the tool asks you before it writes. A client that can show a
confirmation prompt (Claude Code) shows one with exactly what would be sent. Elsewhere
(claude.ai, Claude Desktop) the first call makes *no* network call and returns a preview of
the method, path and body that would be sent, plus a single-use `confirmToken`; only a
repeat call with that token and the same arguments performs the write. A token is refused
if any argument changed since the preview (`DRAFT_CHANGED`), if it was already used
(`TOKEN_REUSED`) or once it expires.
### Confirmations
| variable | default | |
|---|---|---|
| `MCP_CONFIRM_MODE` | `ask-user` | What a write does on a client that cannot show a confirmation prompt (claude.ai, Claude Desktop). `ask-user`: two steps — the first call does nothing and returns a preview plus a token, and the model must get your approval in chat before calling again with it. `auto`: the same two steps, but the model may use the token after reviewing the preview itself. `refuse`: writes are refused on such clients. A client that can show prompts (Claude Code) always gets the real prompt. An unrecognised value is treated as `refuse`. |
| `MCP_CONFIRM_TTL_SECONDS` | `600` | How long a token stays valid. |
| `MCP_CONFIRM_SECRET` | random per process | Signing key; set it only if tokens must survive a server restart. |
### Estimate writes
Acceptance is an **action on the estimate**, not a status field: `status` (int),
`display_status` and `ui_status` are computed and read-only, and they disagree with each
other by design (a viewed estimate reads `status: 3`, `display_status: "viewed"`,
`ui_status: "open"`). Accepting is `PUT estimates/estimates/{id}` with
`{"estimate": {"action_accept": true}}` — see
[`docs/FRESHBOOKS-API.md`](docs/FRESHBOOKS-API.md) for where that shape comes from.
- **Accept is idempotent.** An estimate already accepted (or invoiced) comes back with
`changed: false` and no write is sent — acceptance cannot be undone through the API, so
a repeat call must not re-fire it.
- **There is no decline.** FreshBooks' estimate statuses are draft / sent / viewed /
replied / accepted / invoiced; no declined state, no `action_deny`, no
`estimate.decline` webhook. `freshbooks_decline_estimate` exists only to say so and
point at the alternatives, rather than leave an agent to invent a write that changes
nothing.
- **Every write returns the re-fetched estimate**, plus `before` / `after` state and
`changed` / `changedFields`, so success is verified against the record rather than
inferred from a `200`. `changed` covers the status fields *and* the fields that write
actually set, so a successful notes edit reports `changed: true` even though no status
moves. On `freshbooks_send_estimate` it describes the record only — emailing an
already-sent estimate moves nothing, and retrying on `changed: false` would send the
client a second copy.
## Writes require an owner/admin accounting account
FreshBooks separates the role you hold on a *business* from the role you hold on an
*accounting account*. You can own a business that has **no** accounting account
(`account_id: null`) while being only a **client** on the account you can actually see —
in which case reads succeed and every write returns `403 Permission Denied`, even though
your OAuth token carries all the `:write` scopes.
`freshbooks_get_identity` reports `accountRole` and `businessRole` so this is visible up
front. If `accountRole` is `client`, the invoicing write tools will not work against that
account — that is an account permission, not a configuration problem.
### Two things the API reports misleadingly
- **`total` counts records you may not be able to read.** Expenses reported `total: 16`
while returning zero rows. List results attach a `note` when that happens, so it reads
as a permission boundary rather than an empty account.
- **Projects and time tracking are keyed by `businessId`, not `accountId`**, and paginate
under a `meta` block instead of flat `page`/`pages`/`total`. They also work on a
business with no accounting account at all.
## The three identifiers
FreshBooks hands out three non-interchangeable ids, and using the wrong one returns a bare
**404** that reads like a missing record:
| Identifier | Used by |
| --- | --- |
| `accountId` (alphanumeric) | `/accounting/account/…`, `/payments/account/…` |
| `businessId` (integer) | `/projects/business/…`, `/timetracking/business/…` |
| `businessUuid` (UUID) | `/accounting/businesses/…` |
Call `freshbooks_get_identity` first. Full API notes, including the four different error
envelopes, are in [`docs/FRESHBOOKS-API.md`](docs/FRESHBOOKS-API.md).
## Shell access without the server
[`skills/freshbooks-curl`](skills/freshbooks-curl/SKILL.md) covers the same API from a
shell with `curl` + `jq`, including the OAuth bootstrap and rotation-safe token handling.
## Development
```sh
npm install
npm run build
npm test
```
## License
MIT
TDQS
Scored across 34 tools
Tools are organized by resource and action, and most are clearly distinct. The main ambiguity is the generic list_records/get_record pair, which can also target resources that have dedicated list/get tools, so an agent might occasionally pick the generic reader instead of the typed one.
All tools share the freshbooks_ prefix and use snake_case with a mostly verb_noun pattern (list_clients, get_invoice, create_project). Minor outliers like freshbooks_auth_url, freshbooks_healthcheck, and freshbooks_auth_exchange are noun-ish rather than verb-first, but the overall scheme remains predictable.
34 tools is on the heavy side for an MCP server, and the generic list_records/get_record pair duplicates much of the typed read surface. However, FreshBooks has several distinct resource domains (clients, invoices, estimates, expenses, projects, time, items, payments), so the count is not completely unjustified.
The surface has notable gaps: there is no create_estimate, no update or delete for clients/expenses/projects/time entries, no invoice send/delete action, and items are read-only. These missing operations will cause real agent failures when trying to execute common accounting workflows.