@sellable/mcp 0.1.769 → 0.1.770

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.
@@ -38,6 +38,16 @@
38
38
  */
39
39
  export declare const AGENT_INTEGRATION_CONTEXT_TOOL_NAMES: readonly ["integrations_list_tools", "integrations_describe_tool", "integrations_call_tool"];
40
40
  export type AgentIntegrationContextToolName = (typeof AGENT_INTEGRATION_CONTEXT_TOOL_NAMES)[number];
41
+ /**
42
+ * Plan 142-05's connect trio, in a SECOND frozen set rather than appended to the
43
+ * one above: two suites pin `AGENT_INTEGRATION_CONTEXT_TOOL_NAMES` to exactly
44
+ * the three `integrations_*` names 141-06 registers, and those pins are correct
45
+ * — they guard 141's registration, not 142's. `isAgentIntegrationContextTool`
46
+ * spans both sets, so the fail-closed dispatch guard in `server.ts` covers the
47
+ * connect tools with no change to the switch-side plumbing.
48
+ */
49
+ export declare const AGENT_INTEGRATION_CONNECT_TOOL_NAMES: readonly ["search_available_integrations", "start_integration_connection", "get_integration_connection_status"];
50
+ export type AgentIntegrationConnectToolName = (typeof AGENT_INTEGRATION_CONNECT_TOOL_NAMES)[number];
41
51
  /** The trusted actor triple a managed-integration call forwards to the backend. */
