@sellable/install
Version:
One-command installer for Sellable MCP in Claude Code, Codex, and Hermes
779 lines (725 loc) • 22.2 kB
JavaScript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const MAX_STDERR_LINES = 80;
const DEFAULT_VERIFY_TIMEOUT_MS = 10_000;
const DEFAULT_VERIFY_ATTEMPTS = 2;
const DEFAULT_VERIFY_RETRY_DELAY_MS = 750;
export const REQUIRED_SELLABLE_MCP_TOOLS = [
"get_auth_status",
"start_cli_login",
"wait_for_cli_login",
"bootstrap_create_campaign",
"bootstrap_find_leads",
"get_find_leads_run",
"update_find_leads_run",
"wait_for_find_leads_run",
"cancel_find_leads",
"reissue_find_leads_watch_link",
"preflight_find_leads_provider",
"get_subskill_prompt",
"get_subskill_asset",
"search_subskill_prompts",
"create_campaign",
"import_leads",
"search_sales_nav",
"search_prospeo",
"search_signals",
"select_promising_posts",
"confirm_lead_list",
"wait_for_lead_list_ready",
"export_table_csv",
"wait_for_campaign_table_ready",
"update_campaign",
"update_campaign_brief",
"get_campaign",
"get_campaign_context",
"get_campaign_framework",
"get_campaign_navigation_state",
"get_campaign_messages_preview",
"attach_sequence",
"attach_recommended_sequence",
"resolve_campaign_fill_route",
"get_campaign_refill_state",
"get_refill_target_plan",
"get_scheduler_fill_capacity",
"refresh_paid_inmail_credits",
"run_scheduler_sweep",
"refill_sends",
"refresh_sender_engagement",
"setup_evergreen_campaigns",
"start_campaign_message_preparation",
"get_campaign_message_preparation_status",
"cancel_campaign_message_preparation",
"start_campaign",
];
export const FORBIDDEN_SELLABLE_MCP_TOOLS = ["refill_campaign_sends"];
const CONNECTION_ONLY_EVERGREEN_PROMPT_CHUNK_LIMIT = 48_000;
const CONNECTION_ONLY_EVERGREEN_MAX_PROMPT_CHUNKS = 8;
const CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES = {
campaignSequenceOptions: 'campaignSequenceOptions:{ mode:"connection_only" }',
templateRef: 'templateRef:"connection_only"',
sendInviteReceipt: 'sequenceReceipt.actionTypes:["send_invite"]',
};
export const CREATE_CAMPAIGN_SMOKE_CALLS = [
{
name: "get_auth_status",
arguments: {},
},
{
name: "bootstrap_create_campaign",
arguments: {
flowVersion: "v2",
includeTableCheck: false,
host: "sellable-install-verify",
model: "installer-smoke",
reasoningEffort: "none",
},
},
{
name: "get_subskill_prompt",
arguments: {
subskillName: "create-campaign-v2",
offset: 0,
limit: 1200,
},
},
{
name: "get_subskill_prompt",
arguments: {
subskillName: "find-leads-v2",
offset: 0,
limit: 1200,
},
},
{
name: "get_subskill_prompt",
arguments: {
subskillName: "refill-sends-workflow",
offset: 0,
limit: 1200,
},
},
{
name: "get_subskill_prompt",
arguments: {
subskillName: "refresh-sender-engagement",
offset: 0,
limit: 1200,
},
},
{
name: "get_subskill_prompt",
arguments: {
subskillName: "create-evergreen-campaigns",
offset: 0,
limit: CONNECTION_ONLY_EVERGREEN_PROMPT_CHUNK_LIMIT,
},
},
{
name: "refill_sends",
arguments: {
yolo: false,
senders: ["smoke"],
horizonSendDays: 2,
},
},
{
name: "get_subskill_asset",
arguments: {
subskillName: "create-campaign-v2",
assetPath: "core/flow.v2.json",
offset: 0,
limit: 1200,
},
},
{
name: "get_subskill_asset",
arguments: {
subskillName: "find-leads-v2",
assetPath: "core/flow.v1.json",
offset: 0,
limit: 1200,
},
},
];
function collectStderrLines(stream) {
const lines = [];
if (!stream) return lines;
let buffer = "";
stream.on("data", (chunk) => {
buffer += chunk.toString("utf8");
const parts = buffer.split(/\r?\n/);
buffer = parts.pop() ?? "";
for (const line of parts) {
if (!line) continue;
lines.push(redact(line));
if (lines.length > MAX_STDERR_LINES) lines.shift();
}
});
stream.on("end", () => {
if (!buffer) return;
lines.push(redact(buffer));
if (lines.length > MAX_STDERR_LINES) lines.shift();
});
return lines;
}
function redact(value) {
let out = String(value ?? "");
const secretValues = [
process.env.SELLABLE_TOKEN,
process.env.SUPABASE_SERVICE_ROLE_KEY,
process.env.OPENAI_API_KEY,
process.env.ANTHROPIC_API_KEY,
].filter(Boolean);
for (const secret of secretValues) {
out = out.split(secret).join("[redacted]");
}
out = out.replace(
/(https?:\/\/[^:\s/]+:)([^@\s/]+)(@)/g,
"$1[redacted]$3"
);
out = out.replace(/skt_(?:live|test|dev)_[A-Za-z0-9_-]+/g, "[redacted-token]");
out = out.replace(/Bearer\s+[A-Za-z0-9._-]+/g, "Bearer [redacted]");
return out;
}
function makeEnv(env) {
const out = {};
for (const [key, value] of Object.entries(env || process.env)) {
if (typeof value === "string") out[key] = value;
}
return out;
}
export function missingRequiredTools(tools, requiredTools) {
const available = new Set((tools || []).map((tool) => tool.name));
return requiredTools.filter((name) => !available.has(name));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function numberFromEnv(name, fallback) {
const parsed = Number(process.env[name] || "");
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function textFromToolResult(result) {
return (result?.content || [])
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join("\n");
}
function parseJsonText(text) {
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
}
function emptyConnectionOnlyEvergreenProofs() {
return Object.fromEntries(
Object.keys(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES).map((key) => [
key,
false,
])
);
}
function getConnectionOnlyEvergreenProofs(prompt) {
return Object.fromEntries(
Object.entries(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES).map(
([key, needle]) => [key, prompt.includes(needle)]
)
);
}
function mergeConnectionOnlyEvergreenProofs(target, source) {
if (!source) return target;
for (const key of Object.keys(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES)) {
target[key] = target[key] === true || source[key] === true;
}
return target;
}
function summarizeConnectionOnlyEvergreenProofs(proofs) {
const merged = { ...emptyConnectionOnlyEvergreenProofs(), ...(proofs || {}) };
const missing = Object.keys(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES).filter(
(key) => merged[key] !== true
);
return {
ok: missing.length === 0,
proofs: merged,
missing,
};
}
function summarizeToolResult(name, result) {
const text = redact(textFromToolResult(result));
const parsed = parseJsonText(text);
const summary = {
isError: result?.isError === true,
textChars: text.length,
};
if (name === "get_auth_status" && parsed) {
return {
...summary,
authOk: parsed.ok === true,
configPath: parsed.configPath || null,
configExists: parsed.configExists === true,
configsDir: parsed.configsDir || null,
tokenPresent: parsed.tokenPresent === true,
errorType: parsed.error?.type || null,
activeWorkspaceId: parsed.activeWorkspaceId || null,
activeWorkspaceName: parsed.activeWorkspaceName || null,
};
}
if (name === "bootstrap_create_campaign" && parsed) {
return {
...summary,
safeToProceed: parsed.safeToProceed === true,
flowVersion: parsed.flowVersion || null,
blockingChecks: Array.isArray(parsed.blockingErrors)
? parsed.blockingErrors.map((entry) => entry?.check).filter(Boolean)
: [],
requiredChecks: Array.isArray(parsed.requiredChecks)
? parsed.requiredChecks.map((entry) => ({
key: entry?.key || null,
ok: entry?.ok === true,
blocking: entry?.blocking === true,
}))
: [],
nextStepChars:
typeof parsed.nextStep === "string" ? parsed.nextStep.length : 0,
};
}
if (name === "get_subskill_prompt" && parsed) {
const prompt = typeof parsed.prompt === "string" ? parsed.prompt : "";
const connectionOnlyEvergreenProofs =
parsed.name === "create-evergreen-campaigns"
? getConnectionOnlyEvergreenProofs(prompt)
: null;
return {
...summary,
subskillName: parsed.name || null,
promptChars: prompt.length,
promptLength:
typeof parsed.promptLength === "number" ? parsed.promptLength : null,
offset: typeof parsed.offset === "number" ? parsed.offset : null,
limit: typeof parsed.limit === "number" ? parsed.limit : null,
connectionOnlyEvergreenProofs,
connectionOnlyEvergreenGuidance:
parsed.name === "create-evergreen-campaigns"
? summarizeConnectionOnlyEvergreenProofs(
connectionOnlyEvergreenProofs
).ok
: null,
hasMore: parsed.hasMore === true,
nextOffset:
typeof parsed.nextOffset === "number" ? parsed.nextOffset : null,
};
}
if (name === "get_subskill_asset" && parsed) {
return {
...summary,
subskillName: parsed.subskillName || null,
assetPath: parsed.assetPath || null,
assetChars: typeof parsed.content === "string" ? parsed.content.length : 0,
hasMore: parsed.hasMore === true,
nextOffset:
typeof parsed.nextOffset === "number" ? parsed.nextOffset : null,
};
}
return summary;
}
async function verifyCreateCampaignSmoke({
client,
availableTools,
timeoutMs,
smokeCalls = CREATE_CAMPAIGN_SMOKE_CALLS,
}) {
const startedAt = new Date().toISOString();
const available = new Set(availableTools || []);
const missingTools = smokeCalls
.map((call) => call.name)
.filter((name) => !available.has(name));
const calls = [];
const connectionOnlyEvergreenProofs = emptyConnectionOnlyEvergreenProofs();
if (missingTools.length > 0) {
return {
ok: false,
missingTools,
calls,
error: `Missing or stale Sellable MCP tool(s): ${missingTools.join(", ")}. Reinstall @sellable/install and @sellable/mcp, restart or reload the host, then retry Find Leads.`,
startedAt,
completedAt: new Date().toISOString(),
};
}
for (const call of smokeCalls) {
let nextCall = call;
let evergreenPromptChunks = 0;
const evergreenPromptOffsets = new Set();
while (nextCall) {
const callStartedAt = new Date().toISOString();
try {
const result = await client.callTool(
{ name: nextCall.name, arguments: nextCall.arguments },
undefined,
{
timeout: timeoutMs,
maxTotalTimeout: timeoutMs,
}
);
const summary = summarizeToolResult(nextCall.name, result);
calls.push({
name: nextCall.name,
arguments: nextCall.arguments,
ok: summary.isError !== true,
summary,
startedAt: callStartedAt,
completedAt: new Date().toISOString(),
});
if (summary.isError === true) {
return {
ok: false,
missingTools: [],
calls,
error: `${nextCall.name} returned an MCP tool error`,
startedAt,
completedAt: new Date().toISOString(),
};
}
const isConnectionOnlyEvergreenPrompt =
nextCall.name === "get_subskill_prompt" &&
nextCall.arguments?.subskillName === "create-evergreen-campaigns";
if (!isConnectionOnlyEvergreenPrompt) {
break;
}
evergreenPromptChunks += 1;
evergreenPromptOffsets.add(nextCall.arguments?.offset ?? 0);
mergeConnectionOnlyEvergreenProofs(
connectionOnlyEvergreenProofs,
summary.connectionOnlyEvergreenProofs
);
if (summary.hasMore !== true) {
break;
}
if (typeof summary.nextOffset !== "number") {
return {
ok: false,
missingTools: [],
calls,
connectionOnlyEvergreenGuidance:
summarizeConnectionOnlyEvergreenProofs(
connectionOnlyEvergreenProofs
),
error:
"create-evergreen-campaigns prompt chunk is missing nextOffset",
startedAt,
completedAt: new Date().toISOString(),
};
}
if (evergreenPromptOffsets.has(summary.nextOffset)) {
return {
ok: false,
missingTools: [],
calls,
connectionOnlyEvergreenGuidance:
summarizeConnectionOnlyEvergreenProofs(
connectionOnlyEvergreenProofs
),
error:
"create-evergreen-campaigns prompt chunk repeated nextOffset",
startedAt,
completedAt: new Date().toISOString(),
};
}
if (
evergreenPromptChunks >= CONNECTION_ONLY_EVERGREEN_MAX_PROMPT_CHUNKS
) {
return {
ok: false,
missingTools: [],
calls,
connectionOnlyEvergreenGuidance:
summarizeConnectionOnlyEvergreenProofs(
connectionOnlyEvergreenProofs
),
error:
"create-evergreen-campaigns prompt exceeded smoke chunk limit",
startedAt,
completedAt: new Date().toISOString(),
};
}
nextCall = {
...nextCall,
arguments: {
...nextCall.arguments,
offset: summary.nextOffset,
limit:
nextCall.arguments?.limit ||
CONNECTION_ONLY_EVERGREEN_PROMPT_CHUNK_LIMIT,
},
};
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
calls.push({
name: nextCall.name,
arguments: nextCall.arguments,
ok: false,
summary: null,
error: redact(error),
startedAt: callStartedAt,
completedAt: new Date().toISOString(),
});
return {
ok: false,
missingTools: [],
calls,
error: redact(error),
startedAt,
completedAt: new Date().toISOString(),
};
}
}
}
const connectionOnlyEvergreenGuidance =
summarizeConnectionOnlyEvergreenProofs(connectionOnlyEvergreenProofs);
if (connectionOnlyEvergreenGuidance.ok !== true) {
return {
ok: false,
missingTools: [],
calls,
connectionOnlyEvergreenGuidance,
error: `create-evergreen-campaigns prompt is missing connection-only guidance: ${connectionOnlyEvergreenGuidance.missing.join(", ")}`,
startedAt,
completedAt: new Date().toISOString(),
};
}
return {
ok: true,
missingTools: [],
calls,
connectionOnlyEvergreenGuidance,
error: null,
startedAt,
completedAt: new Date().toISOString(),
};
}
function verifyConnectionOnlyEvergreenContract(tools) {
const setupTool = (tools || []).find(
(tool) => tool?.name === "setup_evergreen_campaigns"
);
const schema = setupTool?.inputSchema || {};
const properties = schema.properties || {};
const sequenceOptions = properties.campaignSequenceOptions || {};
const mode = sequenceOptions.properties?.mode || {};
const issues = [];
if (!properties.workspaceId) {
issues.push("setup_evergreen_campaigns.workspaceId");
}
if (!properties.campaignSequenceOptions) {
issues.push("setup_evergreen_campaigns.campaignSequenceOptions");
}
if (!Array.isArray(mode.enum) || !mode.enum.includes("connection_only")) {
issues.push("setup_evergreen_campaigns.campaignSequenceOptions.mode");
}
if (sequenceOptions.additionalProperties !== false) {
issues.push(
"setup_evergreen_campaigns.campaignSequenceOptions.additionalProperties"
);
}
return {
ok: issues.length === 0,
issues,
};
}
function summarizeAttempt(result, attempt) {
return {
attempt,
ok: result.ok === true,
availableToolCount: result.availableTools?.length || 0,
missingToolCount: result.missingTools?.length || 0,
forbiddenToolCount: result.forbiddenTools?.length || 0,
connectionOnlyEvergreenContract: result.connectionOnlyEvergreenContract
? {
ok: result.connectionOnlyEvergreenContract.ok === true,
issues: result.connectionOnlyEvergreenContract.issues || [],
}
: null,
error: result.error || null,
createCampaignSmoke: result.createCampaignSmoke
? {
ok: result.createCampaignSmoke.ok === true,
error: result.createCampaignSmoke.error || null,
missingTools: result.createCampaignSmoke.missingTools || [],
calls: (result.createCampaignSmoke.calls || []).map((call) => ({
name: call.name,
ok: call.ok === true,
})),
}
: null,
stderrTail: result.stderrTail || [],
startedAt: result.startedAt,
completedAt: result.completedAt,
};
}
function shouldRetryRuntimeVerification(result, requiredTools) {
if (result.ok || result.skipped) return false;
if (result.error) return true;
const availableToolCount = result.availableTools?.length || 0;
const missingToolCount = result.missingTools?.length || 0;
return availableToolCount === 0 || missingToolCount === requiredTools.length;
}
async function verifySellableMcpRuntimeOnce({
command,
args = [],
cwd = process.cwd(),
env = process.env,
requiredTools = REQUIRED_SELLABLE_MCP_TOOLS,
timeoutMs = Number(process.env.SELLABLE_VERIFY_TIMEOUT_MS || "") ||
DEFAULT_VERIFY_TIMEOUT_MS,
}) {
const startedAt = new Date().toISOString();
if (command === "hosted") {
return {
ok: false,
skipped: true,
reason:
"Hosted MCP verification is not available through stdio tools/list.",
command,
args,
requiredTools,
availableTools: [],
missingTools: requiredTools,
stderrTail: [],
startedAt,
completedAt: new Date().toISOString(),
};
}
const transport = new StdioClientTransport({
command,
args,
cwd,
env: makeEnv(env),
stderr: "pipe",
});
const stderrLines = collectStderrLines(transport.stderr);
const client = new Client(
{ name: "sellable-install-verify", version: "1.0.0" },
{ capabilities: {} }
);
let availableTools = [];
let missingTools = [...requiredTools];
let forbiddenTools = [];
let createCampaignSmoke = null;
let connectionOnlyEvergreenContract = { ok: false, issues: [] };
let error = null;
try {
await client.connect(transport);
const toolList = await client.listTools(undefined, {
timeout: timeoutMs,
maxTotalTimeout: timeoutMs,
});
availableTools = toolList.tools.map((tool) => tool.name).sort();
missingTools = missingRequiredTools(toolList.tools, requiredTools);
forbiddenTools = availableTools.filter((tool) =>
FORBIDDEN_SELLABLE_MCP_TOOLS.includes(tool)
);
connectionOnlyEvergreenContract =
verifyConnectionOnlyEvergreenContract(toolList.tools);
if (forbiddenTools.length > 0) {
createCampaignSmoke = {
ok: false,
missingTools: [],
calls: [],
error: `Forbidden MCP tool(s) exposed: ${forbiddenTools.join(", ")}`,
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
} else if (missingTools.length === 0) {
createCampaignSmoke = await verifyCreateCampaignSmoke({
client,
availableTools,
timeoutMs,
});
} else {
createCampaignSmoke = {
ok: false,
missingTools: CREATE_CAMPAIGN_SMOKE_CALLS.map((call) => call.name).filter(
(name) => !availableTools.includes(name)
),
calls: [],
error:
"Sellable MCP is missing required tools. Reinstall @sellable/install and @sellable/mcp, restart or reload the host, then retry Find Leads.",
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
try {
await client.close();
} catch {
// Preserve the verification result even if the child exits noisily.
}
}
return {
ok:
!error &&
missingTools.length === 0 &&
forbiddenTools.length === 0 &&
connectionOnlyEvergreenContract.ok === true &&
createCampaignSmoke?.ok === true,
skipped: false,
command,
args,
requiredTools,
availableTools,
missingTools,
forbiddenTools,
connectionOnlyEvergreenContract,
createCampaignSmoke,
stderrTail: stderrLines,
error: error ? redact(error) : null,
startedAt,
completedAt: new Date().toISOString(),
};
}
export async function verifySellableMcpRuntime({
command,
args = [],
cwd = process.cwd(),
env = process.env,
requiredTools = REQUIRED_SELLABLE_MCP_TOOLS,
timeoutMs = Number(process.env.SELLABLE_VERIFY_TIMEOUT_MS || "") ||
DEFAULT_VERIFY_TIMEOUT_MS,
attempts = numberFromEnv("SELLABLE_VERIFY_ATTEMPTS", DEFAULT_VERIFY_ATTEMPTS),
retryDelayMs = numberFromEnv(
"SELLABLE_VERIFY_RETRY_DELAY_MS",
DEFAULT_VERIFY_RETRY_DELAY_MS
),
}) {
const attemptSummaries = [];
let lastResult = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const result = await verifySellableMcpRuntimeOnce({
command,
args,
cwd,
env,
requiredTools,
timeoutMs,
});
lastResult = result;
attemptSummaries.push(summarizeAttempt(result, attempt));
if (
attempt >= attempts ||
!shouldRetryRuntimeVerification(result, requiredTools)
) {
return {
...result,
attempts: attemptSummaries,
};
}
await sleep(retryDelayMs);
}
return {
...lastResult,
attempts: attemptSummaries,
};
}