@ai-sdk/harness-acp 1.0.38 → 1.0.40

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.
@@ -1,7 +1,13 @@
1
- import { randomBytes, timingSafeEqual } from 'node:crypto';
1
+ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { createServer, type IncomingMessage, type Server } from 'node:http';
3
3
  import type { AddressInfo } from 'node:net';
4
4
  import type { HarnessV1BridgeToolWire } from '@ai-sdk/harness';
5
+ import type { ACPHostToolMCPTransport } from '../acp-v1-settings';
6
+ import {
7
+ createHostToolMCPHttpEndpoint,
8
+ HOST_TOOL_MCP_ENDPOINT_PATH,
9
+ type HostToolMCPHttpEndpoint,
10
+ } from './host-tool-mcp-http';
5
11
 
6
12
  export type HostToolCorrelationInvocation = {
7
13
  readonly token: string;
@@ -34,6 +40,11 @@ export type HostToolRelayTurn = {
34
40
 
35
41
  export type HostToolRelay = {
36
42
  readonly url: string;
43
+ /**
44
+ * MCP endpoint that exposes the host tool catalog over Streamable HTTP.
45
+ * Only present when the relay was started with the `http` MCP transport.
46
+ */
47
+ readonly mcpUrl?: string;
37
48
  readonly credential: string;
38
49
  bindTurn(options: { turn: HostToolRelayTurn }): void;
39
50
  unbindTurn(options: { turn: HostToolRelayTurn }): void;
@@ -64,9 +75,11 @@ type CatalogState = {
64
75
  export async function startHostToolRelay({
65
76
  tools,
66
77
  serverName,
78
+ mcpTransport = 'stdio',
67
79
  }: {
68
80
  tools: ReadonlyArray<HarnessV1BridgeToolWire>;
69
81
  serverName: string;
82
+ mcpTransport?: ACPHostToolMCPTransport;
70
83
  }): Promise<HostToolRelay> {
71
84
  const state: CatalogState = {
72
85
  tools: [...tools],
@@ -81,8 +94,52 @@ export async function startHostToolRelay({
81
94
  let activeTurn: HostToolRelayTurn | undefined;
82
95
  let invocationOrder = 0;
83
96
  let closePromise: Promise<void> | undefined;
97
+ const mcpEndpoint: HostToolMCPHttpEndpoint | undefined =
98
+ mcpTransport === 'http'
99
+ ? createHostToolMCPHttpEndpoint({
100
+ tools: state.tools,
101
+ revision: state.revision,
102
+ invoke: ({ toolName, input, catalogRevision }) =>
103
+ handleInvocation({
104
+ body: {
105
+ requestId: randomUUID(),
106
+ toolName,
107
+ input,
108
+ catalogRevision,
109
+ },
110
+ state,
111
+ serverName,
112
+ turn: activeTurn,
113
+ nextInvocationOrder: () => ++invocationOrder,
114
+ }),
115
+ onListTools: async ({ revision }) => {
116
+ acknowledgeCatalog({ state, revision });
117
+ },
118
+ })
119
+ : undefined;
84
120
  const server = createServer(async (request, response) => {
85
121
  try {
122
+ if (mcpEndpoint != null && request.url === HOST_TOOL_MCP_ENDPOINT_PATH) {
123
+ if (
124
+ !credentialsMatch({
125
+ expected: credential,
126
+ actual: request.headers.authorization,
127
+ })
128
+ ) {
129
+ throw new RelayRequestError({
130
+ status: 401,
131
+ message: 'Invalid host tool relay credential.',
132
+ });
133
+ }
134
+ await mcpEndpoint.handleRequest({
135
+ request,
136
+ response,
137
+ ...(request.method === 'POST'
138
+ ? { body: await readJSONBody({ request }) }
139
+ : {}),
140
+ });
141
+ return;
142
+ }
86
143
  const result = await handleRequest({
87
144
  request,
88
145
  credential,
@@ -94,6 +151,10 @@ export async function startHostToolRelay({
94
151
  response.writeHead(200, { 'content-type': 'application/json' });
95
152
  response.end(JSON.stringify(result));
96
153
  } catch (error) {
154
+ if (response.headersSent) {
155
+ response.end();
156
+ return;
157
+ }
97
158
  const status = error instanceof RelayRequestError ? error.status : 500;
98
159
  response.writeHead(status, { 'content-type': 'application/json' });
99
160
  response.end(
@@ -108,6 +169,11 @@ export async function startHostToolRelay({
108
169
 
109
170
  return {
110
171
  url: `http://127.0.0.1:${address.port}/invoke`,
172
+ ...(mcpEndpoint == null
173
+ ? {}
174
+ : {
175
+ mcpUrl: `http://127.0.0.1:${address.port}${HOST_TOOL_MCP_ENDPOINT_PATH}`,
176
+ }),
111
177
  credential,
112
178
  bindTurn: ({ turn }) => {
113
179
  if (activeTurn != null && activeTurn !== turn) {
@@ -127,6 +193,10 @@ export async function startHostToolRelay({
127
193
  state.fingerprint = fingerprint;
128
194
  state.revision += 1;
129
195
  resolveCatalogChanges({ state });
196
+ void mcpEndpoint?.updateCatalog({
197
+ revision: state.revision,
198
+ tools: state.tools,
199
+ });
130
200
  return { changed: true, revision: state.revision };
131
201
  },
132
202
  waitForCatalogRefresh: ({ revision, timeoutMs }) =>
@@ -136,7 +206,10 @@ export async function startHostToolRelay({
136
206
  state.closed = true;
137
207
  resolveCatalogChanges({ state });
138
208
  resolveRefreshWaiters({ state, closing: true });
139
- closePromise = closeServer({ server });
209
+ closePromise = (async () => {
210
+ await mcpEndpoint?.close();
211
+ await closeServer({ server });
212
+ })();
140
213
  return closePromise;
141
214
  },
142
215
  };
@@ -243,14 +316,21 @@ function handleCatalogSeen({
243
316
  message: 'Invalid host tool catalog acknowledgment.',
244
317
  });
245
318
  }
246
- state.servedRevision = Math.max(
247
- state.servedRevision,
248
- body.revision as number,
249
- );
250
- resolveRefreshWaiters({ state, closing: false });
319
+ acknowledgeCatalog({ state, revision: body.revision as number });
251
320
  return { acknowledged: true };
252
321
  }
253
322
 
323
+ function acknowledgeCatalog({
324
+ state,
325
+ revision,
326
+ }: {
327
+ state: CatalogState;
328
+ revision: number;
329
+ }): void {
330
+ state.servedRevision = Math.max(state.servedRevision, revision);
331
+ resolveRefreshWaiters({ state, closing: false });
332
+ }
333
+
254
334
  async function handleInvocation({
255
335
  body,
256
336
  state,
@@ -7,7 +7,6 @@ import {
7
7
  import * as acp from '@agentclientprotocol/sdk';
8
8
  import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
9
9
  import { readFile, writeFile } from 'node:fs/promises';
10
- import { fileURLToPath } from 'node:url';
11
10
  import { Readable, Writable } from 'node:stream';
12
11
  import { argv, env as processEnv } from 'node:process';
13
12
  import {
@@ -38,6 +37,7 @@ import {
38
37
  type HostToolRelay,
39
38
  type HostToolRelayTurn,
40
39
  } from './host-tool-relay';
40
+ import { createHostToolMcpServerDefinition } from './host-tool-mcp-definition';
41
41
  import {
42
42
  promptAndRefreshInitialHostToolCatalog,
43
43
  refreshHostToolCatalog,
@@ -104,6 +104,13 @@ let coldRestorationMethod: ACPSessionRestorationMethod | undefined;
104
104
  let activePermissionController:
105
105
  | ReturnType<typeof createACPPermissionController>
106
106
  | undefined;
107
+ let activeQuestionRequest:
108
+ | {
109
+ method: string;
110
+ turn: BridgeTurn;
111
+ emitStreamEvent: ReturnType<typeof createEmitStreamEvent>;
112
+ }
113
+ | undefined;
107
114
 
108
115
  await runBridge<StartMessage>({
109
116
  bridgeType,
@@ -252,15 +259,36 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
252
259
  const emitStreamEvent = createEmitStreamEvent({
253
260
  emit: event => turn.emit(event as BridgeEvent),
254
261
  emitToolCallCandidate: ({ toolCall }) => {
262
+ const requestId = crypto.randomUUID();
255
263
  turn.emit({
256
264
  type: 'acp-tool-call-candidate',
265
+ requestId,
257
266
  toolCall,
258
267
  });
268
+ void turn.requestToolResult(requestId).then(result => {
269
+ if (
270
+ result.output != null &&
271
+ typeof result.output === 'object' &&
272
+ 'suppress' in result.output &&
273
+ result.output.suppress === true
274
+ ) {
275
+ emitStreamEvent.suppressToolCall({
276
+ toolCallId: toolCall.toolCallId,
277
+ });
278
+ }
279
+ });
259
280
  },
260
281
  builtinTools: start.builtinTools,
261
282
  hostToolServerName: HOST_TOOL_MCP_SERVER_NAME,
262
283
  hostTools: start.tools ?? [],
263
284
  });
285
+ if (bridgeConfiguration.askUserQuestionsRequestMethod != null) {
286
+ activeQuestionRequest = {
287
+ method: bridgeConfiguration.askUserQuestionsRequestMethod,
288
+ turn,
289
+ emitStreamEvent,
290
+ };
291
+ }
264
292
  const permissionController = createACPPermissionController({
265
293
  turn,
266
294
  sessionId: activeSession.sessionId,
@@ -353,6 +381,9 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
353
381
  if (emitStreamEvent.message({ message })) return;
354
382
  }
355
383
  } finally {
384
+ if (activeQuestionRequest?.turn === turn) {
385
+ activeQuestionRequest = undefined;
386
+ }
356
387
  permissionController.cancelAll();
357
388
  activePermissionController = undefined;
358
389
  activeHostToolRelay.unbindTurn({ turn: relayTurn });
@@ -458,7 +489,7 @@ async function ensureSession({
458
489
  stream: acp.ndJsonStream(input, output),
459
490
  });
460
491
  streamCapture = capturedStream.capture;
461
- connection = acp
492
+ let client = acp
462
493
  .client({ name: clientApp.name })
463
494
  .onRequest(
464
495
  acp.methods.client.session.requestPermission,
@@ -475,8 +506,58 @@ async function ensureSession({
475
506
  notification: params,
476
507
  update: params.update,
477
508
  });
478
- })
479
- .connect(capturedStream.stream);
509
+ });
510
+ if (bridgeConfiguration.askUserQuestionsRequestMethod != null) {
511
+ const method = bridgeConfiguration.askUserQuestionsRequestMethod;
512
+ client = client.onRequest<unknown, unknown>(
513
+ method,
514
+ value => value,
515
+ async ({ params }) => {
516
+ const active = activeQuestionRequest;
517
+ if (active == null || active.method !== method) {
518
+ throw acp.RequestError.methodNotFound(method);
519
+ }
520
+ const requestId = crypto.randomUUID();
521
+ const nativeToolCallId = getNativeQuestionToolCallId({ params });
522
+ active.turn.emit({
523
+ type: 'acp-question-request',
524
+ requestId,
525
+ nativeRequest: params,
526
+ ...(nativeToolCallId == null
527
+ ? {}
528
+ : {
529
+ nativeToolCall:
530
+ active.emitStreamEvent.getToolCall({
531
+ toolCallId: nativeToolCallId,
532
+ }) ?? undefined,
533
+ }),
534
+ });
535
+ const result = await active.turn.requestToolResult(requestId);
536
+ const classification = result.output as
537
+ | { type: 'unhandled' }
538
+ | { type: 'handled'; toolCallId: string }
539
+ | undefined;
540
+ if (classification?.type !== 'handled') {
541
+ throw acp.RequestError.methodNotFound(method);
542
+ }
543
+ active.emitStreamEvent.suppressToolCall({
544
+ toolCallId: nativeToolCallId ?? classification.toolCallId,
545
+ });
546
+ try {
547
+ const nativeResult = await active.turn.requestToolResult(
548
+ classification.toolCallId,
549
+ );
550
+ return nativeResult.output;
551
+ } finally {
552
+ active.turn.emit({
553
+ type: 'acp-question-resolved',
554
+ requestId,
555
+ });
556
+ }
557
+ },
558
+ );
559
+ }
560
+ connection = client.connect(capturedStream.stream);
480
561
 
481
562
  const initializeRequest = createACPInitializeRequest({
482
563
  protocolVersion: acp.PROTOCOL_VERSION,
@@ -512,34 +593,27 @@ async function ensureSession({
512
593
  initialization,
513
594
  });
514
595
  const tools = start.tools ?? [];
596
+ const mcpTransport = bridgeConfiguration.hostToolMcpTransport ?? 'stdio';
515
597
  const catalogPath = `${bridgeStateDir}/host-tools.json`;
516
- await writeFile(catalogPath, JSON.stringify(tools), { mode: 0o600 });
598
+ if (mcpTransport === 'stdio') {
599
+ await writeFile(catalogPath, JSON.stringify(tools), { mode: 0o600 });
600
+ }
517
601
  hostToolRelay = await startHostToolRelay({
518
602
  tools,
519
603
  serverName: HOST_TOOL_MCP_SERVER_NAME,
604
+ mcpTransport,
520
605
  });
521
606
 
522
607
  const mcpServers: acp.McpServer[] = [
523
608
  ...externalMcpServers,
524
- {
525
- name: HOST_TOOL_MCP_SERVER_NAME,
526
- command: process.execPath,
527
- args: [fileURLToPath(new URL('./host-tool-mcp.mjs', import.meta.url))],
528
- env: [
529
- {
530
- name: 'AI_SDK_ACP_HOST_TOOLS_FILE',
531
- value: catalogPath,
532
- },
533
- {
534
- name: 'AI_SDK_ACP_HOST_TOOL_RELAY_URL',
535
- value: hostToolRelay.url,
536
- },
537
- {
538
- name: 'AI_SDK_ACP_HOST_TOOL_RELAY_CREDENTIAL',
539
- value: hostToolRelay.credential,
540
- },
541
- ],
542
- },
609
+ createHostToolMcpServerDefinition({
610
+ mcpTransport,
611
+ relay: hostToolRelay,
612
+ serverName: HOST_TOOL_MCP_SERVER_NAME,
613
+ catalogPath,
614
+ initialization,
615
+ harnessId: bridgeType,
616
+ }),
543
617
  ];
544
618
  let createdSession: ACPActiveSession;
545
619
  if (start.recoveryMode?.type === 'lossy-rerun') {
@@ -627,6 +701,19 @@ async function ensureSession({
627
701
  return { initialHostToolCatalogRefreshRequired: tools.length > 0 };
628
702
  }
629
703
 
704
+ function getNativeQuestionToolCallId({
705
+ params,
706
+ }: {
707
+ params: unknown;
708
+ }): string | undefined {
709
+ if (!isRecord(params)) return undefined;
710
+ if (typeof params.toolCallId === 'string') return params.toolCallId;
711
+ const nestedParams = params.params;
712
+ return isRecord(nestedParams) && typeof nestedParams.toolCallId === 'string'
713
+ ? nestedParams.toolCallId
714
+ : undefined;
715
+ }
716
+
630
717
  function createExternalMcpServers({
631
718
  mcpServers,
632
719
  initialization,
@@ -33,6 +33,18 @@ export async function resolveACPInstructionConfiguration({
33
33
  return { sessionMeta, environment: resolvedEnvironment };
34
34
  }
35
35
 
36
+ if (instructionMapping.type === 'filesystem') {
37
+ if (
38
+ typeof instructionMapping.path !== 'string' ||
39
+ instructionMapping.path.trim().length === 0
40
+ ) {
41
+ throw new Error(
42
+ 'ACP instruction mapping filesystem path must be a non-empty string.',
43
+ );
44
+ }
45
+ return { sessionMeta, environment: resolvedEnvironment };
46
+ }
47
+
36
48
  assertSafePath({ path: instructionMapping.path });
37
49
 
38
50
  if (instructionMapping.type === 'session-meta') {
@@ -68,6 +68,7 @@ export function createACPStreamTranslator({
68
68
  output: unknown;
69
69
  isError?: boolean;
70
70
  }) => void;
71
+ getToolCall: (options: { toolCallId: string }) => ACPToolCall | undefined;
71
72
  } {
72
73
  let openBlock:
73
74
  | {
@@ -215,6 +216,7 @@ export function createACPStreamTranslator({
215
216
  builtinTools,
216
217
  builtinToolsByName,
217
218
  });
219
+ emitToolCallCandidate?.({ toolCall: createACPToolCall({ state }) });
218
220
  if (
219
221
  !forceEmit &&
220
222
  builtin?.inputSchema != null &&
@@ -238,7 +240,6 @@ export function createACPStreamTranslator({
238
240
  programmaticName,
239
241
  toolCallId: state.toolCallId,
240
242
  });
241
- emitToolCallCandidate?.({ toolCall: createACPToolCall({ state }) });
242
243
  }
243
244
  emit({
244
245
  type: 'tool-call',
@@ -381,6 +382,10 @@ export function createACPStreamTranslator({
381
382
  close: closeBlock,
382
383
  hostToolCall,
383
384
  hostToolResult,
385
+ getToolCall: ({ toolCallId }) => {
386
+ const state = toolStates.get(toolCallId);
387
+ return state == null ? undefined : createACPToolCall({ state });
388
+ },
384
389
  finish: response => {
385
390
  if (finished) return;
386
391
  finished = true;
@@ -521,7 +526,41 @@ function resolveBuiltinTool({
521
526
  const schemaMatches = builtinTools.filter(tool =>
522
527
  matchesBuiltinToolInput({ rawInput, inputSchema: tool.inputSchema }),
523
528
  );
524
- return schemaMatches.length === 1 ? schemaMatches[0] : undefined;
529
+ if (schemaMatches.length <= 1) return schemaMatches[0];
530
+ return findMostSpecificSchemaMatch({ rawInput, schemaMatches });
531
+ }
532
+
533
+ /*
534
+ * Multiple schemas can pass `matchesBuiltinToolInput` at once: a tool whose
535
+ * only required field is optional on a richer sibling tool (e.g. `bash`'s
536
+ * optional `shellId` versus `stop_bash`'s required `shellId`) matches any
537
+ * rawInput the richer tool also matches. Preferring whichever candidate
538
+ * declares the most of rawInput's own keys resolves that in favor of the
539
+ * richer, more specific schema, while two equally specific schemas (e.g.
540
+ * identical schemas under different names) still resolve to `undefined`.
541
+ */
542
+ function findMostSpecificSchemaMatch({
543
+ rawInput,
544
+ schemaMatches,
545
+ }: {
546
+ rawInput: unknown;
547
+ schemaMatches: ReadonlyArray<ACPBuiltinToolMapping>;
548
+ }): ACPBuiltinToolMapping | undefined {
549
+ if (!isRecord(rawInput)) return undefined;
550
+ const rawInputKeys = Object.keys(rawInput);
551
+ const coverageOf = (tool: ACPBuiltinToolMapping): number => {
552
+ const properties = isRecord(tool.inputSchema)
553
+ ? isRecord(tool.inputSchema.properties)
554
+ ? tool.inputSchema.properties
555
+ : {}
556
+ : {};
557
+ return rawInputKeys.filter(key => key in properties).length;
558
+ };
559
+ const highestCoverage = Math.max(...schemaMatches.map(coverageOf));
560
+ const mostSpecificMatches = schemaMatches.filter(
561
+ tool => coverageOf(tool) === highestCoverage,
562
+ );
563
+ return mostSpecificMatches.length === 1 ? mostSpecificMatches[0] : undefined;
525
564
  }
526
565
 
527
566
  function findBuiltinToolByTitle({
package/src/v1/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { createACPV1 } from './acp-v1-harness';
2
2
  export type {
3
+ ACPAskUserQuestionsSettings,
3
4
  ACPCredentialBrokering,
4
5
  ACPAuthentication,
5
6
  ACPAuthenticationMode,
7
+ ACPHostToolMCPTransport,
6
8
  ACPInstallCommandSource,
7
9
  ACPInstructionMapping,
8
10
  ACPModelMapping,