@isomorph.ai/app-sdk 1.36.3 → 1.38.0

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/README.md CHANGED
@@ -393,7 +393,7 @@ A job's agent can hand one browser task, writes included, to the computer use se
393
393
  import { createComputerUseTool } from '@isomorph.ai/app-sdk/dist/computer-use.js';
394
394
  // Inside a job handler:
395
395
  const computerUse = createComputerUseTool({ runKey: ctx.runId ?? `my-job/${ctx.scheduledAt}`, progress: ctx.progress, cancelled: ctx.cancelled });
396
- // Add { computerUse } to generateText's tools. It returns { status: 'done' | 'failed', summary, finalUrl }.
396
+ // Add { computerUse } to generateText's tools. It returns { status: 'done' | 'failed', summary, finalUrl, downloads }.
397
397
  ```
398
398
 
399
399
  - The tool calls the task API at `ISOMORPH_COMPUTER_USE_URL` (region `ISOMORPH_COMPUTER_USE_REGION`, default `us-east-1`); Provision sets both when the cell has the API. Each request is signed with SigV4 (`execute-api`) as the runtime's own role: the Lambda environment's credentials, or on a cluster pod its ServiceAccount role through STS `AssumeRoleWithWebIdentity`.
@@ -409,10 +409,11 @@ Options the app passes, never the model:
409
409
  - `session`: the name of a runtime secret a sign-in was saved under (`finishSignIn`'s `saveAs`, below). The task starts signed in and can use only that sign-in's websites. When the task is `done` the refreshed session is saved back to the same secret. A missing one fails with `No saved sign-in named <name>.` Run one task per saved sign-in at a time: two tasks would both save back, and some websites sign out the older copy.
410
410
  - `secrets`: `{ name: value }`, at most 10, names `^[A-Za-z][A-Za-z0-9_-]{0,63}$`, values up to 4096 bytes. The computer types a value into a password field by its name; the model sees only the names. Never put a password or any other credential in the task text; pass it here and say "sign in with the `password` secret".
411
411
  - `allowedHosts`: the websites (1 to 10 lower-case hostnames) a task with `secrets` and no `session` may use. A saved sign-in carries its own.
412
+ - `onDownload({ name, contentType, size, blob })`: called once for each file a `done` task downloaded, one at a time, before the tool returns (at most 10 files, 100 MiB each). Store the `blob` yourself; the CUA keeps the files for a day at most. Without it nothing is fetched. A link that does not answer 200 fails the tool. The model only sees `downloads: [{ name, size }]`.
412
413
 
413
414
  A task with `session` or `secrets` works the page through clicks and typing only. `connectionId` is gone: pass `session`.
414
415
 
415
- (1.25.0, 1.36.0; data plane ADR 0078, ADR 0090)
416
+ (1.25.0, 1.36.0, 1.37.0; data plane ADR 0078, ADR 0090, ADR 0091)
416
417
 
417
418
  ## Browser sign-in for computer use
418
419
 
@@ -626,3 +627,28 @@ subfolders, shortcuts and native Google documents are excluded. No historical ba
626
627
  Background read consent is required. Existing file-read size limits still apply. Provider
627
628
  subscriptions and real upload acceptance must be verified after deployment. Retries retain
628
629
  one durable job run; accounting/email side effects still need their own idempotency keys.
630
+
631
+
632
+ ### Selected MCP tools (server actions and jobs)
633
+
634
+ Connect a compatible MCP server in Integrations, then select the app's tools with
635
+ `isomorph integrations tools select`. This writes accepted descriptors into the
636
+ existing `.isomorph/integrations.json`; `isomorph check` verifies literal usage.
637
+
638
+ ```ts
639
+ const result = await isomorph.integrations.execute('finance', {
640
+ operation: 'mcp.tool.call', resource: 'server',
641
+ input: { tool: 'Invoices.Read', arguments: { id: invoiceId } },
642
+ });
643
+ const tools = await isomorph.integrations.tools('finance', {
644
+ tools: ['Invoices.Read', 'Invoices.Create'],
645
+ runId: persistedWorkflowId,
646
+ });
647
+ ```
648
+
649
+ Use the provider's exact selected tool names. The helper loads accepted schemas
650
+ locally and returns AI SDK tools; it does not rediscover provider tools. For writes,
651
+ keep the workflow run ID and tool-call ID stable across retries. Direct writes
652
+ must supply their own persisted UUID `idempotencyKey`. A tool marked read-only
653
+ does not require a key. An incomplete replay requires reconciliation, not a new
654
+ write. Account consent and app permissions are enforced by the platform.
@@ -3,6 +3,12 @@ export interface ComputerUseFile {
3
3
  contentType: "application/pdf";
4
4
  dataBase64: string;
5
5
  }
6
+ export interface ComputerUseDownload {
7
+ name: string;
8
+ contentType: string;
9
+ size: number;
10
+ blob: Blob;
11
+ }
6
12
  export interface ComputerUseOptions {
7
13
  /** App-authorized PDFs, at most five files and 2 MiB total. Never supplied by the model. */
8
14
  files?: readonly ComputerUseFile[];
@@ -18,6 +24,8 @@ export interface ComputerUseOptions {
18
24
  * Never put a credential in the task text.
19
25
  */
20
26
  secrets?: Readonly<Record<string, string>>;
27
+ /** The app's code gets each file the task downloaded, one at a time, when the task is done. Never seen by the model. */
28
+ onDownload?(download: ComputerUseDownload): Promise<void> | void;
21
29
  /** The job's runId, or `<job name>/<scheduledAt>` for a scheduled job: one task record per job run. */
22
30
  runKey: string;
23
31
  /** The job's progress: called on each new step and at least once a minute, which renews the job's lease. */
@@ -35,6 +43,11 @@ export interface ComputerUseResult {
35
43
  summary: string | null;
36
44
  /** The page the browser was on when the task ended; a sign-in page there means the saved sign-in has expired. */
37
45
  finalUrl: string | null;
46
+ /** The files the task downloaded; their bytes went to onDownload. */
47
+ downloads: {
48
+ name: string;
49
+ size: number;
50
+ }[];
38
51
  }
39
52
  export declare function createComputerUseTool(options: ComputerUseOptions): ({
40
53
  deferLoading?: boolean;
@@ -2,7 +2,8 @@
2
2
  // agent (CUA) and waits for its result. Calls go to the CUA's task API, signed
3
3
  // with SigV4 as the runtime's own role (ADR 0078). A saved sign-in is a runtime
4
4
  // secret: its session goes with the task and the refreshed one is saved back
5
- // to it (ADR 0090).
5
+ // to it (ADR 0090). Files the task downloads go to the app's onDownload, never
6
+ // to the model (ADR 0091).
6
7
  import { setTimeout as sleep } from 'node:timers/promises';
7
8
  import { jsonSchema, tool } from 'ai';
8
9
  import { allowedHostsOf, computerUseApi, refused } from './computer-use-api.js';
@@ -71,7 +72,7 @@ export function createComputerUseTool(options) {
71
72
  }
72
73
  };
73
74
  return tool({
74
- description: 'Do one task in a web browser, such as filling in and submitting a form on a website. Describe the whole task in plain words, with the website address and any details it needs. Waits until the task ends and returns its status and a summary.',
75
+ description: 'Do one task in a web browser, such as filling in and submitting a form on a website. Describe the whole task in plain words, with the website address and any details it needs. Waits until the task ends and returns its status and a summary. Files the task downloads go to the app; the result lists their names.',
75
76
  inputSchema: jsonSchema({ type: 'object', properties: { task: { type: 'string', minLength: 1 } }, required: ['task'], additionalProperties: false }),
76
77
  execute: async ({ task }) => {
77
78
  await api.post('/tasks', {
@@ -101,7 +102,17 @@ export function createComputerUseTool(options) {
101
102
  if (answer.status === 'done' || answer.status === 'failed') {
102
103
  if (answer.status === 'done' && session !== undefined && answer.session)
103
104
  await runtimeSecrets.put(session, JSON.stringify(answer.session), transport);
104
- return { status: answer.status, summary: answer.summary ?? null, finalUrl: answer.final_url ?? null };
105
+ const downloads = answer.downloads ?? [];
106
+ if (options.onDownload) {
107
+ for (const { name, contentType, size, url } of downloads) {
108
+ await options.progress(99, `Computer use download: ${name}`);
109
+ const response = await (options.fetch ?? fetch)(url);
110
+ if (response.status !== 200)
111
+ throw refused(`the download ${name}`, response.status, undefined);
112
+ await options.onDownload({ name, contentType, size, blob: await response.blob() });
113
+ }
114
+ }
115
+ return { status: answer.status, summary: answer.summary ?? null, finalUrl: answer.final_url ?? null, downloads: downloads.map(({ name, size }) => ({ name, size })) };
105
116
  }
106
117
  if (answer.steps > steps || now() - reported >= HEARTBEAT_MS) {
107
118
  steps = Math.max(steps, answer.steps);
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { McpCallRequest, McpCallResult, McpToolsOptions } from './mcp-tools.js';
2
+ export type { McpCallRequest, McpCallResult, McpToolDescriptor, McpSelectedTool, McpToolsOptions } from './mcp-tools.js';
1
3
  import { type IsomorphUserRole } from './user-role.js';
2
4
  export type { IsomorphUserRole } from './user-role.js';
3
5
  export type IsomorphErrorCategory = 'AUTH_REQUIRED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' | 'VALIDATION_FAILED' | 'RATE_LIMITED' | 'UNAVAILABLE' | 'INTERNAL';
@@ -1232,7 +1234,7 @@ export type ZohoBooksRequest = {
1232
1234
  lineItems: BooksInvoiceLineInput[];
1233
1235
  };
1234
1236
  };
1235
- export type IntegrationRequest = ZohoBooksRequest | ServiceRequest | SlackChannelHistoryRequest | SlackMessagePostRequest | SlackMessageUpdateRequest | SlackMessageDeleteRequest | SlackDmSendRequest | SlackUserLookupRequest | WarehouseViewReadRequest | WarehouseTableAppendRequest | PhoneSmsListRequest | PhoneNumberGetRequest | GmailThreadListRequest | GmailThreadReadRequest | GmailMessageReadRequest | GmailAttachmentReadRequest | GmailMessageSendRequest | BasecampProjectListRequest | BasecampTodolistListRequest | BasecampTodoListRequest | BasecampTodoCreateRequest | BasecampTodoCompleteRequest | BasecampMessageListRequest | BasecampMessagePostRequest | BasecampCommentListRequest | BasecampCommentPostRequest | BasecampCampfirePostRequest | BasecampPeopleListRequest | SharePointListListRequest | SharePointItemListRequest | SharePointItemCreateRequest | SharePointItemUpdateRequest | SharePointDriveListRequest | SharePointFileListRequest | SharePointFileSearchRequest | SharePointFileReadRequest | SharePointFileUploadRequest | CalendarCalendarListRequest | CalendarEventListRequest | CalendarEventReadRequest | CalendarEventCreateRequest | CalendarEventUpdateRequest | CalendarEventCancelRequest | DriveFileListRequest | DriveFileSearchRequest | DriveFileReadRequest | DriveFileUploadRequest | DriveFolderCreateRequest | DocsDocumentReadRequest | DocsDocumentCreateRequest | DocsDocumentAppendRequest | DocsTextReplaceRequest | SheetsSpreadsheetReadRequest | SheetsValuesReadRequest | SheetsValuesAppendRequest | SheetsValuesUpdateRequest | SheetsValuesClearRequest | SheetsSpreadsheetCreateRequest | OutlookMessageListRequest | OutlookMessageReadRequest | OutlookAttachmentReadRequest | OutlookMessageSendRequest | OutlookMessageReplyRequest | OutlookEventListRequest | OutlookEventReadRequest | OutlookEventCreateRequest | OutlookEventUpdateRequest | OutlookEventCancelRequest | OneDriveFileListRequest | OneDriveFileSearchRequest | OneDriveFileReadRequest | OneDriveFileUploadRequest | OneDriveFolderCreateRequest | ExcelWorksheetListRequest | ExcelRangeReadRequest | ExcelRangeUpdateRequest | ExcelTableListRequest | ExcelRowAddRequest | ExcelWorksheetAddRequest | TeamsMessageListRequest | TeamsMessagePostRequest | TeamsReplyPostRequest;
1237
+ export type IntegrationRequest = McpCallRequest | ZohoBooksRequest | ServiceRequest | SlackChannelHistoryRequest | SlackMessagePostRequest | SlackMessageUpdateRequest | SlackMessageDeleteRequest | SlackDmSendRequest | SlackUserLookupRequest | WarehouseViewReadRequest | WarehouseTableAppendRequest | PhoneSmsListRequest | PhoneNumberGetRequest | GmailThreadListRequest | GmailThreadReadRequest | GmailMessageReadRequest | GmailAttachmentReadRequest | GmailMessageSendRequest | BasecampProjectListRequest | BasecampTodolistListRequest | BasecampTodoListRequest | BasecampTodoCreateRequest | BasecampTodoCompleteRequest | BasecampMessageListRequest | BasecampMessagePostRequest | BasecampCommentListRequest | BasecampCommentPostRequest | BasecampCampfirePostRequest | BasecampPeopleListRequest | SharePointListListRequest | SharePointItemListRequest | SharePointItemCreateRequest | SharePointItemUpdateRequest | SharePointDriveListRequest | SharePointFileListRequest | SharePointFileSearchRequest | SharePointFileReadRequest | SharePointFileUploadRequest | CalendarCalendarListRequest | CalendarEventListRequest | CalendarEventReadRequest | CalendarEventCreateRequest | CalendarEventUpdateRequest | CalendarEventCancelRequest | DriveFileListRequest | DriveFileSearchRequest | DriveFileReadRequest | DriveFileUploadRequest | DriveFolderCreateRequest | DocsDocumentReadRequest | DocsDocumentCreateRequest | DocsDocumentAppendRequest | DocsTextReplaceRequest | SheetsSpreadsheetReadRequest | SheetsValuesReadRequest | SheetsValuesAppendRequest | SheetsValuesUpdateRequest | SheetsValuesClearRequest | SheetsSpreadsheetCreateRequest | OutlookMessageListRequest | OutlookMessageReadRequest | OutlookAttachmentReadRequest | OutlookMessageSendRequest | OutlookMessageReplyRequest | OutlookEventListRequest | OutlookEventReadRequest | OutlookEventCreateRequest | OutlookEventUpdateRequest | OutlookEventCancelRequest | OneDriveFileListRequest | OneDriveFileSearchRequest | OneDriveFileReadRequest | OneDriveFileUploadRequest | OneDriveFolderCreateRequest | ExcelWorksheetListRequest | ExcelRangeReadRequest | ExcelRangeUpdateRequest | ExcelTableListRequest | ExcelRowAddRequest | ExcelWorksheetAddRequest | TeamsMessageListRequest | TeamsMessagePostRequest | TeamsReplyPostRequest;
1236
1238
  export type IntegrationOperation = IntegrationRequest['operation'];
1237
1239
  export interface SlackMessage {
1238
1240
  id: string;
@@ -1726,7 +1728,7 @@ export interface TeamsMessagePostResult {
1726
1728
  id: string;
1727
1729
  webUrl: string;
1728
1730
  }
1729
- export type IntegrationResult<R extends IntegrationRequest> = R extends {
1731
+ export type IntegrationResult<R extends IntegrationRequest> = R extends McpCallRequest ? McpCallResult : R extends {
1730
1732
  operation: 'zohobooks.contact.list';
1731
1733
  } ? {
1732
1734
  booksContacts: {
@@ -2057,6 +2059,15 @@ export declare function createClient(options?: ClientOptions): {
2057
2059
  page: (path?: string) => Promise<void>;
2058
2060
  };
2059
2061
  integrations: {
2062
+ tools: (connection: string, options: McpToolsOptions) => Promise<Record<string, import("ai").Tool<Record<string, unknown>, {
2063
+ content?: Array<Record<string, unknown>>;
2064
+ structuredContent?: Record<string, unknown>;
2065
+ isError?: boolean;
2066
+ replay?: {
2067
+ complete: boolean;
2068
+ summary?: string;
2069
+ };
2070
+ }>>>;
2060
2071
  execute: <R extends IntegrationRequest>(connection: string, request: R) => Promise<IntegrationResult<R>>;
2061
2072
  connect: (connection: string) => Promise<IntegrationConnectResult>;
2062
2073
  disconnect: (connection: string) => Promise<IntegrationDisconnectResult>;
package/dist/index.js CHANGED
@@ -459,6 +459,28 @@ export function createClient(options = {}) {
459
459
  return user;
460
460
  };
461
461
  const transport = new Transport(options, () => notifyIdentity(null));
462
+ const executeIntegration = (connection, request) => {
463
+ // The contract's per-operation rules, refused here before any request
464
+ // is made, with the platform's own sentence and the field named — so
465
+ // `isomorph check` (which runs retained journeys through this client)
466
+ // and the deployed app fail the same way at build time, not at runtime.
467
+ const checked = validateIntegrationExecuteInput(request.operation, request.input);
468
+ const problem = (request.operation === 'warehouse.table.append' ? warehouseAppendProblem(request) : undefined)
469
+ ?? (!checked.ok ? checked.problem
470
+ : request.operation === 'warehouse.view.read' ? warehouseReadProblem(request.input ?? {}) : undefined);
471
+ if (problem) {
472
+ return Promise.reject(new IsomorphError('VALIDATION_FAILED', problem.message, undefined, { code: 'INPUT_INVALID', field: problem.field }));
473
+ }
474
+ // Exactly the contract's fields go on the wire: metadata declares which
475
+ // operations accept `mode`; identity and stray fields are not sent.
476
+ // phone.number.get takes no input, but the contract keeps `input`
477
+ // required on the wire (ADR 0029), so an omitted one goes out as {}.
478
+ const { operation, resource, input: given, idempotencyKey, mode } = request;
479
+ const input = given === undefined && operation === 'phone.number.get' ? {} : given;
480
+ return transport.json('/_harbour/integrations/execute', {
481
+ method: 'POST', body: JSON.stringify({ connection, operation, ...(operation !== 'service.request' ? { resource } : {}), ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(IntegrationOperations[operation]?.declaredMode && mode !== undefined ? { mode } : {}) }),
482
+ });
483
+ };
462
484
  const socketFactory = options.websocket ?? ((url) => defaultSocketFactory(url, options.identity));
463
485
  const refreshIdentity = () => {
464
486
  if (refreshInFlight)
@@ -805,28 +827,11 @@ export function createClient(options = {}) {
805
827
  }),
806
828
  },
807
829
  integrations: {
808
- execute: (connection, request) => {
809
- // The contract's per-operation rules, refused here before any request
810
- // is made, with the platform's own sentence and the field named — so
811
- // `isomorph check` (which runs retained journeys through this client)
812
- // and the deployed app fail the same way at build time, not at runtime.
813
- const checked = validateIntegrationExecuteInput(request.operation, request.input);
814
- const problem = (request.operation === 'warehouse.table.append' ? warehouseAppendProblem(request) : undefined)
815
- ?? (!checked.ok ? checked.problem
816
- : request.operation === 'warehouse.view.read' ? warehouseReadProblem(request.input ?? {}) : undefined);
817
- if (problem) {
818
- return Promise.reject(new IsomorphError('VALIDATION_FAILED', problem.message, undefined, { code: 'INPUT_INVALID', field: problem.field }));
819
- }
820
- // Exactly the contract's fields go on the wire: metadata declares which
821
- // operations accept `mode`; identity and stray fields are not sent.
822
- // phone.number.get takes no input, but the contract keeps `input`
823
- // required on the wire (ADR 0029), so an omitted one goes out as {}.
824
- const { operation, resource, input: given, idempotencyKey, mode } = request;
825
- const input = given === undefined && operation === 'phone.number.get' ? {} : given;
826
- return transport.json('/_harbour/integrations/execute', {
827
- method: 'POST', body: JSON.stringify({ connection, operation, ...(operation !== 'service.request' ? { resource } : {}), ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(IntegrationOperations[operation]?.declaredMode && mode !== undefined ? { mode } : {}) }),
828
- });
830
+ tools: async (connection, options) => {
831
+ const { createMcpTools } = await import('./mcp-tools.js');
832
+ return createMcpTools(connection, options, request => executeIntegration(connection, request));
829
833
  },
834
+ execute: executeIntegration,
830
835
  // In a browser the consent returns the person to this page (without its
831
836
  // fragment); governance accepts it only as a registered page of this app
832
837
  // (ADR 0030). Outside a browser there is no page, and none is sent.
@@ -2556,6 +2556,21 @@ export declare const IntegrationExecuteInputRules: {
2556
2556
  };
2557
2557
  };
2558
2558
  };
2559
+ readonly "mcp.tool.call": {
2560
+ readonly idempotencyKey: false;
2561
+ readonly fields: {
2562
+ readonly tool: {
2563
+ readonly kind: "string";
2564
+ readonly required: true;
2565
+ readonly minLength: 1;
2566
+ readonly maxLength: 128;
2567
+ };
2568
+ readonly arguments: {
2569
+ readonly kind: "object";
2570
+ readonly required: true;
2571
+ };
2572
+ };
2573
+ };
2559
2574
  };
2560
2575
  export type IntegrationExecuteOperation = keyof typeof IntegrationExecuteInputRules;
2561
2576
  /** The upper bound of one integer input as a literal type: integrationExecuteInputMaximum('slack.channel.history', 'limit') is 15. */
@@ -792,6 +792,13 @@ export const IntegrationExecuteInputRules = {
792
792
  documentId: { kind: "string", required: true, minLength: 0, maxLength: 32, pattern: "^[0-9]{1,32}$" },
793
793
  },
794
794
  },
795
+ "mcp.tool.call": {
796
+ idempotencyKey: false,
797
+ fields: {
798
+ tool: { kind: "string", required: true, minLength: 1, maxLength: 128 },
799
+ arguments: { kind: "object", required: true },
800
+ },
801
+ },
795
802
  };
796
803
  /** The upper bound of one integer input as a literal type: integrationExecuteInputMaximum('slack.channel.history', 'limit') is 15. */
797
804
  export function integrationExecuteInputMaximum(operation, field) {
@@ -665,4 +665,12 @@ export declare const IntegrationOperations: {
665
665
  readonly declaredMode: false;
666
666
  readonly idempotencyKey: false;
667
667
  };
668
+ readonly "mcp.tool.call": {
669
+ readonly kind: "mcp";
670
+ readonly identities: readonly ["user"];
671
+ readonly readOnly: false;
672
+ readonly declaredMode: false;
673
+ readonly idempotencyKey: false;
674
+ readonly runtimeIdempotency: true;
675
+ };
668
676
  };
@@ -860,5 +860,15 @@ export const IntegrationOperations = {
860
860
  "readOnly": true,
861
861
  "declaredMode": false,
862
862
  "idempotencyKey": false
863
+ },
864
+ "mcp.tool.call": {
865
+ "kind": "mcp",
866
+ "identities": [
867
+ "user"
868
+ ],
869
+ "readOnly": false,
870
+ "declaredMode": false,
871
+ "idempotencyKey": false,
872
+ "runtimeIdempotency": true
863
873
  }
864
874
  };
@@ -0,0 +1,45 @@
1
+ import type { Tool } from 'ai';
2
+ export interface McpToolDescriptor {
3
+ name: string;
4
+ description?: string;
5
+ title?: string;
6
+ inputSchema: Record<string, unknown>;
7
+ outputSchema?: Record<string, unknown>;
8
+ annotations?: {
9
+ readOnlyHint?: boolean;
10
+ [key: string]: unknown;
11
+ };
12
+ }
13
+ export interface McpSelectedTool {
14
+ name: string;
15
+ contractHash: string;
16
+ descriptor: McpToolDescriptor;
17
+ }
18
+ export interface McpCallRequest {
19
+ operation: 'mcp.tool.call';
20
+ resource: 'server';
21
+ mode?: 'user';
22
+ input: {
23
+ tool: string;
24
+ arguments: Record<string, unknown>;
25
+ };
26
+ idempotencyKey?: string;
27
+ }
28
+ export interface McpCallResult {
29
+ mcp: {
30
+ content?: Array<Record<string, unknown>>;
31
+ structuredContent?: Record<string, unknown>;
32
+ isError?: boolean;
33
+ replay?: {
34
+ complete: boolean;
35
+ summary?: string;
36
+ };
37
+ };
38
+ }
39
+ export interface McpToolsOptions {
40
+ tools: readonly string[];
41
+ runId: string;
42
+ }
43
+ type Execute = (request: McpCallRequest) => Promise<McpCallResult>;
44
+ export declare function createMcpTools(connection: string, options: McpToolsOptions, execute: Execute): Promise<Record<string, Tool<Record<string, unknown>, McpCallResult['mcp']>>>;
45
+ export {};
@@ -0,0 +1,51 @@
1
+ async function digest(parts) {
2
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(parts))));
3
+ }
4
+ function hex(bytes) {
5
+ return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
6
+ }
7
+ async function invocationKey(runId, tool, callId) {
8
+ const bytes = (await digest([runId, tool, callId])).slice(0, 16);
9
+ bytes[6] = (bytes[6] & 15) | 128;
10
+ bytes[8] = (bytes[8] & 63) | 128;
11
+ const value = hex(bytes);
12
+ return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
13
+ }
14
+ export async function createMcpTools(connection, options, execute) {
15
+ if (typeof window !== 'undefined' && typeof window.document !== 'undefined')
16
+ throw new Error('MCP agent tools run in actions or jobs.');
17
+ if (!options.runId || !Array.isArray(options.tools) || options.tools.length < 1 || options.tools.length > 64 || new Set(options.tools).size !== options.tools.length)
18
+ throw new Error('MCP tools need a durable runId and 1–64 unique selected tool names.');
19
+ const [{ readFile }, { resolve }, { jsonSchema }] = await Promise.all([import('node:fs/promises'), import('node:path'), import('ai')]);
20
+ const root = process.env.ISOMORPH_APP_ROOT || process.cwd();
21
+ const declaration = JSON.parse(await readFile(resolve(root, '.isomorph/integrations.json'), 'utf8'));
22
+ const source = declaration.connections[connection];
23
+ if (declaration.schema !== 'isomorph.app-integrations/2.0' || source?.kind !== 'mcp' || !source.mcp)
24
+ throw new Error('This MCP connection is not declared for the app. Run the integration selection and checks.');
25
+ const allowed = new Map(source.mcp.tools.map(tool => [tool.name, tool]));
26
+ const result = {};
27
+ for (const name of options.tools) {
28
+ const selected = allowed.get(name);
29
+ if (!selected || selected.descriptor.name !== name)
30
+ throw new Error(`MCP tool ${name} is not declared for this app.`);
31
+ const descriptor = selected.descriptor;
32
+ const alias = `mcp_${hex((await digest([connection, name])).slice(0, 20))}`;
33
+ result[alias] = {
34
+ description: descriptor.description ?? descriptor.title ?? name,
35
+ inputSchema: jsonSchema(descriptor.inputSchema),
36
+ execute: async (input, context) => {
37
+ const readOnly = descriptor.annotations?.readOnlyHint === true;
38
+ if (!readOnly && !context.toolCallId)
39
+ throw new Error('MCP writes need a persisted toolCallId. Do not retry a write with a new invocation ID.');
40
+ const response = await execute({
41
+ operation: 'mcp.tool.call', resource: 'server', mode: 'user', input: { tool: name, arguments: input },
42
+ ...(!readOnly ? { idempotencyKey: await invocationKey(options.runId, name, context.toolCallId) } : {}),
43
+ });
44
+ if (response.mcp.replay?.complete === false)
45
+ throw new Error('This MCP action already completed, but its result was not retained. Reconcile the provider record before continuing; do not repeat the action.');
46
+ return response.mcp;
47
+ },
48
+ };
49
+ }
50
+ return result;
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.36.3",
3
+ "version": "1.38.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",