@sellable/mcp 0.1.107 → 0.1.109

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.
package/dist/server.js CHANGED
@@ -29,6 +29,7 @@ import { getSender, listSenders, senderToolDefinitions, } from "./tools/senders.
29
29
  import { attachRecommendedSequence, attachSequence, createWorkflowTable, sequencerToolDefinitions, } from "./tools/sequencer.js";
30
30
  import { listTables, tableToolDefinitions } from "./tools/tables.js";
31
31
  import { handleVerifyTableRow, verifyRowToolDefinitions, } from "./tools/verify-row.js";
32
+ import { sanitizeWatchUrlsForMcpResult } from "./tools/watch-url-security.js";
32
33
  import { addTeammate, createWorkspace, getActiveWorkspace, listWorkspaces, setActiveWorkspace, workspaceToolDefinitions, } from "./tools/workspaces.js";
33
34
  import { checkForUpdates, logUpdateNotice } from "./update-check.js";
34
35
  const server = new Server({
@@ -473,11 +474,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
473
474
  default:
474
475
  throw new Error(`Unknown tool: ${name}`);
475
476
  }
477
+ const safeResult = sanitizeWatchUrlsForMcpResult(result);
476
478
  return {
477
479
  content: [
478
480
  {
479
481
  type: "text",
480
- text: JSON.stringify(result),
482
+ text: JSON.stringify(safeResult),
481
483
  },
482
484
  ],
483
485
  };
@@ -1,3 +1,4 @@
1
+ import { getConfig } from "../auth.js";
1
2
  import { type InteractionMode } from "./interaction-mode.js";
2
3
  import type { CampaignOfferNavigation } from "./navigation.js";
3
4
  declare const LEAD_SOURCE_PROVIDERS: {
@@ -7,6 +8,7 @@ declare const LEAD_SOURCE_PROVIDERS: {
7
8
  readonly SIGNAL_DISCOVERY: "signal-discovery";
8
9
  };
9
10
  type LeadSourceProvider = (typeof LEAD_SOURCE_PROVIDERS)[keyof typeof LEAD_SOURCE_PROVIDERS];
11
+ export declare function buildWatchUrl(config: Pick<ReturnType<typeof getConfig>, "apiUrl" | "activeWorkspaceId" | "workspaceId">, path: string): string;
10
12
  export interface Campaign {
11
13
  id: string;
12
14
  name: string;
@@ -19,12 +19,12 @@ function normalizeLeadSourceProvider(input) {
19
19
  ? input
20
20
  : null;
21
21
  }
22
- function buildWatchUrl(config, redirect) {
22
+ export function buildWatchUrl(config, path) {
23
23
  const workspaceId = config.activeWorkspaceId || config.workspaceId;
24
24
  const workspaceParam = workspaceId
25
- ? `&workspaceId=${encodeURIComponent(workspaceId)}`
25
+ ? `${path.includes("?") ? "&" : "?"}workspaceId=${encodeURIComponent(workspaceId)}`
26
26
  : "";
27
- return `${config.apiUrl}/auth/continue?token=${config.token}&redirect=${redirect}${workspaceParam}`;
27
+ return `${config.apiUrl}${path}${workspaceParam}`;
28
28
  }
29
29
  function isLinkedInProfileUrl(input) {
30
30
  try {
@@ -334,9 +334,7 @@ export async function getCampaign(campaignId) {
334
334
  api.get(`/api/v3/mcp/campaigns/${campaignId}`),
335
335
  fetchCampaignRubrics(campaignId).catch(() => null),
336
336
  ]);
337
- // Build watch URL with auto-auth token
338
- const redirect = encodeURIComponent(`/campaign-builder/${campaignId}?mode=claude`);
339
- const watchUrl = buildWatchUrl(config, redirect);
337
+ const watchUrl = buildWatchUrl(config, `/campaign-builder/${campaignId}?mode=claude`);
340
338
  // Merge rubrics into campaignOffer
341
339
  const rubrics = (rubricsResult?.rubrics || []).map((r) => ({
342
340
  id: r.id || "",
@@ -591,8 +589,7 @@ export async function createCampaign(input) {
591
589
  // Idempotent resume path
592
590
  if (input.campaignId) {
593
591
  const existing = await api.get(`/api/v2/campaign-offers/${input.campaignId}`);
594
- const redirect = encodeURIComponent(`/campaign-builder/${existing.id}?mode=claude`);
595
- const watchUrl = buildWatchUrl(config, redirect);
592
+ const watchUrl = buildWatchUrl(config, `/campaign-builder/${existing.id}?mode=claude`);
596
593
  const hasCreateFields = input.name ||
597
594
  input.clientProspectId ||
598
595
  input.offerPositioning !== undefined ||
@@ -710,9 +707,7 @@ export async function createCampaign(input) {
710
707
  },
711
708
  };
712
709
  const result = await api.post(`/api/v2/campaign-offers`, formattedInput);
713
- // Build watch URL for Claude Code to share with user
714
- const redirect = encodeURIComponent(`/campaign-builder/${result.id}?mode=claude`);
715
- const watchUrl = buildWatchUrl(config, redirect);
710
+ const watchUrl = buildWatchUrl(config, `/campaign-builder/${result.id}?mode=claude`);
716
711
  // Serialize to only essential fields for context efficiency
717
712
  return {
718
713
  id: result.id,
@@ -66,7 +66,9 @@ export const sequencerToolDefinitions = [
66
66
  description: "Attach the tier-recommended sequence template to a campaign. " +
67
67
  "Delegates template selection to the backend's existing tier-aware selector " +
68
68
  "(selectTemplateForTiers), which picks `If Open Profile -> INMAIL_OPEN, else INVITE` " +
69
- "for premium/Sales Nav senders and `INVITE -> accepted -> DM` otherwise. " +
69
+ "for Sales Nav/Recruiter senders and `INVITE -> accepted -> DM` for Basic, Premium, or mixed sender sets. " +
70
+ "Paid InMail Campaign is not auto-selected because it can spend InMail credits; " +
71
+ "use attach_sequence with an explicit paid-InMail template only after the user opts in. " +
70
72
  "Prefer this over attach_sequence during the create-campaign-v2 autonomous tail — " +
71
73
  "it removes the risk of hand-authoring an invalid template mid-long-context. " +
72
74
  "Use attach_sequence directly only when the caller needs a custom non-recommended cadence.",
@@ -0,0 +1 @@
1
+ export declare function sanitizeWatchUrlsForMcpResult<T>(value: T): T;
@@ -0,0 +1,36 @@
1
+ function sanitizeWatchUrlValue(value) {
2
+ try {
3
+ const url = new URL(value);
4
+ if (url.pathname !== "/auth/continue")
5
+ return value;
6
+ const redirect = url.searchParams.get("redirect");
7
+ if (!redirect || !redirect.startsWith("/campaign-builder/")) {
8
+ return value;
9
+ }
10
+ const safeUrl = new URL(redirect, url.origin);
11
+ const workspaceId = url.searchParams.get("workspaceId");
12
+ if (workspaceId && !safeUrl.searchParams.has("workspaceId")) {
13
+ safeUrl.searchParams.set("workspaceId", workspaceId);
14
+ }
15
+ return safeUrl.toString();
16
+ }
17
+ catch {
18
+ return value;
19
+ }
20
+ }
21
+ export function sanitizeWatchUrlsForMcpResult(value) {
22
+ if (!value || typeof value !== "object")
23
+ return value;
24
+ if (Array.isArray(value)) {
25
+ return value.map((item) => sanitizeWatchUrlsForMcpResult(item));
26
+ }
27
+ const input = value;
28
+ const output = {};
29
+ for (const [key, nestedValue] of Object.entries(input)) {
30
+ output[key] =
31
+ key === "watchUrl" && typeof nestedValue === "string"
32
+ ? sanitizeWatchUrlValue(nestedValue)
33
+ : sanitizeWatchUrlsForMcpResult(nestedValue);
34
+ }
35
+ return output;
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.107",
3
+ "version": "0.1.109",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -2192,6 +2192,7 @@
2192
2192
  "requiredVisibleContent": [
2193
2193
  "connect or select a LinkedIn sender",
2194
2194
  "Slack reply review",
2195
+ "Sales Nav vs Premium sequence recommendation, with Paid InMail Campaign only on explicit opt-in",
2195
2196
  "nothing starts until the user confirms launch"
2196
2197
  ],
2197
2198
  "onNoAvailableSender": "surface settings link and wait for sender_connection_required",
@@ -45,9 +45,12 @@ order (all before waiting for the user):
45
45
  currentStep: "sequence" })` to attach the sender via the v3 senders route and
46
46
  move the watch link to Sequence.
47
47
  8. Call `attach_recommended_sequence({ campaignId, currentStep: "send" })` to
48
- bind the tier-aware recommended sequence. If that response does not persist
49
- `currentStep: "send"`, call `update_campaign({ campaignId, currentStep:
50
- "send" })`.
48
+ bind the tier-aware recommended sequence. Explain the sequence choice in the
49
+ handoff: Sales Nav/Recruiter senders get the Sales Nav Open Profile
50
+ strategy, Basic/Premium/mixed senders get the Premium invite-to-DM strategy,
51
+ and Paid InMail Campaign is only an explicit paid-InMail opt-in because it
52
+ can spend InMail credits. If that response does not persist `currentStep:
53
+ "send"`, call `update_campaign({ campaignId, currentStep: "send" })`.
51
54
  9. Re-surface the `watchUrl` using the exact block in
52
55
  `references/watch-link-handoff.md`.
53
56
  10. Surface the `handoff.orientation` string from `auto-execute.yaml`
@@ -11,11 +11,12 @@ gating, and handoff read campaign state first.
11
11
 
12
12
  ## Plumbing Reuse
13
13
 
14
- `create_campaign` already returns a signed `watchUrl` on the response
14
+ `create_campaign` already returns a safe app `watchUrl` on the response
15
15
  (`CampaignDetail.watchUrl` in `mcp/sellable/src/tools/campaigns.ts`). V2 does
16
16
  NOT mint a new token, does NOT call a different route, and does NOT construct
17
17
  the URL locally. Capture whatever `watchUrl` the existing tool returns and
18
- surface it verbatim.
18
+ surface it verbatim. The URL must never include a raw API token, `auth/continue`
19
+ token exchange URL, magic-link secret, or other bearer credential.
19
20
 
20
21
  ## Shell-First Link
21
22
 
@@ -80,18 +81,20 @@ Do not spam the link between internal tool calls.
80
81
  On resume:
81
82
 
82
83
  1. Load the `CampaignOffer` for the campaign being resumed.
83
- 2. Recover the signed link through the existing resume path
84
+ 2. Recover the safe app link through the existing resume path
84
85
  (`create_campaign({ campaignId })` when available).
85
86
  3. Inspect `currentStep` and print a short orientation for where the user will
86
87
  land now.
87
88
  4. Print the recovered `watchUrl`.
88
89
 
89
- The re-surface must use the signed `watchUrl` from the tool response, not a
90
+ The re-surface must use the safe `watchUrl` from the tool response, not a
90
91
  cached or reconstructed URL.
91
92
 
92
93
  ## Hard Rules
93
94
 
94
95
  - Never fabricate or reconstruct the URL locally.
96
+ - Never print a URL containing `token=`, `/auth/continue`, a magic-link secret,
97
+ or any other bearer credential.
95
98
  - Missing `watchUrl` is an error, not a silent skip.
96
99
  - The first watch link must be shown only after the v1 brief is on the campaign.
97
100
  - A watch link is not approval to import, attach sequence, or start.
@@ -459,7 +459,11 @@ Shape:
459
459
  currentStep: "sequence" })` to attach the sender through the v3 senders
460
460
  route and orient the watch link to Sequence.
461
461
  8. Call `attach_recommended_sequence({ campaignId, currentStep: "send" })`
462
- (bind the tier-recommended sequence to the campaign). If the tool response
462
+ (bind the tier-recommended sequence to the campaign). Explain the sequence
463
+ choice plainly: Sales Nav/Recruiter senders get the Sales Nav Open Profile
464
+ strategy, Basic/Premium/mixed senders get the Premium invite-to-DM strategy,
465
+ and Paid InMail Campaign is available only as an explicit paid-InMail
466
+ opt-in because it can spend InMail credits. If the tool response
463
467
  does not persist `currentStep: "send"`, call
464
468
  `update_campaign({ campaignId, currentStep: "send" })`.
465
469
  9. Re-surface the `watchUrl` using `references/watch-link-handoff.md`.