@sellable/mcp 0.1.371 → 0.1.373

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/api.d.ts CHANGED
@@ -2,6 +2,12 @@ export interface ApiResponse<T> {
2
2
  data: T;
3
3
  error?: string;
4
4
  }
5
+ export interface DownloadToFileResult {
6
+ path: string;
7
+ bytes: number;
8
+ contentType: string | null;
9
+ status: number;
10
+ }
5
11
  export declare class SellableApiError extends Error {
6
12
  status: number;
7
13
  body: string;
@@ -15,6 +21,7 @@ export declare class SellableApi {
15
21
  private request;
16
22
  get<T>(path: string): Promise<T>;
17
23
  getText(path: string): Promise<string>;
24
+ downloadToFile(requestPath: string, destinationPath: string): Promise<DownloadToFileResult>;
18
25
  post<T>(path: string, body?: object): Promise<T>;
19
26
  put<T>(path: string, body?: object): Promise<T>;
20
27
  patch<T>(path: string, body?: object): Promise<T>;
package/dist/api.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { getConfig, getConfigPath } from "./auth.js";
2
+ import { createWriteStream } from "node:fs";
3
+ import { mkdir, rename, rm, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
2
7
  export class SellableApiError extends Error {
3
8
  status;
4
9
  body;
@@ -61,6 +66,33 @@ export class SellableApi {
61
66
  const response = await this.requestResponse("GET", path);
62
67
  return response.text();
63
68
  }
69
+ async downloadToFile(requestPath, destinationPath) {
70
+ const response = await this.requestResponse("GET", requestPath);
71
+ if (!response.body) {
72
+ throw new SellableApiError(response.status, "Response body was empty for file download");
73
+ }
74
+ await mkdir(path.dirname(destinationPath), { recursive: true });
75
+ const tempPath = `${destinationPath}.tmp-${process.pid}-${Date.now()}`;
76
+ let completed = false;
77
+ try {
78
+ const nodeStream = Readable.fromWeb(response.body);
79
+ await pipeline(nodeStream, createWriteStream(tempPath, { flags: "wx" }));
80
+ const fileStat = await stat(tempPath);
81
+ await rename(tempPath, destinationPath);
82
+ completed = true;
83
+ return {
84
+ path: destinationPath,
85
+ bytes: fileStat.size,
86
+ contentType: response.headers.get("content-type"),
87
+ status: response.status,
88
+ };
89
+ }
90
+ finally {
91
+ if (!completed) {
92
+ await rm(tempPath, { force: true }).catch(() => undefined);
93
+ }
94
+ }
95
+ }
64
96
  async post(path, body) {
65
97
  return this.request("POST", path, body);
66
98
  }
package/dist/server.js CHANGED
@@ -48,6 +48,7 @@ import { exportTableCsv, listTables } from "./tools/tables.js";
48
48
  import { handleVerifyTableRow } from "./tools/verify-row.js";
49
49
  import { sanitizeWatchUrlsForMcpResult } from "./tools/watch-url-security.js";
50
50
  import { getCampaignWaterfall, setCampaignWaterfallOrder, } from "./tools/waterfalls.js";
51
+ import { exportWorkspaceCsv } from "./tools/workspace-export.js";
51
52
  import { addTeammate, createWorkspace, getActiveWorkspace, listWorkspaces, setActiveWorkspace, } from "./tools/workspaces.js";
52
53
  import { checkForUpdates, logUpdateNotice } from "./update-check.js";
53
54
  const server = new Server({
@@ -75,8 +76,9 @@ function markEvergreenSetupCampaignsDirty(args) {
75
76
  const campaignIds = new Set();
76
77
  const bindings = Array.isArray(args.bindings) ? args.bindings : [];
77
78
  for (const binding of bindings) {
78
- if (!binding || typeof binding !== "object" || Array.isArray(binding))
79
+ if (!binding || typeof binding !== "object" || Array.isArray(binding)) {
79
80
  continue;
81
+ }
80
82
  const campaignId = binding.campaignId;
81
83
  if (typeof campaignId === "string" && campaignId.trim()) {
82
84
  campaignIds.add(campaignId.trim());
@@ -84,8 +86,9 @@ function markEvergreenSetupCampaignsDirty(args) {
84
86
  }
85
87
  const receipts = Array.isArray(args.receipts) ? args.receipts : [];
86
88
  for (const receipt of receipts) {
87
- if (!receipt || typeof receipt !== "object" || Array.isArray(receipt))
89
+ if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) {
88
90
  continue;
91
+ }
89
92
  const campaignId = receipt.campaignId;
90
93
  if (typeof campaignId === "string" && campaignId.trim()) {
91
94
  campaignIds.add(campaignId.trim());
@@ -388,6 +391,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
388
391
  case "export_table_csv":
389
392
  result = await exportTableCsv(args);
390
393
  break;
394
+ case "export_workspace_csv":
395
+ result = await exportWorkspaceCsv(args);
396
+ break;
391
397
  case "search_inbox_threads":
392
398
  result = await searchInboxThreads(args);
393
399
  break;
@@ -42,6 +42,7 @@ import { setupEvergreenCampaignsToolDefinitions } from "./setup-evergreen-campai
42
42
  import { tableToolDefinitions } from "./tables.js";
43
43
  import { verifyRowToolDefinitions } from "./verify-row.js";
44
44
  import { waterfallToolDefinitions } from "./waterfalls.js";
45
+ import { workspaceExportToolDefinitions } from "./workspace-export.js";
45
46
  import { workspaceToolDefinitions } from "./workspaces.js";
46
47
  export const allTools = [
47
48
  ...campaignToolDefinitions,
@@ -83,6 +84,7 @@ export const allTools = [
83
84
  ...engageMemoryToolDefinitions,
84
85
  ...sequencerToolDefinitions,
85
86
  ...tableToolDefinitions,
87
+ ...workspaceExportToolDefinitions,
86
88
  ...columnSchemaToolDefinitions,
87
89
  ...blueprintCommitToolDefinitions,
88
90
  ...columnUpdateToolDefinitions,
@@ -13,7 +13,7 @@ type SetupEvergreenCampaignsInput = {
13
13
  }>;
14
14
  planRevision?: string;
15
15
  selectedActionIds?: string[];
16
- receipts?: unknown[];
16
+ receipts?: Array<Record<string, unknown>>;
17
17
  };
18
18
  export declare const setupEvergreenCampaignsToolDefinitions: {
19
19
  name: string;
@@ -6,7 +6,7 @@ async function postSetupEvergreenCampaigns(body) {
6
6
  export const setupEvergreenCampaignsToolDefinitions = [
7
7
  {
8
8
  name: "setup_evergreen_campaigns",
9
- description: "Evergreen campaign setup plan/verify command. Use plan mode first to inspect exact workspace/sender/campaign/table/source state and receive immutable lane packets. `selectedSenderIds` is a legacy shorthand for both scopes; prefer `postEngagerSenderIds` for the Post Engagers sender scope and `sharedSenderIds` for the shared lane sender scope when they differ. The command plans one Post Engagers lane per post-engager sender plus shared Signal Discovery and Shared Cold Fallback lanes for the shared sender set. yolo is only a parent-skill auto-execution hint for safe lane packets; this backend command remains read-only in plan mode and verifies receipts in verify mode. Each lane packet includes workerDispatch with acceptedRuntimes, rejectedRuntimes, requiresVisibleThreadOrDurableReceipt, and receiptArtifactHint; `multi_agent_v1.spawn_agent`/opaque spawn_agent is not accepted for mutating command proof unless the parent has visible thread or durable receipt proof. When safe-yolo needs normal setup work, the parent skill may ask for bounded delegated approval: one approval over the current planRevision, selected action ids, caps, allowed side-effect classes, and stop conditions lets lane workers execute without per-substep approval while staying inside that packet. Lane workers must explicitly load and use the installed `$sellable:create-campaign` wrapper as the nested workflow entrypoint, then load `create-campaign-v2` and `create-campaign-v2/core/flow.v2.json`; they must execute creation, source import, create-campaign workflow steps, generate-messages, sequence attachment, pause_campaign review-state transition when the current table is still DRAFT, and review readiness through that existing create-campaign workflow/subskills, then return receipts here for verification. Customer-visible verify receipts must set status:'succeeded' or status:'completed' and must include createCampaignStepReceipt with setupPlanCall, createCampaignWorkflowReceipt, campaignBriefReceipt, sourceDecisionReceipt, filterDecisionReceipt, messageDraftingReceipt, reviewBatchReceipt, sequenceReceipt, and verifyCall nested inside createCampaignStepReceipt; top-level-only copies of those objects are not enough. createCampaignWorkflowReceipt must include skillCommand:'$sellable:create-campaign', skillName:'create-campaign', wrapperSkillLoaded:true, workflowPromptName:'create-campaign-v2', workflowPromptLoadedToHasMoreFalse:true, workflowAssetPath:'create-campaign-v2/core/flow.v2.json', workflowAssetLoaded:true, workerRuntime, workerThreadId or receiptArtifactPath, durableReceiptWritten when using a receipt file, and notAdHoc:true. messageDraftingReceipt must use statusSource:'branch' or statusSource:'packaged-generate-messages-worker' and include proof that generate-messages was loaded, start_campaign_message_preparation/get_campaign_message_preparation_status ran when the packaged worker path is used, validationResult:'passed', a passed qualityReview, and at least 3 concrete sampleMessages with rowId, generatedMessageText, verdict, and issues; Do not substitute `message` for `generatedMessageText`; Do not substitute `passVerdict` for `verdict`. Before writing durable receipts, run a receipt self-check: top-level `planRevision`, `actionId`, `laneKey`, `laneType`, `workspaceId`, and `senderIds` must exist; if the self-check fails, fix the receipt before ending. Use start_campaign_message_preparation with approvalMode:\"mark_ready\" only for evergreen setup. Never call `start_campaign_message_preparation` with `approvalMode:\"approve\"`; approve exactly one semantic Approved cell through select_campaign_cells/update_cell and final proof must show approvedGeneratedMessageCount exactly 1. Shared Cold Fallback samples with a standalone name followed by 'Hey there' are rejected. This command does not launch campaigns, does not schedule sends, does not assign scheduler-owned send fields, does not raw-write campaign status, does not archive/delete cleanup targets, and does not spend paid credits.",
9
+ description: "Evergreen campaign setup plan/verify command. Use plan mode first to inspect exact workspace/sender/campaign/table/source state and receive immutable lane packets. `selectedSenderIds` is a legacy shorthand for both scopes; prefer `postEngagerSenderIds` for the Post Engagers sender scope and `sharedSenderIds` for the shared lane sender scope when they differ. The command plans one Post Engagers lane per post-engager sender plus shared Signal Discovery and Shared Cold Fallback lanes for the shared sender set. yolo is only a parent-skill auto-execution hint for safe lane packets; this backend command remains read-only in plan mode and verifies receipts in verify mode. Each lane packet includes workerDispatch with acceptedRuntimes, rejectedRuntimes, requiresVisibleThreadOrDurableReceipt, and receiptArtifactHint; `multi_agent_v1.spawn_agent`/opaque spawn_agent is not accepted for mutating command proof unless the parent has visible thread or durable receipt proof. In local Codex, call `codex_app.list_projects` first, then `codex_app.create_thread` with a local project target; do not create a worktree for lane execution. When safe-yolo needs normal setup work, the parent skill may ask for bounded delegated approval: one approval over the current planRevision, selected action ids, caps, allowed side-effect classes, and stop conditions lets lane workers execute without per-substep approval while staying inside that packet. Lane workers must explicitly load and use the installed `$sellable:create-campaign` wrapper as the nested workflow entrypoint, then load `create-campaign-v2` and `create-campaign-v2/core/flow.v2.json`; they must execute creation, source import, create-campaign workflow steps, generate-messages, sequence attachment, pause_campaign review-state transition when the current table is still DRAFT, and review readiness through that existing create-campaign workflow/subskills, then return receipts here for verification. Customer-visible verify receipts must set status:'succeeded' or status:'completed'; status:'passed', status:'pass', and status:'passed_with_warnings' are rejected as primary success statuses. Receipts must include createCampaignStepReceipt with setupPlanCall, createCampaignWorkflowReceipt, campaignBriefReceipt, sourceDecisionReceipt, filterDecisionReceipt, messageDraftingReceipt, reviewBatchReceipt, sequenceReceipt, and verifyCall nested inside createCampaignStepReceipt; top-level-only copies of those objects are not enough and are not promoted by verify. setupPlanCall must use canonical keys: planRevision, actionId, laneKey, workspaceId, senderIds, campaignId, tableId, createIntent. Do not use laneActionId, lanePacketActionId, delegatedPlanRevision, delegatedActionId, or requestedCall text as a substitute for those canonical fields. createCampaignWorkflowReceipt must include skillCommand:'$sellable:create-campaign', skillName:'create-campaign', wrapperSkillLoaded:true, workflowPromptName:'create-campaign-v2', workflowPromptLoadedToHasMoreFalse:true, workflowAssetPath:'create-campaign-v2/core/flow.v2.json', workflowAssetLoaded:true, workerRuntime, workerThreadId or receiptArtifactPath, durableReceiptWritten when using a receipt file, and notAdHoc:true. messageDraftingReceipt must use exactly statusSource:'branch' or statusSource:'packaged-generate-messages-worker'; descriptive aliases such as statusSource:'package-readback-local-thread' are rejected. It must include proof that generate-messages was loaded, start_campaign_message_preparation/get_campaign_message_preparation_status ran when the packaged worker path is used, validationResult:'passed', a passed qualityReview, and at least 3 concrete sampleMessages with rowId, generatedMessageText, verdict, and issues; Do not substitute `message` for `generatedMessageText`; Do not substitute `passVerdict` for `verdict`. Before writing durable receipts, run a receipt self-check: top-level `planRevision`, `actionId`, `laneKey`, `laneType`, `workspaceId`, and `senderIds` must exist; if the self-check fails, fix the receipt before ending. Use start_campaign_message_preparation with approvalMode:\"mark_ready\" only for evergreen setup. Never call `start_campaign_message_preparation` with `approvalMode:\"approve\"`; approve exactly one semantic Approved cell through select_campaign_cells/update_cell and final proof must show approvedGeneratedMessageCount exactly 1. Shared Cold Fallback samples with a standalone name followed by 'Hey there' are rejected. This command does not launch campaigns, does not schedule sends, does not assign scheduler-owned send fields, does not raw-write campaign status, does not archive/delete cleanup targets, and does not spend paid credits.",
10
10
  inputSchema: {
11
11
  type: "object",
12
12
  properties: {
@@ -75,7 +75,7 @@ export const setupEvergreenCampaignsToolDefinitions = [
75
75
  type: "array",
76
76
  maxItems: 20,
77
77
  items: { type: "object" },
78
- description: "Required for verify. Structured lane worker receipts from create-campaign/source/message/sequence execution. Customer-visible lanes must use status:'succeeded' or status:'completed' and include createCampaignStepReceipt with setupPlanCall, createCampaignWorkflowReceipt, campaignBriefReceipt, sourceDecisionReceipt, filterDecisionReceipt, messageDraftingReceipt, reviewBatchReceipt, sequenceReceipt, and verifyCall nested inside createCampaignStepReceipt. createCampaignWorkflowReceipt proves the lane worker loaded `$sellable:create-campaign`, `create-campaign-v2`, and `create-campaign-v2/core/flow.v2.json`; it must also include a visible workerThreadId or durable receiptArtifactPath with durableReceiptWritten:true. messageDraftingReceipt.sampleMessages must use rowId, generatedMessageText, verdict, and issues. top-level-only proof copies are not enough.",
78
+ description: "Required for verify. Structured lane worker receipts from create-campaign/source/message/sequence execution. Customer-visible lanes must use status:'succeeded' or status:'completed'; status:'passed', status:'pass', and status:'passed_with_warnings' are rejected. Receipts must include createCampaignStepReceipt with setupPlanCall, createCampaignWorkflowReceipt, campaignBriefReceipt, sourceDecisionReceipt, filterDecisionReceipt, messageDraftingReceipt, reviewBatchReceipt, sequenceReceipt, and verifyCall nested inside createCampaignStepReceipt. setupPlanCall must use canonical keys: planRevision, actionId, laneKey, workspaceId, senderIds, campaignId, tableId, createIntent; do not use laneActionId, lanePacketActionId, delegatedPlanRevision, or delegatedActionId aliases. createCampaignWorkflowReceipt proves the lane worker loaded `$sellable:create-campaign`, `create-campaign-v2`, and `create-campaign-v2/core/flow.v2.json`; it must also include a visible workerThreadId or durable receiptArtifactPath with durableReceiptWritten:true. messageDraftingReceipt.statusSource must be exactly 'branch' or 'packaged-generate-messages-worker'; messageDraftingReceipt.sampleMessages must use rowId, generatedMessageText, verdict, and issues. top-level-only proof copies are not enough.",
79
79
  },
80
80
  },
81
81
  required: [],
@@ -0,0 +1,110 @@
1
+ export type WorkspaceExportDataset = "people" | "events";
2
+ export type WorkspaceExportIntent = "reached_out_people" | "people_and_events" | "outreach_event_log" | "dashboard_messages" | "connections_sent";
3
+ type WorkspaceExportActionType = "INVITE" | "DM" | "INMAIL_OPEN" | "INMAIL_CLOSED" | "VIEW_PROFILE" | "COMMENT";
4
+ export interface ExportWorkspaceCsvInput {
5
+ exportType?: "outreach";
6
+ exportIntent?: WorkspaceExportIntent;
7
+ datasets?: WorkspaceExportDataset[];
8
+ actionTypes?: WorkspaceExportActionType[];
9
+ outputDir?: string;
10
+ fromSentAt?: string;
11
+ toSentAt?: string;
12
+ campaignIds?: string[];
13
+ tableIds?: string[];
14
+ }
15
+ export declare const workspaceExportToolDefinitions: {
16
+ name: string;
17
+ description: string;
18
+ inputSchema: {
19
+ type: string;
20
+ properties: {
21
+ exportType: {
22
+ type: string;
23
+ enum: string[];
24
+ description: string;
25
+ };
26
+ exportIntent: {
27
+ type: string;
28
+ enum: string[];
29
+ description: string;
30
+ };
31
+ datasets: {
32
+ type: string;
33
+ items: {
34
+ type: string;
35
+ enum: string[];
36
+ };
37
+ description: string;
38
+ };
39
+ actionTypes: {
40
+ type: string;
41
+ items: {
42
+ type: string;
43
+ enum: string[];
44
+ };
45
+ description: string;
46
+ };
47
+ outputDir: {
48
+ type: string;
49
+ description: string;
50
+ };
51
+ fromSentAt: {
52
+ type: string;
53
+ description: string;
54
+ };
55
+ toSentAt: {
56
+ type: string;
57
+ description: string;
58
+ };
59
+ campaignIds: {
60
+ type: string;
61
+ items: {
62
+ type: string;
63
+ };
64
+ description: string;
65
+ };
66
+ tableIds: {
67
+ type: string;
68
+ items: {
69
+ type: string;
70
+ };
71
+ description: string;
72
+ };
73
+ };
74
+ required: never[];
75
+ additionalProperties: boolean;
76
+ };
77
+ }[];
78
+ export declare function exportWorkspaceCsv(input?: ExportWorkspaceCsvInput): Promise<{
79
+ status: string;
80
+ outputDir: string;
81
+ manifestPath: string;
82
+ datasets: {
83
+ dataset: WorkspaceExportDataset;
84
+ path: string;
85
+ rows: number;
86
+ bytes: number;
87
+ status: "complete" | "failed";
88
+ }[];
89
+ exportCutoff: string;
90
+ exportIntent: WorkspaceExportIntent | null;
91
+ intentDescription: string | null;
92
+ countDefinitions: {
93
+ reachedOutPeople: string;
94
+ outreachEvents: string;
95
+ dashboardMessagesSent: string;
96
+ connectionsSent: string;
97
+ profileViews: string;
98
+ };
99
+ counts: Record<string, unknown>;
100
+ workspace: {
101
+ id?: string;
102
+ name?: string | null;
103
+ };
104
+ skipped: string[];
105
+ failures: {
106
+ dataset: WorkspaceExportDataset;
107
+ error: string;
108
+ }[];
109
+ }>;
110
+ export {};
@@ -0,0 +1,465 @@
1
+ import { parse } from "csv-parse";
2
+ import { createReadStream } from "node:fs";
3
+ import { lstat, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { getApi } from "../api.js";
7
+ import { getConfig } from "../auth.js";
8
+ const MAX_FILTER_IDS = 100;
9
+ const MAX_QUERY_LENGTH = 8000;
10
+ export const workspaceExportToolDefinitions = [
11
+ {
12
+ name: "export_workspace_csv",
13
+ description: "Export active-workspace outreach CSVs to local files on the MCP host. " +
14
+ "Use exportIntent to disambiguate counts before exporting: " +
15
+ "`reached_out_people` means one deduped row per unique prospect who received an actual contact attempt (invite, DM, open InMail, or paid/closed InMail) and is the right choice for 'everyone we reached out to'; " +
16
+ "`dashboard_messages` means message events only (DMs + InMails), matching the dashboard Messages Sent card; " +
17
+ "`connections_sent` means invite events only; " +
18
+ "`outreach_event_log` means one row per action event for audit/reconciliation; " +
19
+ "`people_and_events` exports the reached-out people CSV plus matching contact event rows. " +
20
+ "If the user asks for 'sent', 'reached out', or cites conflicting dashboard/export numbers, ask which intent they want before calling the tool. " +
21
+ "Requires a valid Sellable API key plus active workspace; run list_workspaces and set_active_workspace first if needed. " +
22
+ "The manifest includes file paths, counts, filters, package/runtime metadata, and skipped/deferred scope notes. " +
23
+ "CSV files may contain prospect and outreach metadata, so store them appropriately.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ exportType: {
28
+ type: "string",
29
+ enum: ["outreach"],
30
+ description: "Export family. Phase 70 supports outreach only.",
31
+ },
32
+ exportIntent: {
33
+ type: "string",
34
+ enum: [
35
+ "reached_out_people",
36
+ "people_and_events",
37
+ "outreach_event_log",
38
+ "dashboard_messages",
39
+ "connections_sent",
40
+ ],
41
+ description: "Recommended semantic export. Use reached_out_people for 'everyone we reached out to'; dashboard_messages for the dashboard Messages Sent card; connections_sent for invites; outreach_event_log for audit rows; people_and_events for both reached people and matching contact events.",
42
+ },
43
+ datasets: {
44
+ type: "array",
45
+ items: { type: "string", enum: ["people", "events"] },
46
+ description: "Low-level datasets to export. people is one deduped prospect row; events is one action row. Omit this and use exportIntent for clearer behavior.",
47
+ },
48
+ actionTypes: {
49
+ type: "array",
50
+ items: {
51
+ type: "string",
52
+ enum: [
53
+ "INVITE",
54
+ "DM",
55
+ "INMAIL_OPEN",
56
+ "INMAIL_CLOSED",
57
+ "VIEW_PROFILE",
58
+ "COMMENT",
59
+ ],
60
+ },
61
+ description: "Optional low-level action filter. Usually omit and use exportIntent. Dashboard messages are DM + INMAIL_OPEN + INMAIL_CLOSED; reached-out/contact events are INVITE + DM + INMAIL_OPEN + INMAIL_CLOSED.",
62
+ },
63
+ outputDir: {
64
+ type: "string",
65
+ description: "Optional base directory. A unique run subdirectory is created inside it.",
66
+ },
67
+ fromSentAt: {
68
+ type: "string",
69
+ description: "Optional ISO lower bound for outreach sentAt.",
70
+ },
71
+ toSentAt: {
72
+ type: "string",
73
+ description: "Optional ISO upper bound for outreach sentAt.",
74
+ },
75
+ campaignIds: {
76
+ type: "array",
77
+ items: { type: "string" },
78
+ description: "Optional campaign ids to include, max 100.",
79
+ },
80
+ tableIds: {
81
+ type: "array",
82
+ items: { type: "string" },
83
+ description: "Optional workflow table ids to include, max 100.",
84
+ },
85
+ },
86
+ required: [],
87
+ additionalProperties: false,
88
+ },
89
+ },
90
+ ];
91
+ const CONTACT_ACTION_TYPES = [
92
+ "INVITE",
93
+ "DM",
94
+ "INMAIL_OPEN",
95
+ "INMAIL_CLOSED",
96
+ ];
97
+ const DASHBOARD_MESSAGE_ACTION_TYPES = [
98
+ "DM",
99
+ "INMAIL_OPEN",
100
+ "INMAIL_CLOSED",
101
+ ];
102
+ const ALL_ACTION_TYPES = [
103
+ "INVITE",
104
+ "DM",
105
+ "INMAIL_OPEN",
106
+ "INMAIL_CLOSED",
107
+ "VIEW_PROFILE",
108
+ "COMMENT",
109
+ ];
110
+ const EXPORT_INTENT_CONFIG = {
111
+ reached_out_people: {
112
+ datasets: ["people"],
113
+ actionTypes: CONTACT_ACTION_TYPES,
114
+ description: "Unique prospects who received at least one actual contact attempt: invite, DM, open InMail, or paid/closed InMail.",
115
+ },
116
+ people_and_events: {
117
+ datasets: ["people", "events"],
118
+ actionTypes: CONTACT_ACTION_TYPES,
119
+ description: "Reached-out people plus one matching contact event row per invite, DM, open InMail, or paid/closed InMail.",
120
+ },
121
+ outreach_event_log: {
122
+ datasets: ["events"],
123
+ actionTypes: ALL_ACTION_TYPES,
124
+ description: "Raw workflow-table-backed action event log for audit/reconciliation, including profile views and comments when present.",
125
+ },
126
+ dashboard_messages: {
127
+ datasets: ["events"],
128
+ actionTypes: DASHBOARD_MESSAGE_ACTION_TYPES,
129
+ description: "Message events only, matching the dashboard Messages Sent card: DMs + open InMails + paid/closed InMails.",
130
+ },
131
+ connections_sent: {
132
+ datasets: ["events"],
133
+ actionTypes: ["INVITE"],
134
+ description: "Connection invite events only, matching the dashboard Connections Sent sub-count.",
135
+ },
136
+ };
137
+ function assertIsoDate(value, field) {
138
+ if (value === undefined || value === null || value === "")
139
+ return undefined;
140
+ if (typeof value !== "string")
141
+ throw new Error(`${field} must be a string`);
142
+ const date = new Date(value);
143
+ if (!Number.isFinite(date.getTime())) {
144
+ throw new Error(`${field} must be a valid ISO date`);
145
+ }
146
+ return date.toISOString();
147
+ }
148
+ function assertIdList(value, field) {
149
+ if (value === undefined || value === null)
150
+ return [];
151
+ if (!Array.isArray(value))
152
+ throw new Error(`${field} must be an array`);
153
+ if (value.length > MAX_FILTER_IDS) {
154
+ throw new Error(`${field} supports at most ${MAX_FILTER_IDS} ids`);
155
+ }
156
+ return value.map((id) => {
157
+ if (typeof id !== "string" || !/^[A-Za-z0-9_.:-]+$/.test(id)) {
158
+ throw new Error(`${field} contains an invalid id`);
159
+ }
160
+ return id;
161
+ });
162
+ }
163
+ function normalizeDatasets(value) {
164
+ if (value === undefined || value === null)
165
+ return ["people", "events"];
166
+ if (!Array.isArray(value))
167
+ throw new Error("datasets must be an array");
168
+ if (value.length === 0)
169
+ throw new Error("datasets cannot be empty");
170
+ const unique = Array.from(new Set(value));
171
+ return unique.map((dataset) => {
172
+ if (dataset !== "people" && dataset !== "events") {
173
+ throw new Error("datasets may only contain people or events");
174
+ }
175
+ return dataset;
176
+ });
177
+ }
178
+ function normalizeExportIntent(value) {
179
+ if (value === undefined || value === null || value === "")
180
+ return undefined;
181
+ if (value !== "reached_out_people" &&
182
+ value !== "people_and_events" &&
183
+ value !== "outreach_event_log" &&
184
+ value !== "dashboard_messages" &&
185
+ value !== "connections_sent") {
186
+ throw new Error("exportIntent is not supported");
187
+ }
188
+ return value;
189
+ }
190
+ function normalizeActionTypes(value) {
191
+ if (value === undefined || value === null)
192
+ return [];
193
+ if (!Array.isArray(value))
194
+ throw new Error("actionTypes must be an array");
195
+ if (value.length === 0)
196
+ throw new Error("actionTypes cannot be empty");
197
+ const unique = Array.from(new Set(value));
198
+ return unique.map((actionType) => {
199
+ if (actionType !== "INVITE" &&
200
+ actionType !== "DM" &&
201
+ actionType !== "INMAIL_OPEN" &&
202
+ actionType !== "INMAIL_CLOSED" &&
203
+ actionType !== "VIEW_PROFILE" &&
204
+ actionType !== "COMMENT") {
205
+ throw new Error("actionTypes contains an unsupported action type");
206
+ }
207
+ return actionType;
208
+ });
209
+ }
210
+ function validateInput(input) {
211
+ if (input.exportType && input.exportType !== "outreach") {
212
+ throw new Error("exportType must be outreach");
213
+ }
214
+ const exportIntent = normalizeExportIntent(input.exportIntent);
215
+ const intentConfig = exportIntent ? EXPORT_INTENT_CONFIG[exportIntent] : null;
216
+ const fromSentAt = assertIsoDate(input.fromSentAt, "fromSentAt");
217
+ const toSentAt = assertIsoDate(input.toSentAt, "toSentAt");
218
+ if (fromSentAt && toSentAt && new Date(fromSentAt) > new Date(toSentAt)) {
219
+ throw new Error("fromSentAt must be before toSentAt");
220
+ }
221
+ const actionTypes = normalizeActionTypes(input.actionTypes);
222
+ return {
223
+ exportIntent,
224
+ intentDescription: intentConfig?.description ?? null,
225
+ datasets: input.datasets === undefined || input.datasets === null
226
+ ? (intentConfig?.datasets ?? ["people", "events"])
227
+ : normalizeDatasets(input.datasets),
228
+ actionTypes: actionTypes.length > 0 ? actionTypes : (intentConfig?.actionTypes ?? []),
229
+ outputDir: input.outputDir,
230
+ fromSentAt,
231
+ toSentAt,
232
+ campaignIds: assertIdList(input.campaignIds, "campaignIds"),
233
+ tableIds: assertIdList(input.tableIds, "tableIds"),
234
+ };
235
+ }
236
+ function countDefinitions() {
237
+ return {
238
+ reachedOutPeople: "Deduped prospects with at least one actual contact attempt: INVITE, DM, INMAIL_OPEN, or INMAIL_CLOSED. This is what 'everyone we reached out to' should mean.",
239
+ outreachEvents: "One row per exported LinkedInOutreach action event after workspace, date, campaign/table, and action-type filters.",
240
+ dashboardMessagesSent: "Dashboard Messages Sent equals DM + INMAIL_OPEN + INMAIL_CLOSED events. It excludes connection invites.",
241
+ connectionsSent: "Dashboard Connections Sent equals INVITE events. Accepted invites show separately as Connections Made.",
242
+ profileViews: "VIEW_PROFILE is an action event for audit logs, but it is not a contact attempt and should not define reached-out people.",
243
+ };
244
+ }
245
+ async function packageVersion() {
246
+ const entryDir = process.argv[1]
247
+ ? path.dirname(path.resolve(process.argv[1]))
248
+ : process.cwd();
249
+ const candidates = [
250
+ path.resolve(process.cwd(), "mcp/sellable/package.json"),
251
+ path.resolve(entryDir, "../package.json"),
252
+ path.resolve(entryDir, "../../package.json"),
253
+ ];
254
+ for (const candidate of candidates) {
255
+ try {
256
+ const raw = await readFile(candidate, "utf8");
257
+ const parsed = JSON.parse(raw);
258
+ if (typeof parsed.version === "string")
259
+ return parsed.version;
260
+ }
261
+ catch {
262
+ // Try the next runtime/package layout.
263
+ }
264
+ }
265
+ return null;
266
+ }
267
+ async function ensureSafeBaseDir(outputDir) {
268
+ const baseDir = path.resolve(outputDir || path.join(tmpdir(), "sellable-mcp-exports"));
269
+ await mkdir(baseDir, { recursive: true });
270
+ const baseStat = await lstat(baseDir);
271
+ if (baseStat.isSymbolicLink()) {
272
+ throw new Error("outputDir must not be a symlink");
273
+ }
274
+ if (!baseStat.isDirectory()) {
275
+ throw new Error("outputDir must be a directory");
276
+ }
277
+ return baseDir;
278
+ }
279
+ async function createRunDir(outputDir) {
280
+ const baseDir = await ensureSafeBaseDir(outputDir);
281
+ for (let attempt = 0; attempt < 10; attempt++) {
282
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
283
+ const runDir = path.join(baseDir, `workspace-outreach-${stamp}-${process.pid}-${attempt}`);
284
+ try {
285
+ await mkdir(runDir, { recursive: false });
286
+ return runDir;
287
+ }
288
+ catch (error) {
289
+ if (error.code !== "EEXIST")
290
+ throw error;
291
+ }
292
+ }
293
+ throw new Error("Failed to create unique export directory");
294
+ }
295
+ function buildQuery(params) {
296
+ const searchParams = new URLSearchParams();
297
+ for (const [key, value] of Object.entries(params)) {
298
+ if (Array.isArray(value)) {
299
+ if (value.length > 0)
300
+ searchParams.set(key, value.join(","));
301
+ }
302
+ else if (value) {
303
+ searchParams.set(key, value);
304
+ }
305
+ }
306
+ const query = searchParams.toString();
307
+ if (query.length > MAX_QUERY_LENGTH) {
308
+ throw new Error("Export query string is too long");
309
+ }
310
+ return query ? `?${query}` : "";
311
+ }
312
+ async function countCsvRows(filePath) {
313
+ const parser = createReadStream(filePath).pipe(parse({ bom: true, relax_column_count: true }));
314
+ let records = 0;
315
+ for await (const _record of parser) {
316
+ records += 1;
317
+ }
318
+ return Math.max(0, records - 1);
319
+ }
320
+ function redactManifest(value) {
321
+ if (Array.isArray(value))
322
+ return value.map(redactManifest);
323
+ if (!value || typeof value !== "object")
324
+ return value;
325
+ const entries = Object.entries(value).map(([key, entry]) => {
326
+ if (/token|secret|authorization|api[_-]?key/i.test(key)) {
327
+ return [key, "[redacted]"];
328
+ }
329
+ return [key, redactManifest(entry)];
330
+ });
331
+ return Object.fromEntries(entries);
332
+ }
333
+ async function writeJsonAtomic(filePath, value) {
334
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
335
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
336
+ encoding: "utf8",
337
+ flag: "wx",
338
+ });
339
+ await rename(tempPath, filePath);
340
+ }
341
+ export async function exportWorkspaceCsv(input = {}) {
342
+ const config = getConfig();
343
+ const workspaceId = config.activeWorkspaceId || config.workspaceId || null;
344
+ if (!workspaceId) {
345
+ throw new Error("No active workspace selected. Run list_workspaces then set_active_workspace before export_workspace_csv.");
346
+ }
347
+ const validated = validateInput(input);
348
+ const startedAt = new Date().toISOString();
349
+ const runDir = await createRunDir(validated.outputDir);
350
+ const api = getApi();
351
+ const metadataQuery = buildQuery({
352
+ fromSentAt: validated.fromSentAt,
353
+ toSentAt: validated.toSentAt,
354
+ campaignIds: validated.campaignIds,
355
+ tableIds: validated.tableIds,
356
+ actionTypes: validated.actionTypes,
357
+ });
358
+ const metadataEndpoint = `/api/v3/mcp/workspace-export/outreach/metadata${metadataQuery}`;
359
+ const backendMetadata = await api.get(metadataEndpoint);
360
+ const exportCutoff = backendMetadata.exportCutoff;
361
+ if (!exportCutoff) {
362
+ throw new Error("Export metadata did not include exportCutoff");
363
+ }
364
+ const files = [];
365
+ const failures = [];
366
+ for (const dataset of validated.datasets) {
367
+ const query = buildQuery({
368
+ dataset,
369
+ exportCutoff,
370
+ fromSentAt: validated.fromSentAt,
371
+ toSentAt: validated.toSentAt,
372
+ campaignIds: validated.campaignIds,
373
+ tableIds: validated.tableIds,
374
+ actionTypes: validated.actionTypes,
375
+ });
376
+ const endpoint = `/api/v3/mcp/workspace-export/outreach${query}`;
377
+ const filePath = path.join(runDir, `outreach-${dataset}.csv`);
378
+ try {
379
+ const download = await api.downloadToFile(endpoint, filePath);
380
+ files.push({
381
+ dataset,
382
+ path: download.path,
383
+ rows: await countCsvRows(download.path),
384
+ bytes: download.bytes,
385
+ endpoint,
386
+ contentType: download.contentType,
387
+ status: "complete",
388
+ });
389
+ }
390
+ catch (error) {
391
+ const message = error instanceof Error ? error.message : "Unknown error";
392
+ failures.push({ dataset, error: message });
393
+ files.push({
394
+ dataset,
395
+ path: filePath,
396
+ rows: 0,
397
+ bytes: 0,
398
+ endpoint,
399
+ contentType: null,
400
+ status: "failed",
401
+ error: message,
402
+ });
403
+ await rm(filePath, { force: true }).catch(() => undefined);
404
+ }
405
+ }
406
+ const manifest = redactManifest({
407
+ status: failures.length > 0 ? "partial" : "success",
408
+ tool: "export_workspace_csv",
409
+ exportType: "outreach",
410
+ exportIntent: validated.exportIntent ?? "legacy_datasets",
411
+ intentDescription: validated.intentDescription,
412
+ countDefinitions: countDefinitions(),
413
+ startedAt,
414
+ completedAt: new Date().toISOString(),
415
+ package: {
416
+ name: "@sellable/mcp",
417
+ version: await packageVersion(),
418
+ },
419
+ config: {
420
+ apiUrl: config.apiUrl,
421
+ activeWorkspaceId: workspaceId,
422
+ activeWorkspaceName: config.activeWorkspaceName || config.workspaceName,
423
+ },
424
+ backend: {
425
+ metadataEndpoint,
426
+ metadata: backendMetadata,
427
+ },
428
+ filters: {
429
+ fromSentAt: validated.fromSentAt ?? null,
430
+ toSentAt: validated.toSentAt ?? null,
431
+ campaignIds: validated.campaignIds,
432
+ tableIds: validated.tableIds,
433
+ actionTypes: validated.actionTypes,
434
+ exportCutoff,
435
+ },
436
+ outputDir: runDir,
437
+ files,
438
+ failures,
439
+ });
440
+ const manifestPath = path.join(runDir, "manifest.json");
441
+ await writeJsonAtomic(manifestPath, manifest);
442
+ return {
443
+ status: failures.length > 0 ? "partial" : "success",
444
+ outputDir: runDir,
445
+ manifestPath,
446
+ datasets: files.map((file) => ({
447
+ dataset: file.dataset,
448
+ path: file.path,
449
+ rows: file.rows,
450
+ bytes: file.bytes,
451
+ status: file.status,
452
+ })),
453
+ exportCutoff,
454
+ exportIntent: validated.exportIntent ?? null,
455
+ intentDescription: validated.intentDescription,
456
+ countDefinitions: countDefinitions(),
457
+ counts: backendMetadata.counts ?? {},
458
+ workspace: backendMetadata.workspace ?? {
459
+ id: workspaceId,
460
+ name: config.activeWorkspaceName || config.workspaceName || null,
461
+ },
462
+ skipped: backendMetadata.skipped ?? [],
463
+ failures,
464
+ };
465
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.371",
3
+ "version": "0.1.373",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -155,15 +155,19 @@ execution boundary, not guidance. Read and preserve `workerDispatch.acceptedRunt
155
155
  before dispatch.
156
156
 
157
157
  Worker fan-out must use visible or durable execution. Preferred in local Codex
158
- is a visible Codex thread created with `codex_app.create_thread`; the parent
159
- must record the returned thread id, pass exactly one lane packet into that
160
- thread, and require the worker to write the lane receipt at the artifact path
161
- named by `workerDispatch.receiptArtifactHint`. A streaming worker or branch
162
- worker is acceptable only when it writes a durable per-lane receipt artifact as
163
- it works. `multi_agent_v1.spawn_agent`, raw `spawn_agent`, or any opaque
164
- Task/subagent runtime that cannot expose a visible thread id or durable receipt
165
- artifact is not accepted as command-level UAT proof and must not be used for
166
- mutating evergreen setup.
158
+ is a visible Codex thread created with the actual Codex app thread tools:
159
+ first call `codex_app.list_projects`, select the current repo project, then
160
+ call `codex_app.create_thread` with `target:{type:"project", projectId,
161
+ environment:{type:"local"}}`. Do not create a worktree target for evergreen
162
+ UAT or lane execution. The parent must record the returned thread id, pass
163
+ exactly one lane packet into that thread, and require the worker to write the
164
+ lane receipt at the artifact path named by
165
+ `workerDispatch.receiptArtifactHint`. A streaming worker or branch worker is
166
+ acceptable only when it writes a durable per-lane receipt artifact as it works.
167
+ `multi_agent_v1.spawn_agent`, raw `spawn_agent`, or any opaque Task/subagent
168
+ runtime that cannot expose a visible thread id or durable receipt artifact is
169
+ not accepted as command-level UAT proof and must not be used for mutating
170
+ evergreen setup.
167
171
 
168
172
  Run this preflight before the first irreversible product mutation in any lane:
169
173
  the parent must have recorded either a visible Codex thread id or a durable
@@ -220,7 +224,12 @@ not a parent-thread summary. The receipt must include:
220
224
  planRevision, selected actionId, lane key, workspace id, sender ids, and
221
225
  campaign/table binding or create intent used before work started. For a
222
226
  create lane, this receipt must explicitly include `createIntent:true`; for a
223
- reuse lane, do not report `createIntent:true`.
227
+ reuse lane, do not report `createIntent:true`. The object must use the
228
+ canonical keys `planRevision`, `actionId`, `laneKey`, `workspaceId`,
229
+ `senderIds`, `campaignId`, `tableId`, and `createIntent`. Do not use
230
+ `laneActionId`, `lanePacketActionId`, `delegatedPlanRevision`,
231
+ `delegatedActionId`, or freeform `requestedCall` text as a substitute for
232
+ these fields.
224
233
  - `createCampaignWorkflowReceipt`: proof the worker loaded the actual installed
225
234
  `$sellable:create-campaign` wrapper instead of doing ad hoc MCP calls. It must
226
235
  include `skillCommand:"$sellable:create-campaign"`,
@@ -288,6 +297,8 @@ The receipt status must be `status:"succeeded"` or `status:"completed"` when
288
297
  the lane is complete. Do not use `status:"passed"` or
289
298
  `status:"passed_with_warnings"` as the primary success status. Warnings may go
290
299
  in a separate `warnings` array, but the completion status stays canonical.
300
+ The backend verifier rejects `status:"passed"`, `status:"pass"`, and
301
+ `status:"passed_with_warnings"` as primary completion statuses.
291
302
 
292
303
  All step proof objects must live under `createCampaignStepReceipt`. Do not put
293
304
  `createCampaignWorkflowReceipt`, `campaignBriefReceipt`,
@@ -446,6 +457,9 @@ includes the same prompt/assets/validation proof, current campaign/table/source
446
457
  basis, generated review rows, sample messages, quality review, route-proof
447
458
  approval evidence, sequence proof, and final paused-send proof required of the
448
459
  branch handoff. `statusSource:"parent-thread-fallback"` is still invalid.
460
+ The only accepted statusSource values are exactly `branch` and
461
+ `packaged-generate-messages-worker`; descriptive aliases such as
462
+ `package-readback-local-thread` are rejected.
449
463
 
450
464
  One lane may produce exactly one CampaignOffer/campaign id and one current
451
465
  workflow table id. For `intent:"create"`, the first successful