Skip to main content
Glama
WYRE-AI

QuickBooks Online MCP Server

by WYRE-AI
README.md
# QuickBooks Online MCP Server

Model Context Protocol (MCP) server for the [QuickBooks Online Accounting API](https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/account). Exposes 130+ tools across 22 QBO entities plus 10 financial reports for Claude and other MCP-compatible clients.

## Features

- **Interactive invoice card (MCP Apps, SEP-1865)**: `qbo_invoices_get` renders as a read-only interactive card in MCP Apps hosts (Claude Desktop/web) — customer, status, dates, line items, totals — neutral by default, brandable via `window.__BRAND__` injection or `MCP_BRAND_*` env vars. Non-App hosts see the same JSON payload (plus a `_card` field).

## One-Click Deployment

[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/WYRE-AI/qbo-mcp/tree/main)

[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/WYRE-AI/qbo-mcp)

> **Note on registry auth:** This server depends only on public npm packages, so the Cloudflare and DigitalOcean cloud builders install its dependencies anonymously — no token is required for one-click deploy. (If a future release adds a private `@wyre-ai/*` dependency, you would supply a GitHub PAT with `read:packages` as a build variable — `NODE_AUTH_TOKEN` for Cloudflare Workers, a build-time `GITHUB_TOKEN` secret for DigitalOcean.)
>
> **Installing the published package:** The released package is published to the [GitHub Packages](https://github.com/WYRE-AI/qbo-mcp/pkgs/npm/qbo-mcp) npm registry, which requires authentication on every install (even for public packages). To install it, authenticate npm to `npm.pkg.github.com` with a GitHub PAT that has `read:packages`:
>
> ```bash
> export NODE_AUTH_TOKEN=$(gh auth token)
> npm install @wyre-ai/qbo-mcp
> ```

## Quick Start

### Prerequisites

- Node.js >= 20
- QuickBooks Online OAuth2 app credentials (requires an Intuit developer account)

### Install and Build

```bash
npm install
npm run build
```

### Run (stdio mode)

```bash
QBO_ACCESS_TOKEN=your-access-token QBO_REALM_ID=your-realm-id npm start
```

### Run (HTTP mode)

HTTP refuses to start until `CONDUIT_S2S_SECRET` is set. The listener defaults to `127.0.0.1:8080`.

```bash
CONDUIT_S2S_SECRET="$CONDUIT_S2S_SECRET" \
  MCP_TRANSPORT=http \
  QBO_ACCESS_TOKEN=your-access-token \
  QBO_REALM_ID=your-realm-id \
  npm start
```

Local HTTP without a secret is development-only. It binds `127.0.0.1` only and logs a warning. A non-loopback `MCP_HTTP_HOST` is ignored and the override is logged:

```bash
MCP_TRANSPORT=http MCP_ALLOW_INSECURE_DEV=1 \
  QBO_ACCESS_TOKEN=your-access-token \
  QBO_REALM_ID=your-realm-id \
  npm start
```

### Docker

The image listens on `0.0.0.0` inside the container (`MCP_HTTP_HOST`) and defaults to `AUTH_MODE=gateway`. Publish the port on loopback and pass a real S2S secret. In gateway mode the access token and realm id arrive on each request as `X-Qbo-Access-Token` and `X-Qbo-Realm-Id`; they are not read from the environment.

```bash
# Generate the secret first: openssl rand -hex 32
docker build -t qbo-mcp .
docker run \
  -e CONDUIT_S2S_SECRET \
  -p 127.0.0.1:8080:8080 \
  qbo-mcp
```

Compose refuses to start when `CONDUIT_S2S_SECRET` is unset, defaults `AUTH_MODE` to `gateway`, does not inject `QBO_ACCESS_TOKEN` or `QBO_REALM_ID`, and publishes `127.0.0.1:8080:8080`:

```bash
CONDUIT_S2S_SECRET="$CONDUIT_S2S_SECRET" docker compose up
```

Single-tenant HTTP (`AUTH_MODE=env`) still requires the S2S secret, then uses `QBO_ACCESS_TOKEN` and `QBO_REALM_ID`:

```bash
docker run \
  -e CONDUIT_S2S_SECRET \
  -e AUTH_MODE=env \
  -e QBO_ACCESS_TOKEN=your-access-token \
  -e QBO_REALM_ID=your-realm-id \
  -p 127.0.0.1:8080:8080 \
  qbo-mcp
```

## Environment Variables

| Variable | Required | Default | Description |
|---|---|---|---|
| `QBO_ACCESS_TOKEN` | For stdio and `AUTH_MODE=env` | — | QuickBooks Online OAuth2 access token. The Compose gateway service does not load this variable. |
| `QBO_REALM_ID` | For stdio and `AUTH_MODE=env` | — | QuickBooks Online company (realm) ID. The Compose gateway service does not load this variable. |
| `QBO_ENV` | No | `production` | API environment: `production` or `sandbox` |
| `QBO_CREDENTIALS_FILE` | No | — | Path to a dotenv-format file re-read on every request; its `QBO_ACCESS_TOKEN` / `QBO_REALM_ID` / `QBO_ENV` override the environment variables (see [Token rotation](#token-rotation-env-mode)). Not used by the gateway Compose service. |
| `MCP_TRANSPORT` | No | `stdio` | Transport type: `stdio` or `http` |
| `MCP_HTTP_PORT` | No | `8080` | HTTP server port |
| `MCP_HTTP_HOST` | No | `127.0.0.1` | Bind address when `MCP_TRANSPORT=http`. A configured `CONDUIT_S2S_SECRET` honors this value. The container image sets `0.0.0.0`. Without a secret, `MCP_ALLOW_INSECURE_DEV=1` binds `127.0.0.1` only. |
| `AUTH_MODE` | No | `env` (image and Compose: `gateway`) | `gateway` or `env`. Gateway mode requires `X-Qbo-Access-Token` and `X-Qbo-Realm-Id` on each `/mcp` request and never falls back to `QBO_*`. `env` uses those variables only after S2S auth succeeds. |
| `CONDUIT_S2S_SECRET` | Yes for HTTP | — | Service-to-service secret. Generate with `openssl rand -hex 32`. The HTTP server logs an error and exits non-zero when this is empty or a known placeholder (`changeme`, `your-s2s-secret`, `secret`, `placeholder`, and similar public examples). Compose will not start without it. Never commit a real value. The value is never logged. |
| `MCP_ALLOW_INSECURE_DEV` | No | — | Set to exactly `1` only for local development to start HTTP without `CONDUIT_S2S_SECRET`. The process logs a warning, does not enforce `X-Gateway-S2S`, and forces the listener onto `127.0.0.1` even if `MCP_HTTP_HOST` is a wider address. A known placeholder secret is still refused. |
| `MCP_BRAND_NAME` | No | — | Brand name shown on the MCP Apps invoice card (card is neutral when unset) |
| `MCP_BRAND_LOGO_URL` | No | — | Logo URL for the invoice card |
| `MCP_BRAND_PRIMARY_COLOR` | No | `#2563eb` | Invoice card primary color |
| `MCP_BRAND_ACCENT_COLOR` | No | `#e5e7eb` | Invoice card accent color |
| `MCP_BRAND_BG` | No | `#ffffff` | Invoice card background color |
| `MCP_BRAND_TEXT` | No | `#333333` | Invoice card text color |

## Authentication

The server does not handle the OAuth flow — it consumes a pre-obtained access token. Two modes:

**env mode (process default).** Token comes from `QBO_ACCESS_TOKEN` (or from the file named by `QBO_CREDENTIALS_FILE`, which wins when both are set). Single tenant. The Docker image and Compose file default to gateway mode instead.

**gateway mode.** Token comes from per-request HTTP headers, isolated through `AsyncLocalStorage` so concurrent requests never share credentials. Set `AUTH_MODE=gateway` and send `X-Qbo-Access-Token` and `X-Qbo-Realm-Id`. A request missing either header gets HTTP 401 and does not fall back to `QBO_ACCESS_TOKEN` / `QBO_REALM_ID` in the process environment.

| Header | Required | Description |
|---|---|---|
| `X-Qbo-Access-Token` | Yes | OAuth2 access token |
| `X-Qbo-Realm-Id` | Yes | Company (realm) ID |
| `X-Qbo-Environment` | No | `production` or `sandbox` (defaults to `production`) |

`/health` stays unauthenticated and does not read credentials. The stdio transport does not use `CONDUIT_S2S_SECRET`.

### HTTP authentication

With `MCP_TRANSPORT=http` and `CONDUIT_S2S_SECRET` set, every `/mcp` request must include `X-Gateway-S2S`. The check is `verifyS2sHeader` in [`src/s2s-verify.ts`](src/s2s-verify.ts): the header value is `t=<unix seconds>,v1=<hex>`, where the hex is HMAC-SHA256 of the literal string `t=<unix seconds>` keyed with `CONDUIT_S2S_SECRET` (64 lowercase hex characters). The timestamp must be within 300 seconds of the server clock. A missing or invalid header returns HTTP 401 before vendor credentials are read.

```bash
TS=$(date +%s)
SIG=$(printf 't=%s' "$TS" | openssl dgst -sha256 -hmac "$CONDUIT_S2S_SECRET" -hex | awk '{print $NF}')
curl -sS http://127.0.0.1:8080/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H "X-Gateway-S2S: t=${TS},v1=${SIG}" \
  -H "X-Qbo-Access-Token: $QBO_ACCESS_TOKEN" \
  -H "X-Qbo-Realm-Id: $QBO_REALM_ID" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

In `AUTH_MODE=gateway`, those `X-Qbo-*` headers are the only credential source for the request. With `AUTH_MODE=env`, the signed request uses `QBO_ACCESS_TOKEN` and `QBO_REALM_ID` from the environment when the headers are absent.

When QBO rejects the access token, the server returns an MCP error whose text begins with the literal prefix `QBO_UNAUTHORIZED:`. The intended contract is that the gateway detects this prefix, refreshes the OAuth token, and retries the request.

### Token rotation (env mode)

QBO access tokens expire after ~60 minutes, so env-mode deployments typically rotate them with a cron job. **A rotated token in a Docker `env_file` never reaches a running container**: Docker injects `env_file` only at container *creation*, so `docker restart` keeps the old environment and the refresh loop silently becomes a no-op until calls start failing with `QBO_UNAUTHORIZED` / `Token revoked` (#63).

Set `QBO_CREDENTIALS_FILE` to skip environment reinjection entirely. The server re-reads the file on every request, so a rotation takes effect immediately — no restart or recreate at all:

```yaml
# Single-tenant HTTP. This is separate from the gateway Compose service,
# which does not set QBO_* credentials.
services:
  qbo-mcp:
    image: ghcr.io/wyre-ai/qbo-mcp
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      MCP_TRANSPORT: http
      AUTH_MODE: env
      MCP_HTTP_HOST: 0.0.0.0
      CONDUIT_S2S_SECRET: ${CONDUIT_S2S_SECRET:?set CONDUIT_S2S_SECRET}
      QBO_CREDENTIALS_FILE: /secrets/qbo.env
    volumes:
      - ./secrets:/secrets:ro   # mount the DIRECTORY, not the file
```

Your refresh job then just rewrites `./secrets/qbo.env` (dotenv format: `QBO_ACCESS_TOKEN=...`, optionally `QBO_REALM_ID=...` and `QBO_ENV=...`) and is done — drop the `docker restart` from the script. Mount the containing directory rather than the file itself: tools like `sed -i` replace the file's inode, and a single-file bind mount would keep pointing at the old one. If the file is missing or unreadable, tool calls fail loudly instead of silently falling back to a stale environment token.

If you'd rather keep plain `env_file` injection, the rotation script must recreate the container — `docker compose up -d --force-recreate` — a `docker restart` is never enough.

## Sandbox Testing

Set `QBO_ENV=sandbox` (env mode) or `X-Qbo-Environment: sandbox` (gateway mode) to target Intuit's sandbox API at `https://sandbox-quickbooks.api.intuit.com` instead of production. Unrecognized values fail loudly (no silent fallback to production).

## Available Tools

Tools are organized by domain. Call `qbo_navigate` with a domain name (e.g. `customers`, `vendors`, `bills`) to discover the tools in that domain. All tools are always callable — navigation is a discovery aid, not a prerequisite.

### Entities (config-driven, 122 tools across 22 entities)

Each entity exposes some subset of `list`, `get`, `create`, `update`, `void`, `search`. Transactional entities support `startDate`/`endDate` filtering on the list operation. Updates are sparse and require the current `SyncToken` from a prior get. Void is exposed only where the QBO Accounting API supports it (Invoice uses `?operation=void`; Payment uses `?operation=update&include=void`). Customer has no void or delete — set `Active` to false on update to deactivate.

**Sales workflow**
- `qbo_customers_*` — list, get, create, update, search (no void; deactivate with `Active: false`)
- `qbo_invoices_*` — list (Paid/Unpaid/Overdue status filter), get, create, update, void, send
- `qbo_estimates_*` — list, get, create, update
- `qbo_sales_receipts_*` — list, get, create, update
- `qbo_credit_memos_*` — list, get, create, update
- `qbo_refund_receipts_*` — list, get, create, update
- `qbo_payments_*` — list, get, create, update, void

**Purchase workflow**
- `qbo_vendors_*` — list, get, create, update, search
- `qbo_bills_*` — list, get, create, update, search
- `qbo_bill_payments_*` — list, get, create, update
- `qbo_vendor_credits_*` — list, get, create, update
- `qbo_purchases_*` — list, get, create, update (point-of-sale expenses)
- `qbo_purchase_orders_*` — list, get, create, update

**Bank & money movement**
- `qbo_deposits_*` — list, get, create, update
- `qbo_transfers_*` — list, get, create, update
- `qbo_journal_entries_*` — list, get, create, update (balanced debit/credit)

**Products & accounts**
- `qbo_items_*` — list, get, create, update, search (products and services)
- `qbo_accounts_*` — list, get, create, update, search (chart of accounts)

**Classification & terms**
- `qbo_classes_*` — list, get, create, update, search
- `qbo_departments_*` — list, get, create, update, search
- `qbo_terms_*` — list, get, create, update, search (Net 30, etc.)
- `qbo_payment_methods_*` — list, get, create, update, search

**Tax & company**
- `qbo_tax_codes_*` — list, get, search (read-only)
- `qbo_tax_rates_*` — list, get, search (read-only)
- `qbo_company_info_*` — list, get (read-only singleton)

**People & time**
- `qbo_employees_*` — list, get, create, update, search
- `qbo_time_activities_*` — list, get, create, update (billable time)

**Attachments**
- `qbo_attachables_*` — list, get, create, update (metadata only; file upload uses a separate QBO endpoint)

### Reports (10 tools)

- `qbo_reports_profit_and_loss`
- `qbo_reports_balance_sheet`
- `qbo_reports_cash_flow`
- `qbo_reports_trial_balance`
- `qbo_reports_general_ledger`
- `qbo_reports_aged_receivables`
- `qbo_reports_aged_payables`
- `qbo_reports_customer_sales`
- `qbo_reports_customer_balance`
- `qbo_reports_vendor_expenses`

### Legacy expense tools (backwards compatibility)

`qbo_expenses_list_purchases`, `qbo_expenses_get_purchase`, `qbo_expenses_list_bills`, `qbo_expenses_get_bill` remain available. New work should use the dedicated `qbo_purchases_*` and `qbo_bills_*` tool families, which add create/update/search.

## Testing

```bash
npm test                   # unit suite — fast, no credentials needed
npm run test:integration   # hits a real QBO sandbox; skipped without creds
```

The integration suite calls one read tool per entity tier (customers, vendors, accounts, items, journal entries, company info) against Intuit's sandbox API. It only runs when both `QBO_SANDBOX_ACCESS_TOKEN` and `QBO_SANDBOX_REALM_ID` are present in the environment. CI wires these from the matching repo secrets and skips the job (with a clear notice) when they're absent — so dependabot/fork PRs don't fail.

## License

Apache-2.0

TDQS

B3.2/5.0

Scored across 138 tools

Disambiguation4/5

Most tools target a clearly distinct resource+action combination, and the per-entity CRUD/search patterns are easy to tell apart. However, the qbo_expenses_* tools (list_purchases, get_purchase, get_bill) overlap with the standalone qbo_purchases_* and qbo_bills_* tools, creating genuine ambiguity about which to call. This is a limited but real boundary problem across an otherwise well-separated set.

Naming Consistency5/5

Every tool uses the same qbo_<entity>_<action> snake_case convention with predictable verbs (list, get, create, update, search, void, send, upload). Deviations like qbo_expenses_list_purchases are still readable and follow the same overall scheme. No mixed conventions or camelCase are present.

Tool Count1/5

With 138 tools this is far beyond any practical agent-facing surface and falls into the extreme-mismatch band. Even though QuickBooks has many entities, exposing every CRUD permutation individually is unwieldy and defeats discoverability. A domain-grouped or query-style interface would be far more appropriate.

Completeness4/5

Coverage is broad across sales, purchases, banking, lists, and reports, including less common entities like transfers, time activities, and attachables. Gaps remain: no general delete operations (only void for invoices/payments), no QBO query/batch endpoint, and no send/void for several document types. These are minor workarounds rather than dead ends.

Maintenance

ActivityActive
ResponsivenessUnresponsive