Freshbooks MCP README

The full upstream README, mirrored for reference. Install config, tool schemas, adoption signals and our own overview live on the Freshbooks MCP listing page.

freshbooks-mcp

MCP server for FreshBooks — 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

Terminal
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 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

ToolPurpose
freshbooks_get_identityResolve accountId / businessId / businessUuid
freshbooks_auth_urlGet the consent URL to authorize this connection
freshbooks_auth_exchangeComplete authorization; save tokens privately in local sessions or return the refresh token to the hosted auth flow
freshbooks_healthcheckVerify the OAuth credential and FreshBooks reachability; distinguishes "no credential" from "rejected" from "FreshBooks is down"
freshbooks_list_invoices / freshbooks_get_invoiceBrowse and fetch invoices
freshbooks_list_clients / freshbooks_get_clientBrowse and fetch clients
freshbooks_list_estimates / freshbooks_get_estimateBrowse and fetch estimates
freshbooks_list_payments / freshbooks_get_paymentBrowse and fetch payments
freshbooks_list_items / freshbooks_get_itemBrowse and fetch catalogue items
freshbooks_create_clientCreate a client — confirmation-gated
freshbooks_create_invoiceCreate an invoice — confirmation-gated
freshbooks_update_invoiceUpdate an invoice — confirmation-gated
freshbooks_record_paymentRecord a payment against an invoice — confirmation-gated
freshbooks_accept_estimateAccept an estimate (action_accept) — confirmation-gated, idempotent
freshbooks_update_estimateUpdate an estimate's lines, notes, terms, presentation — confirmation-gated
freshbooks_send_estimateEmail an estimate to the client (action_email) — confirmation-gated
freshbooks_decline_estimateAlways fails: FreshBooks has no decline. Answers with the alternatives
freshbooks_list_expenses / freshbooks_get_expenseBrowse and fetch expenses
freshbooks_list_expense_categoriesCategories supplying categoryid for new expenses
freshbooks_create_expenseRecord an expense — confirmation-gated
freshbooks_list_projects / freshbooks_get_projectProjects (businessId-keyed)
freshbooks_create_projectCreate a project — confirmation-gated
freshbooks_list_time_entriesTracked time, with total_logged / total_unbilled
freshbooks_create_time_entryLog time in seconds — confirmation-gated
freshbooks_list_servicesBillable work types for projects and time entries
freshbooks_list_records / freshbooks_get_recordGeneric 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

variabledefault
MCP_CONFIRM_MODEask-userWhat 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_ELICITATIONonoff 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_SECONDS600How long a token stays valid.
MCP_CONFIRM_SECRETrandom per processSigning 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 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:

IdentifierUsed 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.

Shell access without the server

skills/freshbooks-curl covers the same API from a shell with curl + jq, including the OAuth bootstrap and rotation-safe token handling.

Development

Terminal
npm install
npm run build
npm test

License

MIT