@lenso/mcp 0.2.1 → 0.4.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 CHANGED
@@ -1,11 +1,158 @@
1
- # Local MCP adapter
1
+ # MCP adapters
2
2
 
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.
3
+ `@lenso/mcp` projects **explicitly selected existing Operations**, never inferred
4
+ service methods. It offers a borrowed runtime adapter, dedicated local stdio,
5
+ and an opt-in authenticated Fetch Streamable HTTP entry. HTTP starts no listener
6
+ and does not change host logging. No business handlers, OAuth authorization
7
+ server, shell, Agent loop, tasks, or durable cancellation are supplied.
7
8
 
8
- ## Trusted launch configuration
9
+ ## Borrow one running app
10
+
11
+ ```ts
12
+ import { createMcpAdapter, serveBorrowedStdio } from "@lenso/mcp";
13
+ import { selectManageOperations } from "@lenso/manage";
14
+
15
+ const shared = {
16
+ running: app, // already started by its owner
17
+ plugins: [notes], // exact installed instances
18
+ operations: selectManageOperations(notesManage, ["read", "write"]),
19
+ canList: (operation, request) => policy.canSee(request.identity, operation),
20
+ authorize: (operation, request) => policy.canInvoke(request.identity, operation),
21
+ binding: (_operation, _input, request) => ({
22
+ context: { identity: request.identity, requestId: request.requestId, signal: request.signal },
23
+ }),
24
+ };
25
+ const adapter = await createMcpAdapter(shared);
26
+ // adapter.listTools({identity, requestId, signal})
27
+ // adapter.callTool(discoveredName, input, {identity, requestId, signal})
28
+ await adapter.close(); // app remains usable; its owner alone stops it
29
+ ```
30
+
31
+ `running` is the existing Engine `OperationRuntime`; `binding` returns existing
32
+ `OperationBoundOptions`, including confirmation/approval callbacks where required.
33
+ It must match the real service's second parameter (`context: true`). The request
34
+ context is trusted entry data, not a new registration or actor-in-JSON protocol.
35
+ Every call rechecks both visibility and invocation permission. The ordinary
36
+ service still enforces its tenant/object/owner rules. Manage/`createAgentTools`
37
+ are the canonical schema/metadata source; all selected schemas must convert to
38
+ SDK-compatible object schemas before readiness, including currently hidden tools.
39
+
40
+ The borrowed adapter prepares one immutable Manage selection for its lifetime.
41
+ Discovery, invocation policies and bindings receive the same frozen declaration
42
+ snapshots, retaining exact plugin/schema/function identities. Do not key policies
43
+ by the original declaration object's identity; use the exact plugin and declared
44
+ method. Original declaration edits do not change admission or dispatch. Replace
45
+ the adapter when changing its selection. Existing `operation_N` tool names remain
46
+ entry-local, translated internally to Manage's selection-scoped opaque keys.
47
+ Close revokes admission, waits for actual work to drain, then releases the
48
+ selection's runtime references; policy closures retain any resources they capture.
49
+
50
+ For a **dedicated local process**, replace the adapter creation above with:
51
+
52
+ ```ts
53
+ const stdio = await serveBorrowedStdio({ ...shared, identity: trustedLaunchIdentity });
54
+ // On shutdown await stdio.close(), then let the entry owner stop app.
55
+ ```
56
+
57
+ It reuses that app for all calls, reserves stdio process ownership, routes console
58
+ logs to redacted stderr, and drains on EOF/SIGINT/SIGTERM. Start the app with a
59
+ stderr logger: the adapter cannot redirect logs emitted before it starts.
60
+ Never embed this stdio owner in a general-purpose HTTP host.
61
+
62
+ ## Explicit HTTP mount and identity
63
+
64
+ ```ts
65
+ import { createHttpMcp } from "@lenso/mcp";
66
+ const mcp = await createHttpMcp({
67
+ ...shared,
68
+ resource: "https://api.example.com/mcp",
69
+ authorizationServers: ["https://idp.example.com"],
70
+ requiredScopes: ["mcp:use"],
71
+ allowedOrigins: ["https://trusted-client.example.com"], // [] denies present Origins
72
+ verifyToken: hostIdentityProvider.verifyMcpAccessToken,
73
+ });
74
+ // Mount both /mcp and /.well-known/oauth-protected-resource/mcp:
75
+ // host Fetch router delegates these paths to mcp.fetch(request).
76
+ // Host owns its existing listener, TLS, CORS and reverse-proxy configuration.
77
+ ```
78
+
79
+ `verifyToken(token, signal)` is mandatory. Inject the host's real OAuth/OIDC
80
+ signature verifier or token introspection client; decoding a JWT is not
81
+ verification. For JWT providers, a host-installed `jose` verifier can use
82
+ `createRemoteJWKSet` and `jwtVerify` with a fixed issuer, this resource audience,
83
+ and an explicit algorithm allowlist. Require `exp`; honor `nbf`, provider
84
+ revocation/current grants, and abortable verification. Map the **verified**
85
+ subject to the host's tenant and Lenso business identity. Do not trust a
86
+ client-selected tenant claim without the provider/host membership policy.
87
+ When services require an `@lenso/auth` Actor, retain the original verified Actor
88
+ reference in an identity extension such as `identity.actor` and bind that exact
89
+ reference. A copied MCP identity is not an Auth Actor or permission grant.
90
+
91
+ Return `McpIdentity`: `{subject, tenant, issuer, audience: string[], scopes:
92
+ string[], expiresAt}` (Unix seconds), optionally extending it with verified
93
+ business evidence. The adapter rechecks issuer, resource audience, expiry and
94
+ required scopes on **every** protected request, including notifications and
95
+ DELETE. `canList`, `authorize` and service policy handle current business grants;
96
+ discovery is not call permission. Configure the issuer string exactly, including
97
+ any provider-required trailing slash. Production verification/revocation remains
98
+ the host's responsibility, not the example's deterministic fixture verifier.
99
+
100
+ Missing/invalid credentials return 401 and a Bearer `resource_metadata` challenge;
101
+ missing required scopes return 403 with `insufficient_scope`. Public RFC 9728
102
+ metadata advertises the configured resource and authorization servers. Tokens
103
+ are neither forwarded to services nor to a different resource. Session IDs never
104
+ authenticate. Sessions and rate buckets bind issuer/subject/tenant/resource;
105
+ foreign owners receive 404 and grants are not cached in sessions.
106
+
107
+ Only canonical configured Host/Origin values are accepted. HTTPS is required
108
+ except on loopback; forwarded headers are not trusted. A proxy host must present
109
+ the canonical public Request URL through its own trusted routing. The adapter
110
+ starts no second listener; an independent listener must explicitly bind loopback
111
+ unless the host deliberately configures remote exposure.
112
+
113
+ ## Borrowed-entry bounds and protocol support
114
+
115
+ Defaults: four admitted calls/discoveries (no queue), 30s request timeout,
116
+ 256 KiB business input/catalog, 1 MiB business result, 1 MiB HTTP frame, and
117
+ 2 MiB + 4 KiB complete HTTP response (including cumulative internal stream data).
118
+ HTTP additionally admits 32 requests, 128 sessions, 5-minute idle expiry, and
119
+ 120 requests/minute per identity/tenant with at most 1024 rate buckets.
120
+ Trusted options can change these budgets; output/HTTP error budgets must fit a
121
+ 256-byte fixed diagnostic. HTTP's outer verification/body/transport deadline is
122
+ the request timeout plus 1s. Expired sessions/buckets are swept on ingress.
123
+
124
+ Cancellation and timeout reach binding signals; only a host-bound cooperative
125
+ service can interrupt actual work. Noncooperative work retains its concurrency
126
+ slot until settlement. `close()` stops admission, aborts owned request signals,
127
+ drains actual work and releases protocol resources; it is idempotent and never
128
+ calls `app.stop()`. A hanging noncooperative service/verifier can prolong drain.
129
+ Neither cancellation nor a lost response promises rollback or safe retry.
130
+ HTTP disconnect alone is deliberately not an MCP cancellation notification.
131
+
132
+ Protocol failures use fixed SDK MCP errors. Validation, authorization, business
133
+ failure, cancellation, timeout, busy/closed and size errors have fixed bounded
134
+ tool diagnostics; arbitrary thrown text, stacks, input and credentials are omitted.
135
+ Engine's finite JSON/redaction policy remains in force, not a sandbox.
136
+
137
+ Official SDK **1.32.1** remains exactly pinned. Official-client tests verify
138
+ `2025-11-25` and `2025-03-26` Streamable HTTP, plus existing stdio. SDK owns
139
+ negotiation, messages, session IDs and notification validation. Public HTTP
140
+ responses are finite JSON; GET returns 405 (no standalone SSE), DELETE terminates
141
+ sessions. No HTTP+SSE legacy endpoint, replay/event store, progress, resources,
142
+ prompts, elicitation or task capability is claimed.
143
+
144
+ Two version-specific mitigations use SDK public APIs: its finite POST SSE
145
+ transport is consumed within a budget and normalized to JSON because 1.32.1
146
+ JSON mode retains completed stream resolvers; entry-scoped cancellation handlers
147
+ handle valid IDs `0`/`""` and emit finite replies so cancellation does not retain
148
+ HTTP waiters. Regression tests inspect retained SDK state, cancellation and
149
+ escape-heavy JSON. No SDK private state is patched.
150
+
151
+ See the [offline host example](../../examples/mcp-host/README.md) for copyable
152
+ workspace commands and discovery/multiple read/write calls with one app.
153
+ These new exports are source-workspace changes, not proof of registry publication.
154
+
155
+ ## Existing trusted local launch (unchanged)
9
156
 
