Skip to main content
Glama
chrischall

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

B3.4/5.0

Scored across 34 tools

Disambiguation4/5

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.

Naming Consistency5/5

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.

Tool Count2/5

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.

Completeness2/5

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.

Maintenance

ActivityActive
ResponsivenessResponsive