@ai-sdk/harness-acp 0.0.0 → 1.0.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/CHANGELOG.md +13 -0
- package/LICENSE +13 -0
- package/README.md +70 -3
- package/dist/bridge/host-tool-mcp.mjs +302 -0
- package/dist/bridge/host-tool-mcp.mjs.map +1 -0
- package/dist/bridge/index.mjs +3278 -0
- package/dist/bridge/index.mjs.map +1 -0
- package/dist/bridge/package.json +12 -0
- package/dist/bridge/pnpm-lock.yaml +816 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.js +2094 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -10
- package/src/acp-auth.ts +254 -0
- package/src/acp-bridge-protocol.ts +13 -0
- package/src/acp-harness.ts +160 -0
- package/src/index.ts +19 -0
- package/src/v1/acp-v1-bridge-protocol.ts +151 -0
- package/src/v1/acp-v1-harness.ts +1447 -0
- package/src/v1/acp-v1-lifecycle.ts +115 -0
- package/src/v1/acp-v1-prompt.ts +122 -0
- package/src/v1/acp-v1-settings.ts +98 -0
- package/src/v1/acp-v1-skills.ts +228 -0
- package/src/v1/acp-v1-turn-start-config.ts +93 -0
- package/src/v1/bridge/acp-diagnostics.ts +57 -0
- package/src/v1/bridge/acp-stream-capture.ts +152 -0
- package/src/v1/bridge/acp-v1-bridge-environment.ts +95 -0
- package/src/v1/bridge/create-emit-stream-event.ts +97 -0
- package/src/v1/bridge/host-tool-correlation.ts +640 -0
- package/src/v1/bridge/host-tool-mcp-server.ts +180 -0
- package/src/v1/bridge/host-tool-mcp.ts +185 -0
- package/src/v1/bridge/host-tool-relay-client.ts +53 -0
- package/src/v1/bridge/host-tool-relay.ts +510 -0
- package/src/v1/bridge/index.ts +729 -0
- package/src/v1/bridge/package.json +12 -0
- package/src/v1/bridge/permission-controller.ts +152 -0
- package/src/v1/bridge/permission-mode.ts +163 -0
- package/src/v1/bridge/pnpm-lock.yaml +816 -0
- package/src/v1/bridge/profile-values.ts +97 -0
- package/src/v1/bridge/protocol-configuration.ts +146 -0
- package/src/v1/bridge/recovered-session.ts +145 -0
- package/src/v1/bridge/refresh-host-tool-catalog.ts +33 -0
- package/src/v1/bridge/session-lifecycle.ts +83 -0
- package/src/v1/bridge/stream-translator.ts +901 -0
- package/src/v1/implementation.ts +282 -0
- package/src/v1/index.ts +16 -0
- package/src/version.ts +6 -0
- package/index.js +0 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import {
|
|
3
|
+
CallToolRequestSchema,
|
|
4
|
+
ErrorCode,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
McpError,
|
|
7
|
+
type CallToolResult,
|
|
8
|
+
type Tool,
|
|
9
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import type { HarnessV1BridgeToolWire } from '@ai-sdk/harness';
|
|
11
|
+
|
|
12
|
+
declare const __PACKAGE_VERSION__: string | undefined;
|
|
13
|
+
|
|
14
|
+
const VERSION: string =
|
|
15
|
+
typeof __PACKAGE_VERSION__ !== 'undefined'
|
|
16
|
+
? __PACKAGE_VERSION__
|
|
17
|
+
: '0.0.0-test';
|
|
18
|
+
|
|
19
|
+
export type HostToolMCPInvocationResult = {
|
|
20
|
+
readonly output: unknown;
|
|
21
|
+
readonly isError?: boolean;
|
|
22
|
+
readonly correlationToken: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type HostToolMCPServer = {
|
|
26
|
+
readonly server: Server;
|
|
27
|
+
updateCatalog(options: {
|
|
28
|
+
revision: number;
|
|
29
|
+
tools: ReadonlyArray<HarnessV1BridgeToolWire>;
|
|
30
|
+
}): Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function createHostToolMCPServer({
|
|
34
|
+
tools,
|
|
35
|
+
revision = 1,
|
|
36
|
+
invoke,
|
|
37
|
+
onListTools,
|
|
38
|
+
}: {
|
|
39
|
+
tools: ReadonlyArray<HarnessV1BridgeToolWire>;
|
|
40
|
+
revision?: number;
|
|
41
|
+
invoke: (options: {
|
|
42
|
+
toolName: string;
|
|
43
|
+
input: Readonly<Record<string, unknown>>;
|
|
44
|
+
catalogRevision: number;
|
|
45
|
+
}) => Promise<HostToolMCPInvocationResult>;
|
|
46
|
+
onListTools?: (options: { revision: number }) => Promise<void>;
|
|
47
|
+
}): HostToolMCPServer {
|
|
48
|
+
let catalog = createCatalog({ revision, tools });
|
|
49
|
+
let catalogAcknowledgmentError: unknown;
|
|
50
|
+
const server = new Server(
|
|
51
|
+
{
|
|
52
|
+
name: '@ai-sdk/harness-acp-host-tools',
|
|
53
|
+
version: VERSION,
|
|
54
|
+
},
|
|
55
|
+
{ capabilities: { tools: { listChanged: true } } },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
59
|
+
if (catalogAcknowledgmentError != null) {
|
|
60
|
+
throw catalogAcknowledgmentError;
|
|
61
|
+
}
|
|
62
|
+
const current = catalog;
|
|
63
|
+
if (onListTools != null) {
|
|
64
|
+
setImmediate(() => {
|
|
65
|
+
void onListTools({ revision: current.revision }).catch(error => {
|
|
66
|
+
catalogAcknowledgmentError = error;
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return { tools: current.tools.map(toMCPTool) };
|
|
71
|
+
});
|
|
72
|
+
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
73
|
+
const current = catalog;
|
|
74
|
+
const tool = current.byName.get(request.params.name);
|
|
75
|
+
if (tool == null) {
|
|
76
|
+
throw new McpError(
|
|
77
|
+
ErrorCode.InvalidParams,
|
|
78
|
+
`Unknown host tool: ${request.params.name}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const input = request.params.arguments ?? {};
|
|
82
|
+
const result = await invoke({
|
|
83
|
+
toolName: tool.name,
|
|
84
|
+
input,
|
|
85
|
+
catalogRevision: current.revision,
|
|
86
|
+
});
|
|
87
|
+
return toCallToolResult({ result });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
server,
|
|
92
|
+
updateCatalog: async ({ revision: nextRevision, tools: nextTools }) => {
|
|
93
|
+
catalog = createCatalog({
|
|
94
|
+
revision: nextRevision,
|
|
95
|
+
tools: nextTools,
|
|
96
|
+
});
|
|
97
|
+
await server.sendToolListChanged();
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createCatalog({
|
|
103
|
+
revision,
|
|
104
|
+
tools,
|
|
105
|
+
}: {
|
|
106
|
+
revision: number;
|
|
107
|
+
tools: ReadonlyArray<HarnessV1BridgeToolWire>;
|
|
108
|
+
}): {
|
|
109
|
+
readonly revision: number;
|
|
110
|
+
readonly tools: ReadonlyArray<HarnessV1BridgeToolWire>;
|
|
111
|
+
readonly byName: ReadonlyMap<string, HarnessV1BridgeToolWire>;
|
|
112
|
+
} {
|
|
113
|
+
return {
|
|
114
|
+
revision,
|
|
115
|
+
tools: [...tools],
|
|
116
|
+
byName: new Map(tools.map(tool => [tool.name, tool])),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function toMCPTool(tool: HarnessV1BridgeToolWire): Tool {
|
|
121
|
+
return {
|
|
122
|
+
name: tool.name,
|
|
123
|
+
...(tool.description == null ? {} : { description: tool.description }),
|
|
124
|
+
inputSchema: requireObjectSchema({
|
|
125
|
+
toolName: tool.name,
|
|
126
|
+
schema: tool.inputSchema ?? { type: 'object' },
|
|
127
|
+
}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function requireObjectSchema({
|
|
132
|
+
toolName,
|
|
133
|
+
schema,
|
|
134
|
+
}: {
|
|
135
|
+
toolName: string;
|
|
136
|
+
schema: unknown;
|
|
137
|
+
}): Tool['inputSchema'] {
|
|
138
|
+
if (
|
|
139
|
+
schema == null ||
|
|
140
|
+
typeof schema !== 'object' ||
|
|
141
|
+
Array.isArray(schema) ||
|
|
142
|
+
(Reflect.get(schema, 'type') !== undefined &&
|
|
143
|
+
Reflect.get(schema, 'type') !== 'object')
|
|
144
|
+
) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Host tool ${toolName} must use an object JSON Schema for MCP.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return schema as Tool['inputSchema'];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function toCallToolResult({
|
|
153
|
+
result,
|
|
154
|
+
}: {
|
|
155
|
+
result: HostToolMCPInvocationResult;
|
|
156
|
+
}): CallToolResult {
|
|
157
|
+
return {
|
|
158
|
+
content: [
|
|
159
|
+
{
|
|
160
|
+
type: 'text',
|
|
161
|
+
text: stringifyOutput({ output: result.output }),
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
...(result.isError ? { isError: true } : {}),
|
|
165
|
+
_meta: {
|
|
166
|
+
'ai-sdk-harness-acp-correlation': result.correlationToken,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function stringifyOutput({ output }: { output: unknown }): string {
|
|
172
|
+
if (output === undefined) return 'null';
|
|
173
|
+
try {
|
|
174
|
+
return JSON.stringify(output);
|
|
175
|
+
} catch {
|
|
176
|
+
return JSON.stringify({
|
|
177
|
+
error: 'Host tool output could not be serialized as JSON.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { env } from 'node:process';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import type { HarnessV1BridgeToolWire } from '@ai-sdk/harness';
|
|
6
|
+
import {
|
|
7
|
+
createHostToolMCPServer,
|
|
8
|
+
type HostToolMCPInvocationResult,
|
|
9
|
+
} from './host-tool-mcp-server';
|
|
10
|
+
import { postHostToolRelay } from './host-tool-relay-client';
|
|
11
|
+
|
|
12
|
+
const catalogPath = requireEnvironmentVariable({
|
|
13
|
+
name: 'AI_SDK_ACP_HOST_TOOLS_FILE',
|
|
14
|
+
});
|
|
15
|
+
const relayUrl = requireEnvironmentVariable({
|
|
16
|
+
name: 'AI_SDK_ACP_HOST_TOOL_RELAY_URL',
|
|
17
|
+
});
|
|
18
|
+
const relayCredential = requireEnvironmentVariable({
|
|
19
|
+
name: 'AI_SDK_ACP_HOST_TOOL_RELAY_CREDENTIAL',
|
|
20
|
+
});
|
|
21
|
+
const tools = await readToolCatalog({ path: catalogPath });
|
|
22
|
+
const hostToolServer = createHostToolMCPServer({
|
|
23
|
+
tools,
|
|
24
|
+
invoke: async ({ toolName, input, catalogRevision }) =>
|
|
25
|
+
validateInvocationResult({
|
|
26
|
+
value: await postRelay({
|
|
27
|
+
path: '/invoke',
|
|
28
|
+
body: {
|
|
29
|
+
requestId: randomUUID(),
|
|
30
|
+
toolName,
|
|
31
|
+
input,
|
|
32
|
+
catalogRevision,
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
}),
|
|
36
|
+
onListTools: async ({ revision }) => {
|
|
37
|
+
await postRelay({
|
|
38
|
+
path: '/catalog/seen',
|
|
39
|
+
body: { revision },
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await hostToolServer.server.connect(new StdioServerTransport());
|
|
45
|
+
void watchCatalog({
|
|
46
|
+
initialRevision: 1,
|
|
47
|
+
updateCatalog: hostToolServer.updateCatalog,
|
|
48
|
+
}).catch(error => {
|
|
49
|
+
process.stderr.write(
|
|
50
|
+
`Host tool catalog synchronization failed: ${
|
|
51
|
+
error instanceof Error ? error.message : String(error)
|
|
52
|
+
}\n`,
|
|
53
|
+
);
|
|
54
|
+
void hostToolServer.server.close();
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
async function watchCatalog({
|
|
59
|
+
initialRevision,
|
|
60
|
+
updateCatalog,
|
|
61
|
+
}: {
|
|
62
|
+
initialRevision: number;
|
|
63
|
+
updateCatalog: (options: {
|
|
64
|
+
revision: number;
|
|
65
|
+
tools: ReadonlyArray<HarnessV1BridgeToolWire>;
|
|
66
|
+
}) => Promise<void>;
|
|
67
|
+
}): Promise<void> {
|
|
68
|
+
let revision = initialRevision;
|
|
69
|
+
for (;;) {
|
|
70
|
+
const value = await postRelay({
|
|
71
|
+
path: '/catalog/next',
|
|
72
|
+
body: { afterRevision: revision },
|
|
73
|
+
});
|
|
74
|
+
if (isRecord(value) && value.closed === true) return;
|
|
75
|
+
if (
|
|
76
|
+
!isRecord(value) ||
|
|
77
|
+
!Number.isSafeInteger(value.revision) ||
|
|
78
|
+
(value.revision as number) < revision
|
|
79
|
+
) {
|
|
80
|
+
throw new Error('Invalid host tool catalog poll response.');
|
|
81
|
+
}
|
|
82
|
+
const nextRevision = value.revision as number;
|
|
83
|
+
if (nextRevision === revision) continue;
|
|
84
|
+
const nextTools = validateToolCatalog({ value: value.tools });
|
|
85
|
+
await updateCatalog({ revision: nextRevision, tools: nextTools });
|
|
86
|
+
revision = nextRevision;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function postRelay({
|
|
91
|
+
path,
|
|
92
|
+
body,
|
|
93
|
+
}: {
|
|
94
|
+
path: string;
|
|
95
|
+
body: Readonly<Record<string, unknown>>;
|
|
96
|
+
}): Promise<unknown> {
|
|
97
|
+
const response = await postHostToolRelay({
|
|
98
|
+
relayUrl,
|
|
99
|
+
relayCredential,
|
|
100
|
+
path,
|
|
101
|
+
body,
|
|
102
|
+
});
|
|
103
|
+
const { value } = response;
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw new Error(readErrorMessage({ value, status: response.status }));
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validateToolCatalog({
|
|
111
|
+
value,
|
|
112
|
+
}: {
|
|
113
|
+
value: unknown;
|
|
114
|
+
}): ReadonlyArray<HarnessV1BridgeToolWire> {
|
|
115
|
+
if (!Array.isArray(value) || !value.every(isTool)) {
|
|
116
|
+
throw new Error('Invalid host tool catalog.');
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readToolCatalog({
|
|
122
|
+
path,
|
|
123
|
+
}: {
|
|
124
|
+
path: string;
|
|
125
|
+
}): Promise<ReadonlyArray<HarnessV1BridgeToolWire>> {
|
|
126
|
+
const text = await readFile(path, 'utf8');
|
|
127
|
+
const value = await new Response(text, {
|
|
128
|
+
headers: { 'content-type': 'application/json' },
|
|
129
|
+
}).json();
|
|
130
|
+
return validateToolCatalog({ value });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isTool(value: unknown): value is HarnessV1BridgeToolWire {
|
|
134
|
+
return (
|
|
135
|
+
isRecord(value) &&
|
|
136
|
+
typeof value.name === 'string' &&
|
|
137
|
+
(value.description === undefined ||
|
|
138
|
+
typeof value.description === 'string') &&
|
|
139
|
+
(value.inputSchema === undefined ||
|
|
140
|
+
(isRecord(value.inputSchema) && !Array.isArray(value.inputSchema)))
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateInvocationResult({
|
|
145
|
+
value,
|
|
146
|
+
}: {
|
|
147
|
+
value: unknown;
|
|
148
|
+
}): HostToolMCPInvocationResult {
|
|
149
|
+
if (
|
|
150
|
+
!isRecord(value) ||
|
|
151
|
+
typeof value.correlationToken !== 'string' ||
|
|
152
|
+
(value.isError !== undefined && typeof value.isError !== 'boolean')
|
|
153
|
+
) {
|
|
154
|
+
throw new Error('Invalid host tool relay response.');
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
output: value.output,
|
|
158
|
+
...(value.isError ? { isError: true } : {}),
|
|
159
|
+
correlationToken: value.correlationToken,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readErrorMessage({
|
|
164
|
+
value,
|
|
165
|
+
status,
|
|
166
|
+
}: {
|
|
167
|
+
value: unknown;
|
|
168
|
+
status: number;
|
|
169
|
+
}): string {
|
|
170
|
+
return isRecord(value) && typeof value.error === 'string'
|
|
171
|
+
? value.error
|
|
172
|
+
: `Host tool relay returned HTTP ${status}.`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function requireEnvironmentVariable({ name }: { name: string }): string {
|
|
176
|
+
const value = env[name];
|
|
177
|
+
if (value == null || value.length === 0) {
|
|
178
|
+
throw new Error(`Missing ${name}.`);
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
184
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
185
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Agent, request } from 'node:http';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* A host-tool call can remain pending while a person reviews an approval.
|
|
5
|
+
* The loopback connection therefore must not impose an inactivity deadline.
|
|
6
|
+
*/
|
|
7
|
+
const relayAgent = new Agent({ keepAlive: true, timeout: 0 });
|
|
8
|
+
|
|
9
|
+
export async function postHostToolRelay({
|
|
10
|
+
relayUrl,
|
|
11
|
+
relayCredential,
|
|
12
|
+
path,
|
|
13
|
+
body,
|
|
14
|
+
}: {
|
|
15
|
+
relayUrl: string;
|
|
16
|
+
relayCredential: string;
|
|
17
|
+
path: string;
|
|
18
|
+
body: Readonly<Record<string, unknown>>;
|
|
19
|
+
}): Promise<{ status: number; ok: boolean; value: unknown }> {
|
|
20
|
+
const serializedBody = JSON.stringify(body);
|
|
21
|
+
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const relayRequest = request(
|
|
24
|
+
new URL(path, relayUrl),
|
|
25
|
+
{
|
|
26
|
+
agent: relayAgent,
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: {
|
|
29
|
+
authorization: `Bearer ${relayCredential}`,
|
|
30
|
+
'content-type': 'application/json',
|
|
31
|
+
'content-length': Buffer.byteLength(serializedBody),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
response => {
|
|
35
|
+
const chunks: Buffer[] = [];
|
|
36
|
+
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
37
|
+
response.on('error', reject);
|
|
38
|
+
response.on('end', () => {
|
|
39
|
+
void new Response(Buffer.concat(chunks)).json().then(value => {
|
|
40
|
+
const status = response.statusCode ?? 0;
|
|
41
|
+
resolve({
|
|
42
|
+
status,
|
|
43
|
+
ok: status >= 200 && status < 300,
|
|
44
|
+
value,
|
|
45
|
+
});
|
|
46
|
+
}, reject);
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
relayRequest.on('error', reject);
|
|
51
|
+
relayRequest.end(serializedBody);
|
|
52
|
+
});
|
|
53
|
+
}
|