@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.
- package/CHANGELOG.md +24 -0
- package/README.md +4 -3
- package/dist/bridge/host-tool-mcp.mjs +1 -1
- package/dist/bridge/index.mjs +556 -43
- package/dist/bridge/index.mjs.map +1 -1
- package/dist/index.d.ts +56 -6
- package/dist/index.js +280 -72
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/acp-harness.ts +51 -9
- package/src/index.ts +2 -0
- package/src/v1/acp-v1-bridge-protocol.ts +19 -0
- package/src/v1/acp-v1-harness.ts +307 -19
- package/src/v1/acp-v1-settings.ts +42 -0
- package/src/v1/acp-v1-skills.ts +4 -65
- package/src/v1/bridge/acp-v1-bridge-environment.ts +16 -0
- package/src/v1/bridge/create-emit-stream-event.ts +6 -0
- package/src/v1/bridge/host-tool-correlation.ts +34 -0
- package/src/v1/bridge/host-tool-mcp-definition.ts +69 -0
- package/src/v1/bridge/host-tool-mcp-http.ts +182 -0
- package/src/v1/bridge/host-tool-relay.ts +87 -7
- package/src/v1/bridge/index.ts +111 -24
- package/src/v1/bridge/instruction-mapping.ts +12 -0
- package/src/v1/bridge/stream-translator.ts +41 -2
- package/src/v1/index.ts +2 -0
|
@@ -3,7 +3,9 @@ import type {
|
|
|
3
3
|
HarnessV1CredentialForwarding,
|
|
4
4
|
HarnessV1PermissionMode,
|
|
5
5
|
HarnessV1RequestTransformation,
|
|
6
|
+
HarnessV1StreamPart,
|
|
6
7
|
} from '@ai-sdk/harness';
|
|
8
|
+
import type { ToolResultPart } from '@ai-sdk/provider-utils';
|
|
7
9
|
import type { ACPToolCall } from '../acp-tool-call';
|
|
8
10
|
|
|
9
11
|
export type ACPSerializablePrimitive = string | number | boolean | null;
|
|
@@ -88,9 +90,11 @@ export type ACPModelMapping =
|
|
|
88
90
|
export type ACPCredentialBrokering = ({
|
|
89
91
|
env,
|
|
90
92
|
sandboxEnv,
|
|
93
|
+
headers,
|
|
91
94
|
}: {
|
|
92
95
|
env: Readonly<Record<string, string>>;
|
|
93
96
|
sandboxEnv?: Readonly<Record<string, string>>;
|
|
97
|
+
headers?: Readonly<Record<string, string>>;
|
|
94
98
|
}) => ReadonlyArray<HarnessV1RequestTransformation>;
|
|
95
99
|
|
|
96
100
|
export type ACPPermissionModeTarget =
|
|
@@ -122,6 +126,10 @@ export type ACPInstructionMapping =
|
|
|
122
126
|
readonly type: 'launch-env-json';
|
|
123
127
|
readonly variable: string;
|
|
124
128
|
readonly path: ReadonlyArray<string>;
|
|
129
|
+
}
|
|
130
|
+
| {
|
|
131
|
+
readonly type: 'filesystem';
|
|
132
|
+
readonly path: string;
|
|
125
133
|
};
|
|
126
134
|
|
|
127
135
|
export type ACPOutputSchemaMapping = {
|
|
@@ -129,6 +137,31 @@ export type ACPOutputSchemaMapping = {
|
|
|
129
137
|
readonly path: ReadonlyArray<string>;
|
|
130
138
|
};
|
|
131
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Transport used for the harness-owned MCP server that exposes host tools to
|
|
142
|
+
* an ACP implementation.
|
|
143
|
+
*/
|
|
144
|
+
export type ACPHostToolMCPTransport = 'stdio' | 'http';
|
|
145
|
+
|
|
146
|
+
export type ACPAskUserQuestionsSettings = {
|
|
147
|
+
readonly requestMethod: string;
|
|
148
|
+
readonly isNativeToolCall?: (options: {
|
|
149
|
+
nativeToolCall: ACPToolCall;
|
|
150
|
+
}) => boolean;
|
|
151
|
+
readonly fromNativeRequest: (options: {
|
|
152
|
+
nativeRequest: unknown;
|
|
153
|
+
nativeToolCall?: ACPToolCall;
|
|
154
|
+
}) => Extract<HarnessV1StreamPart, { type: 'tool-call' }> | null;
|
|
155
|
+
readonly toNativeResponse: (options: {
|
|
156
|
+
nativeRequest: unknown;
|
|
157
|
+
toolResult: ToolResultPart;
|
|
158
|
+
}) => unknown;
|
|
159
|
+
readonly matchesNativeRequest?: (options: {
|
|
160
|
+
previousNativeRequest: unknown;
|
|
161
|
+
nativeRequest: unknown;
|
|
162
|
+
}) => boolean;
|
|
163
|
+
};
|
|
164
|
+
|
|
132
165
|
export type ACPV1Settings = {
|
|
133
166
|
readonly version?: 'v1';
|
|
134
167
|
readonly harnessId: string;
|
|
@@ -179,6 +212,15 @@ export type ACPV1Settings = {
|
|
|
179
212
|
* below the ACP session prompt's `_meta` field.
|
|
180
213
|
*/
|
|
181
214
|
readonly outputSchemaMapping?: ACPOutputSchemaMapping;
|
|
215
|
+
/**
|
|
216
|
+
* Transport used for the harness-owned MCP server that exposes host tools to
|
|
217
|
+
* the ACP implementation. Defaults to `stdio`. Set this to `http` for
|
|
218
|
+
* implementations that only accept HTTP or SSE MCP servers from the client,
|
|
219
|
+
* which requires the implementation to advertise
|
|
220
|
+
* `agentCapabilities.mcpCapabilities.http`.
|
|
221
|
+
*/
|
|
222
|
+
readonly hostToolMcpTransport?: ACPHostToolMCPTransport;
|
|
223
|
+
readonly askUserQuestions?: ACPAskUserQuestionsSettings;
|
|
182
224
|
readonly permissionModeMapping?: ACPPermissionModeMapping;
|
|
183
225
|
readonly session?: {
|
|
184
226
|
readonly meta?: Readonly<Record<string, ACPSerializableValue>>;
|
package/src/v1/acp-v1-skills.ts
CHANGED
|
@@ -1,74 +1,35 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import type { HarnessV1Skill } from '@ai-sdk/harness';
|
|
4
|
-
import { writeSkills, type WriteSkillsResult } from '@ai-sdk/harness/utils';
|
|
5
|
-
import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
|
|
6
4
|
|
|
7
|
-
const ACP_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
5
|
+
export const ACP_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
8
6
|
export const DEFAULT_ACP_SKILLS_DIRECTORY = '.agents/skills';
|
|
9
7
|
|
|
10
8
|
export function resolveACPPrivateSessionDirectory({
|
|
11
9
|
sandboxHomeDir,
|
|
12
|
-
sessionWorkDir,
|
|
13
10
|
harnessId,
|
|
14
11
|
sessionId,
|
|
15
12
|
}: {
|
|
16
13
|
sandboxHomeDir: string;
|
|
17
|
-
sessionWorkDir: string;
|
|
18
14
|
harnessId: string;
|
|
19
15
|
sessionId: string;
|
|
20
16
|
}): string {
|
|
21
17
|
const sessionKey = createHash('sha256').update(sessionId).digest('hex');
|
|
22
|
-
|
|
18
|
+
return path.posix.join(
|
|
23
19
|
sandboxHomeDir,
|
|
24
20
|
'.ai-sdk',
|
|
25
21
|
'harness-acp',
|
|
26
22
|
harnessId,
|
|
27
23
|
sessionKey,
|
|
28
24
|
);
|
|
29
|
-
assertOutsideSessionWorkDir({ rootDir, sessionWorkDir });
|
|
30
|
-
return rootDir;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function materializeACPSkills({
|
|
34
|
-
sandbox,
|
|
35
|
-
rootDir,
|
|
36
|
-
sessionWorkDir,
|
|
37
|
-
skills,
|
|
38
|
-
abortSignal,
|
|
39
|
-
}: {
|
|
40
|
-
sandbox: Experimental_SandboxSession;
|
|
41
|
-
rootDir: string;
|
|
42
|
-
sessionWorkDir: string;
|
|
43
|
-
skills: ReadonlyArray<HarnessV1Skill>;
|
|
44
|
-
abortSignal?: AbortSignal;
|
|
45
|
-
}): Promise<WriteSkillsResult> {
|
|
46
|
-
validateACPSkills({ skills });
|
|
47
|
-
assertOutsideSessionWorkDir({ rootDir, sessionWorkDir });
|
|
48
|
-
|
|
49
|
-
return writeSkills({
|
|
50
|
-
sandbox,
|
|
51
|
-
rootDir,
|
|
52
|
-
skills,
|
|
53
|
-
abortSignal,
|
|
54
|
-
skillNamePattern: ACP_SKILL_NAME_PATTERN,
|
|
55
|
-
invalidSkillNameMessage: ({ name }) =>
|
|
56
|
-
`Invalid ACP skill name ${JSON.stringify(name)}: expected a kebab-case slug.`,
|
|
57
|
-
invalidSkillFilePathMessage: ({ skillName, filePath }) =>
|
|
58
|
-
`Invalid ACP skill file path ${JSON.stringify(filePath)} for skill ${JSON.stringify(
|
|
59
|
-
skillName,
|
|
60
|
-
)}: expected a relative POSIX path without traversal.`,
|
|
61
|
-
});
|
|
62
25
|
}
|
|
63
26
|
|
|
64
27
|
export function resolveACPSkillsDirectory({
|
|
65
28
|
implementationHomeDir,
|
|
66
29
|
skillsDirectory = DEFAULT_ACP_SKILLS_DIRECTORY,
|
|
67
|
-
sessionWorkDir,
|
|
68
30
|
}: {
|
|
69
31
|
implementationHomeDir: string;
|
|
70
32
|
skillsDirectory?: string;
|
|
71
|
-
sessionWorkDir: string;
|
|
72
33
|
}): string {
|
|
73
34
|
const containsTraversal = skillsDirectory
|
|
74
35
|
.split(/[\\/]/)
|
|
@@ -90,12 +51,10 @@ export function resolveACPSkillsDirectory({
|
|
|
90
51
|
);
|
|
91
52
|
}
|
|
92
53
|
|
|
93
|
-
|
|
94
|
-
assertOutsideSessionWorkDir({ rootDir, sessionWorkDir });
|
|
95
|
-
return rootDir;
|
|
54
|
+
return path.posix.join(implementationHomeDir, normalizedDirectory);
|
|
96
55
|
}
|
|
97
56
|
|
|
98
|
-
function validateACPSkills({
|
|
57
|
+
export function validateACPSkills({
|
|
99
58
|
skills,
|
|
100
59
|
}: {
|
|
101
60
|
skills: ReadonlyArray<HarnessV1Skill>;
|
|
@@ -174,23 +133,3 @@ function validateACPAttachedFilePath({
|
|
|
174
133
|
}
|
|
175
134
|
return normalizedPath;
|
|
176
135
|
}
|
|
177
|
-
|
|
178
|
-
function assertOutsideSessionWorkDir({
|
|
179
|
-
rootDir,
|
|
180
|
-
sessionWorkDir,
|
|
181
|
-
}: {
|
|
182
|
-
rootDir: string;
|
|
183
|
-
sessionWorkDir: string;
|
|
184
|
-
}): void {
|
|
185
|
-
const relative = path.posix.relative(sessionWorkDir, rootDir);
|
|
186
|
-
const isInside =
|
|
187
|
-
relative === '' ||
|
|
188
|
-
(!relative.startsWith('../') && !path.posix.isAbsolute(relative));
|
|
189
|
-
if (isInside) {
|
|
190
|
-
throw new Error(
|
|
191
|
-
`ACP skill directory ${JSON.stringify(rootDir)} must be outside sessionWorkDir ${JSON.stringify(
|
|
192
|
-
sessionWorkDir,
|
|
193
|
-
)}.`,
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod/v4';
|
|
2
2
|
import type {
|
|
3
3
|
ACPAuthentication,
|
|
4
|
+
ACPHostToolMCPTransport,
|
|
4
5
|
ACPProfileValue,
|
|
5
6
|
ACPSerializableValue,
|
|
6
7
|
} from '../acp-v1-settings';
|
|
@@ -33,6 +34,11 @@ const profileValueSchema: z.ZodType<ACPProfileValue> = z.lazy(() =>
|
|
|
33
34
|
]),
|
|
34
35
|
);
|
|
35
36
|
|
|
37
|
+
const hostToolMcpTransportSchema: z.ZodType<ACPHostToolMCPTransport> = z.enum([
|
|
38
|
+
'stdio',
|
|
39
|
+
'http',
|
|
40
|
+
]);
|
|
41
|
+
|
|
36
42
|
const serializableRecordSchema = z.record(z.string(), serializableValueSchema);
|
|
37
43
|
const profileRecordSchema = z.record(z.string(), profileValueSchema);
|
|
38
44
|
|
|
@@ -60,6 +66,8 @@ export type ACPBridgeConfiguration = {
|
|
|
60
66
|
readonly providerEnvironment?: Readonly<Record<string, string>>;
|
|
61
67
|
readonly sessionMeta?: Readonly<Record<string, ACPSerializableValue>>;
|
|
62
68
|
readonly clientCapabilities?: Readonly<Record<string, ACPSerializableValue>>;
|
|
69
|
+
readonly askUserQuestionsRequestMethod?: string;
|
|
70
|
+
readonly hostToolMcpTransport?: ACPHostToolMCPTransport;
|
|
63
71
|
};
|
|
64
72
|
|
|
65
73
|
const bridgeConfigurationSchema: z.ZodType<ACPBridgeConfiguration> = z.object({
|
|
@@ -68,6 +76,8 @@ const bridgeConfigurationSchema: z.ZodType<ACPBridgeConfiguration> = z.object({
|
|
|
68
76
|
providerEnvironment: z.record(z.string(), z.string()).optional(),
|
|
69
77
|
sessionMeta: serializableRecordSchema.optional(),
|
|
70
78
|
clientCapabilities: serializableRecordSchema.optional(),
|
|
79
|
+
askUserQuestionsRequestMethod: z.string().optional(),
|
|
80
|
+
hostToolMcpTransport: hostToolMcpTransportSchema.optional(),
|
|
71
81
|
});
|
|
72
82
|
|
|
73
83
|
export function createACPBridgeEnvironment({
|
|
@@ -76,6 +86,8 @@ export function createACPBridgeEnvironment({
|
|
|
76
86
|
providerEnvironment,
|
|
77
87
|
sessionMeta,
|
|
78
88
|
clientCapabilities,
|
|
89
|
+
askUserQuestionsRequestMethod,
|
|
90
|
+
hostToolMcpTransport,
|
|
79
91
|
}: ACPBridgeConfiguration): Record<string, string> {
|
|
80
92
|
return {
|
|
81
93
|
[ACP_BRIDGE_CONFIGURATION_ENV]: JSON.stringify({
|
|
@@ -84,6 +96,10 @@ export function createACPBridgeEnvironment({
|
|
|
84
96
|
...(providerEnvironment == null ? {} : { providerEnvironment }),
|
|
85
97
|
...(sessionMeta == null ? {} : { sessionMeta }),
|
|
86
98
|
...(clientCapabilities == null ? {} : { clientCapabilities }),
|
|
99
|
+
...(askUserQuestionsRequestMethod == null
|
|
100
|
+
? {}
|
|
101
|
+
: { askUserQuestionsRequestMethod }),
|
|
102
|
+
...(hostToolMcpTransport == null ? {} : { hostToolMcpTransport }),
|
|
87
103
|
}),
|
|
88
104
|
};
|
|
89
105
|
}
|
|
@@ -51,6 +51,8 @@ export function createEmitStreamEvent({
|
|
|
51
51
|
order: number;
|
|
52
52
|
}) => void;
|
|
53
53
|
removeHostToolCorrelationInvocation: (options: { token: string }) => void;
|
|
54
|
+
suppressToolCall: (options: { toolCallId: string }) => void;
|
|
55
|
+
getToolCall: (options: { toolCallId: string }) => ACPToolCall | undefined;
|
|
54
56
|
} {
|
|
55
57
|
const translator = createACPStreamTranslator({
|
|
56
58
|
emit,
|
|
@@ -100,5 +102,9 @@ export function createEmitStreamEvent({
|
|
|
100
102
|
hostToolResult: translator.hostToolResult,
|
|
101
103
|
registerHostToolCorrelationInvocation: correlation.registerInvocation,
|
|
102
104
|
removeHostToolCorrelationInvocation: correlation.removeInvocation,
|
|
105
|
+
suppressToolCall: correlation.suppressToolCall,
|
|
106
|
+
getToolCall: ({ toolCallId }) =>
|
|
107
|
+
correlation.getToolCall({ toolCallId }) ??
|
|
108
|
+
translator.getToolCall({ toolCallId }),
|
|
103
109
|
};
|
|
104
110
|
}
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ActiveSessionMessage,
|
|
3
3
|
ToolCallUpdate,
|
|
4
4
|
} from '@agentclientprotocol/sdk';
|
|
5
|
+
import type { ACPToolCall } from '../../acp-tool-call';
|
|
5
6
|
|
|
6
7
|
type SessionUpdateMessage = Extract<
|
|
7
8
|
ActiveSessionMessage,
|
|
@@ -55,6 +56,8 @@ export function createHostToolCorrelation({
|
|
|
55
56
|
order: number;
|
|
56
57
|
}): void;
|
|
57
58
|
claimHostToolPermission(options: { toolCall: ToolCallUpdate }): boolean;
|
|
59
|
+
suppressToolCall(options: { toolCallId: string }): void;
|
|
60
|
+
getToolCall(options: { toolCallId: string }): ACPToolCall | undefined;
|
|
58
61
|
flush(): void;
|
|
59
62
|
removeInvocation(options: { token: string }): void;
|
|
60
63
|
close(): void;
|
|
@@ -63,6 +66,7 @@ export function createHostToolCorrelation({
|
|
|
63
66
|
const candidates = new Map<string, CorrelationCandidate>();
|
|
64
67
|
const suppressedToolCallIds = new Set<string>();
|
|
65
68
|
const releasedToolCallIds = new Set<string>();
|
|
69
|
+
const observedToolCalls = new Map<string, ACPToolCall>();
|
|
66
70
|
let nextCandidateOrder = 0;
|
|
67
71
|
let buffered: BufferedUpdate[] = [];
|
|
68
72
|
let flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -155,6 +159,13 @@ export function createHostToolCorrelation({
|
|
|
155
159
|
}
|
|
156
160
|
|
|
157
161
|
const toolCallId = message.update.toolCallId;
|
|
162
|
+
observedToolCalls.set(
|
|
163
|
+
toolCallId,
|
|
164
|
+
mergeObservedToolCall({
|
|
165
|
+
previous: observedToolCalls.get(toolCallId),
|
|
166
|
+
update: message.update,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
158
169
|
if (suppressedToolCallIds.has(toolCallId)) {
|
|
159
170
|
if (isTerminalToolUpdate(message)) {
|
|
160
171
|
suppressedToolCallIds.delete(toolCallId);
|
|
@@ -242,6 +253,8 @@ export function createHostToolCorrelation({
|
|
|
242
253
|
suppressToolCallUpdate({ toolCallId: toolCall.toolCallId });
|
|
243
254
|
return true;
|
|
244
255
|
},
|
|
256
|
+
suppressToolCall: suppressToolCallUpdate,
|
|
257
|
+
getToolCall: ({ toolCallId }) => observedToolCalls.get(toolCallId),
|
|
245
258
|
flush,
|
|
246
259
|
removeInvocation: ({ token }) => {
|
|
247
260
|
deleteInvocation({ invocations, token });
|
|
@@ -257,10 +270,31 @@ export function createHostToolCorrelation({
|
|
|
257
270
|
candidates.clear();
|
|
258
271
|
suppressedToolCallIds.clear();
|
|
259
272
|
releasedToolCallIds.clear();
|
|
273
|
+
observedToolCalls.clear();
|
|
260
274
|
},
|
|
261
275
|
};
|
|
262
276
|
}
|
|
263
277
|
|
|
278
|
+
function mergeObservedToolCall({
|
|
279
|
+
previous,
|
|
280
|
+
update,
|
|
281
|
+
}: {
|
|
282
|
+
previous: ACPToolCall | undefined;
|
|
283
|
+
update: ToolCallUpdate;
|
|
284
|
+
}): ACPToolCall {
|
|
285
|
+
return {
|
|
286
|
+
toolCallId: update.toolCallId,
|
|
287
|
+
title: update.title ?? previous?.title ?? `Tool ${update.toolCallId}`,
|
|
288
|
+
...(update.kind == null ? {} : { kind: update.kind }),
|
|
289
|
+
...(update.status == null ? {} : { status: update.status }),
|
|
290
|
+
...(update.content == null ? {} : { content: update.content }),
|
|
291
|
+
...(update.locations == null ? {} : { locations: update.locations }),
|
|
292
|
+
...(update.rawInput === undefined ? {} : { rawInput: update.rawInput }),
|
|
293
|
+
...(update.rawOutput === undefined ? {} : { rawOutput: update.rawOutput }),
|
|
294
|
+
...(update._meta === undefined ? {} : { _meta: update._meta }),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
264
298
|
function hasPortableEvidence({
|
|
265
299
|
candidate,
|
|
266
300
|
invocation,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { execPath } from 'node:process';
|
|
3
|
+
import type * as acp from '@agentclientprotocol/sdk';
|
|
4
|
+
import { HarnessBridgeCapabilityUnsupportedError } from '@ai-sdk/harness/bridge';
|
|
5
|
+
import type { ACPHostToolMCPTransport } from '../acp-v1-settings';
|
|
6
|
+
import type { HostToolRelay } from './host-tool-relay';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Builds the ACP `session/new` definition for the harness-owned MCP server
|
|
10
|
+
* that exposes host tools. The stdio transport launches `host-tool-mcp.mjs`,
|
|
11
|
+
* which bridges back to the relay over its private HTTP protocol. The http
|
|
12
|
+
* transport points the ACP implementation straight at the relay's own MCP
|
|
13
|
+
* endpoint, which is required by implementations that reject client-supplied
|
|
14
|
+
* stdio MCP servers.
|
|
15
|
+
*/
|
|
16
|
+
export function createHostToolMcpServerDefinition({
|
|
17
|
+
mcpTransport,
|
|
18
|
+
relay,
|
|
19
|
+
serverName,
|
|
20
|
+
catalogPath,
|
|
21
|
+
initialization,
|
|
22
|
+
harnessId,
|
|
23
|
+
}: {
|
|
24
|
+
mcpTransport: ACPHostToolMCPTransport;
|
|
25
|
+
relay: Pick<HostToolRelay, 'url' | 'mcpUrl' | 'credential'>;
|
|
26
|
+
serverName: string;
|
|
27
|
+
catalogPath: string;
|
|
28
|
+
initialization: Pick<acp.InitializeResponse, 'agentCapabilities'>;
|
|
29
|
+
harnessId: string;
|
|
30
|
+
}): acp.McpServer {
|
|
31
|
+
if (mcpTransport === 'stdio') {
|
|
32
|
+
return {
|
|
33
|
+
name: serverName,
|
|
34
|
+
command: execPath,
|
|
35
|
+
args: [fileURLToPath(new URL('./host-tool-mcp.mjs', import.meta.url))],
|
|
36
|
+
env: [
|
|
37
|
+
{
|
|
38
|
+
name: 'AI_SDK_ACP_HOST_TOOLS_FILE',
|
|
39
|
+
value: catalogPath,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'AI_SDK_ACP_HOST_TOOL_RELAY_URL',
|
|
43
|
+
value: relay.url,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'AI_SDK_ACP_HOST_TOOL_RELAY_CREDENTIAL',
|
|
47
|
+
value: relay.credential,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (initialization.agentCapabilities?.mcpCapabilities?.http !== true) {
|
|
53
|
+
throw new HarnessBridgeCapabilityUnsupportedError({
|
|
54
|
+
harnessId,
|
|
55
|
+
message:
|
|
56
|
+
'This harness exposes host tools through an HTTP MCP server, but ' +
|
|
57
|
+
'the ACP agent does not advertise support for HTTP MCP servers.',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (relay.mcpUrl == null) {
|
|
61
|
+
throw new Error('The host tool MCP HTTP endpoint is unavailable.');
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
type: 'http',
|
|
65
|
+
name: serverName,
|
|
66
|
+
url: relay.mcpUrl,
|
|
67
|
+
headers: [{ name: 'Authorization', value: `Bearer ${relay.credential}` }],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -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
|
+
}
|