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. Complete the one-time authorization:
- **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 **whole** URL you land on to
`freshbooks_auth_exchange`. Its `state` must match a consent URL the server
issued (valid 15 minutes, single-use), so a bare code, or a redirect URL from
anyone else's authorization, is refused before its code is spent. Local
sessions save the tokens directly to `FRESHBOOKS_TOKEN_STORE` and return only
a success message. Restart the local MCP server after that message. No token
needs to be copied into chat or configuration. Keep the same app credentials
and store path after restart.
- **Hosted connections:** mcp-host's `authFlow` still captures the refresh
token from the exchange response and supplies it through its secret store.
`MCP_DATA_DIR` identifies this hosted environment. Do not set it for a local
coding-agent connection: hosted mode intentionally returns the token.
Issued states are kept as digests under `MCP_DATA_DIR`, so a child that is
respawned while the person approves can still complete the login.
**The registration's flow must show the person the `authorize_url` that
`freshbooks_auth_url` returns.** That URL carries the one-time `state`,
and the exchange refuses any redirect without it. A static consent link
typed into a step description has no `state`, so every new login or
repair through it fails. Carry `authorize_url` from the
`freshbooks_auth_url` step and show it with a step link
(`link: {from: 'authorize_url', hosts: ['auth.freshbooks.com']}`, mcp-host
2.30.0 or later) on the step that prompts. That prompt must collect the
**whole** redirect URL, not just its code. Make this change before a
release with state checking reaches the hosted pin. Existing connections
keep refreshing; only new logins and repairs depend on it.
- **Legacy/manual setup:** the scripts in
[`skills/freshbooks-curl`](skills/freshbooks-curl/SKILL.md) print tokens.
Run them only in a private terminal, never in an agent's captured terminal.
4. Configure:
```sh
FRESHBOOKS_CLIENT_ID=...
FRESHBOOKS_CLIENT_SECRET=...
FRESHBOOKS_REFRESH_TOKEN=... # optional locally; if set during reauth, keep it set
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
FRESHBOOKS_REQUEST_TIMEOUT_MS=30000 # optional; per-request deadline for API calls
```
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.
The login flow also writes a small file of pending consent `state` digests
(mode `0600`, entries expire after 15 minutes). Locally it sits beside the token
store, at `<FRESHBOOKS_TOKEN_STORE>.oauth-state.json` (by default
`~/.freshbooks-mcp/session.json.oauth-state.json`). Hosted, it is
`$MCP_DATA_DIR/freshbooks-oauth-state.json`. It is created on the first
`freshbooks_auth_url` call, even if the login is never finished, and holds no
tokens.
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.
Local sign-in without an environment token binds the private store to the app's
client ID, secret, and redirect URI. Changing those requires a new sign-in.
For existing environment-token setups, the stored rotation still takes precedence;
changing `FRESHBOOKS_REFRESH_TOKEN` to a new value starts a new token chain.
If you reauthorize locally while `FRESHBOOKS_REFRESH_TOKEN` is set, keep that same
value configured: the saved login is bound to it. To remove the variable, first
unset it and complete local sign-in again so the store is bound to the app instead.
The local authorization exchange and save use the same lock as refreshes. A refresh
writes only while it holds that lock, so it cannot overwrite a newer login after
releasing the lock.
Restart every local server using the store after reauthorization.
## Tools
| Tool | Purpose |
| --- | --- |
| `freshbooks_get_identity` | Resolve accountId / businessId / businessUuid |
| `freshbooks_auth_url` | Get the consent URL to authorize this connection |
| `freshbooks_auth_exchange` | Complete authorization; save tokens privately in local sessions or return the refresh token to the hosted auth flow |
| `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, unless `MCP_CONFIRM_ELICITATION=off`. An unrecognised value is treated as `refuse`. |
| `MCP_CONFIRM_ELICITATION` | `on` | `off` never shows a confirmation prompt, so every client gets the `MCP_CONFIRM_MODE` behaviour. Set it for a client that says it can show prompts but never does (the write hangs — opencode 2.0.x). Any other value is treated as `on`, with a warning on stderr. |
| `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
Most tools have clearly distinct resource+action purposes, and the auth/healthcheck tools are unambiguous. However, generic list_records/get_record explicitly overlap with dedicated tools like list_invoices/get_invoice, and decline_estimate is an intentional always-fail tool that could be mistaken for a real action. These overlaps are described but still create some selection ambiguity.
All tools use a consistent freshbooks_ snake_case prefix followed by a verb_noun pattern (list_invoices, get_client, create_expense, update_estimate) or a clear noun/verb phrase for auth. There is no mixing of camelCase or different naming styles.
With 34 tools, the set is heavy for the apparent scope, exceeding the 25+ threshold that indicates too many tools. Many list/get pairs and the generic list_records/get_record duplicate functionality that could be consolidated, making the surface larger than necessary.
There are significant gaps: no create_estimate (yet send/accept/update estimates exist), no get_time_entry, and no update or delete operations for clients, expenses, projects, invoices, estimates, or time entries. These omissions will block common accounting workflows that require modifying or removing records.