42
52
  export type AgentIntegrationRequestContext = {
43
53
  requesterId: string;
@@ -41,9 +41,23 @@ export const AGENT_INTEGRATION_CONTEXT_TOOL_NAMES = Object.freeze([
41
41
  "integrations_describe_tool",
42
42
  "integrations_call_tool",
43
43
  ]);
44
+ /**
45
+ * Plan 142-05's connect trio, in a SECOND frozen set rather than appended to the
46
+ * one above: two suites pin `AGENT_INTEGRATION_CONTEXT_TOOL_NAMES` to exactly
47
+ * the three `integrations_*` names 141-06 registers, and those pins are correct
48
+ * — they guard 141's registration, not 142's. `isAgentIntegrationContextTool`
49
+ * spans both sets, so the fail-closed dispatch guard in `server.ts` covers the
50
+ * connect tools with no change to the switch-side plumbing.
51
+ */
52
+ export const AGENT_INTEGRATION_CONNECT_TOOL_NAMES = Object.freeze([
53
+ "search_available_integrations",
54
+ "start_integration_connection",
55
+ "get_integration_connection_status",
56
+ ]);
44
57
  export const AGENT_INTEGRATION_CONTEXT_REQUIRED_ERROR = "agent_request_context_required";
45
58
  export function isAgentIntegrationContextTool(name) {
46
- return AGENT_INTEGRATION_CONTEXT_TOOL_NAMES.includes(name);
59
+ return (AGENT_INTEGRATION_CONTEXT_TOOL_NAMES.includes(name) ||
60
+ AGENT_INTEGRATION_CONNECT_TOOL_NAMES.includes(name));
47
61
  }
48
62
  /**
49
63
  * Resolves the trusted actor for a managed-integration tool call.
@@ -3,7 +3,7 @@
3
3
  * manifests. Keep this free of tool implementation imports so Next can bundle
4
4
  * it; registry parity is enforced in sellable-agent-tool-policy.test.ts.
5
5
  */
6
- export declare const SELLABLE_MCP_TOOL_NAMES: readonly ["add_column", "add_on_demand_leads", "add_rubric_item", "add_teammate", "add_to_comment_campaign", "add_to_connection_campaign", "add_to_inmail_campaign", "archive_campaign", "attach_recommended_sequence", "attach_sequence", "bootstrap_create_campaign", "bootstrap_engage", "bootstrap_engage_multi", "bootstrap_find_leads", "bulk_enrich_with_prospeo", "calculate_linkedin_hook_preview", "cancel_campaign_message_preparation", "cancel_find_leads", "cancel_lead_import", "capture_post_idea", "check_inbox_reply_eligibility", "check_rubric", "commit_blueprint", "complete_sender_research", "confirm_harvest_job_companies", "confirm_lead_list", "confirm_prospeo_company_accounts", "copy_sender_config", "create_campaign", "create_on_demand_campaign", "create_on_demand_table", "create_workflow_table", "create_workspace", "delete_column", "delete_rubric_item", "draft_rubrics", "duplicate_campaign", "enrich_with_prospeo", "export_table_csv", "export_workspace_csv", "fetch_company", "fetch_company_posts", "fetch_linkedin_posts", "fetch_linkedin_profile", "fetch_post_engagers", "fill_campaign_horizon", "get_active_workspace", "get_auth_status", "get_campaign", "get_campaign_context", "get_campaign_framework", "get_campaign_message_preparation_status", "get_campaign_messages_preview", "get_campaign_navigation_state", "get_campaign_refill_state", "get_campaign_table_schema", "get_campaign_waterfall", "get_campaigns", "get_column_schema", "get_company_info", "get_engage_memory", "get_engage_state", "get_engaged_posts", "get_find_leads_run", "get_inbox_thread", "get_linkedin_profile", "get_or_create_direct_campaign_table", "get_post_draft", "get_post_find_leads_scout_registry", "get_post_idea", "get_prospeo_credits", "get_provider_prompt", "get_published_post", "get_refill_target_plan", "get_rows", "get_rows_minimal", "get_runtime_identity", "get_scheduler_fill_capacity", "get_sender", "get_sender_routing", "get_source_scout_registry", "get_subskill_asset", "get_subskill_prompt", "get_table_rows", "get_user_posts", "get_workspace", "import_leads", "init_on_demand_sequence", "integrations_call_tool", "integrations_describe_tool", "integrations_list_tools", "list_column_types", "list_dnc_entries", "list_post_draft_iterations", "list_post_drafts", "list_post_ideas", "list_published_posts", "list_senders", "list_subskill_prompts", "list_tables", "list_workspaces", "load_csv_dnc_entries", "load_csv_domains", "load_csv_linkedin_leads", "lookup_sales_nav_filter", "mark_post_published", "migrate_flat_configs", "pause_campaign", "pause_direct_campaign", "pause_on_demand_campaign", "preflight_find_leads_provider", "prepare_campaign_ab_test", "queue_campaign_cells", "queue_cells", "record_campaign_review_batch", "record_engage_proven_search", "refill_sends", "refresh_paid_inmail_credits", "refresh_sender_engagement", "reissue_find_leads_watch_link", "render_linkedin_post_preview", "reorder_columns", "resolve_campaign_fill_route", "revise_message_template_and_rerun", "run_scheduler_sweep", "save_domain_filters", "save_hook_research", "save_post_draft", "save_rubrics", "search_apollo", "search_engagement_posts", "search_harvest_jobs", "search_inbox_threads", "search_prospeo", "search_prospeo_companies", "search_sales_nav", "search_signals", "search_subskill_prompts", "select_campaign_cells", "select_necessary_rubrics", "select_promising_posts", "send_inbox_draft", "send_inbox_manual_reply", "set_active_workspace", "set_campaign_waterfall_order", "set_company_info", "set_engage_state", "set_engage_style_guide", "set_headline_icp_criteria", "set_sender_calendar_link", "set_sender_routing", "setup_evergreen_campaigns", "start_campaign", "start_campaign_message_preparation", "start_cli_login", "start_direct_campaign", "start_on_demand_campaign", "update_campaign", "update_campaign_brief", "update_cell", "update_column", "update_find_leads_run", "update_inbox_draft", "update_post_draft", "update_published_post_metrics", "update_rubric_item", "upsert_engage_tracked_person", "upsert_rubric", "validate_campaign1_kickoff_handoff", "verify_table_row", "wait_for_campaign_processing", "wait_for_campaign_table_ready", "wait_for_cli_login", "wait_for_find_leads_run", "wait_for_lead_list_ready", "wait_for_rubric_results"];
6
+ export declare const SELLABLE_MCP_TOOL_NAMES: readonly ["add_column", "add_on_demand_leads", "add_rubric_item", "add_teammate", "add_to_comment_campaign", "add_to_connection_campaign", "add_to_inmail_campaign", "archive_campaign", "attach_recommended_sequence", "attach_sequence", "bootstrap_create_campaign", "bootstrap_engage", "bootstrap_engage_multi", "bootstrap_find_leads", "bulk_enrich_with_prospeo", "calculate_linkedin_hook_preview", "cancel_campaign_message_preparation", "cancel_find_leads", "cancel_lead_import", "capture_post_idea", "check_inbox_reply_eligibility", "check_rubric", "commit_blueprint", "complete_sender_research", "confirm_harvest_job_companies", "confirm_lead_list", "confirm_prospeo_company_accounts", "copy_sender_config", "create_campaign", "create_on_demand_campaign", "create_on_demand_table", "create_workflow_table", "create_workspace", "delete_column", "delete_rubric_item", "draft_rubrics", "duplicate_campaign", "enrich_with_prospeo", "export_table_csv", "export_workspace_csv", "fetch_company", "fetch_company_posts", "fetch_linkedin_posts", "fetch_linkedin_profile", "fetch_post_engagers", "fill_campaign_horizon", "get_active_workspace", "get_auth_status", "get_campaign", "get_campaign_context", "get_campaign_framework", "get_campaign_message_preparation_status", "get_campaign_messages_preview", "get_campaign_navigation_state", "get_campaign_refill_state", "get_campaign_table_schema", "get_campaign_waterfall", "get_campaigns", "get_column_schema", "get_company_info", "get_engage_memory", "get_engage_state", "get_engaged_posts", "get_find_leads_run", "get_inbox_thread", "get_integration_connection_status", "get_linkedin_profile", "get_or_create_direct_campaign_table", "get_post_draft", "get_post_find_leads_scout_registry", "get_post_idea", "get_prospeo_credits", "get_provider_prompt", "get_published_post", "get_refill_target_plan", "get_rows", "get_rows_minimal", "get_runtime_identity", "get_scheduler_fill_capacity", "get_sender", "get_sender_routing", "get_source_scout_registry", "get_subskill_asset", "get_subskill_prompt", "get_table_rows", "get_user_posts", "get_workspace", "import_leads", "init_on_demand_sequence", "integrations_call_tool", "integrations_describe_tool", "integrations_list_tools", "list_column_types", "list_dnc_entries", "list_post_draft_iterations", "list_post_drafts", "list_post_ideas", "list_published_posts", "list_senders", "list_subskill_prompts", "list_tables", "list_workspaces", "load_csv_dnc_entries", "load_csv_domains", "load_csv_linkedin_leads", "lookup_sales_nav_filter", "mark_post_published", "migrate_flat_configs", "pause_campaign", "pause_direct_campaign", "pause_on_demand_campaign", "preflight_find_leads_provider", "prepare_campaign_ab_test", "queue_campaign_cells", "queue_cells", "record_campaign_review_batch", "record_engage_proven_search", "refill_sends", "refresh_paid_inmail_credits", "refresh_sender_engagement", "reissue_find_leads_watch_link", "render_linkedin_post_preview", "reorder_columns", "resolve_campaign_fill_route", "revise_message_template_and_rerun", "run_scheduler_sweep", "save_domain_filters", "save_hook_research", "save_post_draft", "save_rubrics", "search_apollo", "search_available_integrations", "search_engagement_posts", "search_harvest_jobs", "search_inbox_threads", "search_prospeo", "search_prospeo_companies", "search_sales_nav", "search_signals", "search_subskill_prompts", "select_campaign_cells", "select_necessary_rubrics", "select_promising_posts", "send_inbox_draft", "send_inbox_manual_reply", "set_active_workspace", "set_campaign_waterfall_order", "set_company_info", "set_engage_state", "set_engage_style_guide", "set_headline_icp_criteria", "set_sender_calendar_link", "set_sender_routing", "setup_evergreen_campaigns", "start_campaign", "start_campaign_message_preparation", "start_cli_login", "start_direct_campaign", "start_integration_connection", "start_on_demand_campaign", "update_campaign", "update_campaign_brief", "update_cell", "update_column", "update_find_leads_run", "update_inbox_draft", "update_post_draft", "update_published_post_metrics", "update_rubric_item", "upsert_engage_tracked_person", "upsert_rubric", "validate_campaign1_kickoff_handoff", "verify_table_row", "wait_for_campaign_processing", "wait_for_campaign_table_ready", "wait_for_cli_login", "wait_for_find_leads_run", "wait_for_lead_list_ready", "wait_for_rubric_results"];
7
7
  /**
8
8
  * Explicit mutation classification lives beside (but outside) the flat tool
9
9
  * registry. Neither this list nor `isMutatingTool` is an input to
@@ -69,6 +69,7 @@ export const SELLABLE_MCP_TOOL_NAMES = [
69
69
  "get_engaged_posts",
70
70
  "get_find_leads_run",
71
71
  "get_inbox_thread",
72
+ "get_integration_connection_status",
72
73
  "get_linkedin_profile",
73
74
  "get_or_create_direct_campaign_table",
74
75
  "get_post_draft",
@@ -134,6 +135,7 @@ export const SELLABLE_MCP_TOOL_NAMES = [
134
135
  "save_post_draft",
135
136
  "save_rubrics",
136
137
  "search_apollo",
138
+ "search_available_integrations",
137
139
  "search_engagement_posts",
138
140
  "search_harvest_jobs",
139
141
  "search_inbox_threads",
@@ -160,6 +162,7 @@ export const SELLABLE_MCP_TOOL_NAMES = [
160
162
  "start_campaign_message_preparation",
161
163
  "start_cli_login",
162
164
  "start_direct_campaign",
165
+ "start_integration_connection",
163
166
  "start_on_demand_campaign",
164
167
  "update_campaign",
165
168
  "update_campaign_brief",
package/dist/server.js CHANGED
@@ -41,6 +41,7 @@ import { bootstrapFindLeads, cancelFindLeads, getFindLeadsRun, reissueFindLeadsW
41
41
  import { getCampaignFramework } from "./tools/framework.js";
42
42
  import { confirmHarvestJobCompanies, searchHarvestJobs, } from "./tools/harvest-jobs.js";
43
43
  import { checkInboxReplyEligibility, getInboxThread, searchInboxThreads, sendInboxDraft, sendInboxManualReply, updateInboxDraft, } from "./tools/inbox.js";
44
+ import { getIntegrationConnectionStatus, searchAvailableIntegrations, startIntegrationConnection, } from "./tools/integration-connect.js";
44
45
  import { integrationsCallTool, integrationsDescribeTool, integrationsListTools, } from "./tools/integrations.js";
45
46
  import { cancelLeadImport, confirmLeadList, confirmProspeoCompanyAccounts, getProviderPrompt, importLeads, listDncEntriesTool, loadCsvDncEntriesTool, loadCsvDomains, loadCsvLinkedinLeads, lookupSalesNavFilter, saveDomainFilters, searchApollo, searchProspeo, searchProspeoCompanies, searchSalesNav, searchSignals, selectPromisingPosts, setHeadlineICPCriteria, } from "./tools/leads.js";
46
47
  import { fetchCompany, fetchCompanyPosts, fetchLinkedInPosts, fetchLinkedInProfile, fetchPostEngagers, getLinkedInProfile, getUserPosts, } from "./tools/linkedin.js";
@@ -697,6 +698,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
697
698
  case "integrations_call_tool":
698
699
  result = await integrationsCallTool(args, integrationActor.context, agentEffectId);
699
700
  break;
701
+ // Plan 142-05's connect trio. The same fail-closed actor guard above runs
702
+ // for these names via `isAgentIntegrationContextTool`'s second frozen set,
703
+ // so `integrationActor.context` is verified before any of these arms.
704
+ case "search_available_integrations":
705
+ result = await searchAvailableIntegrations(args, integrationActor.context, agentEffectId);
706
+ break;
707
+ case "start_integration_connection":
708
+ result = await startIntegrationConnection(args, integrationActor.context, agentEffectId);
709
+ break;
710
+ case "get_integration_connection_status":
711
+ result = await getIntegrationConnectionStatus(args, integrationActor.context, agentEffectId);
712
+ break;
700
713
  case "get_company_info":
701
714
  result = await getCompanyInfoTool();
702
715
  if (boundary.mode === "agent_service" &&
@@ -18,6 +18,15 @@ function isPlainObject(value) {
18
18
  export function toMcpToolResult(name, result) {
19
19
  const safeResult = sanitizeWatchUrlsForMcpResult(result);
20
20
  if (isStructuredToolResult(safeResult)) {
21
+ if (OUTPUT_SCHEMA_TOOL_NAMES.has(name) &&
22
+ safeResult.structuredContent === undefined &&
23
+ !safeResult.isError) {
24
+ // A HAND-BUILT `{ content }` is the one remaining way to reproduce the
25
+ // deviation, and nothing here can infer the payload it should have carried.
26
+ // FAIL CLOSED: `isError` is exempt from the client's validation, so the text
27
+ // is still delivered instead of the whole response being discarded.
28
+ return { ...safeResult, isError: true };
29
+ }
21
30
  return safeResult;
22
31
  }
23
32
  const content = [
@@ -0,0 +1,23 @@
1
+ import type { AgentIntegrationRequestContext } from "../agent-integration-request-context.js";
2
+ import { type IntegrationsResultEnvelope } from "./integrations.js";
3
+ type IntegrationConnectToolDefinition = {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: Record<string, unknown>;
7
+ outputSchema: Record<string, unknown>;
8
+ annotations: Record<string, unknown>;
9
+ };
10
+ export declare const integrationConnectToolDefinitions: IntegrationConnectToolDefinition[];
11
+ export declare function searchAvailableIntegrations(input?: {
12
+ query?: unknown;
13
+ }, actor?: AgentIntegrationRequestContext | null, agentEffectId?: string): Promise<IntegrationsResultEnvelope>;
14
+ export declare function startIntegrationConnection(input?: {
15
+ appSlug?: unknown;
16
+ trigger?: unknown;
17
+ }, actor?: AgentIntegrationRequestContext | null, agentEffectId?: string): Promise<IntegrationsResultEnvelope>;
18
+ type StatusInput = {
19
+ attemptId?: unknown;
20
+ appSlug?: unknown;
21
+ };
22
+ export declare function getIntegrationConnectionStatus(input?: StatusInput, actor?: AgentIntegrationRequestContext | null, agentEffectId?: string): Promise<IntegrationsResultEnvelope>;
23
+ export {};
@@ -0,0 +1,570 @@
1
+ import { getApi, SellableApiError } from "../api.js";
2
+ import { envelope, INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA, toolError, } from "./integrations.js";
3
+ /**
4
+ * 142-05 — the THREE Slack-facing connect tools: search, start, status.
5
+ *
6
+ * ## Why a second trio instead of extending the first
7
+ *
8
+ * At the moment a user says "connect Grain" there is no connection and no policy
9
+ * record to route a provider call through, so `integrations_list_tools` and its two
10
+ * siblings have nothing to act on. These three exist for exactly that gap, and they
11
+ * hand off to Phase 141's trio the instant a connection is real.
12
+ *
13
+ * ## ONE ENVELOPE ACROSS BOTH TRIOS (SA142-52)
14
+ *
15
+ * The modal conversation is "connect grain.com" -> connected -> "what can I do with
16
+ * it" -> list -> call, all in ONE Slack thread. A second envelope would make the
17
+ * Agent translate between two shapes mid-conversation. So this module IMPORTS Phase
18
+ * 141's published `INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA` and its `envelope()` /
19
+ * `toolError()` / `normalizeRouteResult()` builders rather than declaring
20
+ * look-alikes. The two `outcome` and `attribution` enums are CLOSED and validated by
21
+ * the MCP CLIENT, so a second copy that gained a member would make one trio's
22
+ * refusals unvalidatable while the other's passed.
23
+ *
24
+ * ## THE ATTRIBUTION MAPPING LIVES SERVER-SIDE, ON PURPOSE
25
+ *
26
+ * This phase's human-facing vocabulary has THREE members (`app`, `configuration`,
27
+ * `policy`); Phase 141's shipped envelope has FOUR (`provider`, `configuration`,
28
+ * `policy`, `sellable`) and says `provider` where this phase says `app`. The routes
29
+ * forward through `integrationConnectAttributionToEnvelope` in
30
+ * `src/lib/sellable-agent/integration-connect-contract.ts` BEFORE answering, because
31
+ * the MCP package cannot import `src/`. That is not a detail: an unmapped `app`
32
+ * arrives off-enum, `envelope()` clamps it fail-closed to `sellable`, and the
33
+ * operator loses WHO refused. This module therefore forwards `attribution` verbatim
34
+ * and does not re-derive it — there is exactly one mapping and it is not here.
35
+ *
36
+ * ## THE ACTOR IS NOT A TOOL INPUT
37
+ *
38
+ * No `inputSchema` below carries a requester, channel, or actor field, and this
39
+ * module DESIGNS NO ACTOR MECHANISM — it CONSUMES plan 141-04 T2's. The trusted
40
+ * triple arrives from `boundary.context` (which the MCP context proxy injects only
41
+ * after REJECTING any model-supplied `_meta` with `model_metadata_rejected`) and
42
+ * `server.ts` passes it here, where it becomes an explicit request BODY field the
43
+ * route re-verifies against `WorkspaceSlackIdentity`. A model-supplied actor claim
44
+ * is worthless, so the model is never offered a place to put one, and an absent
45
+ * actor is a REFUSAL rather than a default.
46
+ *
47
+ * ## NO MCP-SIDE ERROR-CODE LIST
48
+ *
49
+ * `toolError()` forwards the route's `error` string verbatim, so a hand-copied
50
+ * constant enumerating every connect error code would be a second maintained list
51
+ * the MCP never produces — the only possible source of drift. None is exported,
52
+ * and a source scan asserts the absence of any such constant by name.
53
+ *
54
+ * ## POLICY (142-05 T4), recorded where the tools are, not only in the plan
55
+ *
56
+ * These three use REGISTRY-FALLBACK AUTO and add NO `DEFAULT_AGENT_TOOL_CATALOGUE`
57
+ * entry, because `policyHash` is persisted on every `AgentServicePrincipal` and
58
+ * `AgentServiceCredentialVersion` and a catalogue entry would ROTATE EVERY LIVE
59
+ * CUSTOMER CREDENTIAL. The trade-off accepted: registry-fallback AUTO means no
60
+ * `REQUESTER_CONFIRM` gate on the tool call itself. That is defensible because the
61
+ * real consent gate is the OAuth screen the human completes in their OWN browser,
62
+ * on a link delivered by DM to exactly one principal, and because the derived
63
+ * binding is read-only-only with mutating tools DENY by default. These tools cannot
64
+ * themselves grant a provider action. If a future decision DOES want a gate on
65
+ * `start_integration_connection`, the correct route is a per-tool policy on the
66
+ * derived `AgentIntegrationBinding` (plan 142-06), not a catalogue entry.
67
+ */
68
+ const SEARCH_PATH = "/api/v3/sellable-agent/integrations/search";
69
+ const START_PATH = "/api/v3/sellable-agent/integrations/connect/start";
70
+ const STATUS_PATH = "/api/v3/sellable-agent/integrations/connect/status";
71
+ /**
72
+ * Client-side deadlines, sitting just above each route's own `maxDuration`, so the
73
+ * server's truthful error wins the race and this bound only fires when the server
74
+ * never answered at all. Copied in intent from `integrations.ts`.
75
+ */
76
+ const SEARCH_TIMEOUT_MS = 25_000;
77
+ const START_TIMEOUT_MS = 35_000;
78
+ const STATUS_TIMEOUT_MS = 15_000;
79
+ /**
80
+ * RESTATED from `APP_QUERY_BOUNDS` in
81
+ * `src/lib/sellable-agent/integration-app-resolution.ts` because the MCP package
82
+ * cannot import `src/`. The SERVER is still the authority — it re-validates and
83
+ * normalizes every value — so a divergence here can only ever be stricter in
84
+ * practice, never a bypass.
85
+ */
86
+ const QUERY_MIN = 2;
87
+ const QUERY_MAX = 200;
88
+ const SLUG_MIN = 1;
89
+ const SLUG_MAX = 64;
90
+ const SLUG_PATTERN = /^[a-z0-9_-]+$/i;
91
+ const CONNECT_TRIGGERS = ["explicit_request", "not_connected_offer"];
92
+ /** The five DISTINCT search outcomes. `no_tools` and `catalog_incomplete` are not
93
+ * the same answer and must never be collapsed (SA142-46). */
94
+ const SEARCH_KINDS = [
95
+ "resolved",
96
+ "ambiguous",
97
+ "not_found",
98
+ "no_tools",
99
+ "catalog_incomplete",
100
+ ];
101
+ /** The contract's `IntegrationConnectionStatusResult.state` union. */
102
+ const CONNECTION_STATES = [
103
+ "awaiting_user",
104
+ "connected",
105
+ "refused",
106
+ "expired",
107
+ "not_found",
108
+ ];
109
+ /**
110
+ * Phase 141's ENVELOPE with a per-tool `result` sub-schema. The envelope keys, the
111
+ * two closed enums, and the fail-closed clamp all stay 141's — spread from the ONE
112
+ * exported schema object rather than re-declared — while the `result` branch names
113
+ * this tool's own success vocabulary so a client (and a reader) can see what
114
+ * `awaiting_user` or `no_tools` means without leaving the definition. Every
115
+ * property stays PERMISSIVE (`additionalProperties: true`, no `required`): the
116
+ * SDK client validates `structuredContent` against this schema, and a schema that
117
+ * rejects a real answer is an outage, not a documentation nicety.
118
+ */
119
+ function connectEnvelopeSchema(resultSchema) {
120
+ return {
121
+ ...INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA,
122
+ properties: {
123
+ ...INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA.properties,
124
+ result: {
125
+ anyOf: [
126
+ {
127
+ type: "null",
128
+ description: "On refusal the reason travels in `error` and `guidance`; `result` is null.",
129
+ },
130
+ { type: "object", ...resultSchema, additionalProperties: true },
131
+ ],
132
+ },
133
+ },
134
+ };
135
+ }
136
+ const SEARCH_OUTPUT_SCHEMA = connectEnvelopeSchema({
137
+ properties: {
138
+ kind: {
139
+ type: "string",
140
+ enum: [...SEARCH_KINDS],
141
+ description: "Which of the five DISTINCT search answers this is. `no_tools` means the app genuinely publishes no actions; `catalog_incomplete` means the catalog could not be read completely — never treat them as the same.",
142
+ },
143
+ app: {
144
+ type: "object",
145
+ description: "The single resolved app, present when kind is `resolved`.",
146
+ additionalProperties: true,
147
+ },
148
+ options: {
149
+ type: "array",
150
+ description: "Up to three candidates when kind is `ambiguous`; ask the user which one they meant.",
151
+ items: {
152
+ type: "object",
153
+ properties: {
154
+ name: { type: "string" },
155
+ description: { type: ["string", "null"] },
156
+ iconUrl: { type: ["string", "null"] },
157
+ authType: { type: ["string", "null"] },
158
+ },
159
+ additionalProperties: true,
160
+ },
161
+ },
162
+ },
163
+ });
164
+ const START_OUTPUT_SCHEMA = connectEnvelopeSchema({
165
+ properties: {
166
+ status: {
167
+ type: "string",
168
+ enum: ["awaiting_user"],
169
+ description: "The ONLY success status. It means the DM was sent — the user has not finished, and this tool can never claim more.",
170
+ },
171
+ attemptId: { type: "string" },
172
+ deliveredTo: { type: "string", enum: ["dm"] },
173
+ appName: { type: ["string", "null"] },
174
+ authorizationHost: { type: ["string", "null"] },
175
+ },
176
+ });
177
+ const STATUS_OUTPUT_SCHEMA = connectEnvelopeSchema({
178
+ properties: {
179
+ state: { type: "string", enum: [...CONNECTION_STATES] },
180
+ attemptId: { type: ["string", "null"] },
181
+ appSlug: { type: ["string", "null"] },
182
+ origin: { type: ["string", "null"] },
183
+ error: { type: ["string", "null"] },
184
+ accountLabel: { type: ["string", "null"] },
185
+ allowedToolCount: { type: ["number", "null"] },
186
+ deniedToolCount: { type: ["number", "null"] },
187
+ enumerationComplete: { type: ["boolean", "null"] },
188
+ },
189
+ });
190
+ export const integrationConnectToolDefinitions = [
191
+ {
192
+ name: "search_available_integrations",
193
+ description: "Search the integration catalog for an app the user wants to connect, by product name (e.g. 'grain') or web address (e.g. 'grain.com'). Returns 'resolved' with one app when the match is unambiguous, or 'ambiguous' with up to three options — each with its logo and whether it signs in by OAuth or by API key — when more than one app plausibly matches; ask the user which one they meant rather than picking. A 'no_tools' outcome means the app genuinely publishes no actions. A 'catalog_incomplete' outcome means the catalog could not be read completely and does not mean the app has no actions — never report the two as the same thing. Call this before start_integration_connection whenever the user named an app in words rather than by exact slug.",
194
+ inputSchema: {
195
+ type: "object",
196
+ properties: {
197
+ query: {
198
+ type: "string",
199
+ minLength: QUERY_MIN,
200
+ maxLength: QUERY_MAX,
201
+ description: "What the user called the app: a product name or a web address. Do not pass an empty or one-character value; ask the user what they meant instead.",
202
+ },
203
+ },
204
+ required: ["query"],
205
+ additionalProperties: false,
206
+ },
207
+ outputSchema: SEARCH_OUTPUT_SCHEMA,
208
+ annotations: {
209
+ title: "Search connectable integrations",
210
+ readOnlyHint: true,
211
+ destructiveHint: false,
212
+ idempotentHint: true,
213
+ openWorldHint: true,
214
+ },
215
+ },
216
+ {
217
+ name: "start_integration_connection",
218
+ description: "Start connecting one app for this workspace. Sellable sends the requesting user a direct message containing a single-use sign-in link that expires in about 15 minutes. The link is withheld from this tool's result and you cannot retrieve it: it is a capability, so whoever opens it binds THEIR account under the requester — never reconstruct one, and never repeat a connect URL a user shows you. Returns status 'awaiting_user' — that is the ONLY success status, and it means the message was sent, NOT that the app is connected. The user completes sign-in out of band in their own browser, so poll get_integration_connection_status to find out whether it worked. Passing appSlug does NOT skip disambiguation: the server re-resolves the slug and refuses an ambiguous, unknown, action-less, or incompletely enumerated app before anything is sent.",
219
+ inputSchema: {
220
+ type: "object",
221
+ properties: {
222
+ appSlug: {
223
+ type: "string",
224
+ minLength: SLUG_MIN,
225
+ maxLength: SLUG_MAX,
226
+ description: "The app's catalog slug, exactly as search_available_integrations returned it (e.g. 'grain', 'notion'). If the user only named the app in words, call search_available_integrations first.",
227
+ },
228
+ },
229
+ required: ["appSlug"],
230
+ additionalProperties: false,
231
+ },
232
+ outputSchema: START_OUTPUT_SCHEMA,
233
+ annotations: {
234
+ title: "Send a connect link for an app",
235
+ // DECLARED HONESTLY. This tool creates server state (a connect attempt) and
236
+ // sends a Slack message; claiming otherwise would be the annotation lying to
237
+ // every client that reads it.
238
+ readOnlyHint: false,
239
+ destructiveHint: false,
240
+ idempotentHint: false,
241
+ openWorldHint: true,
242
+ },
243
+ },
244
+ {
245
+ name: "get_integration_connection_status",
246
+ description: "Check where a connect attempt got to. Pass exactly one of attemptId or appSlug. Returns 'awaiting_user' while the user has not finished signing in, 'connected' once the account is real, 'refused' with a reason, 'expired' when the link lapsed unused, or 'not_found' when nothing was ever started. When connected it also reports how many provider actions the Agent is allowed to run, how many are held pending a human promotion, and whether the action catalog was read completely — so zero allowed actions with a complete read is a truthful 'there is nothing I can do with this yet', while an incomplete read is not.",
247
+ inputSchema: {
248
+ type: "object",
249
+ properties: {
250
+ attemptId: {
251
+ type: "string",
252
+ minLength: 1,
253
+ maxLength: 128,
254
+ description: "The attempt id returned by start_integration_connection. Use this when you have it; it is exact.",
255
+ },
256
+ appSlug: {
257
+ type: "string",
258
+ minLength: SLUG_MIN,
259
+ maxLength: SLUG_MAX,
260
+ description: "The app's catalog slug, which resolves to the most recent attempt for that app. Use this when the attempt id is not to hand.",
261
+ },
262
+ },
263
+ // Neither is individually required and exactly one must be present, which a
264
+ // JSON Schema `required` array cannot express; the handler enforces it and
265
+ // refuses before any request.
266
+ required: [],
267
+ additionalProperties: false,
268
+ },
269
+ outputSchema: STATUS_OUTPUT_SCHEMA,
270
+ annotations: {
271
+ title: "Check a connect attempt",
272
+ readOnlyHint: true,
273
+ destructiveHint: false,
274
+ idempotentHint: true,
275
+ openWorldHint: true,
276
+ },
277
+ },
278
+ ];
279
+ /**
280
+ * The trusted triple, forwarded as EXPLICIT BODY FIELDS.
281
+ *
282
+ * The `Bearer sat_` middleware branch OVERWRITES `X-authuserid` from
283
+ * `AgentServicePrincipal.subjectUserId` and DELETES every caller-supplied
284
+ * `x-sellable-*` header, so the backend never sees the Slack requester any other
285
+ * way. A header would be stripped; this is why it has to be a body field.
286
+ */
287
+ function actorBody(actor) {
288
+ return {
289
+ slackRequesterId: actor.requesterId,
290
+ slackChannelId: actor.channelId,
291
+ providerRequestId: actor.providerRequestId,
292
+ };
293
+ }
294
+ /** FAILS CLOSED. An absent actor is a refusal, never a synthetic default. */
295
+ function requireActor(actor) {
296
+ if (!actor?.requesterId || !actor.channelId || !actor.providerRequestId) {
297
+ return {
298
+ ok: false,
299
+ envelope: envelope({
300
+ ok: false,
301
+ outcome: "refused",
302
+ attribution: "configuration",
303
+ error: "agent_request_context_required",
304
+ guidance: "I could not tell which Slack conversation this request came from, so I did nothing. Ask me again in a channel where I am installed.",
305
+ }),
306
+ };
307
+ }
308
+ return { ok: true, actor };
309
+ }
310
+ function refuse(error, guidance, attribution = "configuration") {
311
+ return envelope({
312
+ ok: false,
313
+ outcome: "refused",
314
+ attribution,
315
+ error,
316
+ guidance,
317
+ });
318
+ }
319
+ /**
320
+ * The 142 connect routes answer a FULL envelope — `ok`, `outcome`, `attribution`,
321
+ * `error`, `guidance`, and an explicit `result` key — unlike Phase 141's serving
322
+ * routes, whose payload keys travel at the top level and are gathered by
323
+ * `normalizeRouteResult`. Reusing that gatherer here would re-nest the route's
324
+ * `result` one level down (`result.result`), so this normalizer reads the envelope
325
+ * keys by name and forwards `result` as-is, still through the ONE `envelope()`
326
+ * clamp so an off-enum value normalizes identically across both trios.
327
+ */
328
+ function normalizeConnectRouteResult(value) {
329
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
330
+ return envelope({
331
+ ok: false,
332
+ error: "integration_response_malformed",
333
+ guidance: "The Sellable integrations API returned an unexpected shape. Retry after refreshing the Sellable MCP package.",
334
+ });
335
+ }
336
+ const record = value;
337
+ return envelope({
338
+ ok: record.ok === true,
339
+ outcome: typeof record.outcome === "string" ? record.outcome : undefined,
340
+ attribution: typeof record.attribution === "string" ? record.attribution : undefined,
341
+ error: typeof record.error === "string" ? record.error : null,
342
+ guidance: typeof record.guidance === "string" ? record.guidance : null,
343
+ result: withoutModelFacingHandle(record.result),
344
+ });
345
+ }
346
+ /**
347
+ * The single-use handle URL is a CAPABILITY: whoever opens it binds THEIR
348
+ * provider account under the requesting principal. Sellable delivers it to the
349
+ * requester by direct message server-side, so nothing downstream of this
350
+ * boundary needs it — and a model that holds it can paste it into a channel,
351
+ * where the next person to click becomes the connected account.
352
+ *
353
+ * The field is legitimate on the HTTP result: the browser mint path renders it,
354
+ * and the end-to-end seam driver reads it from the route to continue the flow.
355
+ * This strips it only where the model can see, which is the boundary that
356
+ * matters. Removing it from the route instead would break both of those.
357
+ *
358
+ * Recorded by phase 142 verification: the shape was mirrored from the phase 141
359
+ * UI mint, where the browser genuinely needs the URL. On the Slack path it was
360
+ * never load-bearing, and the tool description asserted the model could not see
361
+ * it while the response handed it over.
362
+ */
363
+ function withoutModelFacingHandle(result) {
364
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
365
+ return result ?? null;
366
+ }
367
+ const { connectHandleUrl: _withheld, ...rest } = result;
368
+ return rest;
369
+ }
370
+ function validateSlug(value, error) {
371
+ if (typeof value !== "string" ||
372
+ value.length < SLUG_MIN ||
373
+ value.length > SLUG_MAX ||
374
+ !SLUG_PATTERN.test(value)) {
375
+ return {
376
+ ok: false,
377
+ envelope: refuse(error, `appSlug must be ${SLUG_MIN}-${SLUG_MAX} characters of letters, digits, hyphen, or underscore, exactly as search_available_integrations returned it.`),
378
+ };
379
+ }
380
+ return { ok: true, slug: value };
381
+ }
382
+ export async function searchAvailableIntegrations(input = {}, actor, agentEffectId) {
383
+ const gate = requireActor(actor);
384
+ if (!gate.ok)
385
+ return gate.envelope;
386
+ const query = input.query;
387
+ if (typeof query !== "string" ||
388
+ query.length < QUERY_MIN ||
389
+ query.length > QUERY_MAX) {
390
+ // BEFORE any API call, deliberately: an empty or one-character query
391
+ // normalizes to something the catalog answers with a full-catalogue page,
392
+ // whose top slice always looks ambiguous — so the Agent would offer three
393
+ // arbitrary apps as if they were candidates.
394
+ return refuse("integration_app_query_rejected", `Tell me the app name in ${QUERY_MIN}-${QUERY_MAX} characters, for example "grain" or "grain.com".`);
395
+ }
396
+ try {
397
+ const response = await getApi().post(SEARCH_PATH, { query, ...actorBody(gate.actor) }, {
398
+ timeoutMs: SEARCH_TIMEOUT_MS,
399
+ ...(agentEffectId ? { agentEffectId } : {}),
400
+ });
401
+ return normalizeConnectRouteResult(response);
402
+ }
403
+ catch (error) {
404
+ return toolError("app_search", error);
405
+ }
406
+ }
407
+ export async function startIntegrationConnection(input = {}, actor, agentEffectId) {
408
+ const gate = requireActor(actor);
409
+ if (!gate.ok)
410
+ return gate.envelope;
411
+ const slug = validateSlug(input.appSlug, "integration_app_slug_rejected");
412
+ if (!slug.ok)
413
+ return slug.envelope;
414
+ // BOTH conversational doors converge on ONE mint. `trigger` is the only thing
415
+ // that differs between "connect Grain" and "you have no Grain account, shall
416
+ // I connect it" — and the route changes nothing else because of it.
417
+ const trigger = input.trigger === undefined || input.trigger === null
418
+ ? "explicit_request"
419
+ : input.trigger;
420
+ if (!CONNECT_TRIGGERS.includes(trigger)) {
421
+ return refuse("integration_connect_trigger_rejected", `trigger must be one of ${CONNECT_TRIGGERS.join(", ")}.`);
422
+ }
423
+ try {
424
+ const response = await getApi().post(START_PATH, { appSlug: slug.slug, trigger, ...actorBody(gate.actor) }, {
425
+ timeoutMs: START_TIMEOUT_MS,
426
+ ...(agentEffectId ? { agentEffectId } : {}),
427
+ });
428
+ // The route answers `{status:"awaiting_user", attemptId, deliveredTo, appName,
429
+ // authorizationHost}` and never a credential. `awaiting_user` is the ONLY
430
+ // success status the route can express, so there is nothing to clamp here:
431
+ // the type on both sides simply cannot say `connected`. The `appSlug` argument
432
+ // is a CONVENIENCE, NOT AN AUTHORITY — the route re-resolves it server-side
433
+ // and refuses ambiguous, unknown, action-less and incompletely enumerated apps
434
+ // before minting anything.
435
+ return normalizeConnectRouteResult(response);
436
+ }
437
+ catch (error) {
438
+ return toolError("connect_start", error);
439
+ }
440
+ }
441
+ function statusState(raw) {
442
+ const status = typeof raw.status === "string" ? raw.status : "";
443
+ if (status === "reconciled" || status === "attached")
444
+ return "connected";
445
+ if (status === "refused" || status === "failed")
446
+ return "refused";
447
+ // There is no `expired` attempt status: expiry is `expiresAt` in the past on a
448
+ // row nobody has swept yet, so it is DERIVED rather than read.
449
+ const expiresAt = typeof raw.expiresAt === "string" ? Date.parse(raw.expiresAt) : NaN;
450
+ if (status === "ready" &&
451
+ Number.isFinite(expiresAt) &&
452
+ expiresAt <= Date.now()) {
453
+ return "expired";
454
+ }
455
+ return "awaiting_user";
456
+ }
457
+ function readCount(value) {
458
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
459
+ }
460
+ /**
461
+ * The status route is a plain read route with its OWN body shape (142-04 T5 owns
462
+ * it), not an envelope producer. Adapting it here rather than reshaping that route
463
+ * keeps 142-04's tested contract intact and keeps the adaptation in one place.
464
+ */
465
+ function statusEnvelope(raw) {
466
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
467
+ return envelope({
468
+ ok: false,
469
+ outcome: "refused",
470
+ attribution: "sellable",
471
+ error: "integration_response_malformed",
472
+ guidance: "The Sellable connect status API returned an unexpected shape. Retry after refreshing the Sellable MCP package.",
473
+ });
474
+ }
475
+ const record = raw;
476
+ const state = statusState(record);
477
+ const enumerationComplete = typeof record.enumerationComplete === "boolean"
478
+ ? record.enumerationComplete
479
+ : null;
480
+ const result = {
481
+ state,
482
+ attemptId: typeof record.attemptId === "string" ? record.attemptId : null,
483
+ appSlug: typeof record.resolvedAppSlug === "string"
484
+ ? record.resolvedAppSlug
485
+ : typeof record.appSlug === "string"
486
+ ? record.appSlug
487
+ : null,
488
+ origin: typeof record.origin === "string" ? record.origin : null,
489
+ error: typeof record.errorCode === "string" ? record.errorCode : null,
490
+ accountLabel: typeof record.accountLabel === "string" ? record.accountLabel : null,
491
+ allowedToolCount: readCount(record.allowedToolCount),
492
+ // The phase's own vocabulary for "pending a human promotion": every DENY'd
493
+ // tool IS a pending promotion, which is why `tool_approval_required` is
494
+ // documented as a POINTER at one rather than a terminal failure. A fourth
495
+ // counter would be a name with no table behind it.
496
+ deniedToolCount: readCount(record.deniedToolCount),
497
+ enumerationComplete,
498
+ };
499
+ // SA142-46 — a truncated enumeration is reported as its OWN outcome so the Agent
500
+ // can never say "this app has no tools" about an app it failed to read. The
501
+ // provider's default actions page returns 10 of Gmail's 22 actions and omits its
502
+ // only search action, so the two answers are not interchangeable.
503
+ const incomplete = state === "connected" && enumerationComplete === false;
504
+ return envelope({
505
+ ok: true,
506
+ outcome: incomplete ? "catalog_incomplete" : "ok",
507
+ attribution: "policy",
508
+ result,
509
+ });
510
+ }
511
+ export async function getIntegrationConnectionStatus(input = {}, actor, agentEffectId) {
512
+ // Required for UNIFORMITY with its two siblings even though the route scopes by
513
+ // the authenticated principal rather than by a body field: the tool-name set that
514
+ // drives the fail-closed guard is one set, so a future status variant cannot
515
+ // quietly opt out of it. Nothing is forwarded — putting a Slack user id in a
516
+ // query string would write it into every access log on the path.
517
+ const gate = requireActor(actor);
518
+ if (!gate.ok)
519
+ return gate.envelope;
520
+ const hasAttempt = typeof input.attemptId === "string" && input.attemptId.length > 0;
521
+ const hasSlug = typeof input.appSlug === "string" && input.appSlug.length > 0;
522
+ if (hasAttempt === hasSlug) {
523
+ return refuse("integration_status_selector_required", "Pass exactly one of attemptId or appSlug. Both would be ambiguous and neither would be a lookup.");
524
+ }
525
+ if (hasAttempt) {
526
+ const attemptId = input.attemptId;
527
+ if (attemptId.length > 128) {
528
+ return refuse("integration_status_selector_required", "attemptId must be 1-128 characters.");
529
+ }
530
+ }
531
+ else {
532
+ const slug = validateSlug(input.appSlug, "integration_status_selector_required");
533
+ if (!slug.ok)
534
+ return slug.envelope;
535
+ }
536
+ const query = hasAttempt
537
+ ? `attemptId=${encodeURIComponent(input.attemptId)}`
538
+ : `appSlug=${encodeURIComponent(input.appSlug)}`;
539
+ try {
540
+ const response = await getApi().get(`${STATUS_PATH}?${query}`, {
541
+ timeoutMs: STATUS_TIMEOUT_MS,
542
+ ...(agentEffectId ? { agentEffectId } : {}),
543
+ });
544
+ return statusEnvelope(response);
545
+ }
546
+ catch (error) {
547
+ // A 404 is an ANSWER, not a fault: "nothing was ever started for this app" is
548
+ // something the Agent should say plainly rather than reporting as a system
549
+ // error the user cannot act on.
550
+ if (error instanceof SellableApiError && error.status === 404) {
551
+ return envelope({
552
+ ok: true,
553
+ outcome: "ok",
554
+ attribution: "policy",
555
+ result: {
556
+ state: "not_found",
557
+ attemptId: null,
558
+ appSlug: null,
559
+ origin: null,
560
+ error: null,
561
+ accountLabel: null,
562
+ allowedToolCount: null,
563
+ deniedToolCount: null,
564
+ enumerationComplete: null,
565
+ },
566
+ });
567
+ }
568
+ return toolError("connect_status", error);
569
+ }
570
+ }
@@ -96,6 +96,39 @@ type IntegrationHomeWorkspacePin = {
96
96
  * authorization boundary and must not replace the server refusal.
97
97
  */
98
98
  export declare function resolveIntegrationHomeWorkspacePin(env?: NodeJS.ProcessEnv, token?: string): IntegrationHomeWorkspacePin;
99
+ /**
100
+ * EXPORTED for plan 142-05's connect trio (SA142-52), which must declare the SAME
101
+ * schema object rather than a look-alike copy. The two `enum` arrays below are
102
+ * CLOSED and the MCP client validates against them, so a second copy that gained
103
+ * a member would make one trio's refusals unvalidatable while the other's passed.
104
+ * Exporting is what makes "mirror this envelope" mechanically true instead of a
105
+ * comment. Nothing about this object changed when it was exported.
106
+ */
107
+ export declare const INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA: {
108
+ readonly type: "object";
109
+ readonly properties: {
110
+ readonly ok: {
111
+ readonly type: "boolean";
112
+ };
113
+ readonly outcome: {
114
+ readonly type: "string";
115
+ readonly enum: readonly ["ok", "refused", "approval_required", "catalog_incomplete", "not_connected", "upstream_degraded"];
116
+ };
117
+ readonly attribution: {
118
+ readonly type: "string";
119
+ readonly enum: readonly ["provider", "configuration", "policy", "sellable"];
120
+ };
121
+ readonly error: {
122
+ readonly type: readonly ["string", "null"];
123
+ };
124
+ readonly guidance: {
125
+ readonly type: readonly ["string", "null"];
126
+ };
127
+ readonly result: {};
128
+ };
129
+ readonly required: readonly ["ok", "outcome", "attribution", "error", "guidance", "result"];
130
+ readonly additionalProperties: false;
131
+ };
99
132
  export declare const integrationsToolDefinitions: ({
100
133
  name: string;
101
134
  description: string;
@@ -327,6 +360,23 @@ type SelectorInput = {
327
360
  bindingId?: unknown;
328
361
  accountLabel?: unknown;
329
362
  };
363
+ /**
364
+ * EXPORTED for the connect trio, same reason as the schema above: one builder owns
365
+ * the fail-closed clamp of both closed enums, so a connect refusal and an
366
+ * `integrations_*` refusal cannot normalize differently.
367
+ */
368
+ export declare function envelope(value: Partial<IntegrationsResultEnvelope> & {
369
+ ok: boolean;
370
+ }): IntegrationsResultEnvelope;
371
+ /**
372
+ * Errors are RETURNED, never thrown, and the route's `error` is forwarded VERBATIM.
373
+ *
374
+ * No MCP-side error-code list is exported: this helper forwards whatever the route
375
+ * said, so a hand-copied list would be a second maintained list the MCP never
376
+ * produces. Copied in shape from `company-info.ts:96-125`.
377
+ */
378
+ export declare function toolError(action: string, error: unknown): IntegrationsResultEnvelope;
379
+ export declare function normalizeRouteResult(value: unknown): IntegrationsResultEnvelope;
330
380
  export declare function integrationsListTools(input?: SelectorInput, actor?: AgentIntegrationRequestContext | null, agentEffectId?: string): Promise<IntegrationsResultEnvelope>;
331
381
  export declare function integrationsDescribeTool(input?: SelectorInput & {
332
382
  toolName?: unknown;
@@ -143,7 +143,15 @@ const SELECTOR_PROPERTIES = {
143
143
  description: "The human label the user gave the connected account. This is the ONLY way to tell two connections over the same inbox apart, because the provider's own account name can be byte-identical for both.",
144
144
  },
145
145
  };
146
- const ENVELOPE_OUTPUT_SCHEMA = {
146
+ /**
147
+ * EXPORTED for plan 142-05's connect trio (SA142-52), which must declare the SAME
148
+ * schema object rather than a look-alike copy. The two `enum` arrays below are
149
+ * CLOSED and the MCP client validates against them, so a second copy that gained
150
+ * a member would make one trio's refusals unvalidatable while the other's passed.
151
+ * Exporting is what makes "mirror this envelope" mechanically true instead of a
152
+ * comment. Nothing about this object changed when it was exported.
153
+ */
154
+ export const INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA = {
147
155
  type: "object",
148
156
  properties: {
149
157
  ok: { type: "boolean" },
@@ -179,7 +187,7 @@ export const integrationsToolDefinitions = [
179
187
  required: [],
180
188
  additionalProperties: false,
181
189
  },
182
- outputSchema: ENVELOPE_OUTPUT_SCHEMA,
190
+ outputSchema: INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA,
183
191
  annotations: {
184
192
  title: "List allowed integration actions",
185
193
  readOnlyHint: true,
@@ -217,7 +225,7 @@ export const integrationsToolDefinitions = [
217
225
  required: ["toolName"],
218
226
  additionalProperties: false,
219
227
  },
220
- outputSchema: ENVELOPE_OUTPUT_SCHEMA,
228
+ outputSchema: INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA,
221
229
  annotations: {
222
230
  title: "Describe an allowed integration action",
223
231
  // Resolving options is a READ: `components/configure` creates no provider
@@ -256,7 +264,7 @@ export const integrationsToolDefinitions = [
256
264
  required: ["toolName"],
257
265
  additionalProperties: false,
258
266
  },
259
- outputSchema: ENVELOPE_OUTPUT_SCHEMA,
267
+ outputSchema: INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA,
260
268
  annotations: {
261
269
  title: "Run an allowed integration action",
262
270
  /**
@@ -280,7 +288,7 @@ export const integrationsToolDefinitions = [
280
288
  * The two CLOSED enums, read off the declared `outputSchema` so there is one source.
281
289
  *
282
290
  * `outcome` and `attribution` are validated by the MCP CLIENT against
283
- * `ENVELOPE_OUTPUT_SCHEMA` (`@modelcontextprotocol/sdk` runs ajv over
291
+ * `INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA` (`@modelcontextprotocol/sdk` runs ajv over
284
292
  * `structuredContent`; the python SDK does the same via `jsonschema`). An off-enum
285
293
  * value therefore does not degrade the envelope — it makes the client reject the
286
294
  * WHOLE response, so the operator loses the refusal AND its reason.
@@ -291,9 +299,14 @@ export const integrationsToolDefinitions = [
291
299
  * normalized FAIL-CLOSED to a refusal by `sellable`, while the route's `error` code —
292
300
  * the field that actually carries the reason — is still forwarded verbatim.
293
301
  */
294
- const ENVELOPE_OUTCOMES = ENVELOPE_OUTPUT_SCHEMA.properties.outcome.enum;
295
- const ENVELOPE_ATTRIBUTIONS = ENVELOPE_OUTPUT_SCHEMA.properties.attribution.enum;
296
- function envelope(value) {
302
+ const ENVELOPE_OUTCOMES = INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA.properties.outcome.enum;
303
+ const ENVELOPE_ATTRIBUTIONS = INTEGRATIONS_ENVELOPE_OUTPUT_SCHEMA.properties.attribution.enum;
304
+ /**
305
+ * EXPORTED for the connect trio, same reason as the schema above: one builder owns
306
+ * the fail-closed clamp of both closed enums, so a connect refusal and an
307
+ * `integrations_*` refusal cannot normalize differently.
308
+ */
309
+ export function envelope(value) {
297
310
  const outcome = value.outcome ?? (value.ok ? "ok" : "refused");
298
311
  const attribution = value.attribution ?? (value.ok ? "policy" : "sellable");
299
312
  return {
@@ -318,7 +331,7 @@ function envelope(value) {
318
331
  * said, so a hand-copied list would be a second maintained list the MCP never
319
332
  * produces. Copied in shape from `company-info.ts:96-125`.
320
333
  */
321
- function toolError(action, error) {
334
+ export function toolError(action, error) {
322
335
  if (error instanceof SellableApiError) {
323
336
  let forwarded = {};
324
337
  try {
@@ -434,7 +447,7 @@ function actorBody(actor) {
434
447
  providerRequestId: actor.providerRequestId,
435
448
  };
436
449
  }
437
- function normalizeRouteResult(value) {
450
+ export function normalizeRouteResult(value) {
438
451
  if (!value || typeof value !== "object" || Array.isArray(value)) {
439
452
  return envelope({
440
453
  ok: false,
@@ -8765,6 +8765,12 @@ export declare const allTools: ({
8765
8765
  idempotentHint: boolean;
8766
8766
  openWorldHint: boolean;
8767
8767
  };
8768
+ } | {
8769
+ name: string;
8770
+ description: string;
8771
+ inputSchema: Record<string, unknown>;
8772
+ outputSchema: Record<string, unknown>;
8773
+ annotations: Record<string, unknown>;
8768
8774
  } | {
8769
8775
  name: string;
8770
8776
  description: string;
@@ -27,6 +27,7 @@ import { findLeadsRunToolDefinitions } from "./find-leads-runs.js";
27
27
  import { frameworkToolDefinitions } from "./framework.js";
28
28
  import { harvestJobToolDefinitions } from "./harvest-jobs.js";
29
29
  import { inboxToolDefinitions } from "./inbox.js";
30
+ import { integrationConnectToolDefinitions } from "./integration-connect.js";
30
31
  import { integrationsToolDefinitions } from "./integrations.js";
31
32
  import { leadToolDefinitions } from "./leads.js";
32
33
  import { linkedinToolDefinitions } from "./linkedin.js";
@@ -83,10 +84,11 @@ export const allTools = [
83
84
  ...leadToolDefinitions,
84
85
  ...harvestJobToolDefinitions,
85
86
  ...inboxToolDefinitions,
86
- // Phase 142 plan 142-05 adds its three CONNECT tools to this same array.
87
- // 141 merges first, so 142 is additive; neither plan owns this file exclusively
88
- // and neither may reorder the other's entries.
87
+ // Phase 142 plan 142-05's three CONNECT tools sit beside 141-06's
88
+ // `integrations_*` trio. Neither plan owns this file exclusively and neither
89
+ // may reorder the other's entries.
89
90
  ...integrationsToolDefinitions,
91
+ ...integrationConnectToolDefinitions,
90
92
  ...enrichmentToolDefinitions,
91
93
  ...processingToolDefinitions,
92
94
  ...rubricToolDefinitions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.769",
3
+ "version": "0.1.770",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code, Codex, and Hermes campaign workflows",
6
6
  "main": "dist/index.js",