@lenso/mcp 0.2.1 → 0.3.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/README.md +149 -10
- package/dist/adapter.d.ts +29 -0
- package/dist/borrowed-stdio.d.ts +13 -0
- package/dist/http.d.ts +39 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +897 -43
- package/dist/protocol.d.ts +11 -0
- package/package.json +5 -6
package/dist/index.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/index.ts
|
|
3
|
-
import { Console } from "console";
|
|
3
|
+
import { Console as Console2 } from "console";
|
|
4
4
|
import { join, resolve } from "path";
|
|
5
|
-
import { Writable } from "stream";
|
|
6
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { Writable as Writable2 } from "stream";
|
|
6
|
+
import { Server as Server3 } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
+
import { StdioServerTransport as StdioServerTransport2 } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
8
|
import {
|
|
9
|
-
CallToolRequestSchema,
|
|
10
|
-
ErrorCode,
|
|
11
|
-
ListToolsRequestSchema,
|
|
12
|
-
McpError,
|
|
13
|
-
ToolSchema
|
|
9
|
+
CallToolRequestSchema as CallToolRequestSchema2,
|
|
10
|
+
ErrorCode as ErrorCode4,
|
|
11
|
+
ListToolsRequestSchema as ListToolsRequestSchema2,
|
|
12
|
+
McpError as McpError4,
|
|
13
|
+
ToolSchema as ToolSchema2
|
|
14
14
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
15
15
|
import { invoke, CliError, diagnostic } from "@lenso/cli";
|
|
16
16
|
import { readApplication } from "@lenso/engine/application";
|
|
@@ -19,22 +19,870 @@ import {
|
|
|
19
19
|
redactOperationDescription,
|
|
20
20
|
validateOperations
|
|
21
21
|
} from "@lenso/engine/operations";
|
|
22
|
-
import { environmentSecrets, redact, stableJson } from "@lenso/engine/diagnostics";
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
import { environmentSecrets as environmentSecrets2, redact as redact2, stableJson } from "@lenso/engine/diagnostics";
|
|
23
|
+
|
|
24
|
+
// src/borrowed-stdio.ts
|
|
25
|
+
import { Console } from "console";
|
|
26
|
+
import { Writable } from "stream";
|
|
27
|
+
import { finished } from "stream/promises";
|
|
28
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
29
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
30
|
+
import {
|
|
31
|
+
ErrorCode as ErrorCode3,
|
|
32
|
+
McpError as McpError3,
|
|
33
|
+
isJSONRPCErrorResponse,
|
|
34
|
+
isJSONRPCRequest,
|
|
35
|
+
isJSONRPCResultResponse
|
|
36
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
37
|
+
import { environmentSecrets, redact } from "@lenso/engine/diagnostics";
|
|
38
|
+
|
|
39
|
+
// src/adapter.ts
|
|
40
|
+
import { createAgentTools, createManageAdapter } from "@lenso/manage";
|
|
41
|
+
import {
|
|
42
|
+
boundedJson
|
|
43
|
+
} from "@lenso/engine/operations";
|
|
44
|
+
import { EngineError } from "@lenso/engine/diagnostics";
|
|
45
|
+
import {
|
|
46
|
+
ErrorCode,
|
|
47
|
+
McpError,
|
|
48
|
+
ToolSchema
|
|
49
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
50
|
+
var messages = {
|
|
51
|
+
"invalid-input": "Input does not satisfy the operation schema.",
|
|
52
|
+
"input-too-large": "Operation input exceeds the host limit.",
|
|
53
|
+
"output-too-large": "Operation output exceeds the host limit.",
|
|
54
|
+
"serialization-failed": "Operation did not return finite JSON.",
|
|
55
|
+
"confirmation-required": "Required confirmation is unavailable.",
|
|
56
|
+
"approval-required": "Required approval is unavailable.",
|
|
57
|
+
"missing-context-binding": "Required operation context is unavailable.",
|
|
58
|
+
"forbidden-operation": "Operation is not available to this caller.",
|
|
59
|
+
"unavailable-operation": "Operation is unavailable.",
|
|
60
|
+
"invocation-failed": "Operation invocation failed.",
|
|
61
|
+
"request-cancelled": "Request cancelled; external effects may have occurred.",
|
|
62
|
+
"request-timeout": "Request timed out; work may continue and external effects may have occurred.",
|
|
63
|
+
"adapter-busy": "Adapter is busy; no request was queued.",
|
|
64
|
+
"adapter-closed": "Adapter is closed.",
|
|
65
|
+
UNAUTHORIZED: "Authentication required.",
|
|
66
|
+
REAUTHENTICATION_REQUIRED: "Reauthentication required.",
|
|
67
|
+
FORBIDDEN: "Access denied.",
|
|
68
|
+
SERVICE_UNAVAILABLE: "Service unavailable.",
|
|
69
|
+
"resource-not-found": "Resource not found.",
|
|
70
|
+
"not-found": "Resource not found.",
|
|
71
|
+
forbidden: "Access denied.",
|
|
72
|
+
"permission-denied": "Access denied.",
|
|
73
|
+
"authorization-denied": "Access denied.",
|
|
74
|
+
conflict: "Operation conflicts with current state.",
|
|
75
|
+
"deduplication-conflict": "Operation conflicts with current state.",
|
|
76
|
+
"invalid-key": "Invalid operation input.",
|
|
77
|
+
"invalid-task": "Invalid operation input.",
|
|
78
|
+
"invalid-options": "Invalid operation input.",
|
|
79
|
+
"too-large": "Operation exceeds the service limit.",
|
|
80
|
+
"provider-unavailable": "Service unavailable.",
|
|
81
|
+
closed: "Service unavailable.",
|
|
82
|
+
provider: "Provider operation failed.",
|
|
83
|
+
unsupported: "Operation is unsupported.",
|
|
84
|
+
aborted: "Request cancelled; external effects may have occurred."
|
|
85
|
+
};
|
|
86
|
+
function positive(value, fallback, min = 1) {
|
|
87
|
+
const actual = value ?? fallback;
|
|
88
|
+
if (!Number.isSafeInteger(actual) || actual < min)
|
|
89
|
+
throw new TypeError("Invalid MCP limit.");
|
|
90
|
+
return actual;
|
|
91
|
+
}
|
|
92
|
+
function failure(code) {
|
|
93
|
+
const safe = Object.hasOwn(messages, code) ? code : "invocation-failed";
|
|
94
|
+
const phase = safe === "invalid-input" || safe === "input-too-large" ? "input" : safe === "output-too-large" || safe === "serialization-failed" ? "output" : "invoke";
|
|
95
|
+
return {
|
|
96
|
+
isError: true,
|
|
97
|
+
content: [
|
|
98
|
+
{ type: "text", text: JSON.stringify({ code: safe, phase, message: messages[safe] }) }
|
|
99
|
+
]
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
async function createMcpAdapter(options) {
|
|
103
|
+
const maxInput = positive(options.maxInputBytes, 256 * 1024);
|
|
104
|
+
const maxOutput = positive(options.maxOutputBytes, 1024 * 1024, 256);
|
|
105
|
+
const maxCatalog = positive(options.maxCatalogBytes, 256 * 1024);
|
|
106
|
+
const maxConcurrent = positive(options.maxConcurrentCalls, 4);
|
|
107
|
+
const timeout = positive(options.requestTimeoutMs, 30000);
|
|
108
|
+
const operations = Object.freeze([...options.operations]);
|
|
109
|
+
const plugins = Object.freeze([...options.plugins]);
|
|
110
|
+
if ([options.binding, options.canList, options.authorize].some((fn) => typeof fn !== "function"))
|
|
111
|
+
throw new TypeError("MCP requires trusted binding, catalog and invocation policies.");
|
|
112
|
+
const canonical = createManageAdapter({
|
|
113
|
+
running: options.running,
|
|
114
|
+
plugins,
|
|
115
|
+
operations,
|
|
116
|
+
canList: () => true,
|
|
117
|
+
binding: () => {
|
|
118
|
+
throw new Error("Metadata adapter cannot invoke.");
|
|
119
|
+
},
|
|
120
|
+
maxOutputBytes: maxCatalog
|
|
121
|
+
});
|
|
122
|
+
const [catalog, agentTools] = await Promise.all([
|
|
123
|
+
canonical.catalog(),
|
|
124
|
+
createAgentTools(canonical)
|
|
125
|
+
]);
|
|
126
|
+
const tools = agentTools.map((item, index) => {
|
|
127
|
+
const operation = catalog[index];
|
|
128
|
+
const tool = {
|
|
129
|
+
name: item.name,
|
|
130
|
+
title: `${operation.pluginId}.${operation.method}`,
|
|
131
|
+
description: [
|
|
132
|
+
item.description,
|
|
133
|
+
...operation.outputDescription ? [`Output: ${operation.outputDescription}`] : [],
|
|
134
|
+
"Cancellation signals reach host bindings. Only cooperative services interrupt; cancellation does not roll back effects."
|
|
135
|
+
].join(`
|
|
136
|
+
`),
|
|
137
|
+
inputSchema: item.inputSchema,
|
|
138
|
+
_meta: { "lenso/operation": operation },
|
|
139
|
+
execution: { taskSupport: "forbidden" },
|
|
140
|
+
annotations: {
|
|
141
|
+
readOnlyHint: operation.effect === "read",
|
|
142
|
+
destructiveHint: operation.destructive ?? true,
|
|
143
|
+
idempotentHint: operation.retry === "safe",
|
|
144
|
+
openWorldHint: true
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
if (!ToolSchema.safeParse(tool).success)
|
|
148
|
+
throw new TypeError("Operation cannot be represented as an MCP tool.");
|
|
149
|
+
return tool;
|
|
150
|
+
});
|
|
151
|
+
boundedJson({ tools }, maxCatalog);
|
|
152
|
+
let closed = false;
|
|
153
|
+
let closePromise;
|
|
154
|
+
const pending = new Set;
|
|
155
|
+
const controllers = new Set;
|
|
156
|
+
function tracked(request, action) {
|
|
157
|
+
const controller = new AbortController;
|
|
158
|
+
const signal = AbortSignal.any([request.signal, controller.signal]);
|
|
159
|
+
controllers.add(controller);
|
|
160
|
+
let timer;
|
|
161
|
+
let abort;
|
|
162
|
+
let timedOut = false;
|
|
163
|
+
const interrupted = new Promise((_, reject) => {
|
|
164
|
+
abort = () => reject(new EngineError({
|
|
165
|
+
code: timedOut ? "request-timeout" : "request-cancelled",
|
|
166
|
+
phase: "invoke",
|
|
167
|
+
message: "MCP request interrupted."
|
|
168
|
+
}));
|
|
169
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
170
|
+
if (signal.aborted)
|
|
171
|
+
abort();
|
|
172
|
+
timer = setTimeout(() => {
|
|
173
|
+
timedOut = true;
|
|
174
|
+
controller.abort();
|
|
175
|
+
}, timeout);
|
|
176
|
+
});
|
|
177
|
+
const work = Promise.resolve().then(() => {
|
|
178
|
+
signal.throwIfAborted();
|
|
179
|
+
return action({ ...request, signal });
|
|
180
|
+
});
|
|
181
|
+
pending.add(work);
|
|
182
|
+
const cleanup = () => {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
signal.removeEventListener("abort", abort);
|
|
185
|
+
controllers.delete(controller);
|
|
186
|
+
pending.delete(work);
|
|
187
|
+
};
|
|
188
|
+
work.then(cleanup, cleanup);
|
|
189
|
+
return Promise.race([work, interrupted]);
|
|
190
|
+
}
|
|
191
|
+
return Object.freeze({
|
|
192
|
+
async listTools(request) {
|
|
193
|
+
if (closed)
|
|
194
|
+
throw new McpError(ErrorCode.InvalidRequest, "Adapter is closed.");
|
|
195
|
+
if (pending.size >= maxConcurrent)
|
|
196
|
+
throw new McpError(ErrorCode.InternalError, "Adapter is busy.");
|
|
197
|
+
try {
|
|
198
|
+
return await tracked(request, async (context) => {
|
|
199
|
+
const visible = [];
|
|
200
|
+
for (const [index, operation] of operations.entries()) {
|
|
201
|
+
if (await options.canList(operation, context) === true)
|
|
202
|
+
visible.push(tools[index]);
|
|
203
|
+
context.signal.throwIfAborted();
|
|
204
|
+
}
|
|
205
|
+
return JSON.parse(boundedJson({ tools: visible }, maxCatalog));
|
|
206
|
+
});
|
|
207
|
+
} catch {
|
|
208
|
+
throw new McpError(ErrorCode.InternalError, "Tool discovery failed or was interrupted.");
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
async callTool(name, input, request) {
|
|
212
|
+
const match = /^operation_(0|[1-9][0-9]*)$/.exec(name);
|
|
213
|
+
if (!match || !operations[Number(match[1])])
|
|
214
|
+
throw new McpError(ErrorCode.InvalidParams, "Unknown tool.");
|
|
215
|
+
if (closed)
|
|
216
|
+
return failure("adapter-closed");
|
|
217
|
+
if (pending.size >= maxConcurrent)
|
|
218
|
+
return failure("adapter-busy");
|
|
219
|
+
if (request.signal.aborted)
|
|
220
|
+
return failure("request-cancelled");
|
|
221
|
+
try {
|
|
222
|
+
boundedJson(input, maxInput);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return failure(error instanceof EngineError && error.diagnostic.code === "output-too-large" ? "input-too-large" : "invalid-input");
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const value = await tracked(request, async (context) => {
|
|
228
|
+
const scoped = createManageAdapter({
|
|
229
|
+
running: options.running,
|
|
230
|
+
plugins,
|
|
231
|
+
operations,
|
|
232
|
+
maxOutputBytes: maxOutput,
|
|
233
|
+
canList: async (operation) => {
|
|
234
|
+
const visible = await options.canList(operation, context);
|
|
235
|
+
const allowed = visible === true && await options.authorize(operation, context) === true;
|
|
236
|
+
context.signal.throwIfAborted();
|
|
237
|
+
return allowed;
|
|
238
|
+
},
|
|
239
|
+
binding: async (operation, validated) => {
|
|
240
|
+
const binding = await options.binding(operation, validated, context);
|
|
241
|
+
context.signal.throwIfAborted();
|
|
242
|
+
return {
|
|
243
|
+
...binding,
|
|
244
|
+
signal: binding.signal ? AbortSignal.any([context.signal, binding.signal]) : context.signal
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
const result = await scoped.invokeEntry(name, input);
|
|
249
|
+
context.signal.throwIfAborted();
|
|
250
|
+
return result;
|
|
251
|
+
});
|
|
252
|
+
return { content: [{ type: "text", text: boundedJson(value, maxOutput) }] };
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return failure(error instanceof EngineError ? error.diagnostic.code : request.signal.aborted ? "request-cancelled" : "invocation-failed");
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
close() {
|
|
258
|
+
if (closePromise)
|
|
259
|
+
return closePromise;
|
|
260
|
+
closed = true;
|
|
261
|
+
for (const controller of controllers)
|
|
262
|
+
controller.abort();
|
|
263
|
+
closePromise = Promise.allSettled(pending).then(() => {});
|
|
264
|
+
return closePromise;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/protocol.ts
|
|
270
|
+
import {
|
|
271
|
+
CallToolRequestSchema,
|
|
272
|
+
CancelledNotificationSchema,
|
|
273
|
+
ErrorCode as ErrorCode2,
|
|
274
|
+
ListToolsRequestSchema,
|
|
275
|
+
McpError as McpError2
|
|
276
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
277
|
+
function bindMcpServer(server, adapter, evidence) {
|
|
278
|
+
const cancellations = new Map;
|
|
279
|
+
const prepare = (requestId) => {
|
|
280
|
+
const controller = new AbortController;
|
|
281
|
+
cancellations.set(requestId, controller);
|
|
282
|
+
return () => {
|
|
283
|
+
if (cancellations.get(requestId) === controller)
|
|
284
|
+
cancellations.delete(requestId);
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
const dispatch = async (extra, action) => {
|
|
288
|
+
const controller = cancellations.get(extra.requestId) ?? new AbortController;
|
|
289
|
+
cancellations.set(extra.requestId, controller);
|
|
290
|
+
try {
|
|
291
|
+
const request = evidence(extra);
|
|
292
|
+
return await action({
|
|
293
|
+
...request,
|
|
294
|
+
signal: AbortSignal.any([extra.signal, controller.signal])
|
|
295
|
+
});
|
|
296
|
+
} finally {
|
|
297
|
+
cancellations.delete(extra.requestId);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
server.setNotificationHandler(CancelledNotificationSchema, (notification) => {
|
|
301
|
+
if (notification.params.requestId !== undefined)
|
|
302
|
+
cancellations.get(notification.params.requestId)?.abort();
|
|
303
|
+
});
|
|
304
|
+
server.setRequestHandler(ListToolsRequestSchema, (_request, extra) => dispatch(extra, (context) => adapter.listTools(context)));
|
|
305
|
+
server.setRequestHandler(CallToolRequestSchema, (request, extra) => {
|
|
306
|
+
if (request.params.task)
|
|
307
|
+
throw new McpError2(ErrorCode2.InvalidParams, "Task-augmented calls are not supported.");
|
|
308
|
+
return dispatch(extra, (context) => adapter.callTool(request.params.name, request.params.arguments ?? {}, context));
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
prepare,
|
|
312
|
+
finish: (requestId) => {
|
|
313
|
+
cancellations.delete(requestId);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/borrowed-stdio.ts
|
|
319
|
+
var stdioOwned = false;
|
|
320
|
+
function acquireStdioOwnership() {
|
|
321
|
+
if (stdioOwned)
|
|
322
|
+
throw new TypeError("Only one stdio adapter may own this process.");
|
|
323
|
+
stdioOwned = true;
|
|
324
|
+
let released = false;
|
|
325
|
+
return () => {
|
|
326
|
+
if (released)
|
|
327
|
+
return;
|
|
328
|
+
released = true;
|
|
329
|
+
stdioOwned = false;
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
async function serveBorrowedStdio(options) {
|
|
333
|
+
const maxFrame = options.maxFrameBytes ?? 1024 * 1024;
|
|
334
|
+
if (!Number.isSafeInteger(maxFrame) || maxFrame < 1)
|
|
335
|
+
throw new TypeError("Invalid MCP limit.");
|
|
336
|
+
const release = acquireStdioOwnership();
|
|
337
|
+
const originalConsole = globalThis.console;
|
|
338
|
+
let identity;
|
|
339
|
+
const logs = new Writable({
|
|
340
|
+
write(chunk, _encoding, done) {
|
|
341
|
+
process.stderr.write(String(redact(chunk.toString(), environmentSecrets())), done);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
let adapter;
|
|
345
|
+
let server;
|
|
346
|
+
let transport;
|
|
347
|
+
let closing = false;
|
|
348
|
+
let closePromise;
|
|
349
|
+
const close = () => {
|
|
350
|
+
if (closePromise)
|
|
351
|
+
return closePromise;
|
|
352
|
+
closing = true;
|
|
353
|
+
closePromise = Promise.resolve().then(async () => {
|
|
354
|
+
const errors = [];
|
|
355
|
+
try {
|
|
356
|
+
try {
|
|
357
|
+
await adapter?.close();
|
|
358
|
+
} catch (error) {
|
|
359
|
+
errors.push(error);
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
await server?.close();
|
|
363
|
+
} catch (error) {
|
|
364
|
+
errors.push(error);
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
await transport?.close();
|
|
368
|
+
} catch (error) {
|
|
369
|
+
errors.push(error);
|
|
370
|
+
}
|
|
371
|
+
} finally {
|
|
372
|
+
process.stdin.off("end", onEnd);
|
|
373
|
+
process.off("SIGINT", onEnd);
|
|
374
|
+
process.off("SIGTERM", onEnd);
|
|
375
|
+
globalThis.console = originalConsole;
|
|
376
|
+
try {
|
|
377
|
+
logs.end();
|
|
378
|
+
await finished(logs);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
errors.push(error);
|
|
381
|
+
}
|
|
382
|
+
release();
|
|
383
|
+
}
|
|
384
|
+
if (errors.length)
|
|
385
|
+
throw new AggregateError(errors, "MCP close failed.");
|
|
386
|
+
});
|
|
387
|
+
return closePromise;
|
|
388
|
+
};
|
|
389
|
+
const onEnd = () => {
|
|
390
|
+
close().catch(() => {
|
|
391
|
+
process.exitCode = 1;
|
|
392
|
+
});
|
|
393
|
+
};
|
|
394
|
+
const context = () => {
|
|
395
|
+
if (closing)
|
|
396
|
+
throw new McpError3(ErrorCode3.InvalidRequest, "Adapter is shutting down.");
|
|
397
|
+
return { identity, requestId: crypto.randomUUID() };
|
|
398
|
+
};
|
|
399
|
+
try {
|
|
400
|
+
identity = options.identity;
|
|
401
|
+
globalThis.console = Object.assign(new Console({ stdout: logs, stderr: logs }), {
|
|
402
|
+
write(...values) {
|
|
403
|
+
const text = values.map((value) => typeof value === "string" ? value : Buffer.from(ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value)).toString()).join("");
|
|
404
|
+
logs.write(text);
|
|
405
|
+
return Buffer.byteLength(text);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
for (const level of [
|
|
409
|
+
"log",
|
|
410
|
+
"info",
|
|
411
|
+
"warn",
|
|
412
|
+
"error",
|
|
413
|
+
"debug",
|
|
414
|
+
"dir",
|
|
415
|
+
"dirxml",
|
|
416
|
+
"table",
|
|
417
|
+
"trace"
|
|
418
|
+
]) {
|
|
419
|
+
globalThis.console[level] = (...values) => {
|
|
420
|
+
const safe = values.map((value) => value instanceof Error ? "[Application error text omitted]" : redact(value, environmentSecrets()));
|
|
421
|
+
logs.write(`${safe.map((value) => typeof value === "string" ? value : JSON.stringify(value)).join(" ")}
|
|
422
|
+
`);
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
globalThis.console.assert = (condition, ...values) => {
|
|
426
|
+
if (!condition)
|
|
427
|
+
globalThis.console.error("Assertion failed:", ...values);
|
|
428
|
+
};
|
|
429
|
+
adapter = await createMcpAdapter(options);
|
|
430
|
+
const borrowed = adapter;
|
|
431
|
+
server = new Server({ name: "lenso", version: "0.2.1" }, { capabilities: { tools: {} } });
|
|
432
|
+
const protocol = bindMcpServer(server, borrowed, context);
|
|
433
|
+
server.onerror = () => {
|
|
434
|
+
process.stderr.write(`MCP protocol error (details omitted).
|
|
435
|
+
`);
|
|
436
|
+
};
|
|
437
|
+
server.onclose = onEnd;
|
|
438
|
+
process.stdin.once("end", onEnd);
|
|
439
|
+
process.on("SIGINT", onEnd);
|
|
440
|
+
process.on("SIGTERM", onEnd);
|
|
441
|
+
transport = new StdioServerTransport(process.stdin, process.stdout, {
|
|
442
|
+
maxBufferSize: maxFrame
|
|
443
|
+
});
|
|
444
|
+
await server.connect(transport);
|
|
445
|
+
const incoming = transport.onmessage;
|
|
446
|
+
transport.onmessage = (message) => {
|
|
447
|
+
if (isJSONRPCRequest(message) && (message.method === "tools/list" || message.method === "tools/call"))
|
|
448
|
+
protocol.prepare(message.id);
|
|
449
|
+
incoming?.(message);
|
|
450
|
+
};
|
|
451
|
+
const send = transport.send.bind(transport);
|
|
452
|
+
transport.send = async (message) => {
|
|
453
|
+
try {
|
|
454
|
+
await send(message);
|
|
455
|
+
} finally {
|
|
456
|
+
if ((isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) && message.id !== undefined)
|
|
457
|
+
protocol.finish(message.id);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
if (process.stdin.readableEnded)
|
|
461
|
+
onEnd();
|
|
462
|
+
if (closing) {
|
|
463
|
+
await close();
|
|
464
|
+
throw new McpError3(ErrorCode3.InvalidRequest, "Adapter closed during startup.");
|
|
465
|
+
}
|
|
466
|
+
return Object.freeze({ close });
|
|
467
|
+
} catch (error) {
|
|
468
|
+
try {
|
|
469
|
+
await close();
|
|
470
|
+
} catch (closeError) {
|
|
471
|
+
throw new AggregateError([error, closeError], "MCP startup and close failed.", {
|
|
472
|
+
cause: closeError
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// src/http.ts
|
|
479
|
+
import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
|
|
480
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
481
|
+
import {
|
|
482
|
+
SUPPORTED_PROTOCOL_VERSIONS,
|
|
483
|
+
isInitializeRequest
|
|
484
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
485
|
+
function positive2(value, fallback) {
|
|
486
|
+
const result = value ?? fallback;
|
|
487
|
+
if (!Number.isSafeInteger(result) || result < 1)
|
|
488
|
+
throw new TypeError("Invalid MCP limit.");
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
function trustedUrl(value) {
|
|
492
|
+
const url = new URL(value);
|
|
493
|
+
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
|
494
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username || url.password || url.hash || url.search)
|
|
495
|
+
throw new TypeError("MCP URLs require HTTPS (HTTP only on loopback), without credentials, query or fragment.");
|
|
496
|
+
return url;
|
|
497
|
+
}
|
|
498
|
+
function reply(status, code, headers) {
|
|
499
|
+
return Response.json({ error: code }, { status, headers: { "cache-control": "no-store", ...headers } });
|
|
500
|
+
}
|
|
501
|
+
async function readBody(request, maxBytes, signal) {
|
|
502
|
+
const declared = request.headers.get("content-length");
|
|
503
|
+
if (declared && (!/^\d+$/.test(declared) || Number(declared) > maxBytes))
|
|
504
|
+
throw new RangeError("Body limit.");
|
|
505
|
+
const reader = request.body?.getReader();
|
|
506
|
+
if (!reader)
|
|
507
|
+
throw new SyntaxError("Missing body.");
|
|
508
|
+
const chunks = [];
|
|
509
|
+
let size = 0;
|
|
510
|
+
const abort = () => {
|
|
511
|
+
reader.cancel().catch(() => {});
|
|
512
|
+
};
|
|
513
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
514
|
+
try {
|
|
515
|
+
while (true) {
|
|
516
|
+
signal.throwIfAborted();
|
|
517
|
+
const { value, done } = await reader.read();
|
|
518
|
+
signal.throwIfAborted();
|
|
519
|
+
if (done)
|
|
520
|
+
break;
|
|
521
|
+
size += value.byteLength;
|
|
522
|
+
if (size > maxBytes)
|
|
523
|
+
throw new RangeError("Body limit.");
|
|
524
|
+
chunks.push(value);
|
|
525
|
+
}
|
|
526
|
+
const bytes = new Uint8Array(size);
|
|
527
|
+
let offset = 0;
|
|
528
|
+
for (const chunk of chunks) {
|
|
529
|
+
bytes.set(chunk, offset);
|
|
530
|
+
offset += chunk.byteLength;
|
|
531
|
+
}
|
|
532
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
533
|
+
} finally {
|
|
534
|
+
signal.removeEventListener("abort", abort);
|
|
535
|
+
reader.cancel().catch(() => {});
|
|
536
|
+
reader.releaseLock();
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async function protocolResponse(response, maxBytes, signal) {
|
|
540
|
+
if (!response.body)
|
|
541
|
+
return response;
|
|
542
|
+
const reader = response.body.getReader();
|
|
543
|
+
const abort = () => {
|
|
544
|
+
reader.cancel().catch(() => {});
|
|
545
|
+
};
|
|
546
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
547
|
+
const chunks = [];
|
|
548
|
+
let size = 0;
|
|
549
|
+
try {
|
|
550
|
+
while (true) {
|
|
551
|
+
signal.throwIfAborted();
|
|
552
|
+
const { value, done } = await reader.read();
|
|
553
|
+
signal.throwIfAborted();
|
|
554
|
+
if (done)
|
|
555
|
+
break;
|
|
556
|
+
size += value.byteLength;
|
|
557
|
+
if (size > maxBytes)
|
|
558
|
+
return reply(500, "response-too-large");
|
|
559
|
+
chunks.push(value);
|
|
560
|
+
}
|
|
561
|
+
const bytes = new Uint8Array(size);
|
|
562
|
+
let offset = 0;
|
|
563
|
+
for (const chunk of chunks) {
|
|
564
|
+
bytes.set(chunk, offset);
|
|
565
|
+
offset += chunk.byteLength;
|
|
566
|
+
}
|
|
567
|
+
const text = new TextDecoder().decode(bytes);
|
|
568
|
+
const frames = response.headers.get("content-type")?.startsWith("text/event-stream") ? text.split(`
|
|
569
|
+
`).filter((line) => line.startsWith("data: ")).map((line) => line.slice(6)) : [text];
|
|
570
|
+
if (frames.length !== 1)
|
|
571
|
+
return reply(500, "invalid-protocol-response");
|
|
572
|
+
const payload = JSON.parse(frames[0]);
|
|
573
|
+
if (payload && typeof payload === "object" && payload.error)
|
|
574
|
+
payload.error = { code: payload.error.code, message: "MCP request rejected." };
|
|
575
|
+
const headers = new Headers(response.headers);
|
|
576
|
+
headers.delete("content-length");
|
|
577
|
+
headers.set("content-type", "application/json");
|
|
578
|
+
headers.set("cache-control", "no-store");
|
|
579
|
+
if (Buffer.byteLength(JSON.stringify(payload)) > maxBytes)
|
|
580
|
+
return reply(500, "response-too-large");
|
|
581
|
+
return Response.json(payload, { status: response.status, headers });
|
|
582
|
+
} finally {
|
|
583
|
+
signal.removeEventListener("abort", abort);
|
|
584
|
+
reader.cancel().catch(() => {});
|
|
585
|
+
reader.releaseLock();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async function createHttpMcp(options) {
|
|
589
|
+
const resource = trustedUrl(options.resource);
|
|
590
|
+
if (!options.authorizationServers.length)
|
|
591
|
+
throw new TypeError("An authorization server is required.");
|
|
592
|
+
const issuers = options.authorizationServers.map((value) => {
|
|
593
|
+
trustedUrl(value);
|
|
594
|
+
return value;
|
|
595
|
+
});
|
|
596
|
+
const origins = new Set(options.allowedOrigins.map((value) => {
|
|
597
|
+
const url = trustedUrl(value);
|
|
598
|
+
if (url.href !== `${url.origin}/`)
|
|
599
|
+
throw new TypeError("Allowed origins must be origins.");
|
|
600
|
+
return url.origin;
|
|
601
|
+
}));
|
|
602
|
+
const scopes = [...options.requiredScopes];
|
|
603
|
+
if (scopes.some((scope) => !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)))
|
|
604
|
+
throw new TypeError("Invalid OAuth scope.");
|
|
605
|
+
if (typeof options.verifyToken !== "function")
|
|
606
|
+
throw new TypeError("A trusted verifier is required.");
|
|
607
|
+
const metadataUrl = new URL(`/.well-known/oauth-protected-resource${resource.pathname}`, resource);
|
|
608
|
+
const metadata = {
|
|
609
|
+
resource: resource.href,
|
|
610
|
+
authorization_servers: issuers,
|
|
611
|
+
scopes_supported: scopes,
|
|
612
|
+
bearer_methods_supported: ["header"]
|
|
613
|
+
};
|
|
614
|
+
const maxFrame = positive2(options.maxFrameBytes, 1024 * 1024);
|
|
615
|
+
const maxHttp = positive2(options.maxHttpRequests, 32);
|
|
616
|
+
const maxSessions = positive2(options.maxSessions, 128);
|
|
617
|
+
const idleMs = positive2(options.sessionIdleMs, 5 * 60000);
|
|
618
|
+
const timeoutMs = positive2(options.requestTimeoutMs, 30000);
|
|
619
|
+
const maxResponse = positive2(options.maxResponseBytes, 2 * 1024 * 1024 + 4096);
|
|
620
|
+
if (maxResponse < 256)
|
|
621
|
+
throw new TypeError("MCP response budget must fit a safe error.");
|
|
622
|
+
if (Buffer.byteLength(JSON.stringify(metadata)) > positive2(options.maxCatalogBytes, 256 * 1024))
|
|
623
|
+
throw new TypeError("MCP metadata exceeds the catalog budget.");
|
|
624
|
+
const rate = {
|
|
625
|
+
requests: positive2(options.rateLimit?.requests, 120),
|
|
626
|
+
windowMs: positive2(options.rateLimit?.windowMs, 60000),
|
|
627
|
+
maxIdentities: positive2(options.rateLimit?.maxIdentities, 1024)
|
|
628
|
+
};
|
|
629
|
+
const adapter = await createMcpAdapter(options);
|
|
630
|
+
const sessions = new Map;
|
|
631
|
+
const buckets = new Map;
|
|
632
|
+
const active = new Set;
|
|
633
|
+
const controllers = new Set;
|
|
634
|
+
let closing = false;
|
|
635
|
+
let reservations = 0;
|
|
636
|
+
let closePromise;
|
|
637
|
+
const challenge = (error) => ({
|
|
638
|
+
"www-authenticate": `Bearer resource_metadata="${metadataUrl.href}", scope="${scopes.join(" ")}"${error ? `, error="${error}"` : ""}`
|
|
639
|
+
});
|
|
640
|
+
const retire = async (session) => {
|
|
641
|
+
if (session.transport.sessionId)
|
|
642
|
+
sessions.delete(session.transport.sessionId);
|
|
643
|
+
await session.server.close();
|
|
644
|
+
};
|
|
645
|
+
const sweep = async () => {
|
|
646
|
+
const now = Date.now();
|
|
647
|
+
for (const [key, bucket] of buckets)
|
|
648
|
+
if (bucket.reset <= now)
|
|
649
|
+
buckets.delete(key);
|
|
650
|
+
for (const session of sessions.values())
|
|
651
|
+
if (!session.active && now - session.lastUsed > idleMs)
|
|
652
|
+
await retire(session);
|
|
653
|
+
};
|
|
654
|
+
const startSession = async (owner) => {
|
|
655
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
656
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
657
|
+
enableJsonResponse: false,
|
|
658
|
+
maxRequestBodySize: maxFrame,
|
|
659
|
+
keepAliveMs: 0,
|
|
660
|
+
onsessioninitialized: (id) => {
|
|
661
|
+
sessions.set(id, session);
|
|
662
|
+
},
|
|
663
|
+
onsessionclosed: () => retire(session)
|
|
664
|
+
});
|
|
665
|
+
const server = new Server2({ name: "lenso", version: "0.2.1" }, { capabilities: { tools: {} } });
|
|
666
|
+
const protocol = bindMcpServer(server, adapter, (extra) => {
|
|
667
|
+
const request = extra.authInfo?.extra?.lenso;
|
|
668
|
+
if (!request)
|
|
669
|
+
throw new Error("Missing trusted MCP request context.");
|
|
670
|
+
return request;
|
|
671
|
+
});
|
|
672
|
+
const session = {
|
|
673
|
+
owner,
|
|
674
|
+
server,
|
|
675
|
+
transport,
|
|
676
|
+
protocol,
|
|
677
|
+
ids: new Set,
|
|
678
|
+
lastUsed: Date.now(),
|
|
679
|
+
active: 0
|
|
680
|
+
};
|
|
681
|
+
try {
|
|
682
|
+
await server.connect(transport);
|
|
683
|
+
return session;
|
|
684
|
+
} catch (error) {
|
|
685
|
+
await server.close();
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
const handle = async (request, controller) => {
|
|
690
|
+
const url = new URL(request.url);
|
|
691
|
+
if (url.origin !== resource.origin)
|
|
692
|
+
return reply(403, "invalid-host");
|
|
693
|
+
const host = request.headers.get("host");
|
|
694
|
+
if (host && host !== resource.host)
|
|
695
|
+
return reply(403, "invalid-host");
|
|
696
|
+
const origin = request.headers.get("origin");
|
|
697
|
+
if (origin && !origins.has(origin))
|
|
698
|
+
return reply(403, "invalid-origin");
|
|
699
|
+
if (url.pathname === metadataUrl.pathname && request.method === "GET")
|
|
700
|
+
return Response.json(metadata, { headers: { "cache-control": "no-store" } });
|
|
701
|
+
if (url.pathname !== resource.pathname || url.search)
|
|
702
|
+
return reply(404, "not-found");
|
|
703
|
+
const credential = request.headers.get("authorization");
|
|
704
|
+
const match = credential?.match(/^Bearer ([\x21-\x7E]+)$/i);
|
|
705
|
+
if (!match || match[1].length > 8192)
|
|
706
|
+
return reply(401, "unauthorized", challenge());
|
|
707
|
+
let identity;
|
|
708
|
+
try {
|
|
709
|
+
const verified = await options.verifyToken(match[1], controller.signal);
|
|
710
|
+
if (!verified || typeof verified.subject !== "string" || !verified.subject || verified.subject.length > 512 || typeof verified.tenant !== "string" || !verified.tenant || verified.tenant.length > 512 || typeof verified.issuer !== "string" || !issuers.includes(verified.issuer) || !Array.isArray(verified.audience) || !verified.audience.includes(resource.href) || !Array.isArray(verified.scopes) || verified.scopes.some((scope) => typeof scope !== "string") || !Number.isFinite(verified.expiresAt) || verified.expiresAt <= Date.now() / 1000)
|
|
711
|
+
return reply(401, "invalid-token", challenge("invalid_token"));
|
|
712
|
+
identity = Object.freeze({
|
|
713
|
+
...verified,
|
|
714
|
+
audience: Object.freeze([...verified.audience]),
|
|
715
|
+
scopes: Object.freeze([...verified.scopes])
|
|
716
|
+
});
|
|
717
|
+
} catch {
|
|
718
|
+
return reply(401, "invalid-token", challenge("invalid_token"));
|
|
719
|
+
}
|
|
720
|
+
if (scopes.some((scope) => !identity.scopes.includes(scope)))
|
|
721
|
+
return reply(403, "insufficient-scope", challenge("insufficient_scope"));
|
|
722
|
+
controller.signal.throwIfAborted();
|
|
723
|
+
await sweep();
|
|
724
|
+
const owner = JSON.stringify([
|
|
725
|
+
identity.issuer,
|
|
726
|
+
identity.subject,
|
|
727
|
+
identity.tenant,
|
|
728
|
+
resource.href
|
|
729
|
+
]);
|
|
730
|
+
let bucket = buckets.get(owner);
|
|
731
|
+
if (!bucket) {
|
|
732
|
+
if (buckets.size >= rate.maxIdentities)
|
|
733
|
+
return reply(429, "rate-limit-capacity");
|
|
734
|
+
bucket = { reset: Date.now() + rate.windowMs, count: 0 };
|
|
735
|
+
buckets.set(owner, bucket);
|
|
736
|
+
}
|
|
737
|
+
if (++bucket.count > rate.requests)
|
|
738
|
+
return reply(429, "rate-limited", {
|
|
739
|
+
"retry-after": String(Math.ceil((bucket.reset - Date.now()) / 1000))
|
|
740
|
+
});
|
|
741
|
+
const sessionId = request.headers.get("mcp-session-id");
|
|
742
|
+
let session = sessionId ? sessions.get(sessionId) : undefined;
|
|
743
|
+
if (sessionId && (!session || session.owner !== owner))
|
|
744
|
+
return reply(404, "session-not-found");
|
|
745
|
+
if (request.method === "GET") {
|
|
746
|
+
const version = request.headers.get("mcp-protocol-version");
|
|
747
|
+
if (version && !SUPPORTED_PROTOCOL_VERSIONS.includes(version))
|
|
748
|
+
return reply(400, "unsupported-protocol");
|
|
749
|
+
return reply(405, "standalone-stream-not-supported", { allow: "POST, DELETE" });
|
|
750
|
+
}
|
|
751
|
+
if (request.method !== "POST" && request.method !== "DELETE")
|
|
752
|
+
return reply(405, "method-not-allowed", { allow: "POST, DELETE" });
|
|
753
|
+
let body;
|
|
754
|
+
if (request.method === "POST") {
|
|
755
|
+
try {
|
|
756
|
+
body = await readBody(request, maxFrame, controller.signal);
|
|
757
|
+
} catch (error) {
|
|
758
|
+
return reply(error instanceof RangeError ? 413 : 400, error instanceof RangeError ? "request-too-large" : "invalid-request");
|
|
759
|
+
}
|
|
760
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
761
|
+
return reply(400, "invalid-request");
|
|
762
|
+
}
|
|
763
|
+
if (!session) {
|
|
764
|
+
controller.signal.throwIfAborted();
|
|
765
|
+
if (!isInitializeRequest(body))
|
|
766
|
+
return reply(400, "session-required");
|
|
767
|
+
if (sessions.size + reservations >= maxSessions)
|
|
768
|
+
return reply(503, "session-capacity");
|
|
769
|
+
reservations++;
|
|
770
|
+
try {
|
|
771
|
+
session = await startSession(owner);
|
|
772
|
+
} catch (error) {
|
|
773
|
+
reservations--;
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const id = body && typeof body === "object" && "id" in body ? JSON.stringify(body.id) : undefined;
|
|
778
|
+
if (id && session.ids.has(id))
|
|
779
|
+
return reply(409, "duplicate-request");
|
|
780
|
+
if (id)
|
|
781
|
+
session.ids.add(id);
|
|
782
|
+
session.active++;
|
|
783
|
+
session.lastUsed = Date.now();
|
|
784
|
+
const selected = session;
|
|
785
|
+
const release = body && typeof body === "object" && "method" in body && (body.method === "tools/call" || body.method === "tools/list") && "id" in body && (typeof body.id === "string" || typeof body.id === "number") ? session.protocol.prepare(body.id) : () => {};
|
|
786
|
+
const abort = () => {
|
|
787
|
+
retire(selected).catch(() => {});
|
|
788
|
+
};
|
|
789
|
+
controller.signal.addEventListener("abort", abort, { once: true });
|
|
790
|
+
try {
|
|
791
|
+
controller.signal.throwIfAborted();
|
|
792
|
+
const transportResponse = session.transport.handleRequest(request, {
|
|
793
|
+
parsedBody: body,
|
|
794
|
+
authInfo: {
|
|
795
|
+
token: "",
|
|
796
|
+
clientId: identity.subject,
|
|
797
|
+
scopes: [...identity.scopes],
|
|
798
|
+
expiresAt: identity.expiresAt,
|
|
799
|
+
resource,
|
|
800
|
+
extra: { lenso: { identity, requestId: crypto.randomUUID() } }
|
|
801
|
+
}
|
|
802
|
+
}).then((response) => protocolResponse(response, maxResponse, controller.signal));
|
|
803
|
+
let interrupt = () => {};
|
|
804
|
+
const interrupted = new Promise((resolve) => {
|
|
805
|
+
interrupt = () => resolve(reply(504, "request-interrupted"));
|
|
806
|
+
controller.signal.addEventListener("abort", interrupt, { once: true });
|
|
807
|
+
if (controller.signal.aborted)
|
|
808
|
+
interrupt();
|
|
809
|
+
});
|
|
810
|
+
let response;
|
|
811
|
+
try {
|
|
812
|
+
response = await Promise.race([transportResponse, interrupted]);
|
|
813
|
+
} finally {
|
|
814
|
+
controller.signal.removeEventListener("abort", interrupt);
|
|
815
|
+
}
|
|
816
|
+
if (!session.transport.sessionId)
|
|
817
|
+
await retire(session);
|
|
818
|
+
return response;
|
|
819
|
+
} finally {
|
|
820
|
+
release();
|
|
821
|
+
controller.signal.removeEventListener("abort", abort);
|
|
822
|
+
if (id)
|
|
823
|
+
selected.ids.delete(id);
|
|
824
|
+
selected.active--;
|
|
825
|
+
selected.lastUsed = Date.now();
|
|
826
|
+
if (!sessionId) {
|
|
827
|
+
reservations--;
|
|
828
|
+
if (!selected.transport.sessionId || controller.signal.aborted)
|
|
829
|
+
await retire(selected);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
const fetch = (request) => {
|
|
834
|
+
if (closing)
|
|
835
|
+
return Promise.resolve(reply(503, "adapter-closed"));
|
|
836
|
+
if (active.size >= maxHttp)
|
|
837
|
+
return Promise.resolve(reply(503, "adapter-busy"));
|
|
838
|
+
const controller = new AbortController;
|
|
839
|
+
controllers.add(controller);
|
|
840
|
+
let timer;
|
|
841
|
+
const interrupted = new Promise((resolve) => {
|
|
842
|
+
controller.signal.addEventListener("abort", () => resolve(reply(504, "request-interrupted")), { once: true });
|
|
843
|
+
timer = setTimeout(() => controller.abort(), timeoutMs + 1000);
|
|
844
|
+
});
|
|
845
|
+
const execution = handle(request, controller).catch(() => reply(500, "mcp-failed"));
|
|
846
|
+
active.add(execution);
|
|
847
|
+
execution.finally(() => {
|
|
848
|
+
active.delete(execution);
|
|
849
|
+
controllers.delete(controller);
|
|
850
|
+
clearTimeout(timer);
|
|
851
|
+
});
|
|
852
|
+
return Promise.race([execution, interrupted]);
|
|
853
|
+
};
|
|
854
|
+
const close = () => {
|
|
855
|
+
if (closePromise)
|
|
856
|
+
return closePromise;
|
|
857
|
+
closing = true;
|
|
858
|
+
closePromise = (async () => {
|
|
859
|
+
for (const controller of controllers)
|
|
860
|
+
controller.abort();
|
|
861
|
+
await adapter.close();
|
|
862
|
+
await Promise.all([...sessions.values()].map(retire));
|
|
863
|
+
await Promise.all(active);
|
|
864
|
+
buckets.clear();
|
|
865
|
+
})();
|
|
866
|
+
return closePromise;
|
|
867
|
+
};
|
|
868
|
+
return Object.freeze({ fetch, close });
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// src/index.ts
|
|
872
|
+
function failure2(code, phase, message) {
|
|
25
873
|
return new CliError({ code, phase, message });
|
|
26
874
|
}
|
|
27
875
|
function limit(value, fallback) {
|
|
28
876
|
const bytes = value ?? fallback;
|
|
29
877
|
if (!Number.isSafeInteger(bytes) || bytes < 1)
|
|
30
|
-
throw
|
|
878
|
+
throw failure2("invalid-arguments", "arguments", "Limits must be positive safe integers.");
|
|
31
879
|
return bytes;
|
|
32
880
|
}
|
|
33
881
|
function result(data, maxBytes) {
|
|
34
882
|
stableJson(data);
|
|
35
|
-
const text = stableJson(
|
|
883
|
+
const text = stableJson(redact2(data, environmentSecrets2()));
|
|
36
884
|
if (Buffer.byteLength(text) > maxBytes)
|
|
37
|
-
throw
|
|
885
|
+
throw failure2("output-too-large", "output", "Operation output exceeds the host limit.");
|
|
38
886
|
return { content: [{ type: "text", text }] };
|
|
39
887
|
}
|
|
40
888
|
var oversizedDiagnostic = stableJson({
|
|
@@ -46,14 +894,14 @@ function errorResult(error, maxBytes) {
|
|
|
46
894
|
let text;
|
|
47
895
|
try {
|
|
48
896
|
const detail = diagnostic(error);
|
|
49
|
-
text = stableJson(
|
|
897
|
+
text = stableJson(redact2(detail, environmentSecrets2()));
|
|
50
898
|
if (Buffer.byteLength(text) > maxBytes) {
|
|
51
|
-
text = stableJson(
|
|
899
|
+
text = stableJson(redact2({
|
|
52
900
|
code: detail.code,
|
|
53
901
|
phase: detail.phase,
|
|
54
902
|
message: "Diagnostic truncated to the host output limit.",
|
|
55
903
|
truncated: true
|
|
56
|
-
},
|
|
904
|
+
}, environmentSecrets2()));
|
|
57
905
|
}
|
|
58
906
|
} catch {
|
|
59
907
|
text = stableJson({
|
|
@@ -67,22 +915,25 @@ function errorResult(error, maxBytes) {
|
|
|
67
915
|
return { isError: true, content: [{ type: "text", text }] };
|
|
68
916
|
}
|
|
69
917
|
async function serveStdio(options) {
|
|
70
|
-
if (serving)
|
|
71
|
-
throw failure("invalid-arguments", "arguments", "Only one stdio adapter may own this process.");
|
|
72
918
|
const root = resolve(options.root);
|
|
73
919
|
const maxInput = limit(options.maxInputBytes, 256 * 1024);
|
|
74
920
|
const maxOutput = limit(options.maxOutputBytes, 1024 * 1024);
|
|
75
921
|
if (maxOutput < Buffer.byteLength(oversizedDiagnostic))
|
|
76
|
-
throw
|
|
922
|
+
throw failure2("invalid-arguments", "arguments", "Output limit must fit a bounded diagnostic.");
|
|
77
923
|
const maxFrame = limit(options.maxFrameBytes, 1024 * 1024);
|
|
78
|
-
|
|
924
|
+
let releaseStdio;
|
|
925
|
+
try {
|
|
926
|
+
releaseStdio = acquireStdioOwnership();
|
|
927
|
+
} catch {
|
|
928
|
+
throw failure2("invalid-arguments", "arguments", "Only one stdio adapter may own this process.");
|
|
929
|
+
}
|
|
79
930
|
const originalConsole = globalThis.console;
|
|
80
|
-
const logs = new
|
|
931
|
+
const logs = new Writable2({
|
|
81
932
|
write(chunk, _encoding, done) {
|
|
82
|
-
process.stderr.write(String(
|
|
933
|
+
process.stderr.write(String(redact2(chunk.toString(), environmentSecrets2())), done);
|
|
83
934
|
}
|
|
84
935
|
});
|
|
85
|
-
globalThis.console = Object.assign(new
|
|
936
|
+
globalThis.console = Object.assign(new Console2({ stdout: logs, stderr: logs }), {
|
|
86
937
|
write(...values) {
|
|
87
938
|
const text = values.map((value) => typeof value === "string" ? value : Buffer.from(ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value)).toString()).join("");
|
|
88
939
|
logs.write(text);
|
|
@@ -101,7 +952,7 @@ async function serveStdio(options) {
|
|
|
101
952
|
"trace"
|
|
102
953
|
]) {
|
|
103
954
|
globalThis.console[level] = (...values) => {
|
|
104
|
-
const safe = values.map((value) => value instanceof Error ? "[Application error text omitted]" :
|
|
955
|
+
const safe = values.map((value) => value instanceof Error ? "[Application error text omitted]" : redact2(value, environmentSecrets2()));
|
|
105
956
|
logs.write(`${safe.map((value) => typeof value === "string" ? value : JSON.stringify(value)).join(" ")}
|
|
106
957
|
`);
|
|
107
958
|
};
|
|
@@ -116,7 +967,7 @@ async function serveStdio(options) {
|
|
|
116
967
|
let closePromise;
|
|
117
968
|
const restore = () => {
|
|
118
969
|
globalThis.console = originalConsole;
|
|
119
|
-
|
|
970
|
+
releaseStdio();
|
|
120
971
|
};
|
|
121
972
|
const close = () => {
|
|
122
973
|
if (closePromise)
|
|
@@ -149,15 +1000,15 @@ async function serveStdio(options) {
|
|
|
149
1000
|
for (const allowed of options.allow) {
|
|
150
1001
|
const key = stableJson([allowed.pluginId, allowed.method]);
|
|
151
1002
|
if (seen.has(key))
|
|
152
|
-
throw
|
|
1003
|
+
throw failure2("duplicate-operation", "discovery", "Duplicate MCP allowlist entry.");
|
|
153
1004
|
seen.add(key);
|
|
154
1005
|
const ref = selected.find((item) => item.plugin.id === allowed.pluginId && item.method === allowed.method);
|
|
155
1006
|
const operation = ref ? redactOperationDescription(describeOperation(ref, configPath)) : undefined;
|
|
156
1007
|
if (!operation)
|
|
157
|
-
throw
|
|
1008
|
+
throw failure2("unknown-operation", "discovery", "MCP allowlist operation is not declared.");
|
|
158
1009
|
const schema = operation.inputSchema;
|
|
159
1010
|
if (!schema || schema.type !== "object")
|
|
160
|
-
throw
|
|
1011
|
+
throw failure2("unsupported-input-schema", "discovery", "MCP tools require a convertible object input schema.");
|
|
161
1012
|
stableJson(schema);
|
|
162
1013
|
const name = `operation_${tools.length}`;
|
|
163
1014
|
const tool = {
|
|
@@ -179,38 +1030,38 @@ async function serveStdio(options) {
|
|
|
179
1030
|
openWorldHint: true
|
|
180
1031
|
}
|
|
181
1032
|
};
|
|
182
|
-
if (!
|
|
183
|
-
throw
|
|
1033
|
+
if (!ToolSchema2.safeParse(tool).success)
|
|
1034
|
+
throw failure2("unsupported-input-schema", "discovery", "Operation schema cannot be represented as an MCP tool.");
|
|
184
1035
|
tools.push(tool);
|
|
185
1036
|
bindings.set(name, { pluginId: allowed.pluginId, method: allowed.method });
|
|
186
1037
|
}
|
|
187
|
-
server = new
|
|
188
|
-
server.setRequestHandler(
|
|
189
|
-
server.setRequestHandler(
|
|
1038
|
+
server = new Server3({ name: "lenso", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
1039
|
+
server.setRequestHandler(ListToolsRequestSchema2, async () => ({ tools }));
|
|
1040
|
+
server.setRequestHandler(CallToolRequestSchema2, async (request, extra) => {
|
|
190
1041
|
const binding = bindings.get(request.params.name);
|
|
191
1042
|
if (!binding)
|
|
192
|
-
throw new
|
|
1043
|
+
throw new McpError4(ErrorCode4.InvalidParams, "Tool is not allowlisted.", {
|
|
193
1044
|
code: "unknown-operation"
|
|
194
1045
|
});
|
|
195
1046
|
if (request.params.task)
|
|
196
|
-
throw new
|
|
1047
|
+
throw new McpError4(ErrorCode4.InvalidParams, "Task-augmented calls are not supported.");
|
|
197
1048
|
if (closing)
|
|
198
|
-
return errorResult(
|
|
1049
|
+
return errorResult(failure2("adapter-closed", "invoke", "Adapter is shutting down."), maxOutput);
|
|
199
1050
|
if (active)
|
|
200
|
-
return errorResult(
|
|
1051
|
+
return errorResult(failure2("adapter-busy", "invoke", "One operation is already running; no request was queued."), maxOutput);
|
|
201
1052
|
const execute = async () => {
|
|
202
1053
|
try {
|
|
203
1054
|
if (extra.signal.aborted)
|
|
204
|
-
throw
|
|
1055
|
+
throw failure2("request-cancelled", "invoke", "Request cancelled before invocation.");
|
|
205
1056
|
const input = request.params.arguments ?? {};
|
|
206
1057
|
if (Buffer.byteLength(stableJson(input)) > maxInput)
|
|
207
|
-
throw
|
|
1058
|
+
throw failure2("input-too-large", "input", "Operation input exceeds the host limit.");
|
|
208
1059
|
const data = await invoke({ ...app, operations: selected, operationBinding: undefined }, binding.pluginId, binding.method, input, async (operation, validatedInput, running) => ({
|
|
209
1060
|
...options.binding ? await options.binding(operation, validatedInput, running) : {},
|
|
210
1061
|
maxOutputBytes: maxOutput
|
|
211
1062
|
}));
|
|
212
1063
|
if (extra.signal.aborted)
|
|
213
|
-
throw
|
|
1064
|
+
throw failure2("request-cancelled", "invoke", "Request cancelled; operation may have completed. Cleanup was awaited.");
|
|
214
1065
|
return result(data, maxOutput);
|
|
215
1066
|
} catch (error) {
|
|
216
1067
|
return errorResult(error, maxOutput);
|
|
@@ -231,7 +1082,7 @@ async function serveStdio(options) {
|
|
|
231
1082
|
process.stdin.once("end", onEnd);
|
|
232
1083
|
process.on("SIGINT", onEnd);
|
|
233
1084
|
process.on("SIGTERM", onEnd);
|
|
234
|
-
await server.connect(new
|
|
1085
|
+
await server.connect(new StdioServerTransport2(process.stdin, process.stdout, { maxBufferSize: maxFrame }));
|
|
235
1086
|
return { close };
|
|
236
1087
|
} catch (error) {
|
|
237
1088
|
try {
|
|
@@ -245,5 +1096,8 @@ async function serveStdio(options) {
|
|
|
245
1096
|
}
|
|
246
1097
|
}
|
|
247
1098
|
export {
|
|
1099
|
+
createHttpMcp,
|
|
1100
|
+
createMcpAdapter,
|
|
1101
|
+
serveBorrowedStdio,
|
|
248
1102
|
serveStdio
|
|
249
1103
|
};
|