@sellable/mcp 0.1.110 → 0.1.111

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.
@@ -21,10 +21,11 @@ function normalizeLeadSourceProvider(input) {
21
21
  }
22
22
  export function buildWatchUrl(config, path) {
23
23
  const workspaceId = config.activeWorkspaceId || config.workspaceId;
24
- const workspaceParam = workspaceId
25
- ? `&workspaceId=${encodeURIComponent(workspaceId)}`
26
- : "";
27
- return `${config.apiUrl}/auth/continue?token=${encodeURIComponent(config.token)}&redirect=${encodeURIComponent(path)}${workspaceParam}`;
24
+ const url = new URL(path, config.apiUrl);
25
+ if (workspaceId && !url.searchParams.has("workspaceId")) {
26
+ url.searchParams.set("workspaceId", workspaceId);
27
+ }
28
+ return url.toString();
28
29
  }
29
30
  function isLinkedInProfileUrl(input) {
30
31
  try {
@@ -81,7 +81,7 @@ export declare function getCampaignContext(input: GetCampaignContextInput): Prom
81
81
  };
82
82
  watchUrl: {
83
83
  urlPresent: boolean;
84
- signed: boolean;
84
+ direct: boolean | undefined;
85
85
  redirectPath: string | null;
86
86
  };
87
87
  table: {
@@ -178,7 +178,7 @@ export function markCampaignContextDirty(campaignId, reason) {
178
178
  },
179
179
  watchUrl: {
180
180
  urlPresent: false,
181
- signed: false,
181
+ direct: false,
182
182
  redirectPath: null,
183
183
  },
184
184
  table: {
@@ -2460,6 +2460,11 @@ export declare function confirmLeadList(input: ConfirmLeadListInput): Promise<{
2460
2460
  rowIds?: string[];
2461
2461
  requestedTargetLeadCount?: number;
2462
2462
  sourceRowLimit?: number;
2463
+ async?: boolean;
2464
+ campaignTableReady?: boolean;
2465
+ importStatus?: string | null;
2466
+ remainingRowCount?: number | null;
2467
+ rowCount?: number | null;
2463
2468
  };
2464
2469
  boundedReviewBatch: {
2465
2470
  requestedTargetLeadCount: number;
@@ -2199,7 +2199,30 @@ export async function confirmLeadList(input) {
2199
2199
  if (!resolvedLeadListId) {
2200
2200
  throw new Error("confirm_lead_list requires sourceLeadListId");
2201
2201
  }
2202
- const effectiveCurrentStep = currentStep === undefined ? "confirm-lead-list" : currentStep;
2202
+ const safePostConfirmCurrentSteps = new Set([
2203
+ "filter-choice",
2204
+ "create-icp-rubric",
2205
+ "apply-icp-rubric",
2206
+ "validate-sample",
2207
+ "auto-execute-leads",
2208
+ "messages",
2209
+ "auto-execute-messaging",
2210
+ "awaiting-user-greenlight",
2211
+ "settings",
2212
+ "sequence",
2213
+ "send",
2214
+ "claude-greenlight",
2215
+ "running",
2216
+ ]);
2217
+ const effectiveCurrentStep = currentStep === undefined
2218
+ ? "filter-choice"
2219
+ : typeof currentStep === "string" &&
2220
+ currentStep.length > 0 &&
2221
+ safePostConfirmCurrentSteps.has(currentStep)
2222
+ ? currentStep
2223
+ : currentStep === null
2224
+ ? null
2225
+ : "filter-choice";
2203
2226
  const shouldSetCurrentStep = typeof effectiveCurrentStep === "string" && effectiveCurrentStep.length > 0;
2204
2227
  let readiness;
2205
2228
  const leadListMeta = await api.get(`/api/v3/workflow-tables/${resolvedLeadListId}?mode=meta`);
@@ -2257,13 +2280,27 @@ export async function confirmLeadList(input) {
2257
2280
  }
2258
2281
  throw new Error("Import still in progress. Keep the campaign at confirm-lead-list; retry readiness, cancel the import, or re-run-source before launching post-import scouts.");
2259
2282
  }
2260
- const importResult = await api.post(`/api/v3/campaign-builder/import-leads`, {
2283
+ const isTerminalAccessError = (error) => error instanceof SellableApiError && [401, 403, 404].includes(error.status);
2284
+ const formatTerminalAccessError = (error) => {
2285
+ if (error.status === 401 || error.status === 403) {
2286
+ return "Campaign or lead-list access failed. Fix workspace/auth access before retrying confirm_lead_list.";
2287
+ }
2288
+ return "Campaign or source lead list could not be found. Re-open the correct campaign or reselect the source list before retrying confirm_lead_list.";
2289
+ };
2290
+ const importResult = await api
2291
+ .post(`/api/v3/campaign-builder/import-leads`, {
2261
2292
  sourceLeadListId: resolvedLeadListId,
2262
2293
  campaignOfferId,
2263
2294
  campaignName,
2264
2295
  keepInSync,
2265
2296
  ...(typeof targetLeadCount === "number" ? { targetLeadCount } : {}),
2266
2297
  ...(shouldSetCurrentStep ? { currentStep: effectiveCurrentStep } : {}),
2298
+ })
2299
+ .catch((error) => {
2300
+ if (isTerminalAccessError(error)) {
2301
+ throw new Error(formatTerminalAccessError(error));
2302
+ }
2303
+ throw error;
2267
2304
  });
2268
2305
  const campaignTableId = importResult.workflowTableId ?? importResult.campaignTableId;
2269
2306
  const requestedTargetLeadCount = typeof targetLeadCount === "number" && Number.isFinite(targetLeadCount)
@@ -2275,6 +2312,29 @@ export async function confirmLeadList(input) {
2275
2312
  const overflowRowIds = campaignTableId && requestedTargetLeadCount !== null
2276
2313
  ? importedRowIds.slice(requestedTargetLeadCount)
2277
2314
  : [];
2315
+ const importedRowCount = importedRowIds.length > 0
2316
+ ? importedRowIds.length
2317
+ : typeof importResult.rowCount === "number"
2318
+ ? importResult.rowCount
2319
+ : typeof importResult.leadsImported === "number"
2320
+ ? importResult.leadsImported
2321
+ : 0;
2322
+ const remainingRowCount = typeof importResult.remainingRowCount === "number"
2323
+ ? importResult.remainingRowCount
2324
+ : 0;
2325
+ const importStatus = String(importResult.importStatus ?? "").toLowerCase();
2326
+ const campaignTableReady = importResult.campaignTableReady === true ||
2327
+ (!importResult.async &&
2328
+ importStatus !== "pending" &&
2329
+ importStatus !== "partial" &&
2330
+ remainingRowCount <= 0 &&
2331
+ importedRowCount > 0);
2332
+ if (!campaignTableReady) {
2333
+ throw new Error("Campaign review rows are still importing. Stay on lead import until the bounded campaign table is ready, then retry confirm_lead_list or wait_for_campaign_table_ready.");
2334
+ }
2335
+ if (importedRowCount <= 0) {
2336
+ throw new Error("No usable review rows were kept for the campaign table. Tighten or change the source before continuing.");
2337
+ }
2278
2338
  if (campaignTableId && overflowRowIds.length > 0) {
2279
2339
  const deleteBatchSize = 25;
2280
2340
  for (let index = 0; index < overflowRowIds.length; index += deleteBatchSize) {
@@ -2310,7 +2370,10 @@ export async function confirmLeadList(input) {
2310
2370
  trimmedOverflowRowCount: overflowRowIds.length,
2311
2371
  }
2312
2372
  : undefined,
2313
- message: "Lead list imported into campaign table. Next: move to filter-choice, then wait_for_campaign_table_ready, then sample with get_rows_minimal.",
2373
+ message: requestedTargetLeadCount !== null &&
2374
+ leadListRowCount > requestedTargetLeadCount
2375
+ ? `I found ${leadListRowCount} source candidates and imported the first ${Math.min(importedRowCount, requestedTargetLeadCount)} into the campaign review table.`
2376
+ : "Lead list imported into the campaign review table. Next: sample rows with get_rows_minimal or continue into filter work.",
2314
2377
  };
2315
2378
  }
2316
2379
  export function getProviderPrompt(input) {
@@ -81,7 +81,7 @@ export declare function computeCampaignNavigationStateFromCampaign(campaign: Cam
81
81
  };
82
82
  watchUrl: {
83
83
  urlPresent: boolean;
84
- signed: boolean;
84
+ direct: boolean | undefined;
85
85
  redirectPath: string | null;
86
86
  };
87
87
  table: {
@@ -125,7 +125,7 @@ export declare function getCampaignNavigationState(input: GetCampaignNavigationS
125
125
  };
126
126
  watchUrl: {
127
127
  urlPresent: boolean;
128
- signed: boolean;
128
+ direct: boolean | undefined;
129
129
  redirectPath: string | null;
130
130
  };
131
131
  table: {
@@ -281,9 +281,9 @@ function mapHeadlessToUiStep(step) {
281
281
  return "signal-discovery";
282
282
  if (step === "filter-choice")
283
283
  return "filter-choice";
284
- if (step === "create-icp-rubric")
285
- return "filter-rules";
286
- if (step === "apply-icp-rubric" || step === "validate-sample") {
284
+ if (step === "create-icp-rubric" ||
285
+ step === "apply-icp-rubric" ||
286
+ step === "validate-sample") {
287
287
  return "filter-leads";
288
288
  }
289
289
  if (step === "messages" ||
@@ -381,7 +381,7 @@ function describeVisibleStep(step) {
381
381
  };
382
382
  }
383
383
  }
384
- function parseSignedWatchUrl(watchUrl, campaignId) {
384
+ function parseWatchUrl(watchUrl, campaignId) {
385
385
  if (!watchUrl) {
386
386
  return {
387
387
  urlPresent: false,
@@ -392,26 +392,39 @@ function parseSignedWatchUrl(watchUrl, campaignId) {
392
392
  }
393
393
  try {
394
394
  const url = new URL(watchUrl);
395
+ const directPath = `/campaign-builder/${campaignId}`;
396
+ const isDirectCampaignUrl = url.pathname === directPath &&
397
+ (url.searchParams.get("mode") === "claude" ||
398
+ url.searchParams.get("mode") === "codex" ||
399
+ url.searchParams.get("mode") === "watch");
400
+ if (isDirectCampaignUrl) {
401
+ return {
402
+ urlPresent: true,
403
+ direct: true,
404
+ redirectPath: `${url.pathname}?${url.searchParams.toString()}`.replace(/\?$/, ""),
405
+ warning: null,
406
+ };
407
+ }
395
408
  const redirect = url.searchParams.get("redirect");
396
409
  const decodedRedirect = redirect ? decodeURIComponent(redirect) : null;
397
- const signed = url.pathname === "/auth/continue" &&
410
+ const isLegacySigned = url.pathname === "/auth/continue" &&
398
411
  Boolean(url.searchParams.get("token")) &&
399
- decodedRedirect === `/campaign-builder/${campaignId}?mode=claude`;
412
+ decodedRedirect?.startsWith(`${directPath}?mode=`) === true;
400
413
  return {
401
414
  urlPresent: true,
402
- signed,
415
+ direct: false,
403
416
  redirectPath: decodedRedirect,
404
- warning: signed
405
- ? null
406
- : "Watch URL is not a signed /auth/continue watch link for /campaign-builder/{campaignId}?mode=claude.",
417
+ warning: isLegacySigned
418
+ ? "Watch URL still uses a tokenized /auth/continue handoff. Return the safe direct /campaign-builder/{campaignId}?mode=claude watch URL instead."
419
+ : "Watch URL is not a safe direct /campaign-builder/{campaignId}?mode=claude watch link.",
407
420
  };
408
421
  }
409
422
  catch {
410
423
  return {
411
424
  urlPresent: true,
412
- signed: false,
425
+ direct: false,
413
426
  redirectPath: null,
414
- warning: "Watch URL is malformed; recover a fresh signed /auth/continue watch link before browser handoff.",
427
+ warning: "Watch URL is malformed; recover a fresh safe direct campaign-builder watch link before browser handoff.",
415
428
  };
416
429
  }
417
430
  }
@@ -554,7 +567,7 @@ export function computeCampaignNavigationStateFromCampaign(campaign, options) {
554
567
  checkpoint: "send",
555
568
  }
556
569
  : describeVisibleStep(orientationStep);
557
- const watchUrlState = parseSignedWatchUrl(campaign.watchUrl, campaign.id);
570
+ const watchUrlState = parseWatchUrl(campaign.watchUrl, campaign.id);
558
571
  if (watchUrlState.warning) {
559
572
  warnings.push(watchUrlState.warning);
560
573
  }
@@ -579,7 +592,7 @@ export function computeCampaignNavigationStateFromCampaign(campaign, options) {
579
592
  },
580
593
  watchUrl: {
581
594
  urlPresent: watchUrlState.urlPresent,
582
- signed: watchUrlState.signed,
595
+ direct: watchUrlState.direct,
583
596
  redirectPath: watchUrlState.redirectPath,
584
597
  },
585
598
  table: {
@@ -134,7 +134,7 @@ export declare function waitForCampaignTableReady(input: WaitForCampaignTableRea
134
134
  };
135
135
  watchUrl: {
136
136
  urlPresent: boolean;
137
- signed: boolean;
137
+ direct: boolean | undefined;
138
138
  redirectPath: string | null;
139
139
  };
140
140
  table: {
@@ -183,7 +183,7 @@ export declare function waitForCampaignTableReady(input: WaitForCampaignTableRea
183
183
  };
184
184
  watchUrl: {
185
185
  urlPresent: boolean;
186
- signed: boolean;
186
+ direct: boolean | undefined;
187
187
  redirectPath: string | null;
188
188
  };
189
189
  table: {
@@ -234,7 +234,7 @@ export declare function waitForCampaignTableReady(input: WaitForCampaignTableRea
234
234
  };
235
235
  watchUrl: {
236
236
  urlPresent: boolean;
237
- signed: boolean;
237
+ direct: boolean | undefined;
238
238
  redirectPath: string | null;
239
239
  };
240
240
  table: {
@@ -1,7 +1,22 @@
1
1
  function sanitizeWatchUrlValue(value) {
2
- // Watch URLs are intentional auth continuation handoffs for fresh browsers.
3
- // Do not strip them here; redaction belongs in logs, not MCP tool results.
4
- return value;
2
+ try {
3
+ const url = new URL(value);
4
+ if (url.pathname === "/auth/continue") {
5
+ const redirect = url.searchParams.get("redirect");
6
+ if (redirect) {
7
+ const direct = new URL(decodeURIComponent(redirect), url.origin);
8
+ const workspaceId = url.searchParams.get("workspaceId");
9
+ if (workspaceId && !direct.searchParams.has("workspaceId")) {
10
+ direct.searchParams.set("workspaceId", workspaceId);
11
+ }
12
+ return direct.toString();
13
+ }
14
+ }
15
+ return value;
16
+ }
17
+ catch {
18
+ return value;
19
+ }
5
20
  }
6
21
  export function sanitizeWatchUrlsForMcpResult(value) {
7
22
  if (!value || typeof value !== "object")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.110",
3
+ "version": "0.1.111",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -162,11 +162,11 @@ copy.
162
162
  ## Codex Watch Browser Handoff
163
163
 
164
164
  When a campaign tool returns `watchUrl`, treat it as an active browser handoff,
165
- not only a URL to print. A valid handoff link must be a signed
166
- `/auth/continue?token=...&redirect=/campaign-builder/{campaignId}?mode=claude`
167
- URL. `create_campaign.watchUrl`, `create_campaign({ campaignId }).watchUrl`,
168
- and `get_campaign.watchUrl` are all acceptable only when they return that signed
169
- shape.
165
+ not only a URL to print. A valid handoff link must be a safe direct
166
+ `/campaign-builder/{campaignId}?mode=claude` URL. `create_campaign.watchUrl`,
167
+ `create_campaign({ campaignId }).watchUrl`, and `get_campaign.watchUrl` are all
168
+ acceptable only when they return that direct campaign-builder shape, with
169
+ `workspaceId` used only as a safe routing hint when needed.
170
170
 
171
171
  In Codex Desktop, when in-app browser control is available, open the returned watch link on the user's behalf. After opening it, inspect the browser-visible campaign state, then explain what the user is seeing before continuing with more campaign tools. Use customer language such as:
172
172
 
@@ -175,8 +175,8 @@ I’ll open the campaign view and keep it in sync as I build.
175
175
  ```
176
176
 
177
177
  If browser control is unavailable, provide the watch link without discussing the
178
- runtime limitation. In off-desktop Codex runs, make the signed link easy to
179
- copy/open, then continue with explicit customer-facing campaign progress and
178
+ runtime limitation. In off-desktop Codex runs, make the direct watch link easy
179
+ to copy/open, then continue with explicit customer-facing campaign progress and
180
180
  `get_campaign_navigation_state` orientation checks.
181
181
  Use this fallback shape:
182
182
 
@@ -188,9 +188,9 @@ here as I build.
188
188
  ```
189
189
 
190
190
  If opening the watch link lands on an auth, 404, permission, blank, or visible
191
- error state, report only what is visible, recover a fresh signed watch link once
192
- with `create_campaign({ campaignId })` or `get_campaign`, and try that link. Do
193
- not claim the browser was opened, inspected, or synchronized unless the visible
191
+ error state, report only what is visible, recover a fresh watch link once with
192
+ `create_campaign({ campaignId })` or `get_campaign`, and try that link. Do not
193
+ claim the browser was opened, inspected, or synchronized unless the visible
194
194
  browser state was actually observed. Do not claim the browser was opened unless
195
195
  it was opened and observed.
196
196
 
@@ -330,7 +330,7 @@
330
330
  "campaignBrief"
331
331
  ],
332
332
  "watchUrlSource": "create_campaign.watchUrl",
333
- "requiredWatchUrlShape": "signed /auth/continue?token=...&redirect=/campaign-builder/{campaignId}?mode=claude",
333
+ "requiredWatchUrlShape": "direct /campaign-builder/{campaignId}?mode=claude watch URL",
334
334
  "codexBrowserHandoff": {
335
335
  "openWhenAvailable": true,
336
336
  "inspectBeforeContinuing": true,
@@ -11,12 +11,12 @@ gating, and handoff read campaign state first.
11
11
 
12
12
  ## Plumbing Reuse
13
13
 
14
- `create_campaign` already returns a signed `/auth/continue` `watchUrl` on the response
15
- (`CampaignDetail.watchUrl` in `mcp/sellable/src/tools/campaigns.ts`). V2 does
16
- NOT mint a new token, does NOT call a different route, and does NOT construct
17
- the URL locally. Capture whatever `watchUrl` the existing tool returns and
18
- surface it verbatim. The link must be able to authenticate a fresh browser and
19
- land on the campaign builder without sending the user to signup.
14
+ `create_campaign` already returns a safe direct campaign-builder `watchUrl` on
15
+ the response (`CampaignDetail.watchUrl` in
16
+ `mcp/sellable/src/tools/campaigns.ts`). V2 does NOT mint a new token, does NOT call
17
+ a different route, and does NOT construct the URL locally. Capture whatever
18
+ `watchUrl` the existing tool returns and surface it verbatim. The link must
19
+ land on the campaign builder without exposing token secrets in the MCP result.
20
20
 
21
21
  ## Shell-First Link
22
22
 
@@ -59,7 +59,7 @@ If shell creation fails or the response is missing `watchUrl`, stop and surface
59
59
  the error. Do not continue into campaignless source scouting in the active
60
60
  shell-first flow.
61
61
  If a legacy atomic-mint response is missing `watchUrl`, it is not a silent skip.
62
- Stop before `save_rubrics` and recover the missing signed URL first.
62
+ Stop before `save_rubrics` and recover the missing watch URL first.
63
63
 
64
64
  ## Step Orientation
65
65
 
@@ -81,19 +81,19 @@ Do not spam the link between internal tool calls.
81
81
  On resume:
82
82
 
83
83
  1. Load the `CampaignOffer` for the campaign being resumed.
84
- 2. Recover the signed auth continuation link through the existing resume path
84
+ 2. Recover the direct watch link through the existing resume path
85
85
  (`create_campaign({ campaignId })` when available).
86
86
  3. Inspect `currentStep` and print a short orientation for where the user will
87
87
  land now.
88
88
  4. Print the recovered `watchUrl`.
89
89
 
90
- The re-surface must use the signed `watchUrl` from the tool response, not a
90
+ The re-surface must use the `watchUrl` from the tool response, not a
91
91
  cached or reconstructed URL.
92
92
 
93
93
  ## Hard Rules
94
94
 
95
95
  - Never fabricate or reconstruct the URL locally.
96
- - The watch link must authenticate a fresh browser via `/auth/continue`.
96
+ - The watch link must be a safe direct campaign-builder URL.
97
97
  - Missing `watchUrl` is an error, not a silent skip.
98
98
  - The first watch link must be shown only after the v1 brief is on the campaign.
99
99
  - A watch link is not approval to import, attach sequence, or start.