Listed from the MCP registry; nobody on Odel publishes it.
FreshBooks invoicing for Claude — invoices, clients, estimates, and payments
Runs on your machine from its package; the steps are below.
At most: moves money, runs code or commands, deletes data, or sends messages for you.
Odel charges nothing per call. The publisher may charge for its own service.
Runs locally over stdio
This server isn't hosted — your MCP client launches it from a package registry. Use one of the commands below, or drop the config into your client (e.g. Claude Desktop).
Run
npx -y @chrischall/freshbooks-mcp
MCP client config
{
"mcpServers": {
"freshbooks": {
"command": "npx",
"args": [
"-y",
"@chrischall/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.
npm install -g @chrischall/freshbooks-mcp
FreshBooks is OAuth2 only — there is no API key and no personal access token, so a one-time browser authorization is required.
https://localhost works and never needs to resolve.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.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.skills/freshbooks-curl print tokens.
Run them only in a private terminal, never in an agent's captured terminal.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.
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:
freshbooks-curl
skill keep separate state files on purpose; sharing one makes them spend each other's
tokens and locks both out.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.
| 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.
| 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. |
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.
changed: false and no write is sent — acceptance cannot be undone through the API, so
a repeat call must not re-fire it.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.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.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.
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.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.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.
skills/freshbooks-curl covers the same API from a
shell with curl + jq, including the OAuth bootstrap and rotation-safe token handling.
npm install
npm run build
npm test
MIT