@harness-control/runner 0.1.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/LICENSE +202 -0
- package/README.md +21 -0
- package/dist/audit/index.d.ts +18 -0
- package/dist/audit/index.js +28 -0
- package/dist/config/index.d.ts +179 -0
- package/dist/config/index.js +124 -0
- package/dist/connection/index.d.ts +2 -0
- package/dist/connection/index.js +2 -0
- package/dist/connection/runner-connection.d.ts +22 -0
- package/dist/connection/runner-connection.js +631 -0
- package/dist/harnesses/adapters/providers/claude-runtime.d.ts +5 -0
- package/dist/harnesses/adapters/providers/claude-runtime.js +186 -0
- package/dist/harnesses/adapters/providers/claude.d.ts +24 -0
- package/dist/harnesses/adapters/providers/claude.js +189 -0
- package/dist/harnesses/adapters/providers/cli-process.d.ts +44 -0
- package/dist/harnesses/adapters/providers/cli-process.js +195 -0
- package/dist/harnesses/adapters/providers/codex-models.d.ts +4 -0
- package/dist/harnesses/adapters/providers/codex-models.js +62 -0
- package/dist/harnesses/adapters/providers/codex-rpc.d.ts +21 -0
- package/dist/harnesses/adapters/providers/codex-rpc.js +114 -0
- package/dist/harnesses/adapters/providers/codex-runtime.d.ts +3 -0
- package/dist/harnesses/adapters/providers/codex-runtime.js +267 -0
- package/dist/harnesses/adapters/providers/codex.d.ts +22 -0
- package/dist/harnesses/adapters/providers/codex.js +161 -0
- package/dist/harnesses/adapters/providers/mock.d.ts +13 -0
- package/dist/harnesses/adapters/providers/mock.js +64 -0
- package/dist/harnesses/adapters/providers/native-process.d.ts +9 -0
- package/dist/harnesses/adapters/providers/native-process.js +41 -0
- package/dist/harnesses/adapters/providers/native-turn.d.ts +17 -0
- package/dist/harnesses/adapters/providers/native-turn.js +139 -0
- package/dist/harnesses/adapters/providers/opencode.d.ts +44 -0
- package/dist/harnesses/adapters/providers/opencode.js +416 -0
- package/dist/harnesses/adapters/providers/shared.d.ts +9 -0
- package/dist/harnesses/adapters/providers/shared.js +97 -0
- package/dist/harnesses/adapters/registry.d.ts +12 -0
- package/dist/harnesses/adapters/registry.js +47 -0
- package/dist/harnesses/adapters/types.d.ts +54 -0
- package/dist/harnesses/adapters/types.js +9 -0
- package/dist/harnesses/adapters.d.ts +8 -0
- package/dist/harnesses/adapters.js +8 -0
- package/dist/harnesses/index.d.ts +93 -0
- package/dist/harnesses/index.js +620 -0
- package/dist/host/provider-registry.d.ts +34 -0
- package/dist/host/provider-registry.js +162 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +201 -0
- package/dist/local-actions/dispatcher.d.ts +28 -0
- package/dist/local-actions/dispatcher.js +407 -0
- package/dist/local-actions/executors.d.ts +159 -0
- package/dist/local-actions/executors.js +1103 -0
- package/dist/local-actions/index.d.ts +74 -0
- package/dist/local-actions/index.js +275 -0
- package/dist/logs/index.d.ts +6 -0
- package/dist/logs/index.js +9 -0
- package/dist/mcp/McpAttachmentClient.d.ts +111 -0
- package/dist/mcp/McpAttachmentClient.js +345 -0
- package/dist/mcp/McpProxyServer.d.ts +18 -0
- package/dist/mcp/McpProxyServer.js +188 -0
- package/dist/mcp/McpStdioProfileClient.d.ts +19 -0
- package/dist/mcp/McpStdioProfileClient.js +91 -0
- package/dist/mcp/index.d.ts +5 -0
- package/dist/mcp/index.js +5 -0
- package/dist/mcp/redaction.d.ts +3 -0
- package/dist/mcp/redaction.js +40 -0
- package/dist/pairing/index.d.ts +38 -0
- package/dist/pairing/index.js +180 -0
- package/dist/state/index.d.ts +76 -0
- package/dist/state/index.js +242 -0
- package/dist/workspaces/index.d.ts +13 -0
- package/dist/workspaces/index.js +110 -0
- package/package.json +76 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import { redactHeaders, redactValue } from "./redaction.js";
|
|
5
|
+
export class McpToolPolicyError extends Error {
|
|
6
|
+
attachmentName;
|
|
7
|
+
toolName;
|
|
8
|
+
constructor(attachmentName, toolName, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.attachmentName = attachmentName;
|
|
11
|
+
this.toolName = toolName;
|
|
12
|
+
this.name = "McpToolPolicyError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class McpAttachmentExpiredError extends Error {
|
|
16
|
+
attachmentName;
|
|
17
|
+
constructor(attachmentName) {
|
|
18
|
+
super(`MCP attachment "${attachmentName}" has expired.`);
|
|
19
|
+
this.attachmentName = attachmentName;
|
|
20
|
+
this.name = "McpAttachmentExpiredError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class McpProofBindingError extends Error {
|
|
24
|
+
attachmentName;
|
|
25
|
+
constructor(attachmentName, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.attachmentName = attachmentName;
|
|
28
|
+
this.name = "McpProofBindingError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class McpAttachmentClient {
|
|
32
|
+
attachment;
|
|
33
|
+
allowedTools;
|
|
34
|
+
deniedTools;
|
|
35
|
+
eventSink;
|
|
36
|
+
sdkFactory;
|
|
37
|
+
now;
|
|
38
|
+
proofContext;
|
|
39
|
+
proofSigner;
|
|
40
|
+
hasProofSigner;
|
|
41
|
+
client;
|
|
42
|
+
connected = false;
|
|
43
|
+
constructor(attachment, options = {}) {
|
|
44
|
+
this.attachment = attachment;
|
|
45
|
+
this.allowedTools = attachment.allowed_tools ? new Set(attachment.allowed_tools) : undefined;
|
|
46
|
+
this.deniedTools = new Set(attachment.denied_tools ?? []);
|
|
47
|
+
this.eventSink = options.eventSink;
|
|
48
|
+
this.sdkFactory = options.sdkFactory ?? defaultMcpSdkFactory;
|
|
49
|
+
this.now = options.now ?? (() => new Date());
|
|
50
|
+
this.proofContext = options.proofContext;
|
|
51
|
+
this.proofSigner = options.proofSigner ?? missingProofSigner;
|
|
52
|
+
this.hasProofSigner = options.proofSigner !== undefined;
|
|
53
|
+
}
|
|
54
|
+
async connect() {
|
|
55
|
+
if (this.connected) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this.assertNotExpired();
|
|
59
|
+
this.assertProofBindingConfigured();
|
|
60
|
+
if (this.attachment.transport !== "streamable_http") {
|
|
61
|
+
throw new Error(`Unsupported MCP attachment transport: ${this.attachment.transport}`);
|
|
62
|
+
}
|
|
63
|
+
await this.emitEvent("runtime.warning", {
|
|
64
|
+
event: "mcp.attachment.connecting",
|
|
65
|
+
attachment: this.attachment.name,
|
|
66
|
+
transport: this.attachment.transport,
|
|
67
|
+
url: this.attachment.url,
|
|
68
|
+
headers: redactHeaders(this.attachment.headers),
|
|
69
|
+
});
|
|
70
|
+
const client = this.sdkFactory.createClient();
|
|
71
|
+
const transport = this.sdkFactory.createStreamableHttpTransport(this.attachment, {
|
|
72
|
+
fetch: this.createProofFetch(),
|
|
73
|
+
});
|
|
74
|
+
await client.connect(transport);
|
|
75
|
+
this.client = client;
|
|
76
|
+
this.connected = true;
|
|
77
|
+
await this.emitEvent("runtime.warning", {
|
|
78
|
+
event: "mcp.attachment.connected",
|
|
79
|
+
attachment: this.attachment.name,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async listTools() {
|
|
83
|
+
this.assertNotExpired();
|
|
84
|
+
const client = this.requireConnectedClient();
|
|
85
|
+
const result = await client.listTools();
|
|
86
|
+
const tools = result.tools.filter((tool) => this.isToolAllowed(tool.name));
|
|
87
|
+
await this.emitEvent("runtime.warning", {
|
|
88
|
+
event: "mcp.tools.discovered",
|
|
89
|
+
attachment: this.attachment.name,
|
|
90
|
+
total_tools: result.tools.length,
|
|
91
|
+
allowed_tools: tools.map((tool) => tool.name),
|
|
92
|
+
});
|
|
93
|
+
return tools.map(toMcpToolDescriptor);
|
|
94
|
+
}
|
|
95
|
+
async callTool(name, arguments_ = {}) {
|
|
96
|
+
this.assertNotExpired();
|
|
97
|
+
await this.assertToolAllowed(name);
|
|
98
|
+
const client = this.requireConnectedClient();
|
|
99
|
+
await this.emitEvent("mcp_tool.started", {
|
|
100
|
+
attachment: this.attachment.name,
|
|
101
|
+
tool_name: name,
|
|
102
|
+
arguments: redactValue(arguments_),
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
const sdkResult = await client.callTool({ name, arguments: arguments_ });
|
|
106
|
+
const result = toMcpToolCallResult(sdkResult);
|
|
107
|
+
await this.emitEvent("mcp_tool.completed", {
|
|
108
|
+
attachment: this.attachment.name,
|
|
109
|
+
tool_name: name,
|
|
110
|
+
result: redactValue(result),
|
|
111
|
+
});
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
await this.emitEvent("runtime.error", {
|
|
116
|
+
event: "mcp.tool.failed",
|
|
117
|
+
attachment: this.attachment.name,
|
|
118
|
+
tool_name: name,
|
|
119
|
+
error: redactError(error),
|
|
120
|
+
});
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async close() {
|
|
125
|
+
const client = this.client;
|
|
126
|
+
this.client = undefined;
|
|
127
|
+
this.connected = false;
|
|
128
|
+
if (client) {
|
|
129
|
+
await client.close();
|
|
130
|
+
await this.emitEvent("runtime.warning", {
|
|
131
|
+
event: "mcp.attachment.closed",
|
|
132
|
+
attachment: this.attachment.name,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
isToolAllowed(toolName) {
|
|
137
|
+
if (this.deniedTools.has(toolName)) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return this.allowedTools ? this.allowedTools.has(toolName) : true;
|
|
141
|
+
}
|
|
142
|
+
async assertToolAllowed(toolName) {
|
|
143
|
+
if (this.isToolAllowed(toolName)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
await this.emitEvent("runtime.error", {
|
|
147
|
+
event: "mcp.tool.denied",
|
|
148
|
+
attachment: this.attachment.name,
|
|
149
|
+
tool_name: toolName,
|
|
150
|
+
});
|
|
151
|
+
throw new McpToolPolicyError(this.attachment.name, toolName, `MCP tool "${toolName}" is not allowed for attachment "${this.attachment.name}".`);
|
|
152
|
+
}
|
|
153
|
+
requireConnectedClient() {
|
|
154
|
+
if (!this.client) {
|
|
155
|
+
throw new Error(`MCP attachment "${this.attachment.name}" is not connected.`);
|
|
156
|
+
}
|
|
157
|
+
return this.client;
|
|
158
|
+
}
|
|
159
|
+
async emitEvent(event_type, data) {
|
|
160
|
+
if (!this.eventSink) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
await this.eventSink({ event_type, data });
|
|
164
|
+
}
|
|
165
|
+
assertNotExpired() {
|
|
166
|
+
if (!this.attachment.expires_at) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const expiresAt = Date.parse(this.attachment.expires_at);
|
|
170
|
+
if (Number.isNaN(expiresAt) || expiresAt > this.now().getTime()) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
void this.close();
|
|
174
|
+
throw new McpAttachmentExpiredError(this.attachment.name);
|
|
175
|
+
}
|
|
176
|
+
assertProofBindingConfigured() {
|
|
177
|
+
if (this.attachment.proof_of_possession.scheme !== "runner_signed_request") {
|
|
178
|
+
throw new McpProofBindingError(this.attachment.name, `Unsupported MCP proof scheme: ${this.attachment.proof_of_possession.scheme}`);
|
|
179
|
+
}
|
|
180
|
+
if (!this.proofContext) {
|
|
181
|
+
throw new McpProofBindingError(this.attachment.name, `MCP attachment "${this.attachment.name}" requires runner proof context.`);
|
|
182
|
+
}
|
|
183
|
+
if (!this.hasProofSigner) {
|
|
184
|
+
throw new McpProofBindingError(this.attachment.name, `MCP attachment "${this.attachment.name}" requires a runner proof signer.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
createProofFetch() {
|
|
188
|
+
const attachment = this.attachment;
|
|
189
|
+
const proofContext = this.proofContext;
|
|
190
|
+
if (!proofContext) {
|
|
191
|
+
throw new McpProofBindingError(attachment.name, `MCP attachment "${attachment.name}" requires runner proof context.`);
|
|
192
|
+
}
|
|
193
|
+
return async (input, init) => {
|
|
194
|
+
const method = init?.method ?? (input instanceof Request ? input.method : "GET");
|
|
195
|
+
const url = requestUrl(input);
|
|
196
|
+
const bodyHash = hashRequestBody(init?.body);
|
|
197
|
+
const timestamp = this.now().toISOString();
|
|
198
|
+
const nonce = randomUUID();
|
|
199
|
+
const signingInput = {
|
|
200
|
+
key_id: attachment.proof_of_possession.key_id,
|
|
201
|
+
method: method.toUpperCase(),
|
|
202
|
+
url,
|
|
203
|
+
body_hash: bodyHash,
|
|
204
|
+
lease_id: attachment.lease_id,
|
|
205
|
+
session_id: proofContext.session_id,
|
|
206
|
+
host_id: proofContext.host_id,
|
|
207
|
+
provider_instance_id: proofContext.provider_instance_id,
|
|
208
|
+
workspace_id: proofContext.workspace_id,
|
|
209
|
+
server_id: proofContext.server_id ?? attachment.name,
|
|
210
|
+
...(proofContext.turn_id ? { turn_id: proofContext.turn_id } : {}),
|
|
211
|
+
timestamp,
|
|
212
|
+
nonce,
|
|
213
|
+
};
|
|
214
|
+
const signature = this.proofSigner(signingInput);
|
|
215
|
+
const headers = new Headers(init?.headers);
|
|
216
|
+
headers.set("x-hcp-proof-key-id", signingInput.key_id);
|
|
217
|
+
headers.set("x-hcp-lease-id", signingInput.lease_id);
|
|
218
|
+
headers.set("x-hcp-session-id", signingInput.session_id);
|
|
219
|
+
headers.set("x-hcp-host-id", signingInput.host_id);
|
|
220
|
+
headers.set("x-hcp-provider-instance-id", signingInput.provider_instance_id);
|
|
221
|
+
headers.set("x-hcp-workspace-id", signingInput.workspace_id);
|
|
222
|
+
headers.set("x-hcp-mcp-server-id", signingInput.server_id);
|
|
223
|
+
headers.set("x-hcp-proof-timestamp", signingInput.timestamp);
|
|
224
|
+
headers.set("x-hcp-proof-nonce", signingInput.nonce);
|
|
225
|
+
headers.set("x-hcp-proof-body-sha256", signingInput.body_hash);
|
|
226
|
+
headers.set("x-hcp-proof-signature", signature);
|
|
227
|
+
if (signingInput.turn_id) {
|
|
228
|
+
headers.set("x-hcp-turn-id", signingInput.turn_id);
|
|
229
|
+
}
|
|
230
|
+
for (const requiredHeader of attachment.proof_of_possession.required_headers) {
|
|
231
|
+
if (!headers.has(requiredHeader)) {
|
|
232
|
+
throw new McpProofBindingError(attachment.name, `Required MCP proof header '${requiredHeader}' was not produced.`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return fetch(input, {
|
|
236
|
+
...init,
|
|
237
|
+
headers,
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const defaultMcpSdkFactory = {
|
|
243
|
+
createClient() {
|
|
244
|
+
return new Client({ name: "hcp-runner", version: "0.0.0" });
|
|
245
|
+
},
|
|
246
|
+
createStreamableHttpTransport(attachment, options) {
|
|
247
|
+
return new StreamableHTTPClientTransport(new URL(attachment.url), {
|
|
248
|
+
requestInit: { headers: attachment.headers },
|
|
249
|
+
fetch: options.fetch,
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
function missingProofSigner() {
|
|
254
|
+
throw new Error("MCP proof signer is not configured.");
|
|
255
|
+
}
|
|
256
|
+
export function createDevelopmentHmacProofSigner(secret) {
|
|
257
|
+
return (input) => defaultProofSigner(input, secret);
|
|
258
|
+
}
|
|
259
|
+
function defaultProofSigner(input, secret) {
|
|
260
|
+
return `hmac-sha256:${createHmac("sha256", secret).update(canonicalProofString(input)).digest("base64url")}`;
|
|
261
|
+
}
|
|
262
|
+
function canonicalProofString(input) {
|
|
263
|
+
return [
|
|
264
|
+
input.method,
|
|
265
|
+
input.url,
|
|
266
|
+
input.body_hash,
|
|
267
|
+
input.lease_id,
|
|
268
|
+
input.session_id,
|
|
269
|
+
input.host_id,
|
|
270
|
+
input.provider_instance_id,
|
|
271
|
+
input.workspace_id,
|
|
272
|
+
input.server_id,
|
|
273
|
+
input.turn_id ?? "",
|
|
274
|
+
input.timestamp,
|
|
275
|
+
input.nonce,
|
|
276
|
+
].join("\n");
|
|
277
|
+
}
|
|
278
|
+
function requestUrl(input) {
|
|
279
|
+
if (typeof input === "string") {
|
|
280
|
+
return input;
|
|
281
|
+
}
|
|
282
|
+
if (input instanceof URL) {
|
|
283
|
+
return input.toString();
|
|
284
|
+
}
|
|
285
|
+
return input.url;
|
|
286
|
+
}
|
|
287
|
+
function hashRequestBody(body) {
|
|
288
|
+
if (body === undefined || body === null) {
|
|
289
|
+
return sha256("");
|
|
290
|
+
}
|
|
291
|
+
if (typeof body === "string") {
|
|
292
|
+
return sha256(body);
|
|
293
|
+
}
|
|
294
|
+
if (body instanceof URLSearchParams) {
|
|
295
|
+
return sha256(body.toString());
|
|
296
|
+
}
|
|
297
|
+
if (body instanceof ArrayBuffer) {
|
|
298
|
+
return sha256(Buffer.from(body));
|
|
299
|
+
}
|
|
300
|
+
if (ArrayBuffer.isView(body)) {
|
|
301
|
+
return sha256(Buffer.from(body.buffer, body.byteOffset, body.byteLength));
|
|
302
|
+
}
|
|
303
|
+
return sha256("[streaming-body]");
|
|
304
|
+
}
|
|
305
|
+
function sha256(value) {
|
|
306
|
+
return `sha256:${createHash("sha256").update(value).digest("base64url")}`;
|
|
307
|
+
}
|
|
308
|
+
function toMcpToolDescriptor(tool) {
|
|
309
|
+
return {
|
|
310
|
+
name: tool.name,
|
|
311
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
312
|
+
input_schema: tool.inputSchema,
|
|
313
|
+
...(tool.outputSchema ? { output_schema: tool.outputSchema } : {}),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function toMcpToolCallResult(result) {
|
|
317
|
+
const converted = {
|
|
318
|
+
is_error: Boolean(result.isError),
|
|
319
|
+
};
|
|
320
|
+
if ("content" in result && Array.isArray(result.content)) {
|
|
321
|
+
converted.content = result.content;
|
|
322
|
+
}
|
|
323
|
+
else if ("toolResult" in result) {
|
|
324
|
+
converted.content = [result.toolResult];
|
|
325
|
+
}
|
|
326
|
+
if ("structuredContent" in result && isRecord(result.structuredContent)) {
|
|
327
|
+
converted.structured_content = result.structuredContent;
|
|
328
|
+
}
|
|
329
|
+
return converted;
|
|
330
|
+
}
|
|
331
|
+
function redactError(error) {
|
|
332
|
+
if (error instanceof Error) {
|
|
333
|
+
return {
|
|
334
|
+
name: error.name,
|
|
335
|
+
message: redactValue(error.message),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
message: redactValue(String(error)),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function isRecord(value) {
|
|
343
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
344
|
+
}
|
|
345
|
+
//# sourceMappingURL=McpAttachmentClient.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HarnessAdapterMcpServer } from "../harnesses/adapters.js";
|
|
2
|
+
import type { McpAttachmentClient, McpToolDescriptor } from "./McpAttachmentClient.js";
|
|
3
|
+
export type McpProxyUpstream = Pick<McpAttachmentClient, "connect" | "listTools" | "callTool" | "close">;
|
|
4
|
+
export type McpProxyServerOptions = {
|
|
5
|
+
attachment: Pick<HarnessAdapterMcpServer, "name" | "allowed_tools" | "denied_tools">;
|
|
6
|
+
upstream: McpProxyUpstream;
|
|
7
|
+
host?: "127.0.0.1" | "localhost";
|
|
8
|
+
port?: number;
|
|
9
|
+
};
|
|
10
|
+
export declare class McpProxyServer {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(options: McpProxyServerOptions);
|
|
13
|
+
get adapterAttachment(): HarnessAdapterMcpServer | undefined;
|
|
14
|
+
connect(): Promise<void>;
|
|
15
|
+
listTools(): Promise<McpToolDescriptor[]>;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=McpProxyServer.d.ts.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
export class McpProxyServer {
|
|
6
|
+
#attachment;
|
|
7
|
+
#upstream;
|
|
8
|
+
#host;
|
|
9
|
+
#port;
|
|
10
|
+
#httpServer;
|
|
11
|
+
#adapterAttachment;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.#attachment = options.attachment;
|
|
14
|
+
this.#upstream = options.upstream;
|
|
15
|
+
this.#host = options.host ?? "127.0.0.1";
|
|
16
|
+
this.#port = options.port ?? 0;
|
|
17
|
+
}
|
|
18
|
+
get adapterAttachment() {
|
|
19
|
+
return this.#adapterAttachment;
|
|
20
|
+
}
|
|
21
|
+
async connect() {
|
|
22
|
+
if (this.#httpServer) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
await this.#upstream.connect();
|
|
26
|
+
const httpServer = createServer((request, response) => {
|
|
27
|
+
this.#handleRequest(request, response).catch((error) => {
|
|
28
|
+
writeJsonRpcError(response, 500, "mcp_proxy_error", error instanceof Error ? error.message : "MCP proxy request failed.");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
let boundPort;
|
|
32
|
+
try {
|
|
33
|
+
boundPort = await listen(httpServer, this.#host, this.#port);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
try {
|
|
37
|
+
await this.#upstream.close();
|
|
38
|
+
}
|
|
39
|
+
catch (cleanupError) {
|
|
40
|
+
throw new Error(`${errorMessage(error, "MCP proxy listen failed.")}; upstream cleanup failed: ${errorMessage(cleanupError, "MCP proxy upstream close failed.")}`);
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
this.#httpServer = httpServer;
|
|
45
|
+
this.#adapterAttachment = {
|
|
46
|
+
name: this.#attachment.name,
|
|
47
|
+
transport: "streamable_http",
|
|
48
|
+
url: `http://${this.#host}:${boundPort}/mcp`,
|
|
49
|
+
headers: {},
|
|
50
|
+
...(this.#attachment.allowed_tools ? { allowed_tools: this.#attachment.allowed_tools } : {}),
|
|
51
|
+
...(this.#attachment.denied_tools ? { denied_tools: this.#attachment.denied_tools } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
async listTools() {
|
|
55
|
+
return await this.#upstream.listTools();
|
|
56
|
+
}
|
|
57
|
+
async close() {
|
|
58
|
+
const httpServer = this.#httpServer;
|
|
59
|
+
this.#httpServer = undefined;
|
|
60
|
+
this.#adapterAttachment = undefined;
|
|
61
|
+
const errors = [];
|
|
62
|
+
if (httpServer) {
|
|
63
|
+
try {
|
|
64
|
+
await closeHttpServer(httpServer);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
errors.push(errorMessage(error, "MCP proxy HTTP close failed."));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await this.#upstream.close();
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
errors.push(errorMessage(error, "MCP proxy upstream close failed."));
|
|
75
|
+
}
|
|
76
|
+
if (errors.length > 0) {
|
|
77
|
+
throw new Error(errors.join("; "));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async #handleRequest(request, response) {
|
|
81
|
+
if (request.url !== "/mcp") {
|
|
82
|
+
writeJsonRpcError(response, 404, "mcp_proxy_not_found", "MCP proxy only serves /mcp.");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const parsedBody = request.method === "POST" ? await readJsonBody(request) : undefined;
|
|
86
|
+
const server = createProxySdkServer(this.#attachment.name, this.#upstream);
|
|
87
|
+
const transport = new StreamableHTTPServerTransport({
|
|
88
|
+
sessionIdGenerator: undefined,
|
|
89
|
+
});
|
|
90
|
+
await server.connect(transport);
|
|
91
|
+
response.on("close", () => {
|
|
92
|
+
transport.close().catch(() => undefined);
|
|
93
|
+
server.close().catch(() => undefined);
|
|
94
|
+
});
|
|
95
|
+
await transport.handleRequest(request, response, parsedBody);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function createProxySdkServer(attachmentName, upstream) {
|
|
99
|
+
const server = new Server({ name: `hcp-mcp-proxy-${attachmentName}`, version: "0.0.0" }, {
|
|
100
|
+
capabilities: {
|
|
101
|
+
tools: {},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
105
|
+
const tools = await upstream.listTools();
|
|
106
|
+
return {
|
|
107
|
+
tools: tools.map(toSdkTool),
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
111
|
+
const result = await upstream.callTool(request.params.name, request.params.arguments ?? {});
|
|
112
|
+
return toSdkToolCallResult(result);
|
|
113
|
+
});
|
|
114
|
+
return server;
|
|
115
|
+
}
|
|
116
|
+
function toSdkTool(tool) {
|
|
117
|
+
return {
|
|
118
|
+
name: tool.name,
|
|
119
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
120
|
+
inputSchema: tool.input_schema,
|
|
121
|
+
...(tool.output_schema ? { outputSchema: tool.output_schema } : {}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function toSdkToolCallResult(result) {
|
|
125
|
+
return {
|
|
126
|
+
content: (result.content ?? []),
|
|
127
|
+
...(result.structured_content ? { structuredContent: result.structured_content } : {}),
|
|
128
|
+
...(result.is_error ? { isError: true } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function listen(server, host, port) {
|
|
132
|
+
return await new Promise((resolve, reject) => {
|
|
133
|
+
const onError = (error) => {
|
|
134
|
+
server.off("listening", onListening);
|
|
135
|
+
reject(error);
|
|
136
|
+
};
|
|
137
|
+
const onListening = () => {
|
|
138
|
+
server.off("error", onError);
|
|
139
|
+
const address = server.address();
|
|
140
|
+
if (typeof address === "object" && address !== null) {
|
|
141
|
+
resolve(address.port);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
reject(new Error("MCP proxy server did not expose a TCP port."));
|
|
145
|
+
};
|
|
146
|
+
server.once("error", onError);
|
|
147
|
+
server.once("listening", onListening);
|
|
148
|
+
server.listen(port, host);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async function closeHttpServer(server) {
|
|
152
|
+
await new Promise((resolve, reject) => {
|
|
153
|
+
server.close((error) => {
|
|
154
|
+
if (error) {
|
|
155
|
+
reject(error);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
resolve();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async function readJsonBody(request) {
|
|
163
|
+
const chunks = [];
|
|
164
|
+
for await (const chunk of request) {
|
|
165
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
166
|
+
}
|
|
167
|
+
const rawBody = Buffer.concat(chunks).toString("utf8");
|
|
168
|
+
return rawBody.length === 0 ? undefined : JSON.parse(rawBody);
|
|
169
|
+
}
|
|
170
|
+
function writeJsonRpcError(response, statusCode, code, message) {
|
|
171
|
+
if (response.headersSent) {
|
|
172
|
+
response.end();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
response.writeHead(statusCode, { "content-type": "application/json" });
|
|
176
|
+
response.end(JSON.stringify({
|
|
177
|
+
jsonrpc: "2.0",
|
|
178
|
+
error: {
|
|
179
|
+
code,
|
|
180
|
+
message,
|
|
181
|
+
},
|
|
182
|
+
id: null,
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
function errorMessage(error, fallback) {
|
|
186
|
+
return error instanceof Error ? error.message : fallback;
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=McpProxyServer.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type McpToolCallArguments, type McpToolCallResult, type McpToolDescriptor } from "./McpAttachmentClient.js";
|
|
2
|
+
export type McpStdioProfileClientOptions = {
|
|
3
|
+
name: string;
|
|
4
|
+
command: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
cwd: string;
|
|
7
|
+
env: Record<string, string>;
|
|
8
|
+
allowedTools?: string[];
|
|
9
|
+
deniedTools?: string[];
|
|
10
|
+
};
|
|
11
|
+
export declare class McpStdioProfileClient {
|
|
12
|
+
#private;
|
|
13
|
+
constructor(options: McpStdioProfileClientOptions);
|
|
14
|
+
connect(): Promise<void>;
|
|
15
|
+
listTools(): Promise<McpToolDescriptor[]>;
|
|
16
|
+
callTool(name: string, arguments_?: McpToolCallArguments): Promise<McpToolCallResult>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=McpStdioProfileClient.d.ts.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client";
|
|
2
|
+
import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { McpToolPolicyError } from "./McpAttachmentClient.js";
|
|
5
|
+
import { redactValue } from "./redaction.js";
|
|
6
|
+
export class McpStdioProfileClient {
|
|
7
|
+
#options;
|
|
8
|
+
#allowedTools;
|
|
9
|
+
#deniedTools;
|
|
10
|
+
#client;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.#options = options;
|
|
13
|
+
this.#allowedTools = options.allowedTools ? new Set(options.allowedTools) : undefined;
|
|
14
|
+
this.#deniedTools = new Set(options.deniedTools ?? []);
|
|
15
|
+
}
|
|
16
|
+
async connect() {
|
|
17
|
+
if (this.#client)
|
|
18
|
+
return;
|
|
19
|
+
const client = new Client({ name: "hcp-runner", version: "0.0.0" });
|
|
20
|
+
const transport = new StdioClientTransport({
|
|
21
|
+
command: this.#options.command,
|
|
22
|
+
args: this.#options.args,
|
|
23
|
+
cwd: this.#options.cwd,
|
|
24
|
+
env: { ...getDefaultEnvironment(), ...this.#options.env },
|
|
25
|
+
stderr: "pipe",
|
|
26
|
+
});
|
|
27
|
+
let stderr = "";
|
|
28
|
+
if (transport.stderr instanceof Readable) {
|
|
29
|
+
transport.stderr.on("data", (chunk) => {
|
|
30
|
+
stderr = `${stderr}${chunk.toString("utf8")}`.slice(-16_384);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
await client.connect(transport);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const message = error instanceof Error ? error.message : "MCP stdio connection failed.";
|
|
38
|
+
const redacted = redactValue(stderr.trim());
|
|
39
|
+
throw new Error(`${message}${typeof redacted === "string" && redacted ? `: ${redacted}` : ""}`);
|
|
40
|
+
}
|
|
41
|
+
this.#client = client;
|
|
42
|
+
}
|
|
43
|
+
async listTools() {
|
|
44
|
+
const client = this.#requireClient();
|
|
45
|
+
const result = await client.listTools();
|
|
46
|
+
return result.tools.filter((tool) => this.#isAllowed(tool.name)).map(toDescriptor);
|
|
47
|
+
}
|
|
48
|
+
async callTool(name, arguments_ = {}) {
|
|
49
|
+
if (!this.#isAllowed(name)) {
|
|
50
|
+
throw new McpToolPolicyError(this.#options.name, name, `MCP tool "${name}" is not allowed for runner profile attachment "${this.#options.name}".`);
|
|
51
|
+
}
|
|
52
|
+
const result = await this.#requireClient().callTool({ name, arguments: arguments_ });
|
|
53
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
54
|
+
const structuredContent = isRecord(result.structuredContent)
|
|
55
|
+
? result.structuredContent
|
|
56
|
+
: undefined;
|
|
57
|
+
return {
|
|
58
|
+
content,
|
|
59
|
+
...(structuredContent ? { structured_content: structuredContent } : {}),
|
|
60
|
+
is_error: result.isError === true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async close() {
|
|
64
|
+
const client = this.#client;
|
|
65
|
+
this.#client = undefined;
|
|
66
|
+
if (client)
|
|
67
|
+
await client.close();
|
|
68
|
+
}
|
|
69
|
+
#isAllowed(toolName) {
|
|
70
|
+
if (this.#deniedTools.has(toolName))
|
|
71
|
+
return false;
|
|
72
|
+
return this.#allowedTools ? this.#allowedTools.has(toolName) : true;
|
|
73
|
+
}
|
|
74
|
+
#requireClient() {
|
|
75
|
+
if (!this.#client)
|
|
76
|
+
throw new Error(`Runner MCP profile '${this.#options.name}' is not connected.`);
|
|
77
|
+
return this.#client;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function toDescriptor(tool) {
|
|
81
|
+
return {
|
|
82
|
+
name: tool.name,
|
|
83
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
84
|
+
input_schema: tool.inputSchema,
|
|
85
|
+
...(tool.outputSchema ? { output_schema: tool.outputSchema } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function isRecord(value) {
|
|
89
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=McpStdioProfileClient.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { McpAttachmentClient, McpAttachmentExpiredError, McpToolPolicyError, createDevelopmentHmacProofSigner, type McpAttachmentClientOptions, type McpAttachmentEvent, type McpAttachmentEventSink, type McpToolDescriptor, type McpToolCallArguments, type McpToolCallResult, } from "./McpAttachmentClient.js";
|
|
2
|
+
export { McpProxyServer, type McpProxyServerOptions, type McpProxyUpstream } from "./McpProxyServer.js";
|
|
3
|
+
export { McpStdioProfileClient, type McpStdioProfileClientOptions } from "./McpStdioProfileClient.js";
|
|
4
|
+
export { redactHeaders, redactValue } from "./redaction.js";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { McpAttachmentClient, McpAttachmentExpiredError, McpToolPolicyError, createDevelopmentHmacProofSigner, } from "./McpAttachmentClient.js";
|
|
2
|
+
export { McpProxyServer } from "./McpProxyServer.js";
|
|
3
|
+
export { McpStdioProfileClient } from "./McpStdioProfileClient.js";
|
|
4
|
+
export { redactHeaders, redactValue } from "./redaction.js";
|
|
5
|
+
//# sourceMappingURL=index.js.map
|