@happyvertical/smrt-app-cli 0.40.52 → 0.40.53

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
@@ -46,6 +46,41 @@ Resources and their commands are discovered from the app's `/_resources`
46
46
  surface. JSON Schema becomes CLI flags, while unsupported schema shapes are
47
47
  reported instead of silently guessed.
48
48
 
49
+ ## Discovery conformance artifact
50
+
51
+ `createResourceListHandler()` includes an `artifact` beside the compatible
52
+ `user`, `warnings`, and `resources` response fields. The artifact has a stable
53
+ schema selector, version, canonical ordering, and a `sha256:<hex>` digest. The
54
+ CLI validates it before using its embedded discovery payload.
55
+
56
+ For a released downstream app, install the exact published
57
+ `@happyvertical/smrt-users` version and pin a captured artifact like this:
58
+
59
+ ```ts
60
+ import {
61
+ validateDiscoveryConformanceArtifact,
62
+ } from '@happyvertical/smrt-users/app-contract';
63
+
64
+ const response = await fetch('https://app.example/api/_resources');
65
+ const body = await response.json();
66
+ const artifact = validateDiscoveryConformanceArtifact(body.artifact);
67
+
68
+ console.log(artifact.schema, artifact.version, artifact.integrity.digest);
69
+ // Store all three selectors and the canonical JSON in your packaged-runtime test.
70
+ ```
71
+
72
+ The result contract embedded in the artifact names the common `code`,
73
+ `message`, `details`, `retryable`, `correlationId`, `idempotencyKey`, and
74
+ `expectedVersion` metadata. `mcp tools` and `mcp call` print this as a JSON
75
+ success/error envelope. HTTP failures may use either a conventional top-level
76
+ metadata object or `{ error: metadata }`; both normalize to that same shape.
77
+ Generated action failures use the latter form with
78
+ `{ error: { ok: false, code, message, status, ... } }`; the CLI reports the
79
+ HTTP status beside that normalized metadata.
80
+ The stdio bridge carries it in MCP `_meta` under
81
+ `io.happyvertical/smrt`. It deliberately does not synthesize MCP
82
+ `structuredContent`.
83
+
49
84
  ## Add an app-specific command
50
85
 
