UNPKG

@sellable/install

Version:

One-command installer for Sellable MCP in Claude Code, Codex, and Hermes

779 lines (725 loc) • 22.2 kB
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, }; }