@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 CHANGED
@@ -1,11 +1,148 @@
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
+ For a **dedicated local process**, replace the adapter creation above with:
41
+
42
+ ```ts
43
+ const stdio = await serveBorrowedStdio({ ...shared, identity: trustedLaunchIdentity });
44
+ // On shutdown await stdio.close(), then let the entry owner stop app.
45
+ ```
46
+
47
+ It reuses that app for all calls, reserves stdio process ownership, routes console
48
+ logs to redacted stderr, and drains on EOF/SIGINT/SIGTERM. Start the app with a
49
+ stderr logger: the adapter cannot redirect logs emitted before it starts.
50
+ Never embed this stdio owner in a general-purpose HTTP host.
51
+
52
+ ## Explicit HTTP mount and identity
53
+
54
+ ```ts
55
+ import { createHttpMcp } from "@lenso/mcp";
56
+ const mcp = await createHttpMcp({
57
+ ...shared,
58
+ resource: "https://api.example.com/mcp",
59
+ authorizationServers: ["https://idp.example.com"],
60
+ requiredScopes: ["mcp:use"],
61
+ allowedOrigins: ["https://trusted-client.example.com"], // [] denies present Origins
62
+ verifyToken: hostIdentityProvider.verifyMcpAccessToken,
63
+ });
64
+ // Mount both /mcp and /.well-known/oauth-protected-resource/mcp:
65
+ // host Fetch router delegates these paths to mcp.fetch(request).
66
+ // Host owns its existing listener, TLS, CORS and reverse-proxy configuration.
67
+ ```
68
+
69
+ `verifyToken(token, signal)` is mandatory. Inject the host's real OAuth/OIDC
70
+ signature verifier or token introspection client; decoding a JWT is not
71
+ verification. For JWT providers, a host-installed `jose` verifier can use
72
+ `createRemoteJWKSet` and `jwtVerify` with a fixed issuer, this resource audience,
73
+ and an explicit algorithm allowlist. Require `exp`; honor `nbf`, provider
74
+ revocation/current grants, and abortable verification. Map the **verified**
75
+ subject to the host's tenant and Lenso business identity. Do not trust a
76
+ client-selected tenant claim without the provider/host membership policy.
77
+ When services require an `@lenso/auth` Actor, retain the original verified Actor
78
+ reference in an identity extension such as `identity.actor` and bind that exact
79
+ reference. A copied MCP identity is not an Auth Actor or permission grant.
80
+
81
+ Return `McpIdentity`: `{subject, tenant, issuer, audience: string[], scopes:
82
+ string[], expiresAt}` (Unix seconds), optionally extending it with verified
83
+ business evidence. The adapter rechecks issuer, resource audience, expiry and
84
+ required scopes on **every** protected request, including notifications and
85
+ DELETE. `canList`, `authorize` and service policy handle current business grants;
86
+ discovery is not call permission. Configure the issuer string exactly, including
87
+ any provider-required trailing slash. Production verification/revocation remains
88
+ the host's responsibility, not the example's deterministic fixture verifier.
89
+
90
+ Missing/invalid credentials return 401 and a Bearer `resource_metadata` challenge;
91
+ missing required scopes return 403 with `insufficient_scope`. Public RFC 9728
92
+ metadata advertises the configured resource and authorization servers. Tokens
93
+ are neither forwarded to services nor to a different resource. Session IDs never
94
+ authenticate. Sessions and rate buckets bind issuer/subject/tenant/resource;
95
+ foreign owners receive 404 and grants are not cached in sessions.
96
+
97
+ Only canonical configured Host/Origin values are accepted. HTTPS is required
98
+ except on loopback; forwarded headers are not trusted. A proxy host must present
99
+ the canonical public Request URL through its own trusted routing. The adapter
100
+ starts no second listener; an independent listener must explicitly bind loopback
101
+ unless the host deliberately configures remote exposure.
102
+
103
+ ## Borrowed-entry bounds and protocol support
104
+
105
+ Defaults: four admitted calls/discoveries (no queue), 30s request timeout,
106
+ 256 KiB business input/catalog, 1 MiB business result, 1 MiB HTTP frame, and
107
+ 2 MiB + 4 KiB complete HTTP response (including cumulative internal stream data).
108
+ HTTP additionally admits 32 requests, 128 sessions, 5-minute idle expiry, and
109
+ 120 requests/minute per identity/tenant with at most 1024 rate buckets.
110
+ Trusted options can change these budgets; output/HTTP error budgets must fit a
111
+ 256-byte fixed diagnostic. HTTP's outer verification/body/transport deadline is
112
+ the request timeout plus 1s. Expired sessions/buckets are swept on ingress.
113
+
114
+ Cancellation and timeout reach binding signals; only a host-bound cooperative
115
+ service can interrupt actual work. Noncooperative work retains its concurrency
116
+ slot until settlement. `close()` stops admission, aborts owned request signals,
117
+ drains actual work and releases protocol resources; it is idempotent and never
118
+ calls `app.stop()`. A hanging noncooperative service/verifier can prolong drain.
119
+ Neither cancellation nor a lost response promises rollback or safe retry.
120
+ HTTP disconnect alone is deliberately not an MCP cancellation notification.
121
+
122
+ Protocol failures use fixed SDK MCP errors. Validation, authorization, business
123
+ failure, cancellation, timeout, busy/closed and size errors have fixed bounded
124
+ tool diagnostics; arbitrary thrown text, stacks, input and credentials are omitted.
125
+ Engine's finite JSON/redaction policy remains in force, not a sandbox.
126
+
127
+ Official SDK **1.32.1** remains exactly pinned. Official-client tests verify
128
+ `2025-11-25` and `2025-03-26` Streamable HTTP, plus existing stdio. SDK owns
129
+ negotiation, messages, session IDs and notification validation. Public HTTP
130
+ responses are finite JSON; GET returns 405 (no standalone SSE), DELETE terminates
131
+ sessions. No HTTP+SSE legacy endpoint, replay/event store, progress, resources,
132
+ prompts, elicitation or task capability is claimed.
133
+
134
+ Two version-specific mitigations use SDK public APIs: its finite POST SSE
135
+ transport is consumed within a budget and normalized to JSON because 1.32.1
136
+ JSON mode retains completed stream resolvers; entry-scoped cancellation handlers
137
+ handle valid IDs `0`/`""` and emit finite replies so cancellation does not retain
138
+ HTTP waiters. Regression tests inspect retained SDK state, cancellation and
139
+ escape-heavy JSON. No SDK private state is patched.
140
+
141
+ See the [offline host example](../../examples/mcp-host/README.md) for copyable
142
+ workspace commands and discovery/multiple read/write calls with one app.
143
+ These new exports are source-workspace changes, not proof of registry publication.
144
+
145
+ ## Existing trusted local launch (unchanged)
9
146
 