51
86
  ```ts
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { l as runMcpStdioBridge } from "../config-DvxwoFks.js";
2
+ import { p as runMcpStdioBridge } from "../config-CDhknIOW.js";
3
3
  //#region src/bin/smrt-mcp-bridge.ts
4
4
  /**
5
5
  * `smrt-mcp-bridge` — generic stdio MCP bridge.
@@ -0,0 +1,2 @@
1
+ import { p as runMcpStdioBridge } from "./config-CDhknIOW.js";
2
+ export { runMcpStdioBridge };
@@ -0,0 +1,422 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { SMRT_MCP_RESULT_METADATA_KEY } from "@happyvertical/smrt-users/app-contract";
6
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
9
+ //#region src/bridge.ts
10
+ /**
11
+ * Stdio MCP bridge — pipes a remote SMRT app's HTTP MCP surface
12
+ * (`/api/mcp/tools` + `/api/mcp/call`) to a local stdio MCP server so that
13
+ * editors and AI clients can connect to it.
14
+ *
15
+ * Apps wire this up by providing their own bin script:
16
+ *
17
+ * ```ts
18
+ * #!/usr/bin/env node
19
+ * import { runMcpStdioBridge } from '@happyvertical/smrt-app-cli';
20
+ * await runMcpStdioBridge({
21
+ * envPrefix: 'WILLGRIFFIN',
22
+ * serverInfo: { name: 'willgriffin-mcp', version: '0.1.0' },
23
+ * });
24
+ * ```
25
+ *
26
+ * The package also ships a `smrt-mcp-bridge` bin (see `bin/smrt-mcp-bridge`)
27
+ * that reads `--env-prefix=…` from argv for ad-hoc use without writing a
28
+ * package-specific entry point.
29
+ *
30
+ * @packageDocumentation
31
+ */
32
+ /**
33
+ * Wire up the stdio server. Use `runMcpStdioBridge` for a one-call entry
34
+ * point in `bin/` scripts; this lower-level form is exposed for tests.
35
+ */
36
+ function createMcpStdioBridge(options) {
37
+ const toolsPath = options.toolsPath ?? "/api/mcp/tools";
38
+ const callPath = options.callPath ?? "/api/mcp/call";
39
+ const server = new Server(options.serverInfo, { capabilities: { tools: {} } });
40
+ server.setRequestHandler(ListToolsRequestSchema, async (_request) => {
41
+ const outcome = await requestJsonResult(options, toolsPath, { method: "GET" }, { fetch: options.fetch });
42
+ if (!outcome.ok) throw toMcpTransportError(outcome.error);
43
+ return withMcpMetadata(outcome.result, outcome.metadata);
44
+ });
45
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
46
+ const { name, arguments: args = {} } = request.params;
47
+ return formatMcpCallResult(await requestJsonResult(options, callPath, {
48
+ body: JSON.stringify({
49
+ arguments: args,
50
+ name
51
+ }),
52
+ method: "POST"
53
+ }, { fetch: options.fetch }));
54
+ });
55
+ return {
56
+ server,
57
+ connect: () => server.connect(new StdioServerTransport())
58
+ };
59
+ }
60
+ /**
61
+ * Preserve the HTTP result/error envelope in protocol-permitted MCP metadata.
62
+ * This deliberately does not create `structuredContent`; #2149 owns declared
63
+ * output schema semantics for generated tools.
64
+ */
65
+ function formatMcpCallResult(outcome) {
66
+ if (!outcome.ok) {
67
+ const error = sanitizeMetadata(outcome.error);
68
+ return withMcpMetadata({
69
+ content: [{
70
+ text: error.message ?? error.code,
71
+ type: "text"
72
+ }],
73
+ isError: true
74
+ }, error);
75
+ }
76
+ const metadata = isMcpErrorResult(outcome.result) ? {
77
+ ...outcome.metadata,
78
+ code: outcome.metadata.code === "ok" ? "mcp_tool_error" : outcome.metadata.code,
79
+ message: outcome.metadata.message ?? resultText(outcome.result)
80
+ } : outcome.metadata;
81
+ return withMcpMetadata(isMcpErrorResult(outcome.result) ? redactMcpErrorContent(outcome.result) : outcome.result, metadata);
82
+ }
83
+ function withMcpMetadata(result, metadata) {
84
+ const existing = isRecord$1(result._meta) ? result._meta : {};
85
+ return {
86
+ ...result,
87
+ _meta: {
88
+ ...existing,
89
+ [SMRT_MCP_RESULT_METADATA_KEY]: sanitizeMetadata(metadata)
90
+ }
91
+ };
92
+ }
93
+ function sanitizeMetadata(metadata) {
94
+ return {
95
+ ...metadata,
96
+ ...metadata.message ? { message: String(redactTransportValue(metadata.message)) } : {},
97
+ ...metadata.details !== void 0 ? { details: redactTransportValue(metadata.details) } : {},
98
+ ...metadata.correlationId ? { correlationId: String(redactTransportValue(metadata.correlationId)) } : {}
99
+ };
100
+ }
101
+ /** Convert an HTTP transport failure into an MCP JSON-RPC error safely. */
102
+ function toMcpTransportError(metadata) {
103
+ const sanitized = sanitizeMetadata(metadata);
104
+ return new McpError(ErrorCode.InternalError, sanitized.message ?? sanitized.code, { [SMRT_MCP_RESULT_METADATA_KEY]: sanitized });
105
+ }
106
+ function isMcpErrorResult(result) {
107
+ return result.isError === true;
108
+ }
109
+ function resultText(result) {
110
+ const content = result.content;
111
+ if (!Array.isArray(content)) return void 0;
112
+ return content.find((entry) => isRecord$1(entry) && typeof entry.text === "string")?.text;
113
+ }
114
+ function redactMcpErrorContent(result) {
115
+ const content = result.content;
116
+ if (!Array.isArray(content)) return result;
117
+ return {
118
+ ...result,
119
+ content: content.map((entry) => isRecord$1(entry) && typeof entry.text === "string" ? {
120
+ ...entry,
121
+ text: String(redactTransportValue(entry.text))
122
+ } : entry)
123
+ };
124
+ }
125
+ function isRecord$1(value) {
126
+ return typeof value === "object" && value !== null && !Array.isArray(value);
127
+ }
128
+ /**
129
+ * One-call entry point — instantiate the bridge and connect stdio. Returns
130
+ * a `Promise<void>` that resolves once the transport disconnects.
131
+ */
132
+ async function runMcpStdioBridge(options) {
133
+ const { connect } = createMcpStdioBridge(options);
134
+ await connect();
135
+ }
136
+ //#endregion
137
+ //#region src/config.ts
138
+ /**
139
+ * Shared CLI helpers for SMRT apps: a small config file format, env var
140
+ * resolution with a configurable prefix, and a minimal JSON HTTP client
141
+ * that knows how to bear the stored CLI token.
142
+ *
143
+ * The same helpers back the stdio bridge (`smrt-mcp-bridge`) and any
144
+ * app-specific `data`-style commands that talk to the app's HTTP API.
145
+ *
146
+ * @packageDocumentation
147
+ */
148
+ var DEFAULT_LOCAL_SERVER = "http://localhost:5173";
149
+ function configFilePath(context) {
150
+ const override = process.env[`${context.envPrefix}_CLI_CONFIG`];
151
+ if (override) return override;
152
+ const slug = context.appSlug ?? context.envPrefix.toLowerCase();
153
+ return join(homedir(), ".config", slug, "config.json");
154
+ }
155
+ /** Read the CLI config file. Missing file → empty config. */
156
+ async function loadCliConfig(context) {
157
+ try {
158
+ const raw = await readFile(configFilePath(context), "utf8");
159
+ if (!raw.trim()) return {};
160
+ return JSON.parse(raw);
161
+ } catch (error) {
162
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return {};
163
+ throw error;
164
+ }
165
+ }
166
+ /**
167
+ * Write the CLI config to disk with 0600 permissions (the token is a bearer
168
+ * credential — anyone who can read the file can impersonate the user).
169
+ */
170
+ async function saveCliConfig(context, config) {
171
+ const path = configFilePath(context);
172
+ const dir = dirname(path);
173
+ await mkdir(dir, {
174
+ recursive: true,
175
+ mode: 448
176
+ });
177
+ await chmod(dir, 448).catch(() => void 0);
178
+ const tmp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
179
+ try {
180
+ await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
181
+ await chmod(tmp, 384);
182
+ await rename(tmp, path);
183
+ } catch (err) {
184
+ await unlink(tmp).catch(() => void 0);
185
+ throw err;
186
+ }
187
+ }
188
+ /** Resolve the server URL: env var → config file → `defaultServerUrl`. */
189
+ async function getServerUrl(context, config) {
190
+ const resolved = config ?? await loadCliConfig(context);
191
+ return (process.env[`${context.envPrefix}_SERVER_URL`] ?? resolved.serverUrl ?? context.defaultServerUrl ?? DEFAULT_LOCAL_SERVER).replace(/\/+$/u, "");
192
+ }
193
+ /** Resolve a bearer token only when it is bound to the exact target server. */
194
+ async function getStoredToken(context, config, serverUrl) {
195
+ const resolved = config ?? await loadCliConfig(context);
196
+ const targetServer = (serverUrl ?? await getServerUrl(context, resolved)).replace(/\/+$/u, "");
197
+ const environmentToken = process.env[`${context.envPrefix}_TOKEN`];
198
+ const environmentServer = process.env[`${context.envPrefix}_SERVER_URL`]?.replace(/\/+$/u, "");
199
+ if (environmentToken) return environmentServer === targetServer ? environmentToken : void 0;
200
+ const configuredServer = resolved.serverUrl?.replace(/\/+$/u, "");
201
+ if (!configuredServer || configuredServer !== targetServer) return void 0;
202
+ if (resolved.credentialIssuer) return resolved.tokensByIssuer?.[resolved.credentialIssuer];
203
+ return resolved.token;
204
+ }
205
+ /** Remove the token from the config file (e.g. on logout). */
206
+ async function clearStoredToken(context) {
207
+ const config = await loadCliConfig(context);
208
+ if (config.credentialIssuer && config.tokensByIssuer) {
209
+ delete config.tokensByIssuer[config.credentialIssuer];
210
+ if (Object.keys(config.tokensByIssuer).length === 0) delete config.tokensByIssuer;
211
+ }
212
+ delete config.credentialIssuer;
213
+ delete config.token;
214
+ await saveCliConfig(context, config);
215
+ }
216
+ /** Persist a login with the bearer token keyed by its exact issuer. */
217
+ async function saveAuth(context, serverUrl, token, issuer = serverUrl) {
218
+ const config = await loadCliConfig(context);
219
+ const normalizedServerUrl = serverUrl.replace(/\/+$/u, "");
220
+ if (!issuer.trim()) throw new Error("Credential issuer must not be empty.");
221
+ const exactIssuer = issuer;
222
+ await saveCliConfig(context, {
223
+ ...config,
224
+ credentialIssuer: exactIssuer,
225
+ serverUrl: normalizedServerUrl,
226
+ token: void 0,
227
+ tokensByIssuer: { [exactIssuer]: token }
228
+ });
229
+ }
230
+ var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
231
+ /** Error compatibility wrapper returned by the legacy throwing helper. */
232
+ var AppCliRequestError = class extends Error {
233
+ status;
234
+ metadata;
235
+ constructor(status, metadata) {
236
+ super(metadata.message ?? `HTTP ${status}`);
237
+ this.name = "AppCliRequestError";
238
+ this.status = status;
239
+ this.metadata = metadata;
240
+ }
241
+ };
242
+ /**
243
+ * JSON request helper that injects the stored bearer token. Returns the
244
+ * parsed JSON body on success; throws an `Error` with the server's `error`
245
+ * field (or `HTTP <status>`) on failure.
246
+ */
247
+ async function requestJson(context, path, init = {}, options = {}) {
248
+ const outcome = await requestJsonResult(context, path, init, options);
249
+ if (!outcome.ok) throw new AppCliRequestError(outcome.status, outcome.error);
250
+ return outcome.result;
251
+ }
252
+ /**
253
+ * Issue a JSON request without flattening a failure to an Error message.
254
+ *
255
+ * Metadata is deliberately copied only from conventional result/error fields
256
+ * and response headers. Every value crossing this boundary is redacted before
257
+ * a CLI or MCP client can observe it.
258
+ */
259
+ async function requestJsonResult(context, path, init = {}, options = {}) {
260
+ const config = options.loadedConfig ?? await loadCliConfig(context);
261
+ const serverUrl = (options.serverUrl ?? await getServerUrl(context, config)).replace(/\/+$/u, "");
262
+ const token = await getStoredToken(context, config, serverUrl);
263
+ const headers = new Headers(init.headers);
264
+ if (options.requireAuth && options.auth !== false && !token) return failure(401, {
265
+ code: "not_authenticated",
266
+ message: `Not authenticated. Run \`${context.envPrefix.toLowerCase()} auth login\` first.`,
267
+ retryable: false
268
+ });
269
+ if (!headers.has("content-type") && init.body) headers.set("content-type", "application/json");
270
+ if (options.auth !== false && token) headers.set("authorization", `Bearer ${token}`);
271
+ const fetchImpl = options.fetch ?? fetch;
272
+ let response;
273
+ try {
274
+ response = await fetchImpl(`${serverUrl}${path}`, {
275
+ ...init,
276
+ headers
277
+ });
278
+ } catch (error) {
279
+ return failure(0, {
280
+ code: "network_error",
281
+ message: redactMessage(error instanceof Error ? error.message : String(error)),
282
+ retryable: true
283
+ });
284
+ }
285
+ const contentType = response.headers.get("content-type") ?? "";
286
+ const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
287
+ let text;
288
+ try {
289
+ text = await readBodyWithCap(response, maxBytes);
290
+ } catch (error) {
291
+ if (!(error instanceof ResponseTooLargeError)) return failure(0, {
292
+ code: "network_error",
293
+ message: redactMessage(error instanceof Error ? error.message : String(error)),
294
+ retryable: true
295
+ });
296
+ return failure(response.status, {
297
+ code: "response_too_large",
298
+ message: redactMessage(error instanceof Error ? error.message : String(error)),
299
+ retryable: response.status >= 500
300
+ });
301
+ }
302
+ let parsed = text;
303
+ if (contentType.includes("application/json") && text) try {
304
+ parsed = JSON.parse(text);
305
+ } catch {
306
+ return failure(response.status, {
307
+ code: "invalid_json_response",
308
+ message: "Server returned invalid JSON.",
309
+ retryable: response.status >= 500
310
+ });
311
+ }
312
+ if (!response.ok) return failure(response.status, metadataFromResponse(parsed, response.headers, `http_${response.status}`, response.status >= 500 || response.status === 408 || response.status === 429, `HTTP ${response.status}: ${response.statusText}`));
313
+ return {
314
+ ok: true,
315
+ result: parsed,
316
+ metadata: metadataFromResponse(parsed, response.headers, "ok", false)
317
+ };
318
+ }
319
+ /** Redact conventional credential fields before emitting transport metadata. */
320
+ function redactTransportValue(value) {
321
+ if (typeof value === "string") return redactMessage(value);
322
+ if (Array.isArray(value)) return value.map(redactTransportValue);
323
+ if (!isRecord(value)) return value;
324
+ const redacted = {};
325
+ for (const [key, nested] of Object.entries(value)) redacted[key] = isSensitiveKey(key) ? "[REDACTED]" : redactTransportValue(nested);
326
+ return redacted;
327
+ }
328
+ function failure(status, error) {
329
+ return {
330
+ ok: false,
331
+ status,
332
+ error
333
+ };
334
+ }
335
+ function metadataFromResponse(payload, headers, fallbackCode, fallbackRetryable, fallbackMessage) {
336
+ const record = isRecord(payload) ? payload : void 0;
337
+ const error = record && isRecord(record.error) ? record.error : void 0;
338
+ const meta = firstRecord(error, record && isRecord(record.metadata) ? record.metadata : void 0, record && isRecord(record.meta) ? record.meta : void 0, getMcpMetadata(record), record);
339
+ const message = firstString(meta?.message, error?.message, typeof record?.error === "string" ? record.error : void 0, fallbackMessage);
340
+ const details = meta?.details ?? error?.details;
341
+ const correlationId = firstString(meta?.correlationId, error?.correlationId, headers.get("x-correlation-id") ?? void 0, headers.get("x-request-id") ?? void 0);
342
+ const idempotencyKey = declaredActionField(meta?.idempotencyKey ?? meta?.idempotency);
343
+ const expectedVersion = declaredActionField(meta?.expectedVersion);
344
+ return {
345
+ code: firstString(meta?.code, error?.code) ?? fallbackCode,
346
+ ...message ? { message: redactMessage(message) } : {},
347
+ ...details !== void 0 ? { details: redactTransportValue(details) } : {},
348
+ retryable: typeof meta?.retryable === "boolean" ? meta.retryable : typeof error?.retryable === "boolean" ? error.retryable : fallbackRetryable,
349
+ ...correlationId ? { correlationId: redactMessage(correlationId) } : {},
350
+ ...idempotencyKey ? { idempotencyKey } : {},
351
+ ...expectedVersion ? { expectedVersion } : {}
352
+ };
353
+ }
354
+ function getMcpMetadata(record) {
355
+ if (!record || !isRecord(record._meta)) return void 0;
356
+ const meta = record._meta[SMRT_MCP_RESULT_METADATA_KEY];
357
+ return isRecord(meta) ? meta : void 0;
358
+ }
359
+ function declaredActionField(value) {
360
+ if (!isRecord(value) || typeof value.field !== "string") return void 0;
361
+ return {
362
+ field: value.field,
363
+ required: value.required === true
364
+ };
365
+ }
366
+ function firstRecord(...values) {
367
+ return values.find((value) => value !== void 0);
368
+ }
369
+ function firstString(...values) {
370
+ return values.find((value) => typeof value === "string" && value !== "");
371
+ }
372
+ function isRecord(value) {
373
+ return typeof value === "object" && value !== null && !Array.isArray(value);
374
+ }
375
+ function isSensitiveKey(key) {
376
+ return /(?:authorization|token|secret|password|cookie|credential|api[-_]?key)/iu.test(key);
377
+ }
378
+ function redactMessage(message) {
379
+ return message.replace(/bearer\s+[a-z0-9._~+/=-]+/giu, "Bearer [REDACTED]").replace(/((?:token|secret|password|api[-_]?key|authorization)=)[^&\s,]+/giu, "$1[REDACTED]");
380
+ }
381
+ var ResponseTooLargeError = class extends Error {
382
+ constructor(message) {
383
+ super(message);
384
+ this.name = "ResponseTooLargeError";
385
+ }
386
+ };
387
+ /**
388
+ * Read the response body into a UTF-8 string, capping at `maxBytes`. On
389
+ * overflow, throw with a clear message — better than OOM-ing the CLI
390
+ * when a server returns a multi-GB body. Streams chunk-by-chunk so the
391
+ * check fires before the whole body is buffered. (#1311 review A4.)
392
+ */
393
+ async function readBodyWithCap(response, maxBytes) {
394
+ if (!response.body) return "";
395
+ const cl = Number(response.headers.get("content-length") ?? "");
396
+ if (Number.isFinite(cl) && cl > maxBytes) {
397
+ try {
398
+ await response.body.cancel();
399
+ } catch {}
400
+ throw new ResponseTooLargeError(`Response too large: ${cl} bytes exceeds ${maxBytes}-byte cap`);
401
+ }
402
+ const reader = response.body.getReader();
403
+ const chunks = [];
404
+ let size = 0;
405
+ try {
406
+ while (true) {
407
+ const { done, value } = await reader.read();
408
+ if (done) break;
409
+ if (!value) continue;
410
+ size += value.byteLength;
411
+ if (size > maxBytes) throw new ResponseTooLargeError(`Response too large: exceeded ${maxBytes}-byte cap mid-stream`);
412
+ chunks.push(value);
413
+ }
414
+ } finally {
415
+ try {
416
+ reader.releaseLock();
417
+ } catch {}
418
+ }
419
+ return new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
420
+ }
421
+ //#endregion
422
+ export { loadCliConfig as a, requestJsonResult as c, createMcpStdioBridge as d, formatMcpCallResult as f, getStoredToken as i, saveAuth as l, toMcpTransportError as m, clearStoredToken as n, redactTransportValue as o, runMcpStdioBridge as p, getServerUrl as r, requestJson as s, AppCliRequestError as t, saveCliConfig as u };
package/dist/index.d.ts CHANGED
@@ -43,6 +43,42 @@ export declare interface AppCliContext {
43
43
  stderr: NodeJS.WriteStream;
44
44
  }
