@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.
- package/dist/agent-integration-request-context.d.ts +10 -0
- package/dist/agent-integration-request-context.js +15 -1
- package/dist/agent-tool-names.d.ts +1 -1
- package/dist/agent-tool-names.js +3 -0
- package/dist/server.js +13 -0
- package/dist/tool-result-envelope.js +9 -0
- package/dist/tools/integration-connect.d.ts +23 -0
- package/dist/tools/integration-connect.js +570 -0
- package/dist/tools/integrations.d.ts +50 -0
- package/dist/tools/integrations.js +23 -10
- package/dist/tools/registry.d.ts +6 -0
- package/dist/tools/registry.js +5 -3
- package/package.json +1 -1
|
@@ -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
|
package/dist/agent-tool-names.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
* `
|
|
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 =
|
|
295
|
-
const ENVELOPE_ATTRIBUTIONS =
|
|
296
|
-
|
|
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,
|
package/dist/tools/registry.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/registry.js
CHANGED
|
@@ -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
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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,
|