@ai-sdk/harness-acp 1.0.39 → 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.
@@ -0,0 +1,182 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
5
+ import type { HarnessV1BridgeToolWire } from '@ai-sdk/harness';
6
+ import {
7
+ createHostToolMCPServer,
8
+ type HostToolMCPInvocationResult,
9
+ type HostToolMCPServer,
10
+ } from './host-tool-mcp-server';
11
+
12
+ export const HOST_TOOL_MCP_ENDPOINT_PATH = '/mcp';
13
+
14
+ export type HostToolMCPHttpEndpoint = {
15
+ handleRequest(options: {
16
+ request: IncomingMessage;
17
+ response: ServerResponse;
18
+ body?: unknown;
19
+ }): Promise<void>;
20
+ updateCatalog(options: {
21
+ revision: number;
22
+ tools: ReadonlyArray<HarnessV1BridgeToolWire>;
23
+ }): Promise<void>;
24
+ close(): Promise<void>;
25
+ };
26
+
27
+ type EndpointSession = {
28
+ readonly transport: StreamableHTTPServerTransport;
29
+ readonly hostToolServer: HostToolMCPServer;
30
+ };
31
+
32
+ /*
33
+ * The ACP host tool catalog is shared by every MCP session opened against this
34
+ * endpoint. Each session needs its own MCP `Server` instance because a server
35
+ * can be connected to a single transport at a time, so catalog updates are
36
+ * fanned out to all live sessions and also retained for sessions opened later.
37
+ */
38
+ export function createHostToolMCPHttpEndpoint({
39
+ tools,
40
+ revision,
41
+ invoke,
42
+ onListTools,
43
+ }: {
44
+ tools: ReadonlyArray<HarnessV1BridgeToolWire>;
45
+ revision: number;
46
+ invoke: (options: {
47
+ toolName: string;
48
+ input: Readonly<Record<string, unknown>>;
49
+ catalogRevision: number;
50
+ }) => Promise<HostToolMCPInvocationResult>;
51
+ onListTools: (options: { revision: number }) => Promise<void>;
52
+ }): HostToolMCPHttpEndpoint {
53
+ let catalog: {
54
+ revision: number;
55
+ tools: ReadonlyArray<HarnessV1BridgeToolWire>;
56
+ } = { revision, tools: [...tools] };
57
+ const sessions = new Map<string, EndpointSession>();
58
+ let closed = false;
59
+
60
+ async function openSession({
61
+ request,
62
+ response,
63
+ body,
64
+ }: {
65
+ request: IncomingMessage;
66
+ response: ServerResponse;
67
+ body: unknown;
68
+ }): Promise<void> {
69
+ const hostToolServer = createHostToolMCPServer({
70
+ tools: catalog.tools,
71
+ revision: catalog.revision,
72
+ invoke,
73
+ onListTools,
74
+ });
75
+ const transport = new StreamableHTTPServerTransport({
76
+ sessionIdGenerator: () => randomUUID(),
77
+ onsessioninitialized: sessionId => {
78
+ sessions.set(sessionId, { transport, hostToolServer });
79
+ },
80
+ onsessionclosed: sessionId => {
81
+ sessions.delete(sessionId);
82
+ },
83
+ });
84
+ transport.onclose = () => {
85
+ const { sessionId } = transport;
86
+ if (sessionId != null) sessions.delete(sessionId);
87
+ };
88
+ await hostToolServer.server.connect(transport);
89
+ await transport.handleRequest(request, response, body);
90
+ }
91
+
92
+ return {
93
+ handleRequest: async ({ request, response, body }) => {
94
+ if (closed) {
95
+ respondWithJSONRPCError({
96
+ response,
97
+ status: 503,
98
+ code: -32000,
99
+ message: 'The host tool MCP endpoint is closed.',
100
+ });
101
+ return;
102
+ }
103
+ const sessionId = readSessionId({ request });
104
+ if (sessionId != null) {
105
+ const session = sessions.get(sessionId);
106
+ if (session == null) {
107
+ respondWithJSONRPCError({
108
+ response,
109
+ status: 404,
110
+ code: -32001,
111
+ message: 'Unknown host tool MCP session.',
112
+ });
113
+ return;
114
+ }
115
+ await session.transport.handleRequest(request, response, body);
116
+ return;
117
+ }
118
+ if (request.method !== 'POST' || !isInitializeRequest(body)) {
119
+ respondWithJSONRPCError({
120
+ response,
121
+ status: 400,
122
+ code: -32000,
123
+ message:
124
+ 'Host tool MCP requests without a session id must be an initialize request.',
125
+ });
126
+ return;
127
+ }
128
+ await openSession({ request, response, body });
129
+ },
130
+ updateCatalog: async ({ revision: nextRevision, tools: nextTools }) => {
131
+ catalog = { revision: nextRevision, tools: [...nextTools] };
132
+ await Promise.all(
133
+ [...sessions.values()].map(session =>
134
+ session.hostToolServer.updateCatalog({
135
+ revision: nextRevision,
136
+ tools: nextTools,
137
+ }),
138
+ ),
139
+ );
140
+ },
141
+ close: async () => {
142
+ if (closed) return;
143
+ closed = true;
144
+ const live = [...sessions.values()];
145
+ sessions.clear();
146
+ await Promise.all(
147
+ live.map(session => session.hostToolServer.server.close()),
148
+ );
149
+ },
150
+ };
151
+ }
152
+
153
+ function readSessionId({
154
+ request,
155
+ }: {
156
+ request: IncomingMessage;
157
+ }): string | undefined {
158
+ const value = request.headers['mcp-session-id'];
159
+ const sessionId = Array.isArray(value) ? value[0] : value;
160
+ return sessionId == null || sessionId.length === 0 ? undefined : sessionId;
161
+ }
162
+
163
+ function respondWithJSONRPCError({
164
+ response,
165
+ status,
166
+ code,
167
+ message,
168
+ }: {
169
+ response: ServerResponse;
170
+ status: number;
171
+ code: number;
172
+ message: string;
173
+ }): void {
174
+ response.writeHead(status, { 'content-type': 'application/json' });
175
+ response.end(
176
+ JSON.stringify({
177
+ jsonrpc: '2.0',
178
+ error: { code, message },
179
+ id: null,
180
+ }),
181
+ );
182
+ }
@@ -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,
@@ -593,34 +593,27 @@ async function ensureSession({
593
593
  initialization,
594
594
  });