45
45
 
46
+ /** Error compatibility wrapper returned by the legacy throwing helper. */
47
+ export declare class AppCliRequestError extends Error {
48
+ readonly status: number;
49
+ readonly metadata: AppCliResultMetadata;
50
+ constructor(status: number, metadata: AppCliResultMetadata);
51
+ }
52
+
53
+ /** Structured metadata preserved for CLI JSON and MCP bridge consumers. */
54
+ export declare type AppCliResultMetadata = AppResultMetadata;
55
+
56
+ declare interface AppResultContract {
57
+ schema: typeof SMRT_APP_RESULT_SCHEMA;
58
+ version: typeof SMRT_APP_RESULT_VERSION;
59
+ mcpMetadataKey: typeof SMRT_MCP_RESULT_METADATA_KEY;
60
+ metadataFields: readonly [
61
+ 'code',
62
+ 'message',
63
+ 'details',
64
+ 'retryable',
65
+ 'correlationId',
66
+ 'idempotencyKey',
67
+ 'expectedVersion'
68
+ ];
69
+ }
70
+
71
+ /** Machine-readable metadata shared by HTTP CLI and MCP bridge results. */
72
+ declare interface AppResultMetadata {
73
+ code: string;
74
+ message?: string;
75
+ details?: JsonValue;
76
+ retryable?: boolean;
77
+ correlationId?: string;
78
+ idempotencyKey?: DeclaredActionField;
79
+ expectedVersion?: DeclaredActionField;
80
+ }
81
+
46
82
  export declare function buildFlagParser(schema: Record<string, unknown> | undefined, options?: ParserOptions): BuildParserResult;
