jobber-mcp
# PineappleCare Jobber MCP
An approval-gated MCP connector for Jobber, maintained by PineappleCare for
Williams Solutions. It is an MIT-licensed derivative of
[Adeocode's Jobber MCP](https://github.com/adeocode/jobber-mcp); upstream
attribution remains in [LICENSE](LICENSE).
## Safety model
- `JOBBER_READ_ONLY=true` is the default and registers no write tools.
- Writes require both `JOBBER_READ_ONLY=false` and a deliberate
`JOBBER_WRITE_CAPABILITIES` allowlist: `records`, `scheduling`, and/or
`communications`.
- Every write requires `confirm_write: true`, uses a fixed reviewed API operation
or browser script, is audited with sensitive values hashed, and is never retried
after an ambiguous result.
- The server never exposes raw GraphQL, card charging, refund, delete, archive,
bulk-mutation, webhook, expense, or timesheet mutation tools.
The MCP host must require interactive approval for every write tool.
## Tools
The original reporting tools remain: client search/history, overdue invoices,
outstanding quotes, job and revenue summaries, schedule lookup, requests inbox,
and audit-log access.
Foundation reads are `search_records`, `get_record`, `catalog_search`, and
`team_list`. They cover clients, properties, requests, quotes, jobs, invoices,
visits, products/services, and assignable users.
`list_timesheet_users`, `list_time_entries`, and `get_time_entry` read native
employee labour entries. The roster includes active employees and deactivated
employees with entries in the selected dates. The native roster accepts 1–7
inclusive days; entry lists accept 1–31 inclusive days;
entry dates default to `America/Toronto`. Entry selection uses the start date,
not overlap: an overnight entry starting before the range is excluded, and a
matching entry's full recorded duration is returned without clipping per day.
Use `finalDuration`, not scheduled visit duration or a job note. Running entries
return `recorded_duration_seconds: null`, never a misleading zero or estimated
payroll total. An optional employee ID is sent as a native filter; an optional
job ID is checked against this account and applied to each returned page.
Continue through `next_cursor` even when a job-filtered page has no matches.
No page alone establishes a complete total. Pay rates and time-entry writes,
approval, payment, deletion and timer control remain unavailable.
Timesheet reads require the app's Timesheets read scope and appropriate user
permissions/feature availability. A scope change can require reauthorization.
Permission denials are errors, not empty results or proof of a required plan
upgrade. Hermes grants these tools through Williams' existing read connection;
other company profiles and neutral default remain excluded.
Property map-pin API check (October 20, 2026): live `2025-04-16` and
`2026-09-25` schemas expose `Property.address.coordinates` for reads, but
`PropertyEditInput.address` accepts only postal address fields and no pin
coordinates. Coordinate inputs on billing/contact addresses are not a verified
way to edit a selected property's map pin. The optional browser extension below provides native pin editing without changing addresses. A link in a client note does not change a native pin.
`list_tasks` and `get_task` expose native tasks with schedules, assignments,
links and a content-derived `task_version`. The `scheduling` capability enables
approval-required `create_task` and `update_task` for non-recurring tasks. Use
explicit anytime (all-day), timed or unscheduled schedules; timezone defaults to
America/Toronto. Optional client/property links are checked against this account.
Assignment emails require `notify_team: true` (default false); they are not SMS
and delivery is not verified. Timed assigned tasks may request a supported team
reminder. Updates require `expected_task_version`, preserve omitted fields and
reject completed/recurring tasks. No task deletion, completion or recurrence
editing is exposed. Creation scans for identical tasks, blocks incomplete scans,
and never retries an ambiguous mutation. Returned IDs remain available when
readback fails or Jobber reports partial errors.
Job reads include `jobType` and billing information. `create_job` defaults to
`job_type: ONE_OFF`, with `FIXED_PRICE` / `ON_COMPLETION` billing. This native
combination was verified through an explicitly authorized internal live probe;
there is no explicit job-type field in Jobber's creation input. Other one-off
billing combinations remain unavailable until verified. Jobs start unscheduled,
without visits, assignments, team notification or review-request SMS. Create
explicitly requested visits separately. Several visits do not imply recurrence.
An explicit `RECURRING` choice requires explicit `VISIT_BASED` billing and a
non-periodic billing schedule; periodic scheduling is outside this tool.
The title is required for complete duplicate checking, bounded at 500 property
jobs; incomplete scans block mutation. Source quote/request relationships are
checked before creation. Only `outcome: created` confirms readback of the type,
billing, destination/source, title, instructions, line items and unscheduled state.
Partial or ambiguous responses preserve returned IDs and reconcile through reads
without repeating the mutation. Candidates found after a timeout are evidence
to review, not proof of successful creation. Never automatically convert, delete
or recreate a mismatched job. See [the creation contract](docs/ONE_OFF_CONTRACT.md).
`list_notes` returns paginated, versioned notes for clients, requests, quotes,
jobs, and invoices. With `records` enabled, `create_note` and `update_note`
create or edit text, pinned state, and supported record links after approval.
They do not delete notes or add/remove attachments. Note writes verify ownership,
version, and readback and never substitute a job or visit instructions field.
With `records` enabled, the server provides client, property, request, draft
quote, job, and draft-invoice creation/update tools. With `scheduling`, it
provides visit creation/update/completion and reviewed job close/reopen.
Visit schedules use an explicit `mode`: `anytime` for a date without a time,
`timed` for exact start/end times, or `unscheduled` for no date. Use
`create_visits` to create 1-20 reviewed visits for one job in one mutation;
the result reports and verifies each visit separately. With
`communications`, it provides `mark_quote_sent` and `mark_invoice_sent`.
These record an external send in Jobber; they do not deliver email. Use a
separate approved mail tool for customer delivery.
`create_draft_invoice` accepts signed, nonzero quantities and nonnegative unit
prices. A separate named discount uses quantity `-1` and unit price `150` to
deduct $150; never net that discount into a charge or its description. Present
all lines and taxability for approval. `subject` is required for the bounded
duplicate scan (up to 500 client invoices); an incomplete scan blocks creation.
`tax_mode` defaults to `hst_13`: resolve an account-owned flat 13% HST rate and
make omitted line taxability explicitly true, including negative discounts.
Include `tax_mode` in the approval request. Only an explicit operator request
uses `tax_mode: none`, which makes omitted taxability false and requires every
line to be non-taxable. Prefer a 0% account rate when available; otherwise retain
a verified flat account rate with zero taxable lines. `tax_rate_id` may
disambiguate existing rates, but cannot select an incompatible rate. Missing or
ambiguous rates block creation; no tax settings are changed. Quote and job creation still require
positive quantities. The invoice stays unsent, and creation does not collect a
payment. A fresh invoice read verifies signed quantities, prices, line totals,
descriptions, taxability/product links, destination, draft status and the exact
due-date instant (equivalent timestamp offsets are accepted). It also verifies
the rate, exclusive tax calculation, subtotal, tax, total, balance and absence
of unrequested invoice-level discounts, deposits, payments and tips.
Only `outcome: created` confirms successful verified creation. Other outcomes
retain any returned invoice ID and require reconciliation before a newly
approved write; creation is never retried automatically. The existing guard
against another invoice on the source job remains in place.
`update_job_line_item_descriptions` (records capability) accepts a job ID,
its reviewed `expected_updated_at`, and up to 20 existing line-item IDs with
exact replacement descriptions. It checks membership through the job's own
paginated line-item connection (bounded at 500 lines), checks the version again,
and sends only IDs and descriptions through `jobEditLineItems`. Readback verifies
the descriptions and unchanged financial fields. Concurrent changes cannot be
locked atomically by this API; any partial, changed, or ambiguous result requires
reconciliation and a new approval. The mutation is never automatically retried.
## Local development
```bash
npm ci
npm run build
npm test
```
Set `JOBBER_CLIENT_ID` and `JOBBER_CLIENT_SECRET`, then ask the MCP host to run
`authenticate`. OAuth tokens are AES-256-GCM encrypted under
`JOBBER_STATE_DIR`, or `~/.jobber-mcp/` when that variable is unset. Container
deployments must mount `JOBBER_STATE_DIR` on persistent storage and provide a
stable vault-supplied `ENCRYPTION_KEY`. Managed state fails closed without it;
keys supplied through the environment are never copied into OS Keychain. OAuth tokens and the audit log both live under that
directory. Never commit tokens, client secrets, or an `.env` file.
The included container image runs the authenticated streamable-HTTP transport.
Set `TRANSPORT=http`, `MCP_BASE_URL`, `MCP_API_KEY`, and `PORT`, and expose only
the `/mcp` endpoint to the intended MCP client. The image runs as an unprivileged
user and expects `JOBBER_STATE_DIR` to be a writable persistent mount.
`npm run schema:pull` downloads the currently configured Jobber GraphQL schema
using the encrypted local OAuth session and writes a versioned public schema
snapshot under `schema/`. Do not use Developer Center's Test in GraphiQL on a
live integration: Jobber says that flow invalidates its refresh token.
## Release checks
```bash
npm test
npm run lint
npm run build
npm run smoke
npm run schema:validate
npm run verify:version-sync
npm run verify:no-secrets
```
This repository is GitHub-source-only today. It deliberately has no MCP
registry `server.json` entry until the `@pineapplecare/jobber-mcp` package is
actually published.
## Private Williams voice API
The optional HTTP surface is separate from staff MCP tools and approvals. Enable
with `JOBBER_VOICE_ENABLED=true`, `JOBBER_VOICE_API_KEY` (at least 32 characters,
different from `MCP_API_KEY`), `JOBBER_VOICE_ACCOUNT_ID`, `JOBBER_STATE_DIR` and
vault-supplied `ENCRYPTION_KEY`. Requires Node 22.13 or newer (`node:sqlite`);
the pinned container runtime supports it. Keep the port on loopback/Tailscale,
never a public ingress. The voice credential cannot authenticate MCP requests.
`POST /voice/v1/execute` accepts these fixed actions with `call_id` and original
`caller_number`: `resolve`, `status`, `prepare`, `preflight`, `submit`, `message`,
`email_prepare`, `contact_email`, `operation_status`, and the server-only `end_call`. Ermine supplies identity from signed active inbound call state;
this is a trusted service interface, not a browser/customer endpoint. The
connector verifies the configured account before any action, and again before
external mutations. It never accepts arbitrary GraphQL or a tenant selector.
See [the Ermine release contract](https://github.com/PineappleCare/ermine/blob/master/docs/WILLIAMS_JOBBER_VOICE.md)
for the intake shape, deployment order and acceptance procedure.
`submit`/`message`/`contact_email` require caller confirmation and a UUID `operation_id`. Bind the
ID to the full immutable payload and call. The SQLite journal in
`JOBBER_STATE_DIR/voice-operations.db` records dispatch before every mutation,
returned IDs and verified readbacks. Retries return the recorded result. Uncertain
writes are reconciled through reads; verified steps may resume undispatched work.
Missing evidence remains uncertain for staff review, with no blind mutation replay.
This is not an exactly-once claim across network or process failures.
New work creates a Request and intake Note, then an Assessment only with a known
property. Assessments have no start/end time, assigned users or team notification.
Scheduling preferences remain in the Note. No Jobs, Visits, appointments, prices,
status changes or customer notifications are created by this surface. Optional
email, single names and incomplete locations are preserved without invented fields.
`GET /voice/v1/health` requires the voice bearer and returns aggregate operation
outcomes and directory refresh progress/failure classifications, without client
data. `scripts/voice-fixture.mjs` is a loopback-only fake
Jobber backend for Ermine's opt-in cross-repository test; it isolates all audit and
OAuth state from the developer's home directory and Keychain. Mock verification
and schema validation do not replace testing scopes and writes on a Jobber test account.
Voice HTTP dispatch returns completed results or durable pending IDs within two
seconds. A background worker resumes pending/uncertain operations from the journal;
safe prerequisite read failures remain pending instead of becoming permanent write
failures. Read-only resolve/status operations are also journaled and freshly
reauthorized when polled. Per-operation scan checkpoints preserve completed pages.
The complete, account-scoped SQLite phone directory refreshes in the background,
including property-level contacts. It provides discovery candidates only; phone,
record membership and property restrictions are reread before disclosure/writes.
An index miss never authorizes new-client creation: complete duplicate checks still
run before creation. `/voice/v1/health` includes directory completeness, refresh and
failure timestamps. All new tables are additive and must survive rollback.
Completed reads remain in the journal when polled. Delivery rechecks account,
caller phones, property restrictions and selected record membership; it does not
restart full discovery. Slow authorization has one in-flight check and a one-use
handoff expiring after two seconds. Pending replies include `retry_at`; callers
must poll promptly to consume a completed check. Expired checks and process
restarts require fresh authorization.
Schema rejection returns `failed` / `invalid_input` with `invalid_fields`.
`submission_rejected: true` is returned only when the operation ID never entered
the connector journal. Existing IDs cannot use that marker to bypass recovery.
Explicit mutation validation errors with no returned record are stored as
`rejected` steps and settle as failed/partial with error details. Transport loss,
missing responses and top-level execution errors retain uncertainty. Rejected
steps survive restarts and are never redispatched. Index account-check failures
are included in refresh health reporting.
Current Ermine submissions bind a ready preflight to the exact intake revision,
caller and selected destination. Corrections invalidate readiness. Assessments
default to false and require explicit on-site intent; propertyless Requests remain
disabled until their API behavior is verified. Phone-index candidates are freshly
authorized and returned for explicit destination selection without a directory
scan. An index miss stays unresolved; it never establishes a new customer.
Duplicate checks use a complete background generation from the last five minutes
as a baseline, then perform a fresh, resumable account census before a new-client
decision. Without that baseline, both passes are fresh. Every pass includes
property contacts independently of client timestamps. Client pages contain the
first contact and paginate all overflow contact, phone, email and property
connections; accounts with zero/one-contact clients no longer need one separate
HTTP request per client. The pinned API requested cost was measured as 885 for a
20-client page (900 reserved), with no writes during that measurement. Neither a
partial census nor a census older than ten minutes permits creation. Transient
background refresh failures resume after 15 seconds, preserving the last complete
index and saved progress. Worker leases, due times and
15-second to five-minute reconciliation backoff protect pending/uncertain writes.
Concurrent preflight reads share an in-flight census only when account, call,
caller and every field used by the duplicate predicate match. Description-only
corrections retain separate immutable preflight receipts; submission still runs
its own fresh census before a client can be created.
The staff MCP interface and approval contract remain unchanged.
Customer status is projected through an allowlist: names and relevant contact
details, authorized service addresses, titles, reviewed status wording and recorded
Visit/Assessment timing. Completed visits require `include_history:true`; no
visit found, unscheduled, date-only and unavailable timing remain distinct. Each
appointment's own client/property/parent is checked. Contact-owned phone matches
retain the matched contact IDs and property restrictions rather than granting
client-wide access. Internal notes, line items and financial states are excluded.
`email_prepare` returns a freshly authorized destination, consequences and a
fingerprint of the complete current email collection. `contact_email` binds that
fingerprint to the confirmed immutable proposal: `client_id`, optional `contact_id`,
`address`, explicit `make_primary`, and `intended_use` (`contact` or `invoices`).
It appends or promotes through narrowly defined `clientEdit` fields, preserves
every prior address and verifies readback. Contact-only callers can update only
their own matched contact. First-email primary behavior is disclosed before
confirmation. Invoice intent is retained for staff where recipient changes are
needed; no invoice, billing role, portal, notification or consent is changed.
An uncertain email write remains journaled for reconciliation, never blind replay.
## Private contact synchronization API
`JOBBER_CONTACT_SYNC_ENABLED=true` exposes fixed paginated client/contact/email/phone
reads at `GET /contact-sync/v1/page?kind=clients`. Configure a separate
`JOBBER_CONTACT_SYNC_API_KEY` (at least 32 characters), pinned
`JOBBER_CONTACT_SYNC_ACCOUNT_ID`, and existing persistent `JOBBER_STATE_DIR`.
The service credential cannot authenticate `/mcp` or the voice API. Browser origins
and arbitrary operations are rejected. No Jobber mutation is available. Keep this
route on the private Docker network. Only the Williams contact worker receives its
service credential; the connector remains the sole owner of OAuth/refresh state.
## Optional browser-backed staff tools
Opt-in Williams Desktop tools add offline payment recording and native property
map-pin edits. `preview_invoice_payment` and `preview_property_map_pin` produce
complete immutable proposals. `record_invoice_payment` and
`update_property_map_pin` require the exact full proposal, a stable UUID
`operation_id`, and `confirm_write: true` after human approval. A preview expires
after 30 minutes, browser reauthentication or service restart. A repeated operation
returns its recorded result; a proposal cannot be dispatched under a second ID.
`get_web_operation` reconciles through reads only; `jobber_web_status` reports
browser readiness separately from OAuth/API health.
Offline payment methods are cash, cheque, e-transfer, bank transfer and other.
Bank transfer is explicitly recorded as Other with a disclosed details prefix.
Amounts are positive decimal strings with two places; this release is restricted
to the verified Canadian Williams account and CAD. Review the invoice, amount,
date, method, reference, details and resulting balance. No card is charged,
receipt sent, refund made, or invoice closed without a payment. The script refuses
multiple selected invoices and targets the exact **Save** button. New payments
are verified against returned payment IDs and fresh paginated API history; a
matching amount/date alone cannot attribute another operator's payment. A lost
response without sufficient evidence remains uncertain and blocks overlapping
writes until staff reconciliation; never create a new ID to retry automatically.
Map-pin previews accept coordinates or a Google Maps link. Link resolution uses
separate unauthenticated requests with checked/pinned network destinations and
bounded approved-host redirects. Ambiguous locations and viewport-only links need
explicit coordinates. Review the exact client/property and current/proposed pin.
Readback checks the new coordinates within one metre and unchanged postal address.
Employee labour writes, arbitrary navigation/JavaScript/HTTP, payment deletion,
card charging and bulk operations remain unavailable.
Enable discovery with `JOBBER_WEB_ENABLED=true`, `JOBBER_WEB_URL` (private browser
service URL), `JOBBER_WEB_SERVICE_KEY` and `JOBBER_WEB_ACCOUNT_ID`. Writes also need
`JOBBER_READ_ONLY=false` and individually reviewed `web_payments` / `web_properties`
values in `JOBBER_WRITE_CAPABILITIES`. Normal API tools work when the browser is
unavailable. Browser operations never enter the voice or contact-sync APIs.
`Dockerfile.web` builds the separate service. Its environment additionally needs
`JOBBER_WEB_STATE_DIR`, a separate 64-hex `JOBBER_WEB_ENCRYPTION_KEY`,
`JOBBER_WEB_VIEWER_ORIGIN` (including `/jobber-browser`),
`JOBBER_WEB_VIEWER_OWNER`, and a distinct `JOBBER_WEB_VIEWER_KEY`. The private API
listens on 3100; only 3101 is routed through SSO. The viewer requires both trusted
owner identity and the proxy credential on HTTP and WebSocket requests. CSRF
Origin checks protect login controls. Manual mode excludes automation, revokes
old proposals and closes viewer connections before resuming. Persistent browser
authentication and operation/proposal data are encrypted; Chromium's temporary
profile is disposable. MFA/login is completed manually and never resumes a write.
See [the inspected website contract](browser/CONTRACT.md) for source observations,
Linux sandbox requirements and live-acceptance limits. Browser and fixture tests
use synthetic records and do not establish a successful live business mutation.
Ansible's opt-in `just play jobber-web` rollout is documented in the Hermes guide;
stop for release review before deployment and use separately approved genuine
business operations for final live acceptance.
TDQS
Scored across 16 tools
Tools are mostly distinct by domain: auth, clients, invoices, quotes, jobs, schedule, requests, catalog, and team. There is mild overlap potential among find_client, search_records, and get_record, but their descriptions generally separate search-by-contact, bounded record search, and single-record fetch.
Names are readable and consistently snake_case, but the naming convention is mixed: some tools use verb-led names like find_client and get_record, while others use noun-phrase names like jobs_summary and revenue_summary. There is no consistent verb_noun pattern across the set.
16 tools is slightly above the ideal compact range, but the server covers a broad Jobber domain and each tool serves a distinct reporting, lookup, or auth purpose. The count feels reasonable rather than bloated.
The tool surface is almost entirely read-only: searches, summaries, history, and lookups. There are no create, update, delete, or workflow tools such as creating a quote, scheduling a visit, or recording a payment, so many common Jobber management tasks would dead-end.