@lenso/mcp 0.2.0 → 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/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
- var serving = false;
24
- function failure(code, phase, message) {
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 failure("invalid-arguments", "arguments", "Limits must be positive safe integers.");
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(redact(data, environmentSecrets()));
883
+ const text = stableJson(redact2(data, environmentSecrets2()));
36
884
  if (Buffer.byteLength(text) > maxBytes)
37
- throw failure("output-too-large", "output", "Operation output exceeds the host limit.");
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(redact(detail, environmentSecrets()));
897
+ text = stableJson(redact2(detail, environmentSecrets2()));
50
898
  if (Buffer.byteLength(text) > maxBytes) {
51
- text = stableJson(redact({
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
- }, environmentSecrets()));
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 failure("invalid-arguments", "arguments", "Output limit must fit a bounded diagnostic.");
922
+ throw failure2("invalid-arguments", "arguments", "Output limit must fit a bounded diagnostic.");
77
923
  const maxFrame = limit(options.maxFrameBytes, 1024 * 1024);
78
- serving = true;
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 Writable({
931
+ const logs = new Writable2({
81
932
  write(chunk, _encoding, done) {
82
- process.stderr.write(String(redact(chunk.toString(), environmentSecrets())), done);
933
+ process.stderr.write(String(redact2(chunk.toString(), environmentSecrets2())), done);
83
934
  }
84
935
  });
85
- globalThis.console = Object.assign(new Console({ stdout: logs, stderr: logs }), {
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]" : redact(value, environmentSecrets()));
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
- serving = false;
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 failure("duplicate-operation", "discovery", "Duplicate MCP allowlist entry.");
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 failure("unknown-operation", "discovery", "MCP allowlist operation is not declared.");
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 failure("unsupported-input-schema", "discovery", "MCP tools require a convertible object input schema.");
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 (!ToolSchema.safeParse(tool).success)
183
- throw failure("unsupported-input-schema", "discovery", "Operation schema cannot be represented as an MCP tool.");
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 Server({ name: "lenso", version: "0.1.0" }, { capabilities: { tools: {} } });
188
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
189
- server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
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 McpError(ErrorCode.InvalidParams, "Tool is not allowlisted.", {
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 McpError(ErrorCode.InvalidParams, "Task-augmented calls are not supported.");
1047
+ throw new McpError4(ErrorCode4.InvalidParams, "Task-augmented calls are not supported.");
197
1048
  if (closing)
198
- return errorResult(failure("adapter-closed", "invoke", "Adapter is shutting down."), maxOutput);
1049
+ return errorResult(failure2("adapter-closed", "invoke", "Adapter is shutting down."), maxOutput);
199
1050
  if (active)
200
- return errorResult(failure("adapter-busy", "invoke", "One operation is already running; no request was queued."), maxOutput);
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 failure("request-cancelled", "invoke", "Request cancelled before invocation.");
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 failure("input-too-large", "input", "Operation input exceeds the host limit.");
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 failure("request-cancelled", "invoke", "Request cancelled; operation may have completed. Cleanup was awaited.");
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,13 +1082,22 @@ 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 StdioServerTransport(process.stdin, process.stdout, { maxBufferSize: maxFrame }));
1085
+ await server.connect(new StdioServerTransport2(process.stdin, process.stdout, { maxBufferSize: maxFrame }));
235
1086
  return { close };
236
1087
  } catch (error) {
237
- await close();
1088
+ try {
1089
+ await close();
1090
+ } catch (closeError) {
1091
+ throw new AggregateError([error, closeError], "MCP startup and close failed.", {
1092
+ cause: closeError
1093
+ });
1094
+ }
238
1095
  throw error;
239
1096
  }
240
1097
  }
241
1098
  export {
1099
+ createHttpMcp,
1100
+ createMcpAdapter,
1101
+ serveBorrowedStdio,
242
1102
  serveStdio
243
1103
  };