honeybook-mcp 1.1.4 → 1.2.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": "1.1.4"
10
+ "version": "1.2.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": "1.1.4",
18
+ "version": "1.2.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": "1.1.4",
4
+ "version": "1.2.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/README.md CHANGED
@@ -76,7 +76,7 @@ Add to Claude Desktop config (`~/Library/Application Support/Claude/claude_deskt
76
76
  }
77
77
  ```
78
78
 
79
- No environment variables are required.
79
+ No environment variables are required. The optional ones below tune how writes are confirmed — see [Confirmations](#confirmations).
80
80
 
81
81
  ## Sessions
82
82
 
@@ -134,13 +134,13 @@ Tools that touch a vendor accept an optional `origin` argument (e.g. `https://ac
134
134
  | `get_workspace_file` | Full detail for one file | Auto |
135
135
  | `get_workspace` | Workspace detail + status flags | Auto |
136
136
  | `list_payment_methods` | Saved payment methods | Auto |
137
- | `sign_contract` | Deep link to sign in portal (requires `confirm:true`) | Confirm |
138
- | `pay_invoice` | Deep link to pay in portal (requires `confirm:true`) | Confirm |
137
+ | `sign_contract` | Deep link to sign in portal (after you confirm) | Confirm |
138
+ | `pay_invoice` | Deep link to pay in portal (after you confirm) | Confirm |
139
139
  | `list_projects` | Your projects with a vendor + their workspace ids | Auto |
140
140
  | `get_project` | Project details: date, location, people, custom fields | Auto |
141
141
  | `list_messages` | Messages (or the activity log) in a workspace, newest first | Auto |
142
142
  | `get_message` | One message in full: body, attachments, delivery status | Auto |
143
- | `send_message` | Send or reply through the portal (requires `confirm:true`) | Confirm |
143
+ | `send_message` | Send or reply through the portal (after you confirm) | Confirm |
144
144
  | `mark_messages_seen` | Mark feed items seen (reads never do this on their own) | Auto |
145
145
  | `list_meetings` | Scheduled meetings with join links, latest time wins | Auto |
146
146
  | `list_tasks` | Tasks the vendor assigned you, with counts | Auto |
@@ -151,7 +151,17 @@ Tools that touch a vendor accept an optional `origin` argument (e.g. `https://ac
151
151
  `send_message` is the one tool that acts on your behalf: it creates the same
152
152
  `send_workspace_message` job the portal's Activity composer creates and waits
153
153
  for HoneyBook to finish it, so the vendor receives a normal HoneyBook email.
154
- Without `confirm:true` it only previews the recipients, subject and body.
154
+ Until you confirm it only previews the recipients, subject and body — see [Confirmations](#confirmations).
155
+
156
+ ## Confirmations
157
+
158
+ `sign_contract`, `pay_invoice` and `send_message` ask you to confirm before they act. A client that can show an MCP confirmation prompt (Claude Code) shows one. On a client that cannot (claude.ai, Claude Desktop), the first call does nothing and returns a preview plus a `confirmToken`; only a repeat call with the same arguments and that token proceeds. A token works once, and is refused if anything it covers changed between the two calls (the file's title, the message body, the workspace's recipients).
159
+
160
+ | variable | default | |
161
+ |---|---|---|
162
+ | `MCP_CONFIRM_MODE` | `ask-user` | What a write does on a client that cannot show a confirmation prompt (claude.ai, Claude Desktop). `ask-user`: two steps — the first call does nothing and returns a preview plus a token, and the model must get your approval in chat before calling again with it. `auto`: the same two steps, but the model may use the token after reviewing the preview itself. `refuse`: writes are refused on such clients. A client that can show prompts (Claude Code) always gets the real prompt. An unrecognised value is treated as `refuse`. |
163
+ | `MCP_CONFIRM_TTL_SECONDS` | `600` | How long a token stays valid. |
164
+ | `MCP_CONFIRM_SECRET` | random per process | Signing key; set it only if tokens must survive a server restart. |
155
165
 
156
166
  ## Troubleshooting
157
167
 
@@ -188,7 +198,7 @@ Nothing else changes: existing sessions in `~/.honeybook-mcp/sessions.json` keep
188
198
 
189
199
  - Sessions are captured locally — auth tokens never leave your machine
190
200
  - `~/.honeybook-mcp/sessions.json` is written with mode 0600; directory with mode 0700
191
- - Write tools (`sign_contract`, `pay_invoice`) require `confirm:true` and return portal deep links rather than signing/paying headlessly
201
+ - Write tools (`sign_contract`, `pay_invoice`, `send_message`) ask you to confirm first (see [Confirmations](#confirmations)); `sign_contract` and `pay_invoice` return portal deep links rather than signing/paying headlessly
192
202
 
193
203
  ## Development
194
204
 
package/dist/bundle.js CHANGED
@@ -25547,7 +25547,7 @@ var require_websocket = __commonJS({
25547
25547
  var http = __require("http");
25548
25548
  var net = __require("net");
25549
25549
  var tls = __require("tls");
25550
- var { randomBytes: randomBytes3, createHash } = __require("crypto");
25550
+ var { randomBytes: randomBytes5, createHash: createHash4 } = __require("crypto");
25551
25551
  var { Duplex, Readable } = __require("stream");
25552
25552
  var { URL: URL2 } = __require("url");
25553
25553
  var PerMessageDeflate2 = require_permessage_deflate();
@@ -26085,7 +26085,7 @@ var require_websocket = __commonJS({
26085
26085
  }
26086
26086
  }
26087
26087
  const defaultPort = isSecure ? 443 : 80;
26088
- const key = randomBytes3(16).toString("base64");
26088
+ const key = randomBytes5(16).toString("base64");
26089
26089
  const request = isSecure ? https.request : http.request;
26090
26090
  const protocolSet = /* @__PURE__ */ new Set();
26091
26091
  let perMessageDeflate;
@@ -26215,7 +26215,7 @@ var require_websocket = __commonJS({
26215
26215
  abortHandshake(websocket, socket, "Invalid Upgrade header");
26216
26216
  return;
26217
26217
  }
26218
- const digest = createHash("sha1").update(key + GUID).digest("base64");
26218
+ const digest = createHash4("sha1").update(key + GUID).digest("base64");
26219
26219
  if (res.headers["sec-websocket-accept"] !== digest) {
26220
26220
  abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header");
26221
26221
  return;
@@ -26584,7 +26584,7 @@ var require_websocket_server = __commonJS({
26584
26584
  var EventEmitter = __require("events");
26585
26585
  var http = __require("http");
26586
26586
  var { Duplex } = __require("stream");
26587
- var { createHash } = __require("crypto");
26587
+ var { createHash: createHash4 } = __require("crypto");
26588
26588
  var extension2 = require_extension();
26589
26589
  var PerMessageDeflate2 = require_permessage_deflate();
26590
26590
  var subprotocol2 = require_subprotocol();
@@ -26891,7 +26891,7 @@ var require_websocket_server = __commonJS({
26891
26891
  );
26892
26892
  }
26893
26893
  if (this._state > RUNNING) return abortHandshake(socket, 503);
26894
- const digest = createHash("sha1").update(key + GUID).digest("base64");
26894
+ const digest = createHash4("sha1").update(key + GUID).digest("base64");
26895
26895
  const headers = [
26896
26896
  "HTTP/1.1 101 Switching Protocols",
26897
26897
  "Upgrade: websocket",
@@ -27011,6 +27011,33 @@ var storage2 = new AsyncLocalStorage2();
27011
27011
  function withCallerCapabilities(capabilities, fn) {
27012
27012
  return capabilities ? storage2.run(capabilities, fn) : fn();
27013
27013
  }
27014
+ function currentCallerCapabilities() {
27015
+ return storage2.getStore();
27016
+ }
27017
+ var ENVELOPE_CAPABILITIES_KEY = "io.modelcontextprotocol/clientCapabilities";
27018
+ var ELICITATION_MODES = ["form", "url"];
27019
+ function isRecord(value) {
27020
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27021
+ }
27022
+ function callerCapabilities(ctx) {
27023
+ const envelope = isRecord(ctx) && isRecord(ctx.mcpReq) ? ctx.mcpReq.envelope : void 0;
27024
+ if (isRecord(envelope)) {
27025
+ const declared = envelope[ENVELOPE_CAPABILITIES_KEY];
27026
+ if (isRecord(declared))
27027
+ return declared;
27028
+ }
27029
+ return currentCallerCapabilities();
27030
+ }
27031
+ function callerAcceptsFormElicitation(ctx) {
27032
+ const capabilities = callerCapabilities(ctx);
27033
+ if (!capabilities)
27034
+ return void 0;
27035
+ const elicitation = capabilities.elicitation;
27036
+ if (!isRecord(elicitation))
27037
+ return false;
27038
+ const namedModes = ELICITATION_MODES.filter((mode) => mode in elicitation);
27039
+ return namedModes.length === 0 || namedModes.includes("form");
27040
+ }
27014
27041
 
27015
27042
  // node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
27016
27043
  var __create2 = Object.create;
@@ -31409,6 +31436,37 @@ var inputRequired = Object.assign(buildInputRequired, {
31409
31436
  return { method: "roots/list" };
31410
31437
  }
31411
31438
  });
31439
+ function acceptedContent(responses, key, schema) {
31440
+ const view = inputResponse(responses, key);
31441
+ if (view.kind !== "elicit" || view.action !== "accept" || view.content === void 0) return void 0;
31442
+ if (schema === void 0) return view.content;
31443
+ const outcome = schema["~standard"].validate(view.content);
31444
+ if (outcome instanceof Promise) throw new TypeError("acceptedContent(responses, key, schema) requires a synchronously-validating schema");
31445
+ return outcome.issues === void 0 ? outcome.value : void 0;
31446
+ }
31447
+ function inputResponse(responses, key) {
31448
+ if (responses === void 0 || typeof responses !== "object" || responses === null) return { kind: "missing" };
31449
+ const entry = responses[key];
31450
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return { kind: "missing" };
31451
+ const candidate = entry;
31452
+ if (candidate["action"] === "accept" || candidate["action"] === "decline" || candidate["action"] === "cancel") {
31453
+ const content = candidate["content"];
31454
+ return {
31455
+ kind: "elicit",
31456
+ action: candidate["action"],
31457
+ ...content !== null && typeof content === "object" && !Array.isArray(content) && { content }
31458
+ };
31459
+ }
31460
+ if (Array.isArray(candidate["roots"])) return {
31461
+ kind: "roots",
31462
+ roots: candidate["roots"]
31463
+ };
31464
+ if (typeof candidate["role"] === "string" && candidate["content"] !== void 0) return {
31465
+ kind: "sampling",
31466
+ result: candidate
31467
+ };
31468
+ return { kind: "missing" };
31469
+ }
31412
31470
  var REQUEST_STATE_ONLY_LEG_PACING_MS = 250;
31413
31471
  function inputRequiredRoundsExceededMessage(method, maxRounds) {
31414
31472
  return `Multi-round-trip request '${method}' still required input after ${maxRounds} rounds (inputRequired.maxRounds)`;
@@ -41456,14 +41514,14 @@ function serveStdio(factory, options = {}) {
41456
41514
  return false;
41457
41515
  };
41458
41516
  const answerLegacyRejection = (request, reason, requestedVersion) => {
41459
- const rejection2 = modernOnlyStrictRejection({
41517
+ const rejection3 = modernOnlyStrictRejection({
41460
41518
  kind: "legacy",
41461
41519
  reason,
41462
41520
  ...requestedVersion !== void 0 && { requestedVersion }
41463
41521
  }, SUPPORTED_MODERN_PROTOCOL_VERSIONS);
41464
- if (rejection2 === void 0) return Promise.resolve();
41465
- reportError(/* @__PURE__ */ new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection2.cell}): ${rejection2.message}`));
41466
- return writeErrorResponse(request.id, rejection2.code, rejection2.message, rejection2.data);
41522
+ if (rejection3 === void 0) return Promise.resolve();
41523
+ reportError(/* @__PURE__ */ new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection3.cell}): ${rejection3.message}`));
41524
+ return writeErrorResponse(request.id, rejection3.code, rejection3.message, rejection3.data);
41467
41525
  };
41468
41526
  const onInstanceClosed = (channel) => {
41469
41527
  if (closing || channel === discarding) return;
@@ -61427,6 +61485,11 @@ var MEDIA_QUALIFIER = "(?:primary|secondary|main|default|cover|hero|profile|mast
61427
61485
  var MEDIA_KEY = new RegExp(`^(?:(?:${MEDIA_QUALIFIER}|${MEDIA_NOUN})[_-]?)?${MEDIA_NOUN}s?(?:[_-]?(?:link|uri|url|src)s?)?$`, "i");
61428
61486
 
61429
61487
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
61488
+ function textResult(data) {
61489
+ return {
61490
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
61491
+ };
61492
+ }
61430
61493
  function rawTextResult(text) {
61431
61494
  return { content: [{ type: "text", text }] };
61432
61495
  }
@@ -61437,6 +61500,421 @@ function errorResult(message) {
61437
61500
  };
61438
61501
  }
61439
61502
 
61503
+ // node_modules/@chrischall/mcp-utils/dist/server/confirmation.js
61504
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
61505
+
61506
+ // node_modules/@chrischall/mcp-utils/dist/server/canonical.js
61507
+ function canonicalJson(value) {
61508
+ if (value === null || typeof value === "string" || typeof value === "boolean")
61509
+ return JSON.stringify(value);
61510
+ if (value === void 0)
61511
+ return "null";
61512
+ if (typeof value === "number")
61513
+ return Number.isFinite(value) ? JSON.stringify(value) : `{"$num":"${String(value)}"}`;
61514
+ if (typeof value === "bigint")
61515
+ return `{"$bigint":"${value.toString()}"}`;
61516
+ if (typeof value === "function" || typeof value === "symbol") {
61517
+ throw new TypeError(`confirmation: cannot bind a ${typeof value} argument value.`);
61518
+ }
61519
+ if (Array.isArray(value))
61520
+ return `[${value.map(canonicalJson).join(",")}]`;
61521
+ if (value instanceof Date) {
61522
+ return `{"$date":${JSON.stringify(Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString())}}`;
61523
+ }
61524
+ if (ArrayBuffer.isView(value)) {
61525
+ const bytes = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
61526
+ return `{"$bytes":${JSON.stringify(bytes.toString("base64"))}}`;
61527
+ }
61528
+ if (value instanceof ArrayBuffer)
61529
+ return `{"$bytes":${JSON.stringify(Buffer.from(value).toString("base64"))}}`;
61530
+ if (value instanceof Map) {
61531
+ const entries2 = [...value.entries()].map(([k, v]) => `[${canonicalJson(k)},${canonicalJson(v)}]`).sort();
61532
+ return `{"$map":[${entries2.join(",")}]}`;
61533
+ }
61534
+ if (value instanceof Set) {
61535
+ return `{"$set":[${[...value].map(canonicalJson).sort().join(",")}]}`;
61536
+ }
61537
+ const proto = Object.getPrototypeOf(value);
61538
+ if (proto !== Object.prototype && proto !== null) {
61539
+ throw new TypeError("confirmation: cannot bind a non-plain object argument value (class instance).");
61540
+ }
61541
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
61542
+ return `{${entries.map(([k, v]) => `${JSON.stringify(escapeKey(k))}:${canonicalJson(v)}`).join(",")}}`;
61543
+ }
61544
+ function escapeKey(key) {
61545
+ return key.startsWith("$") ? `$${key}` : key;
61546
+ }
61547
+
61548
+ // node_modules/@chrischall/mcp-utils/dist/server/confirmation.js
61549
+ var DEFAULT_REQUEST_KEY = "confirmation";
61550
+ var DEFAULT_CONFIRMATION_LABEL = "Confirm this action should proceed.";
61551
+ var UNSUPPORTED_NOTE = "Nothing was done because this client cannot show a confirmation prompt (it declares no MCP elicitation capability), and this action is never taken without one";
61552
+ var STATE_PREFIX = "mcpu.confirm.v1.";
61553
+ function bindingKey(binding) {
61554
+ if (binding.ttlSeconds !== void 0 && !(Number.isFinite(binding.ttlSeconds) && binding.ttlSeconds > 0)) {
61555
+ throw new RangeError("requireConfirmation: binding.ttlSeconds must be a finite number greater than 0.");
61556
+ }
61557
+ const key = typeof binding.key === "string" ? Buffer.from(binding.key, "utf8") : Buffer.from(binding.key);
61558
+ if (key.length < 32) {
61559
+ throw new RangeError("requireConfirmation: binding.key must be at least 32 bytes.");
61560
+ }
61561
+ return key;
61562
+ }
61563
+ function commitment(action, args) {
61564
+ return createHash("sha256").update(`${action}\0${canonicalJson(args)}`).digest("base64url");
61565
+ }
61566
+ function mintState(key, action, binding) {
61567
+ const exp = Math.floor(Date.now() / 1e3) + (binding.ttlSeconds ?? 600);
61568
+ const body = Buffer.from(JSON.stringify({ c: commitment(action, binding.args), exp })).toString("base64url");
61569
+ const mac3 = createHmac("sha256", key).update(`${STATE_PREFIX}${body}`).digest("base64url");
61570
+ return `${STATE_PREFIX}${body}.${mac3}`;
61571
+ }
61572
+ function verifyState(key, action, binding, state) {
61573
+ if (typeof state !== "string" || !state.startsWith(STATE_PREFIX))
61574
+ return false;
61575
+ const rest = state.slice(STATE_PREFIX.length);
61576
+ const dot = rest.indexOf(".");
61577
+ if (dot < 0)
61578
+ return false;
61579
+ const body = rest.slice(0, dot);
61580
+ const mac3 = Buffer.from(rest.slice(dot + 1), "base64url");
61581
+ const expected = createHmac("sha256", key).update(`${STATE_PREFIX}${body}`).digest();
61582
+ if (mac3.length !== expected.length || !timingSafeEqual(mac3, expected))
61583
+ return false;
61584
+ let payload;
61585
+ try {
61586
+ payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
61587
+ } catch {
61588
+ return false;
61589
+ }
61590
+ if (typeof payload.exp !== "number" || payload.exp * 1e3 <= Date.now())
61591
+ return false;
61592
+ const want = Buffer.from(commitment(action, binding.args));
61593
+ const got = Buffer.from(typeof payload.c === "string" ? payload.c : "");
61594
+ return got.length === want.length && timingSafeEqual(got, want);
61595
+ }
61596
+ function echoedState(ctx) {
61597
+ const accessor = ctx.mcpReq.requestState;
61598
+ return typeof accessor === "function" ? accessor() : void 0;
61599
+ }
61600
+ function requireConfirmation(ctx, options) {
61601
+ const requestKey = options.requestKey ?? DEFAULT_REQUEST_KEY;
61602
+ const key = options.binding ? bindingKey(options.binding) : void 0;
61603
+ const confirmationSchema = external_exports.object({
61604
+ confirmed: external_exports.boolean().describe(options.confirmationLabel ?? DEFAULT_CONFIRMATION_LABEL)
61605
+ });
61606
+ const ask = () => {
61607
+ const preview = {
61608
+ action: options.action,
61609
+ ...options.details === void 0 ? {} : { details: options.details }
61610
+ };
61611
+ return inputRequired({
61612
+ inputRequests: {
61613
+ [requestKey]: inputRequired.elicit({
61614
+ message: `${options.message}
61615
+ ${JSON.stringify(preview, null, 2)}`,
61616
+ requestedSchema: confirmationSchema
61617
+ })
61618
+ },
61619
+ ...key && options.binding ? { requestState: mintState(key, options.action, options.binding) } : {}
61620
+ });
61621
+ };
61622
+ const response = inputResponse(ctx.mcpReq.inputResponses, requestKey);
61623
+ const accepted = acceptedContent(ctx.mcpReq.inputResponses, requestKey, confirmationSchema);
61624
+ if (response.kind === "missing") {
61625
+ if (callerAcceptsFormElicitation(ctx) === false) {
61626
+ return textResult({
61627
+ confirmed: false,
61628
+ dispatched: false,
61629
+ action: options.action,
61630
+ reason: "confirmation-unsupported",
61631
+ note: options.unsupportedNote ? `${UNSUPPORTED_NOTE}. ${options.unsupportedNote}` : `${UNSUPPORTED_NOTE}.`
61632
+ });
61633
+ }
61634
+ return ask();
61635
+ }
61636
+ if (response.kind === "elicit" && response.action === "accept" && accepted?.confirmed === true) {
61637
+ if (key && options.binding) {
61638
+ const state = echoedState(ctx);
61639
+ if (state === void 0 || state === null) {
61640
+ return errorResult(`Confirmation for ${options.action} was accepted, but the retry carried no requestState, so it cannot be checked against the prompt that was shown. Nothing was done. The likely cause is that the MCP client or host does not round-trip requestState (it must echo the requestState from the input_required result back on the retry); asking again would loop.`);
61641
+ }
61642
+ if (!verifyState(key, options.action, options.binding, state))
61643
+ return ask();
61644
+ }
61645
+ return void 0;
61646
+ }
61647
+ return textResult({
61648
+ confirmed: false,
61649
+ cancelled: true,
61650
+ action: options.action,
61651
+ note: "Nothing was changed because the confirmation was declined, cancelled, or left unchecked."
61652
+ });
61653
+ }
61654
+
61655
+ // node_modules/@chrischall/mcp-utils/dist/server/confirm-token.js
61656
+ import { createHash as createHash2, createHmac as createHmac2, randomBytes, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
61657
+ function createSpentTokenStore() {
61658
+ const spent = /* @__PURE__ */ new Map();
61659
+ return {
61660
+ has: (nonce) => spent.has(nonce),
61661
+ add: (nonce, exp) => {
61662
+ spent.set(nonce, exp);
61663
+ },
61664
+ prune: (now) => {
61665
+ for (const [nonce, exp] of spent)
61666
+ if (exp < now)
61667
+ spent.delete(nonce);
61668
+ },
61669
+ clear: () => spent.clear(),
61670
+ get size() {
61671
+ return spent.size;
61672
+ }
61673
+ };
61674
+ }
61675
+ var PROCESS_SPENT = createSpentTokenStore();
61676
+ var PREFIX = "mcpu.token.v1.";
61677
+ var DEFAULT_TTL_SECONDS = 600;
61678
+ function tokenKey(key) {
61679
+ const bytes = typeof key === "string" ? Buffer.from(key, "utf8") : Buffer.from(key);
61680
+ if (bytes.length < 32)
61681
+ throw new RangeError("confirm token: key must be at least 32 bytes.");
61682
+ return bytes;
61683
+ }
61684
+ function ttlOf(ttlSeconds) {
61685
+ if (ttlSeconds === void 0)
61686
+ return DEFAULT_TTL_SECONDS;
61687
+ if (!(Number.isFinite(ttlSeconds) && ttlSeconds > 0)) {
61688
+ throw new RangeError("confirm token: ttlSeconds must be a finite number greater than 0.");
61689
+ }
61690
+ return ttlSeconds;
61691
+ }
61692
+ function sign(key, body) {
61693
+ return createHmac2("sha256", key).update(`${PREFIX}${body}`).digest();
61694
+ }
61695
+ function hashConfirmPayload(payload) {
61696
+ return createHash2("sha256").update(canonicalJson(payload)).digest("base64url");
61697
+ }
61698
+ function issueConfirmToken(key, binding, options = {}) {
61699
+ const k = tokenKey(key);
61700
+ const now = options.now ?? Date.now();
61701
+ const exp = now + ttlOf(options.ttlSeconds) * 1e3;
61702
+ const claims = {
61703
+ t: binding.tool,
61704
+ ...binding.account === void 0 ? {} : { a: binding.account },
61705
+ g: binding.target,
61706
+ ...binding.revision === void 0 ? {} : { r: binding.revision },
61707
+ h: binding.payloadHash,
61708
+ exp,
61709
+ n: randomBytes(16).toString("base64url")
61710
+ };
61711
+ const body = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
61712
+ return { token: `${PREFIX}${body}.${sign(k, body).toString("base64url")}`, expiresAt: new Date(exp).toISOString() };
61713
+ }
61714
+ function parseClaims(body) {
61715
+ try {
61716
+ const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
61717
+ return claims && typeof claims === "object" ? claims : void 0;
61718
+ } catch {
61719
+ return void 0;
61720
+ }
61721
+ }
61722
+ function verifyConfirmToken(key, token, binding, options = {}) {
61723
+ const k = tokenKey(key);
61724
+ const now = options.now ?? Date.now();
61725
+ const spent = options.spent ?? PROCESS_SPENT;
61726
+ spent.prune(now);
61727
+ if (!token.startsWith(PREFIX))
61728
+ return { ok: false, error: "TOKEN_INVALID" };
61729
+ const rest = token.slice(PREFIX.length);
61730
+ const dot = rest.indexOf(".");
61731
+ const body = dot < 0 ? "" : rest.slice(0, dot);
61732
+ const mac3 = Buffer.from(dot < 0 ? "" : rest.slice(dot + 1), "base64url");
61733
+ const expected = sign(k, body);
61734
+ if (!body || mac3.length !== expected.length || !timingSafeEqual2(mac3, expected))
61735
+ return { ok: false, error: "TOKEN_INVALID" };
61736
+ const claims = parseClaims(body);
61737
+ if (!claims)
61738
+ return { ok: false, error: "TOKEN_INVALID" };
61739
+ if (claims.t !== binding.tool || claims.a !== binding.account || claims.g !== binding.target) {
61740
+ return { ok: false, error: "TOKEN_INVALID" };
61741
+ }
61742
+ if (spent.has(claims.n))
61743
+ return { ok: false, error: "TOKEN_REUSED" };
61744
+ if (now > claims.exp)
61745
+ return { ok: false, error: "TOKEN_EXPIRED" };
61746
+ if (claims.r !== binding.revision)
61747
+ return { ok: false, error: "DRAFT_CHANGED", reason: "revision-changed" };
61748
+ if (claims.h !== binding.payloadHash)
61749
+ return { ok: false, error: "DRAFT_CHANGED", reason: "payload-changed" };
61750
+ spent.add(claims.n, claims.exp);
61751
+ return { ok: true };
61752
+ }
61753
+ var CONFIRM_TOKEN_INSTRUCTION = "Show this preview to the user verbatim and proceed only after they explicitly approve in chat. Then call again with confirmToken.";
61754
+ var confirmTokenParam = external_exports.string().optional().describe(`ONLY for the two-step confirmation fallback (a client without MCP elicitation). The confirmToken from this same tool's phase-1 "confirmation-required" response, passed back ONLY after the user has seen that preview and explicitly approved it in chat \u2014 never on the first call, never invented, never reused. Call again with the same arguments. Ignored when the client supports elicitation.`);
61755
+ var TOKEN_ERROR_NOTE = {
61756
+ TOKEN_EXPIRED: "Nothing was sent or changed: the confirmToken expired. Call again WITHOUT confirmToken for a fresh preview, and ask the user to approve it again.",
61757
+ TOKEN_REUSED: "Nothing was sent or changed by this call: this confirmToken was already used, and one approval acts once. If doing it again is really intended, call again WITHOUT confirmToken and get a new approval.",
61758
+ TOKEN_INVALID: "Nothing was sent or changed: this confirmToken was not issued by this server for this tool, account and target (or the server has restarted since). Call again WITHOUT confirmToken for a fresh preview and approval."
61759
+ };
61760
+ var CHANGED_NOTE = {
61761
+ "revision-changed": "Nothing was sent or changed: the target was edited since the user approved it (its version rotated), so what would happen is not what they saw.",
61762
+ "payload-changed": "Nothing was sent or changed: what would happen no longer matches what the user approved."
61763
+ };
61764
+ function isToolResult(value) {
61765
+ return Array.isArray(value.content);
61766
+ }
61767
+ function rejection2(data) {
61768
+ return { ...textResult({ status: "confirmation-rejected", confirmed: false, dispatched: false, ...data }), isError: true };
61769
+ }
61770
+ async function tokenConfirmation(action, fb) {
61771
+ const subject = await fb.subject();
61772
+ if (isToolResult(subject))
61773
+ return subject;
61774
+ const binding = {
61775
+ tool: fb.tool,
61776
+ ...fb.account === void 0 ? {} : { account: fb.account },
61777
+ target: subject.target,
61778
+ ...subject.revision === void 0 ? {} : { revision: subject.revision },
61779
+ payloadHash: hashConfirmPayload(subject.payload)
61780
+ };
61781
+ const phaseOne = () => {
61782
+ const { token, expiresAt } = issueConfirmToken(fb.key, binding, { ttlSeconds: fb.ttlSeconds });
61783
+ return {
61784
+ action,
61785
+ preview: subject.preview,
61786
+ confirmToken: token,
61787
+ expiresAt,
61788
+ ttlSeconds: ttlOf(fb.ttlSeconds),
61789
+ instruction: fb.instruction ?? CONFIRM_TOKEN_INSTRUCTION
61790
+ };
61791
+ };
61792
+ if (!fb.confirmToken) {
61793
+ return textResult({ status: "confirmation-required", confirmed: false, dispatched: false, ...phaseOne() });
61794
+ }
61795
+ const verdict = verifyConfirmToken(fb.key, fb.confirmToken, binding, { spent: fb.spent });
61796
+ if (verdict.ok)
61797
+ return void 0;
61798
+ if (verdict.error === "DRAFT_CHANGED") {
61799
+ return rejection2({
61800
+ error: "DRAFT_CHANGED",
61801
+ reason: verdict.reason,
61802
+ note: `${CHANGED_NOTE[verdict.reason]} The current preview and a fresh confirmToken are below.`,
61803
+ ...phaseOne()
61804
+ });
61805
+ }
61806
+ return rejection2({ error: verdict.error, action, note: TOKEN_ERROR_NOTE[verdict.error] });
61807
+ }
61808
+ async function requireConfirmationWithFallback(ctx, options) {
61809
+ const { tokenFallback, ...confirmation } = options;
61810
+ if (tokenFallback && callerAcceptsFormElicitation(ctx) === false) {
61811
+ return tokenConfirmation(options.action, tokenFallback);
61812
+ }
61813
+ return requireConfirmation(ctx, confirmation);
61814
+ }
61815
+
61816
+ // node_modules/@chrischall/mcp-utils/dist/server/confirm-env.js
61817
+ import { createHash as createHash3, randomBytes as randomBytes2 } from "node:crypto";
61818
+
61819
+ // node_modules/@chrischall/mcp-utils/dist/config/index.js
61820
+ var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
61821
+ function readEnvVar(key, opts = {}) {
61822
+ const env = opts.env ?? process.env;
61823
+ const raw = env[key];
61824
+ if (typeof raw === "string") {
61825
+ const trimmed = raw.trim();
61826
+ if (trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null" && !PLACEHOLDER_RE.test(trimmed)) {
61827
+ return trimmed;
61828
+ }
61829
+ }
61830
+ return opts.default;
61831
+ }
61832
+ var TRUE_TOKENS = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
61833
+ var FALSE_TOKENS = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
61834
+ function parseBoolEnv(key, opts = {}) {
61835
+ const fallback = opts.default ?? false;
61836
+ const raw = readEnvVar(key, { env: opts.env });
61837
+ if (raw === void 0)
61838
+ return fallback;
61839
+ const token = raw.toLowerCase();
61840
+ if (TRUE_TOKENS.has(token))
61841
+ return true;
61842
+ if (FALSE_TOKENS.has(token))
61843
+ return false;
61844
+ return fallback;
61845
+ }
61846
+ async function loadDotenvSafely(opts = {}) {
61847
+ try {
61848
+ const mod = await import(
61849
+ /* @vite-ignore */
61850
+ "dotenv"
61851
+ );
61852
+ const result = mod.config({
61853
+ ...opts.path !== void 0 ? { path: opts.path } : {},
61854
+ override: opts.override ?? false,
61855
+ quiet: true
61856
+ });
61857
+ return result.error === void 0;
61858
+ } catch {
61859
+ return false;
61860
+ }
61861
+ }
61862
+
61863
+ // node_modules/@chrischall/mcp-utils/dist/server/confirm-env.js
61864
+ var MODES = /* @__PURE__ */ new Set(["ask-user", "auto", "refuse"]);
61865
+ var DEFAULT_TTL_SECONDS2 = 600;
61866
+ var warned = /* @__PURE__ */ new Set();
61867
+ function readConfirmMode(env = process.env) {
61868
+ const raw = readEnvVar("MCP_CONFIRM_MODE", { env })?.trim().toLowerCase();
61869
+ if (!raw)
61870
+ return "ask-user";
61871
+ if (MODES.has(raw))
61872
+ return raw;
61873
+ if (!warned.has(raw)) {
61874
+ warned.add(raw);
61875
+ process.stderr.write(`MCP_CONFIRM_MODE="${raw}" is not one of ask-user, auto, refuse; treating it as refuse.
61876
+ `);
61877
+ }
61878
+ return "refuse";
61879
+ }
61880
+ function confirmTtlFromEnv(env = process.env) {
61881
+ const raw = readEnvVar("MCP_CONFIRM_TTL_SECONDS", { env })?.trim();
61882
+ return raw && /^[1-9]\d*$/.test(raw) ? Number(raw) : DEFAULT_TTL_SECONDS2;
61883
+ }
61884
+ var processKey;
61885
+ function confirmKeyFromEnv(env = process.env) {
61886
+ const secret = readEnvVar("MCP_CONFIRM_SECRET", { env });
61887
+ if (secret)
61888
+ return createHash3("sha256").update(secret, "utf8").digest();
61889
+ processKey ??= randomBytes2(32);
61890
+ return processKey;
61891
+ }
61892
+ var CONFIRM_TOKEN_AUTO_INSTRUCTION = "Nothing has been done yet. Review this preview; if it is what was intended, call again with the same arguments plus confirmToken. (This server runs with MCP_CONFIRM_MODE=auto, so the user's approval in chat is not required.)";
61893
+ var REFUSE_HINT = "Set MCP_CONFIRM_MODE=ask-user on the server to allow two-step confirmation instead.";
61894
+ function confirmationFromEnv(options) {
61895
+ const { tool, account, confirmToken, subject, instruction, spent, env = process.env, ...confirmation } = options;
61896
+ const mode = readConfirmMode(env);
61897
+ if (mode === "refuse") {
61898
+ return {
61899
+ ...confirmation,
61900
+ unsupportedNote: confirmation.unsupportedNote ? `${confirmation.unsupportedNote} ${REFUSE_HINT}` : REFUSE_HINT
61901
+ };
61902
+ }
61903
+ return {
61904
+ ...confirmation,
61905
+ tokenFallback: {
61906
+ key: confirmKeyFromEnv(env),
61907
+ tool,
61908
+ ...account === void 0 ? {} : { account },
61909
+ ...confirmToken === void 0 ? {} : { confirmToken },
61910
+ subject,
61911
+ ttlSeconds: confirmTtlFromEnv(env),
61912
+ instruction: mode === "auto" ? CONFIRM_TOKEN_AUTO_INSTRUCTION : instruction ?? CONFIRM_TOKEN_INSTRUCTION,
61913
+ ...spent === void 0 ? {} : { spent }
61914
+ }
61915
+ };
61916
+ }
61917
+
61440
61918
  // node_modules/@chrischall/mcp-utils/dist/server/index.js
61441
61919
  var SERVER_PROTOCOL_VERSIONS = Object.freeze([
61442
61920
  "2026-07-28",
@@ -61612,50 +62090,6 @@ function runMcp(opts) {
61612
62090
  return handle;
61613
62091
  }
61614
62092
 
61615
- // node_modules/@chrischall/mcp-utils/dist/config/index.js
61616
- var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
61617
- function readEnvVar(key, opts = {}) {
61618
- const env = opts.env ?? process.env;
61619
- const raw = env[key];
61620
- if (typeof raw === "string") {
61621
- const trimmed = raw.trim();
61622
- if (trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null" && !PLACEHOLDER_RE.test(trimmed)) {
61623
- return trimmed;
61624
- }
61625
- }
61626
- return opts.default;
61627
- }
61628
- var TRUE_TOKENS = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
61629
- var FALSE_TOKENS = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
61630
- function parseBoolEnv(key, opts = {}) {
61631
- const fallback = opts.default ?? false;
61632
- const raw = readEnvVar(key, { env: opts.env });
61633
- if (raw === void 0)
61634
- return fallback;
61635
- const token = raw.toLowerCase();
61636
- if (TRUE_TOKENS.has(token))
61637
- return true;
61638
- if (FALSE_TOKENS.has(token))
61639
- return false;
61640
- return fallback;
61641
- }
61642
- async function loadDotenvSafely(opts = {}) {
61643
- try {
61644
- const mod = await import(
61645
- /* @vite-ignore */
61646
- "dotenv"
61647
- );
61648
- const result = mod.config({
61649
- ...opts.path !== void 0 ? { path: opts.path } : {},
61650
- override: opts.override ?? false,
61651
- quiet: true
61652
- });
61653
- return result.error === void 0;
61654
- } catch {
61655
- return false;
61656
- }
61657
- }
61658
-
61659
62093
  // node_modules/@chrischall/mcp-utils/dist/http/index.js
61660
62094
  function formatApiError(status, method, path, errorText, opts = {}) {
61661
62095
  const service = opts.service ?? "API";
@@ -63977,12 +64411,12 @@ async function awaitSessionReady(ready, opts) {
63977
64411
 
63978
64412
  // node_modules/@fetchproxy/server/dist/extension-trust.js
63979
64413
  import { readFile as readFile2, writeFile as writeFile2, rename as rename2, unlink as unlink2, mkdir as mkdir2, chmod as chmod2, rm as rm2 } from "node:fs/promises";
63980
- import { randomBytes as randomBytes2 } from "node:crypto";
64414
+ import { randomBytes as randomBytes4 } from "node:crypto";
63981
64415
  import { isAbsolute as isAbsolute2, join as join5 } from "node:path";
63982
64416
 
63983
64417
  // node_modules/@fetchproxy/server/dist/identity.js
63984
64418
  import { readFile, readdir, writeFile, mkdir, chmod, rename, rm, unlink, link, lstat } from "node:fs/promises";
63985
- import { randomBytes } from "node:crypto";
64419
+ import { randomBytes as randomBytes3 } from "node:crypto";
63986
64420
  import { isAbsolute, join as join4 } from "node:path";
63987
64421
  import { homedir as homedir2 } from "node:os";
63988
64422
  var SAFE_PLAIN = /^[A-Za-z0-9._-]+$/;
@@ -64103,7 +64537,7 @@ var NO_HARD_LINKS = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "
64103
64537
  async function publishIdentityIfAbsent(dir, serverName, id) {
64104
64538
  const path = identityFilePath(dir, serverName);
64105
64539
  await sweepStagingFiles(dir, serverName, { olderThanMs: 6e4 });
64106
- const tmp = `${path}.${randomBytes(STAGING_BYTES).toString("hex")}.tmp`;
64540
+ const tmp = `${path}.${randomBytes3(STAGING_BYTES).toString("hex")}.tmp`;
64107
64541
  try {
64108
64542
  await writeFile(tmp, serializeIdentity(id), { mode: 384, flag: "wx" });
64109
64543
  await chmod(tmp, 384);
@@ -64232,7 +64666,7 @@ async function readExtensionPin(serverName, dir = defaultTrustDir()) {
64232
64666
  async function writeExtensionPin(serverName, pin, dir = defaultTrustDir()) {
64233
64667
  const path = extensionTrustPath(serverName, dir);
64234
64668
  await mkdir2(dir, { recursive: true, mode: 448 });
64235
- const tmp = `${path}.${randomBytes2(6).toString("hex")}.tmp`;
64669
+ const tmp = `${path}.${randomBytes4(6).toString("hex")}.tmp`;
64236
64670
  try {
64237
64671
  await writeFile2(tmp, JSON.stringify(pin, null, 2), { mode: 384, flag: "wx" });
64238
64672
  await chmod2(tmp, 384);
@@ -67812,7 +68246,7 @@ function bridgeErrorInfo(err) {
67812
68246
  // package.json
67813
68247
  var package_default = {
67814
68248
  name: "honeybook-mcp",
67815
- version: "1.1.4",
68249
+ version: "1.2.0",
67816
68250
  mcpName: "io.github.chrischall/honeybook-mcp",
67817
68251
  description: "HoneyBook client-portal MCP server for Claude",
67818
68252
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -67856,7 +68290,7 @@ var package_default = {
67856
68290
  typecheck: "tsc -p tsconfig.json --noEmit"
67857
68291
  },
67858
68292
  dependencies: {
67859
- "@chrischall/mcp-utils": "^2.4.0",
68293
+ "@chrischall/mcp-utils": "^2.6.0",
67860
68294
  "@fetchproxy/bootstrap": "^3.2.0",
67861
68295
  "@fetchproxy/server": "^3.2.0",
67862
68296
  "@modelcontextprotocol/server": "^2.0.0",
@@ -68737,7 +69171,7 @@ function registerPaymentMethodTools(server) {
68737
69171
  }
68738
69172
 
68739
69173
  // src/tools/contracts.ts
68740
- async function signContract(args) {
69174
+ async function signContract(args, ctx) {
68741
69175
  const client = await getActiveClient(args.origin);
68742
69176
  const file2 = await client.request(
68743
69177
  "GET",
@@ -68751,12 +69185,28 @@ async function signContract(args) {
68751
69185
  if (file2.is_file_accepted) {
68752
69186
  throw new Error(`Contract ${args.file_id} ("${file2.file_title}") is already signed.`);
68753
69187
  }
68754
- if (!args.confirm) {
68755
- return rawTextResult(
68756
- `About to sign "${file2.file_title}" (${file2.status_name || "not signed"}).
68757
- Re-run sign_contract with { confirm: true } to proceed.`
68758
- );
68759
- }
69188
+ const preview = {
69189
+ file_id: file2._id,
69190
+ file_title: file2.file_title,
69191
+ status: file2.status_name || "not signed"
69192
+ };
69193
+ const gate = await requireConfirmationWithFallback(
69194
+ ctx,
69195
+ confirmationFromEnv({
69196
+ action: "contract.sign",
69197
+ message: "Review and confirm signing this contract:",
69198
+ details: preview,
69199
+ tool: "sign_contract",
69200
+ account: client.scope.portalOrigin,
69201
+ confirmToken: args.confirmToken,
69202
+ subject: () => ({
69203
+ target: String(file2._id),
69204
+ payload: { file_id: file2._id, file_title: file2.file_title },
69205
+ preview
69206
+ })
69207
+ })
69208
+ );
69209
+ if (gate) return gate;
68760
69210
  vendorPortalSubdomain(client.scope.portalOrigin);
68761
69211
  const url4 = `${client.scope.portalOrigin}/app/workspace_file/${encodeURIComponent(String(file2._id))}/agreement`;
68762
69212
  return rawTextResult(
@@ -68773,15 +69223,13 @@ function registerContractTools(server) {
68773
69223
  server.registerTool(
68774
69224
  "sign_contract",
68775
69225
  {
68776
- description: "Sign a contract you received from a vendor. In v1 this returns a deep link to the HoneyBook portal instead of signing headlessly. Requires confirm:true.",
69226
+ description: "Sign a contract you received from a vendor. In v1 this returns a deep link to the HoneyBook portal instead of signing headlessly. Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).",
68777
69227
  inputSchema: external_exports.object({
68778
69228
  file_id: external_exports.string().describe("The agreement file _id from list_workspace_files (file_type=agreement)."),
68779
69229
  origin: schemaOrigin.describe(
68780
69230
  "Portal origin (e.g. https://<vendor>.hbportal.co). Optional when only one session is active."
68781
69231
  ),
68782
- confirm: schemaConfirm.describe(
68783
- "Must be true to proceed. Without this, tool returns a preview."
68784
- )
69232
+ confirmToken: confirmTokenParam
68785
69233
  }),
68786
69234
  annotations: { destructiveHint: true }
68787
69235
  },
@@ -68790,7 +69238,7 @@ function registerContractTools(server) {
68790
69238
  }
68791
69239
 
68792
69240
  // src/tools/invoices.ts
68793
- async function payInvoice(args) {
69241
+ async function payInvoice(args, ctx) {
68794
69242
  const client = await getActiveClient(args.origin);
68795
69243
  const file2 = await client.request("GET", apiPath`/api/v2/workspace_files/${args.file_id}`);
68796
69244
  if (file2.file_type !== "invoice") {
@@ -68798,12 +69246,29 @@ async function payInvoice(args) {
68798
69246
  `File ${args.file_id} is not an invoice (file_type=${file2.file_type}). Only invoices can be paid.`
68799
69247
  );
68800
69248
  }
68801
- if (!args.confirm) {
68802
- return rawTextResult(
68803
- `About to pay "${file2.file_title}" (${file2.status_name || "open"}).
68804
- Re-run pay_invoice with { confirm: true } to proceed.`
68805
- );
68806
- }
69249
+ const preview = {
69250
+ file_id: file2._id,
69251
+ file_title: file2.file_title,
69252
+ status: file2.status_name || "open",
69253
+ has_pending_payment: Boolean(file2.has_pending_payment)
69254
+ };
69255
+ const gate = await requireConfirmationWithFallback(
69256
+ ctx,
69257
+ confirmationFromEnv({
69258
+ action: "invoice.pay",
69259
+ message: "Review and confirm paying this invoice:",
69260
+ details: preview,
69261
+ tool: "pay_invoice",
69262
+ account: client.scope.portalOrigin,
69263
+ confirmToken: args.confirmToken,
69264
+ subject: () => ({
69265
+ target: String(file2._id),
69266
+ payload: { file_id: file2._id, file_title: file2.file_title, has_pending_payment: preview.has_pending_payment },
69267
+ preview
69268
+ })
69269
+ })
69270
+ );
69271
+ if (gate) return gate;
68807
69272
  vendorPortalSubdomain(client.scope.portalOrigin);
68808
69273
  const url4 = `${client.scope.portalOrigin}/app/workspace_file/${encodeURIComponent(String(file2._id))}/invoice`;
68809
69274
  const pendingNote = file2.has_pending_payment ? "\n\nNote: this invoice already has a pending payment \u2014 check the status before re-paying." : "";
@@ -68819,15 +69284,13 @@ function registerInvoiceTools(server) {
68819
69284
  server.registerTool(
68820
69285
  "pay_invoice",
68821
69286
  {
68822
- description: "Pay an invoice from a vendor. In v1 this returns a deep link to the HoneyBook portal instead of paying headlessly. Requires confirm:true.",
69287
+ description: "Pay an invoice from a vendor. In v1 this returns a deep link to the HoneyBook portal instead of paying headlessly. Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).",
68823
69288
  inputSchema: external_exports.object({
68824
69289
  file_id: external_exports.string().describe("The invoice file _id from list_workspace_files (file_type=invoice)."),
68825
69290
  origin: schemaOrigin.describe(
68826
69291
  "Portal origin (e.g. https://<vendor>.hbportal.co). Optional when only one session is active."
68827
69292
  ),
68828
- confirm: schemaConfirm.describe(
68829
- "Must be true to proceed. Without this, tool returns a preview."
68830
- )
69293
+ confirmToken: confirmTokenParam
68831
69294
  }),
68832
69295
  annotations: { destructiveHint: true }
68833
69296
  },
@@ -69298,7 +69761,7 @@ function bodyToHtml(body) {
69298
69761
  if (HTML_TAG.test(body)) return body;
69299
69762
  return body.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\r?\n/g, "<br>");
69300
69763
  }
69301
- async function sendMessage(args) {
69764
+ async function sendMessage(args, ctx) {
69302
69765
  const client = await getActiveClient(args.origin);
69303
69766
  const meId = client.scope.userId;
69304
69767
  const feed = await fetchWorkspaceFeed(client, args.workspace_id);
@@ -69321,18 +69784,7 @@ async function sendMessage(args) {
69321
69784
  }
69322
69785
  if (!args.body.trim()) throw new Error("The message body is empty.");
69323
69786
  const recipients = Object.values(feed.users).filter((u) => u._id !== meId);
69324
- const recipientLine = recipients.map((u) => `${u.name}${u.email ? ` <${u.email}>` : ""}`).join(", ");
69325
- if (!args.confirm) {
69326
- return rawTextResult(
69327
- `About to send a message in workspace ${args.workspace_id} via the HoneyBook portal.
69328
- To: ${recipientLine || "(everyone in the workspace)"}
69329
- Subject: ${subject}${replyTo ? ` (reply to "${String(replyTo._id)}")` : ""}
69330
-
69331
- ${args.body}
69332
-
69333
- Re-run send_message with { confirm: true } to send it.`
69334
- );
69335
- }
69787
+ const to = recipients.map((u) => `${u.name}${u.email ? ` <${u.email}>` : ""}`);
69336
69788
  const taskData = {
69337
69789
  ws_id: args.workspace_id,
69338
69790
  subject,
@@ -69343,6 +69795,33 @@ Re-run send_message with { confirm: true } to send it.`
69343
69795
  flow_attachments: []
69344
69796
  };
69345
69797
  if (replyTo) taskData.feed_to_reply_id = replyTo._id;
69798
+ const preview = {
69799
+ workspace_id: args.workspace_id,
69800
+ via: "HoneyBook portal",
69801
+ to: to.length ? to : ["(everyone in the workspace)"],
69802
+ subject,
69803
+ reply_to: replyTo ? replyTo._id : null,
69804
+ body: args.body
69805
+ };
69806
+ const gate = await requireConfirmationWithFallback(
69807
+ ctx,
69808
+ confirmationFromEnv({
69809
+ action: "message.send",
69810
+ message: "Review and confirm sending this message:",
69811
+ details: preview,
69812
+ tool: "send_message",
69813
+ account: client.scope.portalOrigin,
69814
+ confirmToken: args.confirmToken,
69815
+ subject: () => ({
69816
+ target: args.workspace_id,
69817
+ // What goes out, and who HoneyBook emails it to: a membership change
69818
+ // between the two calls changes the recipients, so it must be re-approved.
69819
+ payload: { task: taskData, recipients: recipients.map((u) => u._id) },
69820
+ preview
69821
+ })
69822
+ })
69823
+ );
69824
+ if (gate) return gate;
69346
69825
  let outcome;
69347
69826
  try {
69348
69827
  outcome = await runClientPendingTask(client, "send_workspace_message", taskData);
@@ -69417,16 +69896,14 @@ function registerMessageTools(server) {
69417
69896
  server.registerTool(
69418
69897
  "send_message",
69419
69898
  {
69420
- description: 'Send a message to the vendor (and the other members of the workspace) through the HoneyBook portal, exactly as the Activity tab composer does. HoneyBook emails it to every recipient. Pass reply_to_message_id to reply in-thread (the subject is inherited). Requires confirm:true. If HoneyBook has not finished sending within about a minute the result has status "pending" and a task_id: the message may still be delivered, so check list_messages before resending.',
69899
+ description: 'Send a message to the vendor (and the other members of the workspace) through the HoneyBook portal, exactly as the Activity tab composer does. HoneyBook emails it to every recipient. Pass reply_to_message_id to reply in-thread (the subject is inherited). Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE). If HoneyBook has not finished sending within about a minute the result has status "pending" and a task_id: the message may still be delivered, so check list_messages before resending.',
69421
69900
  inputSchema: external_exports.object({
69422
69901
  workspace_id: external_exports.string().describe(WORKSPACE_DESC),
69423
69902
  body: external_exports.string().describe("Message text. Plain text is sent as-is (line breaks preserved); HTML is passed through."),
69424
69903
  subject: external_exports.string().optional().describe("Required for a new message; optional on a reply."),
69425
69904
  reply_to_message_id: external_exports.string().optional().describe("A message _id from list_messages to reply to."),
69426
69905
  origin: schemaOrigin.describe(ORIGIN_DESC),
69427
- confirm: schemaConfirm.describe(
69428
- "Must be true to actually send. Without it the tool returns a preview of what would go out."
69429
- )
69906
+ confirmToken: confirmTokenParam
69430
69907
  }),
69431
69908
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
69432
69909
  },
@@ -69675,7 +70152,7 @@ var __dirname = dirname2(fileURLToPath(import.meta.url));
69675
70152
  await loadDotenvSafely({ path: join6(__dirname, "..", ".env"), override: false });
69676
70153
  await runMcp({
69677
70154
  name: "honeybook-mcp",
69678
- version: "1.1.4",
70155
+ version: "1.2.0",
69679
70156
  // x-release-please-version
69680
70157
  tools: [
69681
70158
  registerSessionTools,
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ import { registerAttachmentTools } from './tools/attachments.js';
20
20
  import { registerPaymentTools } from './tools/payments.js';
21
21
  await runMcp({
22
22
  name: 'honeybook-mcp',
23
- version: '1.1.4', // x-release-please-version
23
+ version: '1.2.0', // x-release-please-version
24
24
  tools: [
25
25
  registerSessionTools,
26
26
  registerFlowTools,
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod';
2
- import { rawTextResult, schemaOrigin, schemaConfirm } from '@chrischall/mcp-utils';
2
+ import { confirmationFromEnv, confirmTokenParam, rawTextResult, requireConfirmationWithFallback, schemaOrigin, } from '@chrischall/mcp-utils';
3
3
  import { apiPath, getActiveClient } from '../client.js';
4
4
  import { vendorPortalSubdomain } from '../sessions.js';
5
- export async function signContract(args) {
5
+ export async function signContract(args, ctx) {
6
6
  const client = await getActiveClient(args.origin);
7
7
  const file = await client.request('GET', apiPath `/api/v2/workspace_files/${args.file_id}`);
8
8
  if (file.file_type !== 'agreement') {
@@ -11,10 +11,26 @@ export async function signContract(args) {
11
11
  if (file.is_file_accepted) {
12
12
  throw new Error(`Contract ${args.file_id} ("${file.file_title}") is already signed.`);
13
13
  }
14
- if (!args.confirm) {
15
- return rawTextResult(`About to sign "${file.file_title}" (${file.status_name || 'not signed'}).\n` +
16
- `Re-run sign_contract with { confirm: true } to proceed.`);
17
- }
14
+ const preview = {
15
+ file_id: file._id,
16
+ file_title: file.file_title,
17
+ status: file.status_name || 'not signed',
18
+ };
19
+ const gate = await requireConfirmationWithFallback(ctx, confirmationFromEnv({
20
+ action: 'contract.sign',
21
+ message: 'Review and confirm signing this contract:',
22
+ details: preview,
23
+ tool: 'sign_contract',
24
+ account: client.scope.portalOrigin,
25
+ confirmToken: args.confirmToken,
26
+ subject: () => ({
27
+ target: String(file._id),
28
+ payload: { file_id: file._id, file_title: file.file_title },
29
+ preview,
30
+ }),
31
+ }));
32
+ if (gate)
33
+ return gate;
18
34
  // Re-checked here, not only at capture: a sessions.json written before the
19
35
  // host rule existed can still hold a foreign origin (fleet-audit#139).
20
36
  vendorPortalSubdomain(client.scope.portalOrigin);
@@ -25,13 +41,14 @@ export async function signContract(args) {
25
41
  }
26
42
  export function registerContractTools(server) {
27
43
  server.registerTool('sign_contract', {
28
- description: 'Sign a contract you received from a vendor. In v1 this returns a deep link to the HoneyBook portal instead of signing headlessly. Requires confirm:true.',
44
+ description: 'Sign a contract you received from a vendor. In v1 this returns a deep link to the HoneyBook portal instead of signing headlessly. ' +
45
+ 'Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).',
29
46
  inputSchema: z.object({
30
47
  file_id: z
31
48
  .string()
32
49
  .describe('The agreement file _id from list_workspace_files (file_type=agreement).'),
33
50
  origin: schemaOrigin.describe('Portal origin (e.g. https://<vendor>.hbportal.co). Optional when only one session is active.'),
34
- confirm: schemaConfirm.describe('Must be true to proceed. Without this, tool returns a preview.'),
51
+ confirmToken: confirmTokenParam,
35
52
  }),
36
53
  annotations: { destructiveHint: true },
37
54
  }, signContract);
@@ -1,17 +1,34 @@
1
1
  import { z } from 'zod';
2
- import { rawTextResult, schemaOrigin, schemaConfirm } from '@chrischall/mcp-utils';
2
+ import { confirmationFromEnv, confirmTokenParam, rawTextResult, requireConfirmationWithFallback, schemaOrigin, } from '@chrischall/mcp-utils';
3
3
  import { apiPath, getActiveClient } from '../client.js';
4
4
  import { vendorPortalSubdomain } from '../sessions.js';
5
- export async function payInvoice(args) {
5
+ export async function payInvoice(args, ctx) {
6
6
  const client = await getActiveClient(args.origin);
7
7
  const file = await client.request('GET', apiPath `/api/v2/workspace_files/${args.file_id}`);
8
8
  if (file.file_type !== 'invoice') {
9
9
  throw new Error(`File ${args.file_id} is not an invoice (file_type=${file.file_type}). Only invoices can be paid.`);
10
10
  }
11
- if (!args.confirm) {
12
- return rawTextResult(`About to pay "${file.file_title}" (${file.status_name || 'open'}).\n` +
13
- `Re-run pay_invoice with { confirm: true } to proceed.`);
14
- }
11
+ const preview = {
12
+ file_id: file._id,
13
+ file_title: file.file_title,
14
+ status: file.status_name || 'open',
15
+ has_pending_payment: Boolean(file.has_pending_payment),
16
+ };
17
+ const gate = await requireConfirmationWithFallback(ctx, confirmationFromEnv({
18
+ action: 'invoice.pay',
19
+ message: 'Review and confirm paying this invoice:',
20
+ details: preview,
21
+ tool: 'pay_invoice',
22
+ account: client.scope.portalOrigin,
23
+ confirmToken: args.confirmToken,
24
+ subject: () => ({
25
+ target: String(file._id),
26
+ payload: { file_id: file._id, file_title: file.file_title, has_pending_payment: preview.has_pending_payment },
27
+ preview,
28
+ }),
29
+ }));
30
+ if (gate)
31
+ return gate;
15
32
  // Re-checked here, not only at capture: a sessions.json written before the
16
33
  // host rule existed can still hold a foreign origin (fleet-audit#139).
17
34
  vendorPortalSubdomain(client.scope.portalOrigin);
@@ -24,13 +41,14 @@ export async function payInvoice(args) {
24
41
  }
25
42
  export function registerInvoiceTools(server) {
26
43
  server.registerTool('pay_invoice', {
27
- description: 'Pay an invoice from a vendor. In v1 this returns a deep link to the HoneyBook portal instead of paying headlessly. Requires confirm:true.',
44
+ description: 'Pay an invoice from a vendor. In v1 this returns a deep link to the HoneyBook portal instead of paying headlessly. ' +
45
+ 'Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).',
28
46
  inputSchema: z.object({
29
47
  file_id: z
30
48
  .string()
31
49
  .describe('The invoice file _id from list_workspace_files (file_type=invoice).'),
32
50
  origin: schemaOrigin.describe('Portal origin (e.g. https://<vendor>.hbportal.co). Optional when only one session is active.'),
33
- confirm: schemaConfirm.describe('Must be true to proceed. Without this, tool returns a preview.'),
51
+ confirmToken: confirmTokenParam,
34
52
  }),
35
53
  annotations: { destructiveHint: true },
36
54
  }, payInvoice);
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { minifiedResult, rawTextResult, schemaConfirm, schemaOrigin } from '@chrischall/mcp-utils';
2
+ import { confirmationFromEnv, confirmTokenParam, minifiedResult, requireConfirmationWithFallback, schemaOrigin, } from '@chrischall/mcp-utils';
3
3
  import { apiPath, getActiveClient } from '../client.js';
4
4
  import { fetchWorkspaceFeed, isMessageItem, itemDate, summarizeMessage, summarizeActivity, expandMessage, } from '../feed.js';
5
5
  import { runClientPendingTask, isPendingTaskTimeoutError } from '../pending-tasks.js';
@@ -79,7 +79,7 @@ export function bodyToHtml(body) {
79
79
  .replace(/>/g, '&gt;')
80
80
  .replace(/\r?\n/g, '<br>');
81
81
  }
82
- export async function sendMessage(args) {
82
+ export async function sendMessage(args, ctx) {
83
83
  const client = await getActiveClient(args.origin);
84
84
  const meId = client.scope.userId;
85
85
  const feed = await fetchWorkspaceFeed(client, args.workspace_id);
@@ -101,14 +101,7 @@ export async function sendMessage(args) {
101
101
  if (!args.body.trim())
102
102
  throw new Error('The message body is empty.');
103
103
  const recipients = Object.values(feed.users).filter((u) => u._id !== meId);
104
- const recipientLine = recipients.map((u) => `${u.name}${u.email ? ` <${u.email}>` : ''}`).join(', ');
105
- if (!args.confirm) {
106
- return rawTextResult(`About to send a message in workspace ${args.workspace_id} via the HoneyBook portal.\n` +
107
- `To: ${recipientLine || '(everyone in the workspace)'}\n` +
108
- `Subject: ${subject}${replyTo ? ` (reply to "${String(replyTo._id)}")` : ''}\n\n` +
109
- `${args.body}\n\n` +
110
- `Re-run send_message with { confirm: true } to send it.`);
111
- }
104
+ const to = recipients.map((u) => `${u.name}${u.email ? ` <${u.email}>` : ''}`);
112
105
  const taskData = {
113
106
  ws_id: args.workspace_id,
114
107
  subject,
@@ -120,6 +113,31 @@ export async function sendMessage(args) {
120
113
  };
121
114
  if (replyTo)
122
115
  taskData.feed_to_reply_id = replyTo._id;
116
+ const preview = {
117
+ workspace_id: args.workspace_id,
118
+ via: 'HoneyBook portal',
119
+ to: to.length ? to : ['(everyone in the workspace)'],
120
+ subject,
121
+ reply_to: replyTo ? replyTo._id : null,
122
+ body: args.body,
123
+ };
124
+ const gate = await requireConfirmationWithFallback(ctx, confirmationFromEnv({
125
+ action: 'message.send',
126
+ message: 'Review and confirm sending this message:',
127
+ details: preview,
128
+ tool: 'send_message',
129
+ account: client.scope.portalOrigin,
130
+ confirmToken: args.confirmToken,
131
+ subject: () => ({
132
+ target: args.workspace_id,
133
+ // What goes out, and who HoneyBook emails it to: a membership change
134
+ // between the two calls changes the recipients, so it must be re-approved.
135
+ payload: { task: taskData, recipients: recipients.map((u) => u._id) },
136
+ preview,
137
+ }),
138
+ }));
139
+ if (gate)
140
+ return gate;
123
141
  let outcome;
124
142
  try {
125
143
  outcome = await runClientPendingTask(client, 'send_workspace_message', taskData);
@@ -196,7 +214,8 @@ export function registerMessageTools(server) {
196
214
  server.registerTool('send_message', {
197
215
  description: 'Send a message to the vendor (and the other members of the workspace) through the HoneyBook portal, ' +
198
216
  'exactly as the Activity tab composer does. HoneyBook emails it to every recipient. ' +
199
- 'Pass reply_to_message_id to reply in-thread (the subject is inherited). Requires confirm:true. ' +
217
+ 'Pass reply_to_message_id to reply in-thread (the subject is inherited). ' +
218
+ 'Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE). ' +
200
219
  'If HoneyBook has not finished sending within about a minute the result has status "pending" and a ' +
201
220
  'task_id: the message may still be delivered, so check list_messages before resending.',
202
221
  inputSchema: z.object({
@@ -210,7 +229,7 @@ export function registerMessageTools(server) {
210
229
  .optional()
211
230
  .describe('A message _id from list_messages to reply to.'),
212
231
  origin: schemaOrigin.describe(ORIGIN_DESC),
213
- confirm: schemaConfirm.describe('Must be true to actually send. Without it the tool returns a preview of what would go out.'),
232
+ confirmToken: confirmTokenParam,
214
233
  }),
215
234
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
216
235
  }, sendMessage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honeybook-mcp",
3
- "version": "1.1.4",
3
+ "version": "1.2.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>",
@@ -44,7 +44,7 @@
44
44
  "typecheck": "tsc -p tsconfig.json --noEmit"
45
45
  },
46
46
  "dependencies": {
47
- "@chrischall/mcp-utils": "^2.4.0",
47
+ "@chrischall/mcp-utils": "^2.6.0",
48
48
  "@fetchproxy/bootstrap": "^3.2.0",
49
49
  "@fetchproxy/server": "^3.2.0",
50
50
  "@modelcontextprotocol/server": "^2.0.0",
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": "1.1.4",
9
+ "version": "1.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "honeybook-mcp",
14
- "version": "1.1.4",
14
+ "version": "1.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -17,13 +17,13 @@ MCP server for HoneyBook's client portal — contracts, invoices, questionnaires
17
17
  - `get_workspace_file(file_id, section?)` — Full detail for one file. Takes `section`, **not** `view` — see below
18
18
  - `get_workspace` — Workspace detail + status flags
19
19
  - `list_payment_methods` — Saved payment methods
20
- - `sign_contract` — Deep link to sign in portal (requires `confirm:true`)
21
- - `pay_invoice` — Deep link to pay in portal (requires `confirm:true`)
20
+ - `sign_contract` — Deep link to sign in portal (asks the user to confirm first)
21
+ - `pay_invoice` — Deep link to pay in portal (asks the user to confirm first)
22
22
  - `list_projects` — Your projects (HoneyBook "events") with a vendor, each with the `workspace_id` the tools below take
23
23
  - `get_project(project_id, view?)` — Project details: date, time, location, guests, custom fields, people (name/email/phone/role)
24
24
  - `list_messages` — Messages in a workspace (`kind=activity` for the activity log, `kind=all` for both): compact cards, newest first; never marks anything seen
25
25
  - `get_message` — One message in full (`format=text` default, or `html`) with attachments and delivery status
26
- - `send_message` — Send a new message (`subject` + `body`) or reply (`reply_to_message_id`, subject inherited) through the portal; requires `confirm:true`
26
+ - `send_message` — Send a new message (`subject` + `body`) or reply (`reply_to_message_id`, subject inherited) through the portal; asks the user to confirm first
27
27
  - `mark_messages_seen` — Mark feed items seen, the way opening the Activity tab does
28
28
  - `list_meetings` — Meetings the vendor scheduled (consultations, Zoom calls): time, join link, password; rescheduled meetings show their latest time
29
29
  - `list_tasks` — Tasks assigned to you with today/this-week/overdue counts and task groups
@@ -96,10 +96,10 @@ shape legible.
96
96
  - **"Fill in / read the vendor's questionnaire"** → user pastes the `/flow/<id>?hash=…` link → `use_flow_link` → `get_flow`
97
97
  - **"What contracts haven't I signed?"** → `list_workspace_files` with `file_type=agreement`, filter by `is_file_accepted=false`
98
98
  - **"Summarize my HB status with Silk Veil"** → `get_workspace` (status flags) + `list_workspace_files`
99
- - **"Send me a link to sign the photographer's contract"** → `list_workspace_files` → `sign_contract` with `confirm:true`
99
+ - **"Send me a link to sign the photographer's contract"** → `list_workspace_files` → `sign_contract` (confirm when prompted)
100
100
  - **"Which invoices are overdue?"** → `list_workspace_files` with `file_type=invoice`, sort by due date
101
101
  - **"What did the planner send me?" / "Read me the latest checklist"** → `list_projects` → `list_messages` → `get_message`
102
- - **"Reply to Ivy and ask about the rehearsal"** → `list_messages` (find the message) → `send_message` with `reply_to_message_id` and no `confirm` (preview) → re-run with `confirm:true`
102
+ - **"Reply to Ivy and ask about the rehearsal"** → `list_messages` (find the message) → `send_message` with `reply_to_message_id` → show the user the preview → once they approve, re-run with the same arguments plus the returned `confirmToken` (on a client that shows a confirmation prompt, the prompt replaces these two steps)
103
103
  - **"When is my next Zoom with the planner?"** → `list_meetings`; the join link and password are in the row
104
104
  - **"What have I paid and what's left?"** → `list_payments` (`totals.paid` / `totals.unpaid`); to pay one, `pay_invoice`
105
105
  - **"Do I have anything to do?"** → `list_tasks` (`counts.overdue`, `counts.today`)
@@ -112,5 +112,5 @@ shape legible.
112
112
  - The flow storage key contains the flow id, so the fetchproxy extension asks to re-approve the scope once per new questionnaire
113
113
  - Write tools (`sign_contract`, `pay_invoice`) return deep links in v2
114
114
  - Session expires → re-run `use_magic_link` with a fresh URL from the vendor's email
115
- - `send_message` goes out as a real HoneyBook email to everyone in the workspace (the vendor and any co-clients). Always show the preview (no `confirm`) before sending
115
+ - `send_message` goes out as a real HoneyBook email to everyone in the workspace (the vendor and any co-clients). Always show the user the preview and get their explicit approval before passing the `confirmToken` back — never reuse or invent a token
116
116
  - `list_messages` / `get_message` read the same feed the Activity tab renders and do not mark items seen; `unseen_count` counts messages from other people you have not opened