10
157
  Install the optional package in the application and create a dedicated entry:
11
158
 
@@ -56,7 +203,7 @@ processes with separately authorized credentials when local callers require
56
203
  identity isolation. A developer who can change application code/config or use
57
204
  the raw DB has administrative authority outside the tool surface; this adapter
58
205
  does not grant that authority to its client. Remote credentials, audiences and
59
- scopes require a separately designed trusted ingress, not this stdio entry.
206
+ scopes belong to the explicitly enabled HTTP entry, never this stdio entry.
60
207
 
61
208
  Successful tool content contains one text block with Engine's deterministic,
62
209
  redacted JSON result. No output schema is fabricated. Undefined, cyclic,
@@ -129,13 +276,15 @@ Primary sources checked for this implementation:
129
276
 
130
277
  This package deliberately uses the mature official monolithic v1 SDK. It does
131
278
  **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.
279
+ v2-only features. No resources, prompts, tasks or elicitation are advertised.
280
+ HTTP uses the [2025-11-25 authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
281
+ and RFC 9728 protected-resource metadata, not an application login endpoint.
134
282
 
135
283
  ## Development
136
284
 
137
285
  The integration owner installs dependencies and owns the workspace lockfile.
138
- Rebuild `@lenso/core`, `@lenso/engine`, and `@lenso/cli` before consuming their exports.
286
+ Rebuild `@lenso/core`, `@lenso/engine`, `@lenso/web`, `@lenso/auth`,
287
+ `@lenso/manage`, and `@lenso/cli` before consuming their exports.
139
288
  Then run from this package:
140
289
 
141
290
  ```sh
@@ -0,0 +1,29 @@
1
+ import type { Plugin } from "@lenso/core";
2
+ import { type Operation, type OperationBoundOptions, type OperationRuntime } from "@lenso/engine/operations";
3
+ import { type CallToolResult, type Tool } from "@modelcontextprotocol/sdk/types.js";
4
+ export interface McpRequestContext<I> {
5
+ readonly identity: I;
6
+ readonly requestId: string;
7
+ readonly signal: AbortSignal;
8
+ }
9
+ export interface McpAdapterOptions<I, O extends Operation = Operation> {
10
+ readonly running: OperationRuntime;
11
+ readonly plugins: readonly Plugin<unknown>[];
12
+ readonly operations: readonly O[];
13
+ readonly canList: (operation: O, request: McpRequestContext<I>) => boolean | Promise<boolean>;
14
+ readonly authorize: (operation: O, request: McpRequestContext<I>) => boolean | Promise<boolean>;
15
+ readonly binding: (operation: O, input: unknown, request: McpRequestContext<I>) => OperationBoundOptions<NoInfer<O>> | Promise<OperationBoundOptions<NoInfer<O>>>;
16
+ readonly maxInputBytes?: number;
17
+ readonly maxOutputBytes?: number;
18
+ readonly maxCatalogBytes?: number;
19
+ readonly maxConcurrentCalls?: number;
20
+ readonly requestTimeoutMs?: number;
21
+ }
22
+ /** Borrows only runtime capabilities, never app start/stop ownership. */
23
+ export declare function createMcpAdapter<I, O extends Operation = Operation>(options: McpAdapterOptions<I, O>): Promise<Readonly<{
24
+ listTools(request: McpRequestContext<I>): Promise<{
25
+ tools: Tool[];
26
+ }>;
27
+ callTool(name: string, input: unknown, request: McpRequestContext<I>): Promise<CallToolResult>;
28
+ close(): Promise<void>;
29
+ }>>;
@@ -0,0 +1,13 @@
1
+ import type { Operation } from "@lenso/engine/operations";
2
+ import { type McpAdapterOptions } from "./adapter";
3
+ export interface BorrowedStdioOptions<I, O extends Operation = Operation> extends McpAdapterOptions<I, O> {
4
+ /** Trusted, fixed launch identity, never supplied by tool arguments. */
5
+ readonly identity: I;
6
+ readonly maxFrameBytes?: number;
7
+ }
8
+ /** Shared with other dedicated stdio entries; HTTP must not acquire this latch. */
9
+ export declare function acquireStdioOwnership(): () => void;
10
+ /** Borrows the running application; the host alone owns app.stop(). */
11
+ export declare function serveBorrowedStdio<I, O extends Operation = Operation>(options: BorrowedStdioOptions<I, O>): Promise<{
12
+ close(): Promise<void>;
13
+ }>;
package/dist/http.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { Operation } from "@lenso/engine/operations";
2
+ import { type McpAdapterOptions } from "./adapter";
3
+ /** Verified by the host's signature/introspection and current revocation policy, not decoded JSON. */
4
+ export interface McpIdentity {
5
+ readonly subject: string;
6
+ readonly tenant: string;
7
+ readonly issuer: string;
8
+ readonly audience: readonly string[];
9
+ readonly scopes: readonly string[];
10
+ /** Unix seconds. */
11
+ readonly expiresAt: number;
12
+ }
13
+ export interface HttpMcpOptions<I extends McpIdentity, O extends Operation = Operation> extends McpAdapterOptions<I, O> {
14
+ /** Canonical public MCP resource URL. Never inferred from forwarded headers. */
15
+ readonly resource: string;
16
+ readonly authorizationServers: readonly string[];
17
+ readonly requiredScopes: readonly string[];
18
+ readonly allowedOrigins: readonly string[];
19
+ /** Must cryptographically verify or introspect the token, including current validity/revocation. */
20
+ readonly verifyToken: (token: string, signal: AbortSignal) => I | Promise<I>;
21
+ readonly maxFrameBytes?: number;
22
+ readonly maxResponseBytes?: number;
23
+ readonly maxHttpRequests?: number;
24
+ readonly maxSessions?: number;
25
+ readonly sessionIdleMs?: number;
26
+ readonly rateLimit?: {
27
+ readonly requests: number;
28
+ readonly windowMs: number;
29
+ readonly maxIdentities: number;
30
+ };
31
+ }
32
+ /**
33
+ * Owns protocol sessions only. Mount fetch in the host's existing listener;
34
+ * close never stops the borrowed app or changes global logging.
35
+ */
36
+ export declare function createHttpMcp<I extends McpIdentity, O extends Operation>(options: HttpMcpOptions<I, O>): Promise<{
37
+ fetch(request: Request): Promise<Response>;
38
+ close(): Promise<void>;
39
+ }>;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { type OperationBinding } from "@lenso/engine/application";
2
+ export { createMcpAdapter, type McpAdapterOptions, type McpRequestContext } from "./adapter";
3
+ export { createHttpMcp, type HttpMcpOptions, type McpIdentity } from "./http";
4
+ export { serveBorrowedStdio, type BorrowedStdioOptions } from "./borrowed-stdio";
2
5
  export interface StdioOptions {
3
6
  /** Trusted launch configuration, never supplied by an MCP tool. */
4
7
  root: string;