honeybook-mcp 0.3.5 → 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.5"
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.5",
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.5",
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
@@ -34710,8 +34710,12 @@ var API_KEY_RE = new RegExp([
34710
34710
  // webhook signing secret (Stripe-style)
34711
34711
  ].map((p) => `\\b${p}`).join("|"), "g");
34712
34712
  var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
34713
+ var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
34714
+ var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
34715
+ var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
34716
+ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
34713
34717
  function redactSecrets(text) {
34714
- return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
34718
+ return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
34715
34719
  }
34716
34720
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
34717
34721
  const str = text === null || text === void 0 ? "" : String(text);
@@ -35067,6 +35071,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
35067
35071
  "capture_request_header",
35068
35072
  "capture_redirect",
35069
35073
  "read_indexed_db",
35074
+ "read_dom",
35070
35075
  "download"
35071
35076
  ]);
35072
35077
 
@@ -35368,6 +35373,44 @@ function assertIndexedDbScopesArray(value, label) {
35368
35373
  }
35369
35374
  }
35370
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
+ }
35371
35414
  function validateFrame(raw) {
35372
35415
  assertObject(raw, "frame");
35373
35416
  const t = raw.type;
@@ -35443,6 +35486,9 @@ function validateHello(raw) {
35443
35486
  if (raw.sessionStoragePointers !== void 0) {
35444
35487
  assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
35445
35488
  }
35489
+ if (raw.domSelectors !== void 0) {
35490
+ assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
35491
+ }
35446
35492
  assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
35447
35493
  assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
35448
35494
  assertBase64(raw.sessionNonce, "hello.sessionNonce");
@@ -35677,6 +35723,21 @@ function validateInnerRequest(raw) {
35677
35723
  }
35678
35724
  return raw;
35679
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
+ }
35680
35741
  if (raw.op === "download") {
35681
35742
  assertObject(raw.init, "inner.init");
35682
35743
  if (raw.init.url === void 0) {
@@ -35702,7 +35763,7 @@ function validateInnerRequest(raw) {
35702
35763
  }
35703
35764
  return raw;
35704
35765
  }
35705
- 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)}`);
35706
35767
  }
35707
35768
  function assertNonEmptyKeyArray(value, label) {
35708
35769
  if (!Array.isArray(value)) {
@@ -35787,6 +35848,13 @@ function validateInnerResponse(raw) {
35787
35848
  assertObject(raw.values, "inner.values");
35788
35849
  return raw;
35789
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
+ }
35790
35858
  if (op === "download") {
35791
35859
  assertObject(raw.value, "inner.value");
35792
35860
  assertString(raw.value.path, "inner.value.path");
@@ -36126,6 +36194,13 @@ async function buildServerHello(opts) {
36126
36194
  jsonPointer: d.jsonPointer
36127
36195
  }));
36128
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
+ }
36129
36204
  return hello;
36130
36205
  }
36131
36206
 
@@ -36215,7 +36290,8 @@ async function startHost(opts) {
36215
36290
  captureHeaders: opts.ownCaptureHeaders,
36216
36291
  indexedDbScopes: opts.ownIndexedDbScopes,
36217
36292
  localStoragePointers: opts.ownLocalStoragePointers,
36218
- sessionStoragePointers: opts.ownSessionStoragePointers
36293
+ sessionStoragePointers: opts.ownSessionStoragePointers,
36294
+ domSelectors: opts.ownDomSelectors
36219
36295
  });
36220
36296
  const ownSessionNonce = fromB64(ownHello.sessionNonce);
36221
36297
  let extensionWs = null;
@@ -36450,6 +36526,7 @@ async function startPeer(opts) {
36450
36526
  sessionStorageKeys: opts.sessionStorageKeys,
36451
36527
  captureHeaders: opts.captureHeaders,
36452
36528
  indexedDbScopes: opts.indexedDbScopes,
36529
+ domSelectors: opts.domSelectors,
36453
36530
  localStoragePointers: opts.localStoragePointers,
36454
36531
  sessionStoragePointers: opts.sessionStoragePointers
36455
36532
  });
@@ -36857,6 +36934,11 @@ var FetchproxyServer = class {
36857
36934
  key: d.key,
36858
36935
  jsonPointer: d.jsonPointer
36859
36936
  })),
36937
+ domSelectors: (opts.domSelectors ?? []).map((d) => ({
36938
+ name: d.name,
36939
+ selector: d.selector,
36940
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36941
+ })),
36860
36942
  // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36861
36943
  // adapter was about to set these to the same numbers anyway; the
36862
36944
  // back-door is `0` (explicit opt-out) if a caller genuinely wants
@@ -36977,6 +37059,7 @@ var FetchproxyServer = class {
36977
37059
  ownIndexedDbScopes: this.opts.indexedDbScopes,
36978
37060
  ownLocalStoragePointers: this.opts.localStoragePointers,
36979
37061
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
37062
+ ownDomSelectors: this.opts.domSelectors,
36980
37063
  onPairCode: this.opts.onPairCode
36981
37064
  });
36982
37065
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
@@ -37004,7 +37087,8 @@ var FetchproxyServer = class {
37004
37087
  captureHeaders: this.opts.captureHeaders,
37005
37088
  indexedDbScopes: this.opts.indexedDbScopes,
37006
37089
  localStoragePointers: this.opts.localStoragePointers,
37007
- sessionStoragePointers: this.opts.sessionStoragePointers
37090
+ sessionStoragePointers: this.opts.sessionStoragePointers,
37091
+ domSelectors: this.opts.domSelectors
37008
37092
  });
37009
37093
  this.peerHandle.onInner((inner) => this.onInner(inner));
37010
37094
  this.peerHandle.onRenegotiate(() => {
@@ -37979,6 +38063,46 @@ var FetchproxyServer = class {
37979
38063
  await this.sendInnerFrame(inner);
37980
38064
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37981
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
+ }
37982
38106
  assertScopeSubset(requested, declared, label) {
37983
38107
  const undeclared = undeclaredKeys(requested, declared);
37984
38108
  if (undeclared.length > 0) {
@@ -38050,7 +38174,7 @@ var FetchproxyServer = class {
38050
38174
  if (storageCb) {
38051
38175
  this.pendingStorage.delete(inner.id);
38052
38176
  if (inner.ok) {
38053
- 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) {
38054
38178
  storageCb.resolve({ ...inner.values });
38055
38179
  } else {
38056
38180
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
@@ -38408,7 +38532,7 @@ function bridgeErrorInfo(err) {
38408
38532
  // package.json
38409
38533
  var package_default = {
38410
38534
  name: "honeybook-mcp",
38411
- version: "0.3.5",
38535
+ version: "0.4.0",
38412
38536
  mcpName: "io.github.chrischall/honeybook-mcp",
38413
38537
  description: "HoneyBook client-portal MCP server for Claude",
38414
38538
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38448,7 +38572,7 @@ var package_default = {
38448
38572
  "test:watch": "vitest"
38449
38573
  },
38450
38574
  dependencies: {
38451
- "@chrischall/mcp-utils": "^0.10.3",
38575
+ "@chrischall/mcp-utils": "^0.13.0",
38452
38576
  "@fetchproxy/bootstrap": "^1.0.0",
38453
38577
  "@fetchproxy/server": "^1.0.0",
38454
38578
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -38456,10 +38580,10 @@ var package_default = {
38456
38580
  zod: "^4.4.3"
38457
38581
  },
38458
38582
  devDependencies: {
38459
- "@types/node": "^25.9.1",
38583
+ "@types/node": "^26.0.0",
38460
38584
  "@vitest/coverage-v8": "^4.1.7",
38461
38585
  esbuild: "^0.28.0",
38462
- typescript: "^6.0.3",
38586
+ typescript: "^7.0.2",
38463
38587
  vitest: "^4.1.7"
38464
38588
  }
38465
38589
  };
@@ -39045,7 +39169,7 @@ var __dirname = dirname2(fileURLToPath(import.meta.url));
39045
39169
  await loadDotenvSafely({ path: join3(__dirname, "..", ".env"), override: false });
39046
39170
  await runMcp({
39047
39171
  name: "honeybook-mcp",
39048
- version: "0.3.5",
39172
+ version: "0.4.0",
39049
39173
  // x-release-please-version
39050
39174
  tools: [
39051
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.5', // 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.5",
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.10.3",
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",
@@ -48,10 +48,10 @@
48
48
  "zod": "^4.4.3"
49
49
  },
50
50
  "devDependencies": {
51
- "@types/node": "^25.9.1",
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.5",
9
+ "version": "0.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "honeybook-mcp",
14
- "version": "0.3.5",
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`).