@lenso/mcp 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LioRael
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,151 @@
1
- # Temporary Holding Version
1
+ # Local MCP adapter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@lenso/mcp` is an optional local stdio adapter for **existing, explicitly
4
+ declared Lenso operations**. It uses the official MCP SDK's `Server` and
5
+ `StdioServerTransport`. It does not start a remote server, accept credentials,
6
+ implement business handlers, load Engine config, or expose a shell.
7
+
8
+ ## Trusted launch configuration
9
+
10
+ Install the optional package in the application and create a dedicated entry:
11
+
12
+ ```ts
13
+ import { serveStdio } from "@lenso/mcp";
14
+
15
+ const adapter = await serveStdio({
16
+ root: import.meta.dir,
17
+ allow: [{ pluginId: "greeting", method: "greet" }],
18
+ });
19
+
20
+ // For explicit host shutdown, await adapter.close().
21
+ ```
22
+
23
+ Configure an MCP host to launch `bun /absolute/path/to/mcp.ts`. The application
24
+ root and allowlist belong to trusted host configuration, not model/tool input.
25
+ There is deliberately no general-purpose command-line operation selector or
26
+ dynamic config-module loader. Use a fresh process when changing source or the
27
+ allowlist: normal trusted module caching still applies.
28
+
29
+ Startup calls `inspect(root)` from `@lenso/cli` without application setup. Every
30
+ allowlisted `(pluginId, method)` must already be declared. Duplicate, absent,
31
+ runtime-validation-only, and non-object input schemas reject startup before
32
+ protocol readiness. The adapter preserves Engine's converted input schema; it
33
+ does not invent an empty schema or infer one from runtime service methods.
34
+ Schemas must also satisfy the SDK's tool representation.
35
+
36
+ Tool names are `operation_0`, `operation_1`, etc., in allowlist order. Their
37
+ titles identify the canonical plugin/method. Clients should discover names
38
+ through `tools/list`, not persist an index across launch configuration changes.
39
+ Only those names resolve to fixed bindings; business arguments cannot select
40
+ the root, module, method, or shell. Applications still own the meaning and
41
+ safety of their business input.
42
+
43
+ ## Invocation, authorization, and output
44
+
45
+ Each call delegates to `call(root, pluginId, method, input)` from `@lenso/cli`. The
46
+ existing shared Standard Schema validator runs before setup; the existing
47
+ service method runs with its service as `this`; CLI awaits app shutdown,
48
+ including ordered business/cleanup diagnostics. No alternate execution graph,
49
+ retry loop, HTTP actor, or authorization bypass exists here. Service
50
+ authorization must remain in the application. A host allowlist is additional
51
+ exposure policy, **not permission to bypass service authorization**.
52
+
53
+ All requests in one stdio process use the trusted launch environment's identity.
54
+ There is no per-request remote login or serialized-actor adapter. Run separate
55
+ processes with separately authorized credentials when local callers require
56
+ identity isolation. A developer who can change application code/config or use
57
+ the raw DB has administrative authority outside the tool surface; this adapter
58
+ does not grant that authority to its client. Remote credentials, audiences and
59
+ scopes require a separately designed trusted ingress, not this stdio entry.
60
+
61
+ Successful tool content contains one text block with Engine's deterministic,
62
+ redacted JSON result. No output schema is fabricated. Undefined, cyclic,
63
+ nonfinite, and nonplain output fails through the existing serialization policy.
64
+ Business failures use `isError: true` with safe Engine/CLI diagnostic JSON,
65
+ without raw input, arbitrary exception text, or stacks. Unknown tool names
66
+ produce a protocol `InvalidParams` error; malformed messages are SDK-owned.
67
+ Explicit application `CliError` messages must themselves be safe.
68
+
69
+ Descriptions come from canonical operation metadata. MCP hints default
70
+ conservatively: read-only only for an explicit read effect, destructive unless
71
+ explicitly false, idempotent only for declared safe retry, open-world true.
72
+ Hints are descriptive, not authorization or execution guarantees. Output
73
+ descriptions are included when declared. Cancellation is always request-only
74
+ at this adapter boundary, even if application metadata says otherwise.
75
+ The full canonical description, including instance, source, schema availability
76
+ and semantic metadata, is retained in `_meta["lenso/operation"]`.
77
+
78
+ ## Limits and shutdown
79
+
80
+ Defaults are a 1 MiB SDK incoming read buffer, 256 KiB serialized business
81
+ input, and 1 MiB redacted serialized result. Trusted options `maxFrameBytes`,
82
+ `maxInputBytes`, and `maxOutputBytes` must be positive safe integers. These
83
+ bound incoming framing and business payloads, not arbitrary allocations inside
84
+ trusted application code or tool-discovery metadata.
85
+ The output budget also bounds failed tool content. Oversized diagnostics retain
86
+ their code and phase with `truncated: true`, omitting paths, details, causes and
87
+ source to fit the limit. If even that header cannot fit, a fixed
88
+ `output-too-large` diagnostic is returned. `maxOutputBytes` must be at least 91
89
+ bytes so that fallback always fits. Serialization failures likewise use a safe
90
+ bounded diagnostic, never arbitrary error text.
91
+
92
+ Only one business call is admitted at a time; concurrent calls fail with
93
+ `adapter-busy`, without a queue. The adapter checks SDK request cancellation
94
+ before and after the awaited CLI call. The existing service API has no signal:
95
+ cancelling a request does **not** stop in-flight service work, undo effects, or
96
+ promise rollback. The client/SDK may discard the response. The adapter still
97
+ awaits the call and cleanup before admitting another operation.
98
+
99
+ `close()`, stdin EOF, SIGINT, and SIGTERM stop admission and drain the admitted
100
+ call before closing transport and restoring console. No forced-termination or
101
+ cleanup timeout promise is made. Hanging application work can hang shutdown;
102
+ a host that force-kills the process cannot rely on cleanup completion.
103
+ Detached tasks and acquired resources remain application-owned.
104
+
105
+ ## stdout and trust
106
+
107
+ SDK protocol messages are the adapter's only stdout output. During startup,
108
+ operation execution, and drain, console methods (including Bun's
109
+ `console.write`) route to stderr. Common logging methods use the existing
110
+ redaction policy and omit direct Error text. This is a **trusted in-process
111
+ adapter, not a sandbox**: direct `process.stdout.write`, native logging,
112
+ captured pre-start console references, or deliberately unsafe code can still
113
+ corrupt protocol output or disclose secrets. Redaction cannot identify every
114
+ secret under innocuous keys. Do not embed secrets in schema constraints or
115
+ metadata, and do not print them.
116
+
117
+ ## SDK and specification verification
118
+
119
+ Primary sources checked for this implementation:
120
+
121
+ - [npm registry: official SDK latest 1.x](https://registry.npmjs.org/@modelcontextprotocol/sdk/latest),
122
+ pinned here to `1.32.1` (not a floating range).
123
+ - [Official SDK source, v1.x](https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x):
124
+ its protocol latest constant is `2025-11-25`; the SDK owns negotiation.
125
+ - [Current MCP specification](https://modelcontextprotocol.io/specification/latest)
126
+ resolves to `2026-07-28`. The current SDK v2 uses split packages.
127
+ - [2025-11-25 stdio transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
128
+ and [tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
129
+
130
+ This package deliberately uses the mature official monolithic v1 SDK. It does
131
+ **not** claim implementation of the newer 2026-07-28 stateless protocol or
132
+ v2-only features. No resources, prompts, tasks, elicitation, remote auth, or
133
+ credentials are advertised.
134
+
135
+ ## Development
136
+
137
+ The integration owner installs dependencies and owns the workspace lockfile.
138
+ Rebuild `@lenso/core`, `@lenso/engine`, and `@lenso/cli` before consuming their exports.
139
+ Then run from this package:
140
+
141
+ ```sh
142
+ bun run typecheck
143
+ bun run build
144
+ bun test
145
+ ```
146
+
147
+ Tests launch a real subprocess and communicate using the official SDK client
148
+ and stdio transport. They cover discovery without setup, allowlisting, shared
149
+ validation, service lifecycle, redaction, safe authorization/runtime
150
+ diagnostics, unsupported output, payload limits, cancellation, and rejected
151
+ concurrent requests.
@@ -0,0 +1,20 @@
1
+ import { type OperationBinding } from "@lenso/engine/application";
2
+ export interface StdioOptions {
3
+ /** Trusted launch configuration, never supplied by an MCP tool. */
4
+ root: string;
5
+ allow: readonly {
6
+ pluginId: string;
7
+ method: string;
8
+ }[];
9
+ maxInputBytes?: number;
10
+ maxOutputBytes?: number;
11
+ maxFrameBytes?: number;
12
+ binding?: OperationBinding;
13
+ }
14
+ /**
15
+ * Owns a dedicated stdio process. Tools translate only to CLI call; no app or
16
+ * service lifecycle is implemented here. Close drains the admitted call.
17
+ */
18
+ export declare function serveStdio(options: StdioOptions): Promise<{
19
+ close(): Promise<void>;
20
+ }>;
package/dist/index.js ADDED
@@ -0,0 +1,243 @@
1
+ // @bun
2
+ // src/index.ts
3
+ import { Console } from "console";
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";
8
+ import {
9
+ CallToolRequestSchema,
10
+ ErrorCode,
11
+ ListToolsRequestSchema,
12
+ McpError,
13
+ ToolSchema
14
+ } from "@modelcontextprotocol/sdk/types.js";
15
+ import { invoke, CliError, diagnostic } from "@lenso/cli";
16
+ import { readApplication } from "@lenso/engine/application";
17
+ import {
18
+ describeOperation,
19
+ redactOperationDescription,
20
+ validateOperations
21
+ } from "@lenso/engine/operations";
22
+ import { environmentSecrets, redact, stableJson } from "@lenso/engine/diagnostics";
23
+ var serving = false;
24
+ function failure(code, phase, message) {
25
+ return new CliError({ code, phase, message });
26
+ }
27
+ function limit(value, fallback) {
28
+ const bytes = value ?? fallback;
29
+ if (!Number.isSafeInteger(bytes) || bytes < 1)
30
+ throw failure("invalid-arguments", "arguments", "Limits must be positive safe integers.");
31
+ return bytes;
32
+ }
33
+ function result(data, maxBytes) {
34
+ stableJson(data);
35
+ const text = stableJson(redact(data, environmentSecrets()));
36
+ if (Buffer.byteLength(text) > maxBytes)
37
+ throw failure("output-too-large", "output", "Operation output exceeds the host limit.");
38
+ return { content: [{ type: "text", text }] };
39
+ }
40
+ var oversizedDiagnostic = stableJson({
41
+ code: "output-too-large",
42
+ phase: "output",
43
+ message: "Diagnostic exceeds the host limit."
44
+ });
45
+ function errorResult(error, maxBytes) {
46
+ let text;
47
+ try {
48
+ const detail = diagnostic(error);
49
+ text = stableJson(redact(detail, environmentSecrets()));
50
+ if (Buffer.byteLength(text) > maxBytes) {
51
+ text = stableJson(redact({
52
+ code: detail.code,
53
+ phase: detail.phase,
54
+ message: "Diagnostic truncated to the host output limit.",
55
+ truncated: true
56
+ }, environmentSecrets()));
57
+ }
58
+ } catch {
59
+ text = stableJson({
60
+ code: "serialization-failed",
61
+ phase: "output",
62
+ message: "Diagnostic is not JSON data."
63
+ });
64
+ }
65
+ if (Buffer.byteLength(text) > maxBytes)
66
+ text = oversizedDiagnostic;
67
+ return { isError: true, content: [{ type: "text", text }] };
68
+ }
69
+ async function serveStdio(options) {
70
+ if (serving)
71
+ throw failure("invalid-arguments", "arguments", "Only one stdio adapter may own this process.");
72
+ const root = resolve(options.root);
73
+ const maxInput = limit(options.maxInputBytes, 256 * 1024);
74
+ const maxOutput = limit(options.maxOutputBytes, 1024 * 1024);
75
+ if (maxOutput < Buffer.byteLength(oversizedDiagnostic))
76
+ throw failure("invalid-arguments", "arguments", "Output limit must fit a bounded diagnostic.");
77
+ const maxFrame = limit(options.maxFrameBytes, 1024 * 1024);
78
+ serving = true;
79
+ const originalConsole = globalThis.console;
80
+ const logs = new Writable({
81
+ write(chunk, _encoding, done) {
82
+ process.stderr.write(String(redact(chunk.toString(), environmentSecrets())), done);
83
+ }
84
+ });
85
+ globalThis.console = Object.assign(new Console({ stdout: logs, stderr: logs }), {
86
+ write(...values) {
87
+ 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
+ logs.write(text);
89
+ return Buffer.byteLength(text);
90
+ }
91
+ });
92
+ for (const level of [
93
+ "log",
94
+ "info",
95
+ "warn",
96
+ "error",
97
+ "debug",
98
+ "dir",
99
+ "dirxml",
100
+ "table",
101
+ "trace"
102
+ ]) {
103
+ globalThis.console[level] = (...values) => {
104
+ const safe = values.map((value) => value instanceof Error ? "[Application error text omitted]" : redact(value, environmentSecrets()));
105
+ logs.write(`${safe.map((value) => typeof value === "string" ? value : JSON.stringify(value)).join(" ")}
106
+ `);
107
+ };
108
+ }
109
+ globalThis.console.assert = (condition, ...values) => {
110
+ if (!condition)
111
+ globalThis.console.error("Assertion failed:", ...values);
112
+ };
113
+ let server;
114
+ let active;
115
+ let closing = false;
116
+ let closePromise;
117
+ const restore = () => {
118
+ globalThis.console = originalConsole;
119
+ serving = false;
120
+ };
121
+ const close = () => {
122
+ if (closePromise)
123
+ return closePromise;
124
+ closing = true;
125
+ closePromise = (async () => {
126
+ try {
127
+ await active;
128
+ await server?.close();
129
+ } finally {
130
+ process.stdin.off("end", onEnd);
131
+ process.off("SIGINT", onEnd);
132
+ process.off("SIGTERM", onEnd);
133
+ restore();
134
+ }
135
+ })();
136
+ return closePromise;
137
+ };
138
+ const onEnd = () => {
139
+ close().catch(() => {
140
+ process.exitCode = 1;
141
+ });
142
+ };
143
+ try {
144
+ const { app, configPath } = await readApplication(root, join(root, "lenso.config.ts"));
145
+ const selected = validateOperations(app.plugins, app.mcpOperations ?? app.operations ?? []);
146
+ const tools = [];
147
+ const bindings = new Map;
148
+ const seen = new Set;
149
+ for (const allowed of options.allow) {
150
+ const key = stableJson([allowed.pluginId, allowed.method]);
151
+ if (seen.has(key))
152
+ throw failure("duplicate-operation", "discovery", "Duplicate MCP allowlist entry.");
153
+ seen.add(key);
154
+ const ref = selected.find((item) => item.plugin.id === allowed.pluginId && item.method === allowed.method);
155
+ const operation = ref ? redactOperationDescription(describeOperation(ref, configPath)) : undefined;
156
+ if (!operation)
157
+ throw failure("unknown-operation", "discovery", "MCP allowlist operation is not declared.");
158
+ const schema = operation.inputSchema;
159
+ if (!schema || schema.type !== "object")
160
+ throw failure("unsupported-input-schema", "discovery", "MCP tools require a convertible object input schema.");
161
+ stableJson(schema);
162
+ const name = `operation_${tools.length}`;
163
+ const tool = {
164
+ name,
165
+ title: `${operation.pluginId}.${operation.method}`,
166
+ description: [
167
+ operation.description,
168
+ ...operation.outputDescription ? [`Output: ${operation.outputDescription}`] : [],
169
+ "Cancellation is request-only: in-flight work and cleanup are awaited; no rollback or automatic retry."
170
+ ].join(`
171
+ `),
172
+ inputSchema: schema,
173
+ _meta: { "lenso/operation": operation },
174
+ execution: { taskSupport: "forbidden" },
175
+ annotations: {
176
+ readOnlyHint: operation.effect === "read",
177
+ destructiveHint: operation.destructive ?? true,
178
+ idempotentHint: operation.retry === "safe",
179
+ openWorldHint: true
180
+ }
181
+ };
182
+ if (!ToolSchema.safeParse(tool).success)
183
+ throw failure("unsupported-input-schema", "discovery", "Operation schema cannot be represented as an MCP tool.");
184
+ tools.push(tool);
185
+ bindings.set(name, { pluginId: allowed.pluginId, method: allowed.method });
186
+ }
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) => {
190
+ const binding = bindings.get(request.params.name);
191
+ if (!binding)
192
+ throw new McpError(ErrorCode.InvalidParams, "Tool is not allowlisted.", {
193
+ code: "unknown-operation"
194
+ });
195
+ if (request.params.task)
196
+ throw new McpError(ErrorCode.InvalidParams, "Task-augmented calls are not supported.");
197
+ if (closing)
198
+ return errorResult(failure("adapter-closed", "invoke", "Adapter is shutting down."), maxOutput);
199
+ if (active)
200
+ return errorResult(failure("adapter-busy", "invoke", "One operation is already running; no request was queued."), maxOutput);
201
+ const execute = async () => {
202
+ try {
203
+ if (extra.signal.aborted)
204
+ throw failure("request-cancelled", "invoke", "Request cancelled before invocation.");
205
+ const input = request.params.arguments ?? {};
206
+ if (Buffer.byteLength(stableJson(input)) > maxInput)
207
+ throw failure("input-too-large", "input", "Operation input exceeds the host limit.");
208
+ const data = await invoke({ ...app, operations: selected, operationBinding: undefined }, binding.pluginId, binding.method, input, async (operation, validatedInput, running) => ({
209
+ ...options.binding ? await options.binding(operation, validatedInput, running) : {},
210
+ maxOutputBytes: maxOutput
211
+ }));
212
+ if (extra.signal.aborted)
213
+ throw failure("request-cancelled", "invoke", "Request cancelled; operation may have completed. Cleanup was awaited.");
214
+ return result(data, maxOutput);
215
+ } catch (error) {
216
+ return errorResult(error, maxOutput);
217
+ }
218
+ };
219
+ active = execute();
220
+ try {
221
+ return await active;
222
+ } finally {
223
+ active = undefined;
224
+ }
225
+ });
226
+ server.onerror = () => {
227
+ process.stderr.write(`MCP protocol error (details omitted).
228
+ `);
229
+ };
230
+ server.onclose = onEnd;
231
+ process.stdin.once("end", onEnd);
232
+ process.on("SIGINT", onEnd);
233
+ process.on("SIGTERM", onEnd);
234
+ await server.connect(new StdioServerTransport(process.stdin, process.stdout, { maxBufferSize: maxFrame }));
235
+ return { close };
236
+ } catch (error) {
237
+ await close();
238
+ throw error;
239
+ }
240
+ }
241
+ export {
242
+ serveStdio
243
+ };
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@lenso/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/LioRael/lenso.git",
8
+ "directory": "packages/mcp"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "rm -rf dist && bun build src/index.ts --outdir dist --target bun --packages external && tsc -p tsconfig.build.json",
23
+ "typecheck": "tsc --noEmit -p tsconfig.json",
24
+ "test": "bun test"
25
+ },
26
+ "dependencies": {
27
+ "@lenso/cli": "^0.19.0",
28
+ "@lenso/engine": "^0.2.0",
29
+ "@modelcontextprotocol/sdk": "1.32.1",
30
+ "zod": "4.6.5"
31
+ },
32
+ "devDependencies": {
33
+ "@lenso/core": "^0.2.0"
34
+ }
35
+ }