47
83
 
48
84
  export declare interface BuildParserResult {
@@ -129,12 +165,20 @@ declare interface CommandDefinition_2 {
129
165
  description?: string;
130
166
  /** JSONSchema describing the command's argv-flag surface. */
131
167
  parameters?: Record<string, unknown>;
168
+ /** Retry and optimistic-concurrency fields declared by the action schema. */
169
+ requirements?: CommandRequirements;
132
170
  }
133
171
 
134
172
  export declare type CommandKind = CommandKind_2;
135
173
 
136
174
  declare type CommandKind_2 = 'crud' | 'custom';
137
175
 
176
+ /** Retry and concurrency declarations projected from a command schema. */
177
+ declare interface CommandRequirements {
178
+ idempotencyKey?: DeclaredActionField;
179
+ expectedVersion?: DeclaredActionField;
180
+ }
181
+
138
182
  export declare type CommandScope = CommandScope_2;
139
183
 
140
184
  declare type CommandScope_2 = 'item' | 'collection';
@@ -183,6 +227,32 @@ export declare function createMcpStdioBridge(options: McpStdioBridgeOptions): {
183
227
  connect: () => Promise<void>;
184
228
  };
185
229
 
230
+ /** A declared input field which controls retry or optimistic-concurrency use. */
231
+ declare interface DeclaredActionField {
232
+ /** JSON Schema property name supplied by the action. */
233
+ field: string;
234
+ /** Whether the action marks the field required. */
235
+ required: boolean;
236
+ }
237
+
238
+ declare interface DiscoveryArtifactIntegrity {
239
+ algorithm: 'sha256';
240
+ /** `sha256:<lowercase-hex>` over canonical unsigned artifact JSON. */
241
+ digest: string;
242
+ }
243
+
244
+ /** Versioned payload a consumer can validate and pin. */
245
+ declare interface DiscoveryConformanceArtifact {
246
+ schema: typeof SMRT_DISCOVERY_CONFORMANCE_SCHEMA;
247
+ version: typeof SMRT_DISCOVERY_CONFORMANCE_VERSION;
248
+ discovery: DiscoveryPayload;
249
+ resultContract: AppResultContract;
250
+ integrity: DiscoveryArtifactIntegrity;
251
+ }
252
+
253
+ /** The pre-artifact wire payload, kept for the existing `/_resources` fields. */
254
+ declare type DiscoveryPayload = Omit<ResourceListResponseBody, 'artifact'>;
255
+
186
256
  /**
187
257
  * Fetch the discovery payload.
188
258
  *
@@ -241,6 +311,11 @@ declare interface InvokeOptions {
241
311
  fetch?: typeof fetch;
242
312
  }
243
313
 
314
+ /** JSON-safe value used by structured details and parameter schemas. */
315
+ declare type JsonValue = null | boolean | number | string | JsonValue[] | {
316
+ [key: string]: JsonValue;
317
+ };
318
+
244
319
  /** Read the CLI config file. Missing file → empty config. */
245
320
  export declare function loadCliConfig(context: CliConfigContext): Promise<CliConfig>;
246
321
 
@@ -332,6 +407,9 @@ declare interface ParserOptions {
332
407
  positionalOnly?: boolean;
333
408
  }
334
409
 
410
+ /** Redact conventional credential fields before emitting transport metadata. */
411
+ export declare function redactTransportValue(value: unknown): unknown;
412
+
335
413
  /**
336
414
  * Complete client registration against discovered authorization-server
337
415
  * metadata. A Client ID Metadata Document needs no registration request: the
@@ -356,6 +434,12 @@ export declare interface RenderResult {
356
434
  */
357
435
  export declare function requestJson<T = unknown>(context: CliConfigContext, path: string, init?: RequestInit, options?: RequestJsonOptions): Promise<T>;
358
436
 
437
+ declare interface RequestJsonFailure {
438
+ ok: false;
439
+ status: number;
440
+ error: AppCliResultMetadata;
441
+ }
442
+
359
443
  /** Options for `requestJson`. */
360
444
  export declare interface RequestJsonOptions {
361
445
  /**
@@ -390,6 +474,24 @@ export declare interface RequestJsonOptions {
390
474
  loadedConfig?: CliConfig;
391
475
  }
392
476
 
477
+ /** Generic result/error envelope for a JSON request. */
478
+ export declare type RequestJsonResult<T> = RequestJsonSuccess<T> | RequestJsonFailure;
479
+
480
+ /**
481
+ * Issue a JSON request without flattening a failure to an Error message.
482
+ *
483
+ * Metadata is deliberately copied only from conventional result/error fields
484
+ * and response headers. Every value crossing this boundary is redacted before
485
+ * a CLI or MCP client can observe it.
486
+ */
487
+ export declare function requestJsonResult<T = unknown>(context: CliConfigContext, path: string, init?: RequestInit, options?: RequestJsonOptions): Promise<RequestJsonResult<T>>;
488
+
489
+ declare interface RequestJsonSuccess<T> {
490
+ ok: true;
491
+ result: T;
492
+ metadata: AppCliResultMetadata;
493
+ }
494
+
393
495
  /**
394
496
  * Select registration in MCP priority order: Client ID Metadata Documents
395
497
  * first, then RFC 7591 DCR as a compatibility fallback.
@@ -405,6 +507,8 @@ declare interface ResourceListResponseBody {
405
507
  };
406
508
  warnings: string[];
407
509
  resources: CliResource_2[];
510
+ /** Versioned deterministic artifact for consumers that integrity-pin discovery. */
511
+ artifact?: DiscoveryConformanceArtifact;
408
512
  }
409
513
 
410
514
  /**
@@ -431,4 +535,19 @@ export declare type SchemaSupportStatus = {
431
535
  kind: 'missing';
432
536
  };
433
537
 
538
+ /** Versioned selector for structured application results and errors. */
539
+ declare const SMRT_APP_RESULT_SCHEMA = "https://smrt.dev/schemas/app-result/v1";
540
+
541
+ /** Version of the structured application result contract. */
542
+ declare const SMRT_APP_RESULT_VERSION: 1;
543
+
544
+ /** Immutable selector for this discovery artifact family. */
545
+ declare const SMRT_DISCOVERY_CONFORMANCE_SCHEMA = "https://smrt.dev/schemas/discovery-conformance/v1";
546
+
547
+ /** Version of the discovery artifact payload. */
548
+ declare const SMRT_DISCOVERY_CONFORMANCE_VERSION: 1;
549
+
550
+ /** MCP result `_meta` member carrying the app-result metadata. */
551
+ export declare const SMRT_MCP_RESULT_METADATA_KEY = "io.happyvertical/smrt";
552
+
434
553
  export { }
package/dist/index.js CHANGED
@@ -1,7 +1,24 @@
1
- import { a as requestJson, c as createMcpStdioBridge, i as loadCliConfig, l as runMcpStdioBridge, n as getServerUrl, o as saveAuth, r as getStoredToken, s as saveCliConfig, t as clearStoredToken } from "./config-DvxwoFks.js";
1
+ import { a as loadCliConfig, c as requestJsonResult, d as createMcpStdioBridge, i as getStoredToken, l as saveAuth, n as clearStoredToken, o as redactTransportValue, p as runMcpStdioBridge, r as getServerUrl, s as requestJson, t as AppCliRequestError, u as saveCliConfig } from "./config-CDhknIOW.js";
2
+ import { SMRT_MCP_RESULT_METADATA_KEY, validateDiscoveryConformanceArtifact } from "@happyvertical/smrt-users/app-contract";
2
3
  import { spawn, spawnSync } from "node:child_process";
3
4
  //#region src/discovery.ts
4
5
  /**
6
+ * Fetches the resource list from `GET /api/_resources` and re-exports the
7
+ * wire types so consumers can build typed extensions on top of the discovery
8
+ * output.
9
+ *
10
+ * Types are imported type-only from `@happyvertical/smrt-users/sveltekit`
11
+ * — the single source of truth for the wire contract. The CLI does not
12
+ * depend on smrt-users at runtime (the peer dep is `optional` in the
13
+ * package.json) so importing types is free of bundle cost.
14
+ *
15
+ * Importing type-only means a new field added to `CommandDefinition` /
16
+ * `CliResource` on the handler side automatically propagates to the CLI's
17
+ * typecheck — no manual mirror-update required. (#1311 review D-1.)
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+ /**
5
22
  * Fetch the discovery payload.
6
23
  *
7
24
  * Translates a 401 into a friendlier error so the CLI can prompt the
@@ -9,11 +26,17 @@ import { spawn, spawnSync } from "node:child_process";
9
26
  */
10
27
  async function fetchResourceList(context, options = {}) {
11
28
  try {
12
- return await requestJson(context, options.path ?? "/api/_resources", { method: "GET" }, {
29
+ const response = await requestJson(context, options.path ?? "/api/_resources", { method: "GET" }, {
13
30
  fetch: options.fetch,
14
31
  requireAuth: options.requireAuth,
15
32
  loadedConfig: options.loadedConfig
16
33
  });
34
+ if (!response.artifact) return response;
35
+ const artifact = validateDiscoveryConformanceArtifact(response.artifact);
36
+ return {
37
+ ...artifact.discovery,
38
+ artifact
39
+ };
17
40
  } catch (error) {
18
41
  if (error instanceof Error && /401|unauthor/i.test(error.message)) throw new Error(`Not authenticated to ${context.envPrefix.toLowerCase()}. Run \`${context.envPrefix.toLowerCase()} auth login\` first.`);
19
42
  throw error;
@@ -758,11 +781,13 @@ function openVerificationUrl(url) {
758
781
  //#region src/commands/mcp.ts
759
782
  async function runMcpCommand(options, args) {
760
783
  const stdout = options.stdout ?? process.stdout;
784
+ const stderr = options.stderr ?? process.stderr;
761
785
  const sub = args[0];
762
786
  if (sub === "tools") {
763
- const result = await requestJson(options.context, "/api/mcp/tools", { method: "GET" }, { fetch: options.fetch });
787
+ const result = await requestJsonResult(options.context, "/api/mcp/tools", { method: "GET" }, { fetch: options.fetch });
764
788
  stdout.write(`${JSON.stringify(result, null, 2)}\n`);
765
- return;
789
+ if (!result.ok) stderr.write(`${result.error.message ?? result.error.code}\n`);
790
+ return result.ok;
766
791
  }
767
792
  if (sub === "call") {
768
793
  const name = args[1];
@@ -774,7 +799,7 @@ async function runMcpCommand(options, args) {
774
799
  } catch (error) {
775
800
  throw new Error(`Could not parse mcp call payload: ${error.message}`);
776
801
  }
777
- const result = await requestJson(options.context, "/api/mcp/call", {
802
+ const result = await requestJsonResult(options.context, "/api/mcp/call", {
778
803
  body: JSON.stringify({
779
804
  arguments: parsed,
780
805
  name
@@ -782,7 +807,8 @@ async function runMcpCommand(options, args) {
782
807
  method: "POST"
783
808
  }, { fetch: options.fetch });
784
809
  stdout.write(`${JSON.stringify(result, null, 2)}\n`);
785
- return;
810
+ if (!result.ok) stderr.write(`${result.error.message ?? result.error.code}\n`);
811
+ return result.ok;
786
812
  }
787
813
  throw new Error("Usage: mcp tools | mcp call <tool> [<json>]");
788
814
  }
@@ -847,7 +873,7 @@ function createAppCli(options) {
847
873
  return {
848
874
  run: (argv) => runCli(context, options, extraByName, argv),
849
875
  startMcpBridge: async (serverInfo) => {
850
- const { runMcpStdioBridge } = await import("./bridge-D0LJc3mN.js");
876
+ const { runMcpStdioBridge } = await import("./bridge-BgtGXmYP.js");
851
877
  await runMcpStdioBridge({
852
878
  ...context,
853
879
  serverInfo: {
@@ -899,10 +925,14 @@ async function dispatchCli(context, options, extras, argv, stdout, stderr) {
899
925
  if (sub === "logout") return runAuthLogout(opts);
900
926
  throw new Error("Usage: auth login [--server <url>] [--no-open] | status | logout");
901
927
  }
902
- if (command === "mcp") return runMcpCommand({
903
- context,
904
- stdout
905
- }, rest);
928
+ if (command === "mcp") {
929
+ if (!await runMcpCommand({
930
+ context,
931
+ stdout,
932
+ stderr
933
+ }, rest)) process.exitCode = 1;
934
+ return;
935
+ }
906
936
  if (command === "resources") return runResourcesCommand({
907
937
  context,
908
938
  stdout,
@@ -1000,4 +1030,4 @@ function similar(a, b) {
1000
1030
  return true;
1001
1031
  }
1002
1032
  //#endregion
1003
- export { buildFlagParser, buildUrl, classifySchema, clearStoredToken, createAppCli, createMcpClientIdMetadataDocument, createMcpStdioBridge, fetchResourceList, findCommand, findResourceBySlug, getServerUrl, getStoredToken, invokeCommand, loadCliConfig, registerMcpClient, renderResponse, requestJson, resolveMcpClientRegistration, runMcpStdioBridge, saveAuth, saveCliConfig };
1033
+ export { AppCliRequestError, SMRT_MCP_RESULT_METADATA_KEY, buildFlagParser, buildUrl, classifySchema, clearStoredToken, createAppCli, createMcpClientIdMetadataDocument, createMcpStdioBridge, fetchResourceList, findCommand, findResourceBySlug, getServerUrl, getStoredToken, invokeCommand, loadCliConfig, redactTransportValue, registerMcpClient, renderResponse, requestJson, requestJsonResult, resolveMcpClientRegistration, runMcpStdioBridge, saveAuth, saveCliConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-app-cli",
3
- "version": "0.40.52",
3
+ "version": "0.40.53",
4
4
  "description": "Reusable CLI factory for SMRT apps — branded `<name> <resource> <command>` CLI + stdio MCP bridge with decorator-driven resource discovery",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "@modelcontextprotocol/sdk": "^1.25.2",
24
- "@happyvertical/smrt-users": "0.40.52"
24
+ "@happyvertical/smrt-users": "0.40.53"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "24.13.2",
@@ -29,7 +29,7 @@
29
29
  "vite": "8.1.4",
30
30
  "vite-plugin-dts": "4.5.4",
31
31
  "vitest": "4.1.10",
32
- "@happyvertical/smrt-core": "0.40.52"
32
+ "@happyvertical/smrt-core": "0.40.53"
33
33
  },
34
34
  "engines": {
35
35
  "node": ">=24.18.0"
@@ -1,2 +0,0 @@
1
- import { l as runMcpStdioBridge } from "./config-DvxwoFks.js";
2
- export { runMcpStdioBridge };
@@ -1,231 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
8
- //#region src/bridge.ts
9
- /**
10
- * Stdio MCP bridge — pipes a remote SMRT app's HTTP MCP surface
11
- * (`/api/mcp/tools` + `/api/mcp/call`) to a local stdio MCP server so that
12
- * editors and AI clients can connect to it.
13
- *
14
- * Apps wire this up by providing their own bin script:
15
- *
16
- * ```ts
17
- * #!/usr/bin/env node
18
- * import { runMcpStdioBridge } from '@happyvertical/smrt-app-cli';
19
- * await runMcpStdioBridge({
20
- * envPrefix: 'WILLGRIFFIN',
21
- * serverInfo: { name: 'willgriffin-mcp', version: '0.1.0' },
22
- * });
23
- * ```
24
- *
25
- * The package also ships a `smrt-mcp-bridge` bin (see `bin/smrt-mcp-bridge`)
26
- * that reads `--env-prefix=…` from argv for ad-hoc use without writing a
27
- * package-specific entry point.
28
- *
29
- * @packageDocumentation
30
- */
31
- /**
32
- * Wire up the stdio server. Use `runMcpStdioBridge` for a one-call entry
33
- * point in `bin/` scripts; this lower-level form is exposed for tests.
34
- */
35
- function createMcpStdioBridge(options) {
36
- const toolsPath = options.toolsPath ?? "/api/mcp/tools";
37
- const callPath = options.callPath ?? "/api/mcp/call";
38
- const server = new Server(options.serverInfo, { capabilities: { tools: {} } });
39
- server.setRequestHandler(ListToolsRequestSchema, async (_request) => {
40
- return requestJson(options, toolsPath, { method: "GET" }, { fetch: options.fetch });
41
- });
42
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
43
- const { name, arguments: args = {} } = request.params;
44
- try {
45
- return await requestJson(options, callPath, {
46
- body: JSON.stringify({
47
- arguments: args,
48
- name
49
- }),
50
- method: "POST"
51
- }, { fetch: options.fetch });
52
- } catch (error) {
53
- return {
54
- content: [{
55
- text: error instanceof Error ? error.message : "MCP tool call failed.",
56
- type: "text"
57
- }],
58
- isError: true
59
- };
60
- }
61
- });
62
- return {
63
- server,
64
- connect: () => server.connect(new StdioServerTransport())
65
- };
66
- }
67
- /**
68
- * One-call entry point — instantiate the bridge and connect stdio. Returns
69
- * a `Promise<void>` that resolves once the transport disconnects.
70
- */
71
- async function runMcpStdioBridge(options) {
72
- const { connect } = createMcpStdioBridge(options);
73
- await connect();
74
- }
75
- //#endregion
76
- //#region src/config.ts
77
- /**
78
- * Shared CLI helpers for SMRT apps: a small config file format, env var
79
- * resolution with a configurable prefix, and a minimal JSON HTTP client
80
- * that knows how to bear the stored CLI token.
81
- *
82
- * The same helpers back the stdio bridge (`smrt-mcp-bridge`) and any
83
- * app-specific `data`-style commands that talk to the app's HTTP API.
84
- *
85
- * @packageDocumentation
86
- */
87
- var DEFAULT_LOCAL_SERVER = "http://localhost:5173";
88
- function configFilePath(context) {
89
- const override = process.env[`${context.envPrefix}_CLI_CONFIG`];
90
- if (override) return override;
91
- const slug = context.appSlug ?? context.envPrefix.toLowerCase();
92
- return join(homedir(), ".config", slug, "config.json");
93
- }
94
- /** Read the CLI config file. Missing file → empty config. */
95
- async function loadCliConfig(context) {
96
- try {
97
- const raw = await readFile(configFilePath(context), "utf8");
98
- if (!raw.trim()) return {};
99
- return JSON.parse(raw);
100
- } catch (error) {
101
- if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return {};
102
- throw error;
103
- }
104
- }
105
- /**
106
- * Write the CLI config to disk with 0600 permissions (the token is a bearer
107
- * credential — anyone who can read the file can impersonate the user).
108
- */
109
- async function saveCliConfig(context, config) {
110
- const path = configFilePath(context);
111
- const dir = dirname(path);
112
- await mkdir(dir, {
113
- recursive: true,
114
- mode: 448
115
- });
116
- await chmod(dir, 448).catch(() => void 0);
117
- const tmp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
118
- try {
119
- await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
120
- await chmod(tmp, 384);
121
- await rename(tmp, path);
122
- } catch (err) {
123
- await unlink(tmp).catch(() => void 0);
124
- throw err;
125
- }
126
- }
127
- /** Resolve the server URL: env var → config file → `defaultServerUrl`. */
128
- async function getServerUrl(context, config) {
129
- const resolved = config ?? await loadCliConfig(context);
130
- return (process.env[`${context.envPrefix}_SERVER_URL`] ?? resolved.serverUrl ?? context.defaultServerUrl ?? DEFAULT_LOCAL_SERVER).replace(/\/+$/u, "");
131
- }
132
- /** Resolve a bearer token only when it is bound to the exact target server. */
133
- async function getStoredToken(context, config, serverUrl) {
134
- const resolved = config ?? await loadCliConfig(context);
135
- const targetServer = (serverUrl ?? await getServerUrl(context, resolved)).replace(/\/+$/u, "");
136
- const environmentToken = process.env[`${context.envPrefix}_TOKEN`];
137
- const environmentServer = process.env[`${context.envPrefix}_SERVER_URL`]?.replace(/\/+$/u, "");
138
- if (environmentToken) return environmentServer === targetServer ? environmentToken : void 0;
139
- const configuredServer = resolved.serverUrl?.replace(/\/+$/u, "");
140
- if (!configuredServer || configuredServer !== targetServer) return void 0;
141
- if (resolved.credentialIssuer) return resolved.tokensByIssuer?.[resolved.credentialIssuer];
142
- return resolved.token;
143
- }
144
- /** Remove the token from the config file (e.g. on logout). */
145
- async function clearStoredToken(context) {
146
- const config = await loadCliConfig(context);
147
- if (config.credentialIssuer && config.tokensByIssuer) {
148
- delete config.tokensByIssuer[config.credentialIssuer];
149
- if (Object.keys(config.tokensByIssuer).length === 0) delete config.tokensByIssuer;
150
- }
151
- delete config.credentialIssuer;
152
- delete config.token;
153
- await saveCliConfig(context, config);
154
- }
155
- /** Persist a login with the bearer token keyed by its exact issuer. */
156
- async function saveAuth(context, serverUrl, token, issuer = serverUrl) {
157
- const config = await loadCliConfig(context);
158
- const normalizedServerUrl = serverUrl.replace(/\/+$/u, "");
159
- if (!issuer.trim()) throw new Error("Credential issuer must not be empty.");
160
- const exactIssuer = issuer;
161
- await saveCliConfig(context, {
162
- ...config,
163
- credentialIssuer: exactIssuer,
164
- serverUrl: normalizedServerUrl,
165
- token: void 0,
166
- tokensByIssuer: { [exactIssuer]: token }
167
- });
168
- }
169
- var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
170
- /**
171
- * JSON request helper that injects the stored bearer token. Returns the
172
- * parsed JSON body on success; throws an `Error` with the server's `error`
173
- * field (or `HTTP <status>`) on failure.
174
- */
175
- async function requestJson(context, path, init = {}, options = {}) {
176
- const config = options.loadedConfig ?? await loadCliConfig(context);
177
- const serverUrl = (options.serverUrl ?? await getServerUrl(context, config)).replace(/\/+$/u, "");
178
- const token = await getStoredToken(context, config, serverUrl);
179
- const headers = new Headers(init.headers);
180
- if (options.requireAuth && options.auth !== false && !token) throw new Error(`Not authenticated. Run \`${context.envPrefix.toLowerCase()} auth login\` first.`);
181
- if (!headers.has("content-type") && init.body) headers.set("content-type", "application/json");
182
- if (options.auth !== false && token) headers.set("authorization", `Bearer ${token}`);
183
- const response = await (options.fetch ?? fetch)(`${serverUrl}${path}`, {
184
- ...init,
185
- headers
186
- });
187
- const contentType = response.headers.get("content-type") ?? "";
188
- const text = await readBodyWithCap(response, options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES);
189
- const parsed = contentType.includes("application/json") && text ? JSON.parse(text) : text;
190
- if (!response.ok) {
191
- const message = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `HTTP ${response.status}: ${response.statusText}`;
192
- throw Object.assign(new Error(message), { status: response.status });
193
- }
194
- return parsed;
195
- }
196
- /**
197
- * Read the response body into a UTF-8 string, capping at `maxBytes`. On
198
- * overflow, throw with a clear message — better than OOM-ing the CLI
199
- * when a server returns a multi-GB body. Streams chunk-by-chunk so the
200
- * check fires before the whole body is buffered. (#1311 review A4.)
201
- */
202
- async function readBodyWithCap(response, maxBytes) {
203
- if (!response.body) return "";
204
- const cl = Number(response.headers.get("content-length") ?? "");
205
- if (Number.isFinite(cl) && cl > maxBytes) {
206
- try {
207
- await response.body.cancel();
208
- } catch {}
209
- throw new Error(`Response too large: ${cl} bytes exceeds ${maxBytes}-byte cap`);
210
- }
211
- const reader = response.body.getReader();
212
- const chunks = [];
213
- let size = 0;
214
- try {
215
- while (true) {
216
- const { done, value } = await reader.read();
217
- if (done) break;
218
- if (!value) continue;
219
- size += value.byteLength;
220
- if (size > maxBytes) throw new Error(`Response too large: exceeded ${maxBytes}-byte cap mid-stream`);
221
- chunks.push(value);
222
- }
223
- } finally {
224
- try {
225
- reader.releaseLock();
226
- } catch {}
227
- }
228
- return new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
229
- }
230
- //#endregion
231
- export { requestJson as a, createMcpStdioBridge as c, loadCliConfig as i, runMcpStdioBridge as l, getServerUrl as n, saveAuth as o, getStoredToken as r, saveCliConfig as s, clearStoredToken as t };