360 Secure SkillHub
360 安全技能中心

freshbooks-mcp

核心解决FreshBooks两大接入痛点:单次使用的刷新令牌轮换持久化、三类不通用业务ID的匹配问题,封装令牌管理、ID解析、自动带鉴权的curl请求方法,无需部署freshbooks-mcp服务,即可在Shell或脚本中读写发票、客户、付款、支出、项目、工时等业务数据,适合未安装MCP的机器。请求透传curl参数,返回JSON可配合jq处理,附排障方案与调用示例。

chrischall
chrischall
热度 62.77
最近更新 2026-10-10 07:27:49
分类:开发依赖:代码执行 / 第三方 API / 读写文件应用场景:API 集成 / 自动化脚本 / 财务 / 数据处理

安装技能

复制下面的话术发给你的 Agent,它会通过 secure-skills 检索并安装该技能。

执行命令安装此 skill: npx secure-skills-cli add freshbooks-mcp
Metadata
name
freshbooks-curl
description
Query FreshBooks (invoices, clients, estimates, payments, expenses, projects, time tracking) from a shell with curl and a rotating OAuth token. Use when you want FreshBooks data without running the freshbooks-mcp server, in a script, or on a machine where the MCP isn't installed.

FreshBooks from the shell

FreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in tab, no fpx. Authentication is OAuth2 only: there is no API key and no personal access token, so a one-time browser authorize flow is unavoidable.

The two things that break naive clients

1. Refresh tokens are single-use and rotate. Every refresh returns a new refresh token and kills the one you used. If the replacement is not written to disk before the process ends, the account is locked out and the only recovery is re-running the browser flow. Never hand-roll the refresh — use fb_access_token from references/fb-token.sh, which persists the rotation before returning, and never point two tools at the same state file.

2. Three identifiers that are not interchangeable. accountId (alphanumeric, e.g. xZNQ1X), businessId (integer), businessUuid. Each URL family takes a different one and answers a mismatch with a bare 404 that reads like a missing record. Always resolve first with fb_ids.

Family Path Identifier
Accounting /accounting/account/{accountId}/… accountId
Payments /payments/account/{accountId}/… accountId
Accounting (business) /accounting/businesses/{businessUuid}/… businessUuid
Projects /projects/business/{businessId}/… businessId
Time tracking /timetracking/business/{businessId}/… businessId

One-time setup

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. Put FRESHBOOKS_CLIENT_ID and FRESHBOOKS_CLIENT_SECRET somewhere loadable (e.g. ~/.secrets).

Then run the authorize flow once:

set -a; . ~/.secrets; set +a

# 1. Print the authorize URL, open it, click Allow.
node references/fb-bootstrap.mjs url "$FRESHBOOKS_CLIENT_ID"

# 2. The browser fails to load https://localhost?code=… — that is expected.
#    Copy the whole URL from the address bar and exchange it (the code is single-use
#    and expires within minutes).
node references/fb-bootstrap.mjs exchange "$FRESHBOOKS_CLIENT_ID" "$FRESHBOOKS_CLIENT_SECRET" \
  https://localhost 'https://localhost?code=PASTE_HERE'

Save the printed refresh_token as FRESHBOOKS_REFRESH_TOKEN. It seeds the state file on first use; after that the state file is the source of truth and the env value is ignored until you re-bootstrap.

Core call pattern

set -a; . ~/.secrets; set +a
. references/fb-token.sh

fb_ids                                    # resolve accountId / businessId / businessUuid
ACCT=$(fb_account_id)

fb_curl "/accounting/account/$ACCT/invoices/invoices?per_page=5" \
  | jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'

fb_curl <path> [curl args…] attaches the bearer token and Api-Version: alpha; everything after the path is passed to curl, so writes work too.

Response envelopes differ per family

  • Accounting / events → .response.result.<name>, errors at .response.errors[].message
  • Accounting-business → .data, errors at .errors.message + .errors.details[].reason
  • Projects / time tracking / comments → bare object, errors at .error
  • Payments → errors at .errors.details[].field

A single jq path will not work across families — see references/recipes.md.

Writes

Accounting writes wrap the payload in the singular resource name:

fb_curl "/accounting/account/$ACCT/invoices/invoices" \
  -X POST -H 'Content-Type: application/json' \
  -d '{"invoice":{"customerid":123,"create_date":"2026-08-12","lines":[]}}'

Money is {"amount":"150.00","code":"USD"} — the amount is a string, not a number.

Deletes on the accounting family are frequently soft deletes via vis_state on an update rather than HTTP DELETE. Confirm per resource before assuming.

A 200 is not proof a write persisted — re-read the record to confirm.

Troubleshooting

Symptom Cause
invalid_client on refresh The refresh grant needs client_secret and redirect_uri, form-encoded — not JSON
invalid_grant on refresh The token was already spent. Re-run the bootstrap
404 on a valid-looking record Wrong identifier for that URL family — run fb_ids
401 on every call Access token stale and refresh failing; check the state file

See references/recipes.md for ready-to-run request bodies and jq recipes.