Skip to main content
Glama
freshbooks-mcp

freshbooks-mcp

by chrischall
README.md
# 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

A3.6/5.0

Scored across 34 tools

Disambiguation4/5

Each dedicated tool targets a distinct resource+action, and the freshbooks_ prefix plus resource noun makes per-resource selection straightforward. The main fuzziness comes from the generic get_record/list_records overlapping with the dedicated getters/lists, and from decline_estimate being a deliberately failing tool, but the descriptions call both out explicitly.

Naming Consistency5/5

Every tool follows the same freshbooks_verb_noun pattern (list_invoices, get_invoice, create_invoice, update_invoice, record_payment), with singular nouns for single-record operations and plural for collections. Even the odd-man-out (decline_estimate) fits the verb_noun convention.

Tool Count3/5

34 tools is heavy for what is essentially one accounting domain, and some of the load is redundancy: generic get_record/list_records duplicate the per-resource readers, and decline_estimate exists solely to return an error. The breadth (invoices, estimates, clients, payments, items, expenses, projects, time, services, auth) does justify a large surface, so it is borderline rather than excessive.

Completeness3/5

Read coverage is strong and the invoice/estimate lifecycles are well served (create, update, send, accept), but there is no delete anywhere and no update for clients, expenses, projects, time entries, or line-item edits beyond update_estimate. Agents can read most things via get_record/list_records, but write workflows beyond invoices and estimates will dead-end.

Maintenance

ActivityActive
ResponsivenessSyncing