10
147
  Install the optional package in the application and create a dedicated entry:
11
148
 
@@ -56,7 +193,7 @@ processes with separately authorized credentials when local callers require
56
193
  identity isolation. A developer who can change application code/config or use
57
194
  the raw DB has administrative authority outside the tool surface; this adapter
58
195
  does not grant that authority to its client. Remote credentials, audiences and
59
- scopes require a separately designed trusted ingress, not this stdio entry.
196
+ scopes belong to the explicitly enabled HTTP entry, never this stdio entry.
60
197
 
61
198
  Successful tool content contains one text block with Engine's deterministic,
62
199
  redacted JSON result. No output schema is fabricated. Undefined, cyclic,
@@ -129,13 +266,15 @@ Primary sources checked for this implementation:
129
266
 
130
267
  This package deliberately uses the mature official monolithic v1 SDK. It does
131
268
  **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.
269
+ v2-only features. No resources, prompts, tasks or elicitation are advertised.
270
+ HTTP uses the [2025-11-25 authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
271
+ and RFC 9728 protected-resource metadata, not an application login endpoint.
134
272
 
135
273
  ## Development
136
274
 
137
275
  The integration owner installs dependencies and owns the workspace lockfile.
138
- Rebuild `@lenso/core`, `@lenso/engine`, and `@lenso/cli` before consuming their exports.
276
+ Rebuild `@lenso/core`, `@lenso/engine`, `@lenso/web`, `@lenso/auth`,
277
+ `@lenso/manage`, and `@lenso/cli` before consuming their exports.
139
278
  Then run from this package:
140
279
 
141
280
  ```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;