honeybook-mcp 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "HoneyBook client-portal tools for Claude Code",
10
- "version": "0.3.6"
10
+ "version": "0.4.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "HoneyBook",
16
16
  "source": "./",
17
17
  "description": "HoneyBook client-portal MCP for Claude — view wedding-vendor contracts and invoices via MCP",
18
- "version": "0.3.6",
18
+ "version": "0.4.0",
19
19
  "author": {
20
20
  "name": "Chris Chall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "honeybook",
3
3
  "displayName": "HoneyBook",
4
- "version": "0.3.6",
4
+ "version": "0.4.0",
5
5
  "description": "HoneyBook client-portal MCP for Claude — view wedding-vendor contracts and invoices via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall",
package/dist/bundle.js CHANGED
@@ -35071,6 +35071,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
35071
35071
  "capture_request_header",
35072
35072
  "capture_redirect",
35073
35073
  "read_indexed_db",
35074
+ "read_dom",
35074
35075
  "download"
35075
35076
  ]);
35076
35077
 
@@ -35372,6 +35373,44 @@ function assertIndexedDbScopesArray(value, label) {
35372
35373
  }
35373
35374
  }
35374
35375
  }
35376
+ var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
35377
+ var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
35378
+ function assertDomSelectorsArray(value, label) {
35379
+ if (!Array.isArray(value)) {
35380
+ throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
35381
+ }
35382
+ const seen = /* @__PURE__ */ new Set();
35383
+ for (let i = 0; i < value.length; i++) {
35384
+ const entry = value[i];
35385
+ assertObject(entry, `${label}[${i}]`);
35386
+ if (entry.name === void 0) {
35387
+ throw new ProtocolError(`${label}[${i}].name: missing`);
35388
+ }
35389
+ if (entry.selector === void 0) {
35390
+ throw new ProtocolError(`${label}[${i}].selector: missing`);
35391
+ }
35392
+ if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
35393
+ throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
35394
+ }
35395
+ if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
35396
+ throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
35397
+ }
35398
+ if (entry.attribute !== void 0) {
35399
+ if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
35400
+ throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
35401
+ }
35402
+ }
35403
+ if (seen.has(entry.name)) {
35404
+ throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
35405
+ }
35406
+ seen.add(entry.name);
35407
+ for (const k of Object.keys(entry)) {
35408
+ if (k !== "name" && k !== "selector" && k !== "attribute") {
35409
+ throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
35410
+ }
35411
+ }
35412
+ }
35413
+ }
35375
35414
  function validateFrame(raw) {
35376
35415
  assertObject(raw, "frame");
35377
35416
  const t = raw.type;
@@ -35447,6 +35486,9 @@ function validateHello(raw) {
35447
35486
  if (raw.sessionStoragePointers !== void 0) {
35448
35487
  assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
35449
35488
  }
35489
+ if (raw.domSelectors !== void 0) {
35490
+ assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
35491
+ }
35450
35492
  assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
35451
35493
  assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
35452
35494
  assertBase64(raw.sessionNonce, "hello.sessionNonce");
@@ -35681,6 +35723,21 @@ function validateInnerRequest(raw) {
35681
35723
  }
35682
35724
  return raw;
35683
35725
  }
35726
+ if (raw.op === "read_dom") {
35727
+ assertObject(raw.init, "inner.init");
35728
+ if (raw.init.origin === void 0)
35729
+ throw new ProtocolError("inner.init.origin: missing");
35730
+ if (raw.init.names === void 0)
35731
+ throw new ProtocolError("inner.init.names: missing");
35732
+ assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35733
+ assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
35734
+ for (const k of Object.keys(raw.init)) {
35735
+ if (k !== "origin" && k !== "names") {
35736
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
35737
+ }
35738
+ }
35739
+ return raw;
35740
+ }
35684
35741
  if (raw.op === "download") {
35685
35742
  assertObject(raw.init, "inner.init");
35686
35743
  if (raw.init.url === void 0) {
@@ -35706,7 +35763,7 @@ function validateInnerRequest(raw) {
35706
35763
  }
35707
35764
  return raw;
35708
35765
  }
35709
- throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "download"; got ${JSON.stringify(raw.op)}`);
35766
+ throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
35710
35767
  }
35711
35768
  function assertNonEmptyKeyArray(value, label) {
35712
35769
  if (!Array.isArray(value)) {
@@ -35791,6 +35848,13 @@ function validateInnerResponse(raw) {
35791
35848
  assertObject(raw.values, "inner.values");
35792
35849
  return raw;
35793
35850
  }
35851
+ if (op === "read_dom") {
35852
+ if (raw.values === void 0) {
35853
+ throw new ProtocolError("inner.values: missing on read_dom response");
35854
+ }
35855
+ assertStringMap(raw.values, "inner.values");
35856
+ return raw;
35857
+ }
35794
35858
  if (op === "download") {
35795
35859
  assertObject(raw.value, "inner.value");
35796
35860
  assertString(raw.value.path, "inner.value.path");
@@ -36130,6 +36194,13 @@ async function buildServerHello(opts) {
36130
36194
  jsonPointer: d.jsonPointer
36131
36195
  }));
36132
36196
  }
36197
+ if (opts.domSelectors && opts.domSelectors.length > 0) {
36198
+ hello.domSelectors = opts.domSelectors.map((d) => ({
36199
+ name: d.name,
36200
+ selector: d.selector,
36201
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36202
+ }));
36203
+ }
36133
36204
  return hello;
36134
36205
  }
36135
36206
 
@@ -36219,7 +36290,8 @@ async function startHost(opts) {
36219
36290
  captureHeaders: opts.ownCaptureHeaders,
36220
36291
  indexedDbScopes: opts.ownIndexedDbScopes,
36221
36292
  localStoragePointers: opts.ownLocalStoragePointers,
36222
- sessionStoragePointers: opts.ownSessionStoragePointers
36293
+ sessionStoragePointers: opts.ownSessionStoragePointers,
36294
+ domSelectors: opts.ownDomSelectors
36223
36295
  });
36224
36296
  const ownSessionNonce = fromB64(ownHello.sessionNonce);
36225
36297
  let extensionWs = null;
@@ -36454,6 +36526,7 @@ async function startPeer(opts) {
36454
36526
  sessionStorageKeys: opts.sessionStorageKeys,
36455
36527
  captureHeaders: opts.captureHeaders,
36456
36528
  indexedDbScopes: opts.indexedDbScopes,
36529
+ domSelectors: opts.domSelectors,
36457
36530
  localStoragePointers: opts.localStoragePointers,
36458
36531
  sessionStoragePointers: opts.sessionStoragePointers
36459
36532
  });
@@ -36861,6 +36934,11 @@ var FetchproxyServer = class {
36861
36934
  key: d.key,
36862
36935
  jsonPointer: d.jsonPointer
36863
36936
  })),
36937
+ domSelectors: (opts.domSelectors ?? []).map((d) => ({
36938
+ name: d.name,
36939
+ selector: d.selector,
36940
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36941
+ })),
36864
36942
  // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36865
36943
  // adapter was about to set these to the same numbers anyway; the
36866
36944
  // back-door is `0` (explicit opt-out) if a caller genuinely wants
@@ -36981,6 +37059,7 @@ var FetchproxyServer = class {
36981
37059
  ownIndexedDbScopes: this.opts.indexedDbScopes,
36982
37060
  ownLocalStoragePointers: this.opts.localStoragePointers,
36983
37061
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
37062
+ ownDomSelectors: this.opts.domSelectors,
36984
37063
  onPairCode: this.opts.onPairCode
36985
37064
  });
36986
37065
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
@@ -37008,7 +37087,8 @@ var FetchproxyServer = class {
37008
37087
  captureHeaders: this.opts.captureHeaders,
37009
37088
  indexedDbScopes: this.opts.indexedDbScopes,
37010
37089
  localStoragePointers: this.opts.localStoragePointers,
37011
- sessionStoragePointers: this.opts.sessionStoragePointers
37090
+ sessionStoragePointers: this.opts.sessionStoragePointers,
37091
+ domSelectors: this.opts.domSelectors
37012
37092
  });
37013
37093
  this.peerHandle.onInner((inner) => this.onInner(inner));
37014
37094
  this.peerHandle.onRenegotiate(() => {
@@ -37983,6 +38063,46 @@ var FetchproxyServer = class {
37983
38063
  await this.sendInnerFrame(inner);
37984
38064
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37985
38065
  }
38066
+ /**
38067
+ * 1.4.0+: read declared DOM values from the user's signed-in tab.
38068
+ * Requires `'read_dom'` in capabilities AND every requested `name` to
38069
+ * match a declared `domSelectors` entry. The extension reads each
38070
+ * declared selector from the matched tab's DOM (isolated-world
38071
+ * `querySelector`, value or attribute) — no page-JS execution.
38072
+ *
38073
+ * Returns a `Record<string, string>` of `name → value`, with names
38074
+ * whose element (or attribute) was absent omitted. Throws
38075
+ * `FetchproxyProtocolError` on bridge failures and a plain `Error` on
38076
+ * developer mistakes (undeclared capability, undeclared name).
38077
+ */
38078
+ async readDom(opts) {
38079
+ if (!this.opts.capabilities.includes("read_dom")) {
38080
+ throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
38081
+ }
38082
+ await this.ensureConnected();
38083
+ this.throwIfPendingPair();
38084
+ if (!Array.isArray(opts.names) || opts.names.length === 0) {
38085
+ throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
38086
+ }
38087
+ this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
38088
+ if (opts.subdomain !== void 0)
38089
+ assertSubdomainLabel(opts.subdomain);
38090
+ const baseDomain = this.resolveBaseDomain(opts.domain);
38091
+ const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
38092
+ const origin = `https://${host}`;
38093
+ const id = this.nextRequestId++;
38094
+ const inner = {
38095
+ type: "request",
38096
+ id,
38097
+ op: "read_dom",
38098
+ init: { origin, names: [...opts.names] }
38099
+ };
38100
+ const pending = new Promise((resolve, reject) => {
38101
+ this.pendingStorage.set(id, { resolve, reject });
38102
+ });
38103
+ await this.sendInnerFrame(inner);
38104
+ return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
38105
+ }
37986
38106
  assertScopeSubset(requested, declared, label) {
37987
38107
  const undeclared = undeclaredKeys(requested, declared);
37988
38108
  if (undeclared.length > 0) {
@@ -38054,7 +38174,7 @@ var FetchproxyServer = class {
38054
38174
  if (storageCb) {
38055
38175
  this.pendingStorage.delete(inner.id);
38056
38176
  if (inner.ok) {
38057
- if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
38177
+ if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
38058
38178
  storageCb.resolve({ ...inner.values });
38059
38179
  } else {
38060
38180
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
@@ -38412,7 +38532,7 @@ function bridgeErrorInfo(err) {
38412
38532
  // package.json
38413
38533
  var package_default = {
38414
38534
  name: "honeybook-mcp",
38415
- version: "0.3.6",
38535
+ version: "0.4.0",
38416
38536
  mcpName: "io.github.chrischall/honeybook-mcp",
38417
38537
  description: "HoneyBook client-portal MCP server for Claude",
38418
38538
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38452,7 +38572,7 @@ var package_default = {
38452
38572
  "test:watch": "vitest"
38453
38573
  },
38454
38574
  dependencies: {
38455
- "@chrischall/mcp-utils": "^0.12.0",
38575
+ "@chrischall/mcp-utils": "^0.13.0",
38456
38576
  "@fetchproxy/bootstrap": "^1.0.0",
38457
38577
  "@fetchproxy/server": "^1.0.0",
38458
38578
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -38463,7 +38583,7 @@ var package_default = {
38463
38583
  "@types/node": "^26.0.0",
38464
38584
  "@vitest/coverage-v8": "^4.1.7",
38465
38585
  esbuild: "^0.28.0",
38466
- typescript: "^6.0.3",
38586
+ typescript: "^7.0.2",
38467
38587
  vitest: "^4.1.7"
38468
38588
  }
38469
38589
  };
@@ -39049,7 +39169,7 @@ var __dirname = dirname2(fileURLToPath(import.meta.url));
39049
39169
  await loadDotenvSafely({ path: join3(__dirname, "..", ".env"), override: false });
39050
39170
  await runMcp({
39051
39171
  name: "honeybook-mcp",
39052
- version: "0.3.6",
39172
+ version: "0.4.0",
39053
39173
  // x-release-please-version
39054
39174
  tools: [
39055
39175
  registerSessionTools,
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { registerContractTools } from './tools/contracts.js';
11
11
  import { registerInvoiceTools } from './tools/invoices.js';
12
12
  await runMcp({
13
13
  name: 'honeybook-mcp',
14
- version: '0.3.6', // x-release-please-version
14
+ version: '0.4.0', // x-release-please-version
15
15
  tools: [
16
16
  registerSessionTools,
17
17
  registerWorkspaceFileTools,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honeybook-mcp",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "mcpName": "io.github.chrischall/honeybook-mcp",
5
5
  "description": "HoneyBook client-portal MCP server for Claude",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -40,7 +40,7 @@
40
40
  "test:watch": "vitest"
41
41
  },
42
42
  "dependencies": {
43
- "@chrischall/mcp-utils": "^0.12.0",
43
+ "@chrischall/mcp-utils": "^0.13.0",
44
44
  "@fetchproxy/bootstrap": "^1.0.0",
45
45
  "@fetchproxy/server": "^1.0.0",
46
46
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -51,7 +51,7 @@
51
51
  "@types/node": "^26.0.0",
52
52
  "@vitest/coverage-v8": "^4.1.7",
53
53
  "esbuild": "^0.28.0",
54
- "typescript": "^6.0.3",
54
+ "typescript": "^7.0.2",
55
55
  "vitest": "^4.1.7"
56
56
  }
57
57
  }
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/honeybook-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.3.6",
9
+ "version": "0.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "honeybook-mcp",
14
- "version": "0.3.6",
14
+ "version": "0.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -0,0 +1,152 @@
1
+ ---
2
+ name: honeybook-fpx
3
+ description: >-
4
+ Read HoneyBook client-portal data (contracts, invoices, proposals, payment
5
+ methods, workspace status) from a shell with the fpx CLI (@fetchproxy/cli)
6
+ instead of running the honeybook-mcp server — capture a vendor session once
7
+ via the signed-in browser tab, then curl api.honeybook.com directly. Use
8
+ when you want HoneyBook data without the MCP, in a script, or on a machine
9
+ where the MCP isn't installed.
10
+ ---
11
+
12
+ # HoneyBook via fpx + curl (no MCP)
13
+
14
+ HoneyBook has **no server-side login** a script can drive — a client never
15
+ gets a password, only a magic-link email per vendor. The credential is
16
+ whatever the signed-in `*.hbportal.co` portal tab already holds: a bearer
17
+ token + user id + device id in `localStorage["jStorage"]`, plus a per-device
18
+ `hb-api-fingerprint` request header that only appears on a live outgoing
19
+ `api.honeybook.com/api/v2/*` call (it's FingerprintJS, not stored anywhere).
20
+ There's no bot wall on the API itself once you have those — `honeybook-mcp`'s
21
+ own `client.ts` proves plain Node `fetch` works fine against
22
+ `api.honeybook.com`. So this skill is **hybrid**: `fpx` captures the session
23
+ **once** (per vendor), then plain `curl` does every read from then on.
24
+
25
+ This mirrors `src/auth.ts` (`captureSessionViaFetchproxy`) and `src/client.ts`
26
+ (`HoneyBookClient.request`) in `honeybook-mcp` — same headers, same base URL,
27
+ same retry rules.
28
+
29
+ ## Multi-domain scope
30
+
31
+ Two apexes are declared on one profile:
32
+ - `hbportal.co` — the vendor's branded portal (e.g. `acme.hbportal.co`),
33
+ where `jStorage` lives.
34
+ - `honeybook.com` — required so the extension is allowed to capture the
35
+ `hb-api-fingerprint` header off requests to `api.honeybook.com`.
36
+
37
+ ## One-time setup
38
+
39
+ ```sh
40
+ npm install -g @fetchproxy/cli # provides `fpx`
41
+ fpx profile add honeybook --domain honeybook.com --domain hbportal.co
42
+ fpx profile declare honeybook \
43
+ --local-storage jStorage \
44
+ --capture-header 'hb-api-fingerprint@api.honeybook.com/api/v2/*'
45
+ fpx pair -p honeybook # prints a pair code → approve in Transporter
46
+ ```
47
+
48
+ Requirements: the **Transporter** browser extension installed, its Chrome
49
+ **Site access** allowing both `honeybook.com` and `hbportal.co`, and a vendor
50
+ magic-link URL already open (signed in) in that browser. Pairing persists —
51
+ after the first approval every later `fpx` call reuses it.
52
+
53
+ ## Capture a vendor session (once per vendor, and again when it expires)
54
+
55
+ 1. Click the vendor's HoneyBook magic-link email in the browser with
56
+ Transporter installed. This signs you into `<vendor>.hbportal.co`.
57
+ 2. **While that tab is open**, run:
58
+
59
+ ```sh
60
+ fpx session -p honeybook --storage-domain hbportal.co > /tmp/hb-session.json
61
+ ```
62
+
63
+ **The fingerprint capture needs a FRESH request, not a loaded page.** A
64
+ portal tab that finished loading and went idle has no outgoing
65
+ `api.honeybook.com/api/v2/*` call left to sniff, so the capture times out.
66
+ While `fpx session` is waiting (~30s window), refresh the portal tab or open
67
+ a workspace/file so the page issues a new API call.
68
+
69
+ 3. Extract the fields `client.ts` needs (`jStorage` is one raw localStorage
70
+ value packing several keys as JSON — parse it with `fromjson`):
71
+
72
+ ```sh
73
+ AUTH_TOKEN=$(jq -r '.localStorage.jStorage | fromjson | .HB_AUTH_TOKEN' /tmp/hb-session.json)
74
+ USER_ID=$(jq -r '.localStorage.jStorage | fromjson | .HB_AUTH_USER_ID' /tmp/hb-session.json)
75
+ TRUSTED_DEVICE=$(jq -r '.localStorage.jStorage | fromjson | .HB_TRUSTED_DEVICE' /tmp/hb-session.json)
76
+ FINGERPRINT=$(jq -r '.capturedHeaders["hb-api-fingerprint"]' /tmp/hb-session.json)
77
+ PORTAL_ORIGIN='https://<vendor>.hbportal.co' # the magic-link URL's origin
78
+ ```
79
+
80
+ If any of `AUTH_TOKEN`/`USER_ID`/`TRUSTED_DEVICE`/`FINGERPRINT` come back
81
+ empty/`null`, the capture didn't see what it needed — re-open the magic link
82
+ (or interact with the tab) and re-run step 2.
83
+
84
+ With more than one vendor tab open at once, disambiguate with
85
+ `--storage-subdomain <vendor>` (e.g. `--storage-subdomain acme`).
86
+
87
+ 4. Get the current API version (`client.ts`'s `fetchApiVersion` parses this
88
+ same endpoint):
89
+
90
+ ```sh
91
+ API_VERSION=$(curl -s 'https://api.honeybook.com/api/gon?callback=parseGon' \
92
+ | grep -oE '"api_version":[[:space:]]*[0-9]+' | grep -oE '[0-9]+$')
93
+ ```
94
+
95
+ ## Core call pattern
96
+
97
+ Every real request carries the same eight headers
98
+ (`client.ts`'s `HoneyBookClient.request`):
99
+
100
+ ```sh
101
+ curl -s "https://api.honeybook.com/api/v2/users/$USER_ID/workspace_files" \
102
+ -H 'accept: application/json, text/plain, */*' \
103
+ -H "hb-api-auth-token: $AUTH_TOKEN" \
104
+ -H "hb-api-user-id: $USER_ID" \
105
+ -H "hb-trusted-device: $TRUSTED_DEVICE" \
106
+ -H "hb-api-client-version: $API_VERSION" \
107
+ -H "hb-api-fingerprint: $FINGERPRINT" \
108
+ -H "hb-api-duplicate-calls-prevention-uuid: $(uuidgen)" \
109
+ -H 'hb-admin-login: false' \
110
+ | jq '.data'
111
+ ```
112
+
113
+ `hb-api-duplicate-calls-prevention-uuid` must be a **fresh random UUID on
114
+ every request** — the MCP mints one with `crypto.randomUUID()` per call, not
115
+ once per session. Reusing a value risks HoneyBook treating a legitimate
116
+ repeat as a duplicate.
117
+
118
+ Ready-to-run commands for all four read endpoints are in
119
+ `references/requests.md`.
120
+
121
+ ## The rules that matter
122
+
123
+ - **401 → session expired.** Re-run the capture (magic link tab must still
124
+ be open and signed in).
125
+ - **429 → rate limited.** `client.ts` waits 2s and retries once; do the same
126
+ before giving up.
127
+ - **Body contains `"HBWrongAPIVersionError"` → stale `hb-api-client-version`.**
128
+ The error body itself carries the correct value at
129
+ `.error_data.server_api_version` — read that (or re-run the `/api/gon`
130
+ fetch above) and retry the SAME request with the fresh version.
131
+ - **`sign_contract` / `pay_invoice` are not real API calls.** `honeybook-mcp`
132
+ can't replay HoneyBook's browser-side signing/SCA flow, so those tools just
133
+ return a deep link — `$PORTAL_ORIGIN/app/workspace_file/<file_id>/agreement`
134
+ (sign) or `/invoice` (pay) — for the user to open themselves. There's no
135
+ POST body to transcribe for either; don't invent one.
136
+
137
+ ## Output / exit-code contract
138
+
139
+ - `fpx session`/`fpx pair`/`fpx health` are bridge round-trips: exit `0` on a
140
+ successful bridge read regardless of upstream status, `1` on a usage error
141
+ (bad flag, undeclared scope), `2` if the bridge/extension is unreachable or
142
+ pairing is still pending. There's no bot-wall (`3`)/upstream-HTTP (`4`)
143
+ exit code on these — HoneyBook's own API isn't bridge-walled.
144
+ - The actual reads go through plain `curl` afterward — check the HTTP status
145
+ and the `HBWrongAPIVersionError` body text yourself, as above.
146
+
147
+ ## Notes
148
+
149
+ - Session data (`AUTH_TOKEN`, `TRUSTED_DEVICE`, `FINGERPRINT`) is opaque and
150
+ long-lived server-side (no client-visible JWT expiry) — keep it in shell
151
+ variables, not a world-readable file, if you must persist it at all.
152
+ - This project is developed and maintained by AI (Claude).
@@ -0,0 +1,122 @@
1
+ # HoneyBook requests for fpx + curl
2
+
3
+ Ready-to-run commands for the four live read endpoints `honeybook-mcp`
4
+ actually calls (from `src/tools/*.ts` + `src/client.ts`). All are
5
+ `GET api.honeybook.com/api/v2/*`, carrying the eight headers built in
6
+ `HoneyBookClient.request` — see `../SKILL.md` for how to capture
7
+ `$AUTH_TOKEN`/`$USER_ID`/`$TRUSTED_DEVICE`/`$FINGERPRINT`/`$API_VERSION`
8
+ first.
9
+
10
+ ```sh
11
+ hb_get() { # $1 = path (e.g. /api/v2/users/$USER_ID/workspace_files)
12
+ curl -s "https://api.honeybook.com$1" \
13
+ -H 'accept: application/json, text/plain, */*' \
14
+ -H "hb-api-auth-token: $AUTH_TOKEN" \
15
+ -H "hb-api-user-id: $USER_ID" \
16
+ -H "hb-trusted-device: $TRUSTED_DEVICE" \
17
+ -H "hb-api-client-version: $API_VERSION" \
18
+ -H "hb-api-fingerprint: $FINGERPRINT" \
19
+ -H "hb-api-duplicate-calls-prevention-uuid: $(uuidgen)" \
20
+ -H 'hb-admin-login: false'
21
+ }
22
+ ```
23
+
24
+ `sign_contract`/`pay_invoice` are **not** included below — the MCP itself
25
+ doesn't call a signing/payment API; it returns a deep link
26
+ (`$PORTAL_ORIGIN/app/workspace_file/<file_id>/agreement` or `/invoice`) for
27
+ the user to open in their browser. There's no request shape to transcribe.
28
+
29
+ ---
30
+
31
+ ## 1. List a vendor's shared files
32
+
33
+ `list_workspace_files` (`src/tools/workspace_files.ts`):
34
+
35
+ ```sh
36
+ hb_get "/api/v2/users/$USER_ID/workspace_files" > /tmp/hb-files.json
37
+ ```
38
+
39
+ Response envelope (`HBListEnvelope<T>`, `src/types.ts`):
40
+ `{ data: [...], cur_page, last_page, last_id?, total_count? }`.
41
+ **Pagination is not wired up** in the MCP either — if `last_page` is
42
+ `false`, more results exist on later pages that neither the MCP nor this
43
+ skill fetches.
44
+
45
+ ```sh
46
+ # Filter to a file_type client-side (agreement | invoice | brochure | proposal)
47
+ jq '[.data[] | select(.file_type == "agreement")]' /tmp/hb-files.json
48
+
49
+ # Compact listing: id, type, title, accepted/paid flags
50
+ jq -r '.data[] | [.["_id"], .file_type, .file_title, (.is_file_accepted|tostring), (.has_pending_payment|tostring)] | @tsv' /tmp/hb-files.json
51
+ ```
52
+
53
+ ## 2. Get one file's detail
54
+
55
+ `get_workspace_file` (`src/tools/workspace_files.ts`):
56
+
57
+ ```sh
58
+ hb_get "/api/v2/workspace_files/$FILE_ID" > /tmp/hb-file.json
59
+ ```
60
+
61
+ The raw response is large on proposal-class files (a real one hit ~1.3 MB,
62
+ mostly vendor-internal fields the MCP prunes off `company`:
63
+ `vendor_emails`, `workflow_automation_infos`, `brochure_templates`,
64
+ `questionnaires`, `lead_sources`, `proposals`, `agreements`, `invoices`,
65
+ `vendor_packages`, `contact_forms`, `stripe_persons`,
66
+ `user_pipeline_stages`, `project_types`, `company_assets`). Project what you
67
+ need instead of dumping the whole body:
68
+
69
+ ```sh
70
+ # Summary-equivalent: identity, status, vendor, event, pricing totals, payments
71
+ jq '{
72
+ id: .["_id"], title: .file_title, type: .file_type,
73
+ status: .status_name, accepted: .is_file_accepted,
74
+ vendor: .company.company_name,
75
+ event: (.event | {date: .event_date, type, couple_names}),
76
+ total_price: .vendor_proposal.total_price,
77
+ payments: (.payments_container.payments // [] | map({due_date, amount, is_paid}))
78
+ }' /tmp/hb-file.json
79
+
80
+ # Full contract text + signatures (the "agreement" section)
81
+ jq '.agreement' /tmp/hb-file.json
82
+
83
+ # Full line items (the "pricing" section)
84
+ jq '.vendor_proposal' /tmp/hb-file.json
85
+ ```
86
+
87
+ ## 3. Get a workspace (vendor project)
88
+
89
+ `get_workspace` (`src/tools/workspaces.ts`) — `workspace_id` is
90
+ `.workspace._id` on any workspace_file from endpoint 1 or 2:
91
+
92
+ ```sh
93
+ hb_get "/api/v2/workspaces/$WORKSPACE_ID" \
94
+ | jq '{id: .["_id"], has_sent_files, has_signed_files, has_paid_payments}'
95
+ ```
96
+
97
+ ## 4. List saved payment methods
98
+
99
+ `list_payment_methods` (`src/tools/payment_methods.ts`):
100
+
101
+ ```sh
102
+ hb_get "/api/v2/users/$USER_ID/payment_methods" | jq '.'
103
+ ```
104
+
105
+ Empty array (`[]`) if the client has no payment method saved with this
106
+ vendor — not an error.
107
+
108
+ ---
109
+
110
+ ## Error shapes to check on every response
111
+
112
+ ```sh
113
+ # Wrong API version — re-derive it from the error body itself (no /api/gon round-trip needed)
114
+ jq -r 'select(type=="object") | .error_data.server_api_version // empty' /tmp/hb-resp.json
115
+
116
+ # HTTP status: 401 = session expired (re-capture), 429 = rate limited (wait 2s, retry once)
117
+ ```
118
+
119
+ A non-2xx HTTP status with a body matching `HBWrongAPIVersionError` means
120
+ retry the *same* request with `hb-api-client-version` set to
121
+ `.error_data.server_api_version` from the body (or a fresh `/api/gon` fetch,
122
+ per `../SKILL.md`).