595
595
  const tools = start.tools ?? [];
596
+ const mcpTransport = bridgeConfiguration.hostToolMcpTransport ?? 'stdio';
596
597
  const catalogPath = `${bridgeStateDir}/host-tools.json`;
597
- await writeFile(catalogPath, JSON.stringify(tools), { mode: 0o600 });
598
+ if (mcpTransport === 'stdio') {
599
+ await writeFile(catalogPath, JSON.stringify(tools), { mode: 0o600 });
600
+ }
598
601
  hostToolRelay = await startHostToolRelay({
599
602
  tools,
600
603
  serverName: HOST_TOOL_MCP_SERVER_NAME,
604
+ mcpTransport,
601
605
  });
602
606
 
603
607
  const mcpServers: acp.McpServer[] = [
604
608
  ...externalMcpServers,
605
- {
606
- name: HOST_TOOL_MCP_SERVER_NAME,
607
- command: process.execPath,
608
- args: [fileURLToPath(new URL('./host-tool-mcp.mjs', import.meta.url))],
609
- env: [
610
- {
611
- name: 'AI_SDK_ACP_HOST_TOOLS_FILE',
612
- value: catalogPath,
613
- },
614
- {
615
- name: 'AI_SDK_ACP_HOST_TOOL_RELAY_URL',
616
- value: hostToolRelay.url,
617
- },
618
- {
619
- name: 'AI_SDK_ACP_HOST_TOOL_RELAY_CREDENTIAL',
620
- value: hostToolRelay.credential,
621
- },
622
- ],
623
- },
609
+ createHostToolMcpServerDefinition({
610
+ mcpTransport,
611
+ relay: hostToolRelay,
612
+ serverName: HOST_TOOL_MCP_SERVER_NAME,
613
+ catalogPath,
614
+ initialization,
615
+ harnessId: bridgeType,
616
+ }),
624
617
  ];
625
618
  let createdSession: ACPActiveSession;
626
619
  if (start.recoveryMode?.type === 'lossy-rerun') {
@@ -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') {
@@ -526,7 +526,41 @@ function resolveBuiltinTool({
526
526
  const schemaMatches = builtinTools.filter(tool =>
527
527
  matchesBuiltinToolInput({ rawInput, inputSchema: tool.inputSchema }),
528
528
  );
529
- 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;
530
564
  }
531
565
 
532
566
  function findBuiltinToolByTitle({
package/src/v1/index.ts CHANGED
@@ -4,6 +4,7 @@ export type {
4
4
  ACPCredentialBrokering,
5
5
  ACPAuthentication,
6
6
  ACPAuthenticationMode,
7
+ ACPHostToolMCPTransport,
7
8
  ACPInstallCommandSource,
8
9
  ACPInstructionMapping,
9
10
  ACPModelMapping,