@happyvertical/smrt-app-cli 0.40.48 → 0.40.50

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/AGENTS.md CHANGED
@@ -13,6 +13,8 @@ Application CLI support for SMRT-based apps.
13
13
  - Keep commands app-agnostic; pass app identity through `CliConfigContext`.
14
14
  - Treat transient auth polling failures as recoverable until the server-issued expiration window closes.
15
15
  - Do not persist tokens, server URLs, or app slugs outside the configured local CLI config path.
16
+ - Bind persisted bearer tokens to the exact issuer emitted by the device flow
17
+ and to the server selected during login. Never reuse them after either changes.
16
18
  - Keep command output stream-injectable so tests can assert behavior without writing to the real terminal.
17
19
 
18
20
  ## Gotchas
package/README.md CHANGED
@@ -90,6 +90,13 @@ surface is typically mounted with
90
90
  - Device-code login distinguishes pending approval, expiry, transient network
91
91
  failure, and hard rejection.
92
92
  - Tokens and server configuration stay in the app-specific local config path.
93
+ - Device-flow responses identify their issuer. Tokens are keyed by that exact
94
+ issuer and are not reused when the configured server changes.
95
+
96
+ For standards-based remote MCP OAuth registration, use
97
+ `registerMcpClient()` (Client ID Metadata Document first, executable RFC 7591
98
+ DCR fallback with `application_type`) and follow the
99
+ [remote MCP authorization guide](../../docs/content/architecture/remote-mcp-authorization.md).
93
100
 
94
101
  ## Public API
95
102
 
@@ -101,6 +108,8 @@ surface is typically mounted with
101
108
  | `invokeCommand()` | Invoke one generated resource command |
102
109
  | `buildFlagParser()` | Convert supported JSON Schema to flags |
103
110
  | `loadCliConfig()` / `saveCliConfig()` | Read and write namespaced CLI config |
111
+ | `resolveMcpClientRegistration()` | Inspect the selected Client ID Metadata or DCR path |
112
+ | `registerMcpClient()` | Use Client ID Metadata or execute the DCR fallback |
104
113
 
105
114
  ## Development
106
115
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { l as runMcpStdioBridge } from "../config-DB4AMYT0.js";
2
+ import { l as runMcpStdioBridge } from "../config-DvxwoFks.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 { l as runMcpStdioBridge } from "./config-DvxwoFks.js";
2
+ export { runMcpStdioBridge };
@@ -129,23 +129,41 @@ async function getServerUrl(context, config) {
129
129
  const resolved = config ?? await loadCliConfig(context);
130
130
  return (process.env[`${context.envPrefix}_SERVER_URL`] ?? resolved.serverUrl ?? context.defaultServerUrl ?? DEFAULT_LOCAL_SERVER).replace(/\/+$/u, "");
131
131
  }
132
- /** Resolve the stored bearer token (env var wins over config). */
133
- async function getStoredToken(context, config) {
132
+ /** Resolve a bearer token only when it is bound to the exact target server. */
133
+ async function getStoredToken(context, config, serverUrl) {
134
134
  const resolved = config ?? await loadCliConfig(context);
135
- return process.env[`${context.envPrefix}_TOKEN`] ?? resolved.token;
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;
136
143
  }
137
144
  /** Remove the token from the config file (e.g. on logout). */
138
145
  async function clearStoredToken(context) {
139
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;
140
152
  delete config.token;
141
153
  await saveCliConfig(context, config);
142
154
  }
143
- /** Persist a login: writes both serverUrl and token to the config file. */
144
- async function saveAuth(context, serverUrl, token) {
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;
145
161
  await saveCliConfig(context, {
146
- ...await loadCliConfig(context),
147
- serverUrl: serverUrl.replace(/\/+$/u, ""),
148
- token
162
+ ...config,
163
+ credentialIssuer: exactIssuer,
164
+ serverUrl: normalizedServerUrl,
165
+ token: void 0,
166
+ tokensByIssuer: { [exactIssuer]: token }
149
167
  });
150
168
  }
151
169
  var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
@@ -157,7 +175,7 @@ var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
157
175
  async function requestJson(context, path, init = {}, options = {}) {
158
176
  const config = options.loadedConfig ?? await loadCliConfig(context);
159
177
  const serverUrl = (options.serverUrl ?? await getServerUrl(context, config)).replace(/\/+$/u, "");
160
- const token = await getStoredToken(context, config);
178
+ const token = await getStoredToken(context, config, serverUrl);
161
179
  const headers = new Headers(init.headers);
162
180
  if (options.requireAuth && options.auth !== false && !token) throw new Error(`Not authenticated. Run \`${context.envPrefix.toLowerCase()} auth login\` first.`);
163
181
  if (!headers.has("content-type") && init.body) headers.set("content-type", "application/json");
package/dist/index.d.ts CHANGED
@@ -68,8 +68,13 @@ export declare function clearStoredToken(context: CliConfigContext): Promise<voi
68
68
 
69
69
  /** Shape stored on disk in the app's CLI config file. */
70
70
  export declare interface CliConfig {
71
+ /** Issuer selected for the currently configured server. */
72
+ credentialIssuer?: string;
71
73
  serverUrl?: string;
74
+ /** Legacy single-token field. Read only when its stored server still matches. */
72
75
  token?: string;
76
+ /** Bearer credentials keyed by their exact issuer identifier. */
77
+ tokensByIssuer?: Record<string, string>;
73
78
  }
74
79
 
75
80
  /**
@@ -166,6 +171,9 @@ export declare interface CreateAppCliOptions {
166
171
  extraCommands?: AppCliCommand[];
167
172
  }
168
173
 
174
+ /** Build and validate the document hosted at an HTTPS URL used as client_id. */
175
+ export declare function createMcpClientIdMetadataDocument(options: McpClientRegistrationOptions): McpClientIdMetadataDocument;
176
+
169
177
  /**
170
178
  * Wire up the stdio server. Use `runMcpStdioBridge` for a one-call entry
171
179
  * point in `bin/` scripts; this lower-level form is exposed for tests.
@@ -212,8 +220,8 @@ export declare function findResourceBySlug(response: ResourceListResponse, slug:
212
220
  /** Resolve the server URL: env var → config file → `defaultServerUrl`. */
213
221
  export declare function getServerUrl(context: CliConfigContext, config?: CliConfig): Promise<string>;
214
222
 
215
- /** Resolve the stored bearer token (env var wins over config). */
216
- export declare function getStoredToken(context: CliConfigContext, config?: CliConfig): Promise<string | undefined>;
223
+ /** Resolve a bearer token only when it is bound to the exact target server. */
224
+ export declare function getStoredToken(context: CliConfigContext, config?: CliConfig, serverUrl?: string): Promise<string | undefined>;
217
225
 
218
226
  /**
219
227
  * Build the URL the CLI should hit for this command, plus the fetch init
@@ -236,6 +244,41 @@ declare interface InvokeOptions {
236
244
  /** Read the CLI config file. Missing file → empty config. */
237
245
  export declare function loadCliConfig(context: CliConfigContext): Promise<CliConfig>;
238
246
 
247
+ export declare interface McpClientIdMetadataDocument {
248
+ application_type: OAuthApplicationType;
249
+ client_id: string;
250
+ client_name: string;
251
+ grant_types: ['authorization_code'];
252
+ redirect_uris: string[];
253
+ response_types: ['code'];
254
+ token_endpoint_auth_method: 'none';
255
+ }
256
+
257
+ export declare type McpClientRegistration = {
258
+ clientId: string;
259
+ kind: 'client_id_metadata_document';
260
+ metadataDocument: McpClientIdMetadataDocument;
261
+ } | {
262
+ endpoint: string;
263
+ kind: 'dynamic_client_registration';
264
+ request: Omit<McpClientIdMetadataDocument, 'client_id'>;
265
+ };
266
+
267
+ export declare interface McpClientRegistrationOptions {
268
+ applicationType: OAuthApplicationType;
269
+ /** HTTPS metadata-document URL. Required only when CIMD is advertised. */
270
+ clientId?: string;
271
+ clientName: string;
272
+ redirectUris: string[];
273
+ }
274
+
275
+ export declare interface McpRegisteredClient {
276
+ clientId: string;
277
+ kind: McpClientRegistration['kind'];
278
+ metadataDocument?: McpClientIdMetadataDocument;
279
+ registrationResponse?: Record<string, unknown>;
280
+ }
281
+
239
282
  /**
240
283
  * Configuration for the bridge.
241
284
  *
@@ -258,6 +301,14 @@ export declare interface McpStdioBridgeOptions extends CliConfigContext {
258
301
  fetch?: typeof fetch;
259
302
  }
260
303
 
304
+ /** MCP OAuth client-registration helpers for remote HTTP deployments. */
305
+ export declare type OAuthApplicationType = 'native' | 'web';
306
+
307
+ export declare interface OAuthAuthorizationServerMetadata {
308
+ client_id_metadata_document_supported?: boolean;
309
+ registration_endpoint?: string;
310
+ }
311
+
261
312
  declare interface OutputOptions {
262
313
  stdout?: NodeJS.WriteStream | Writable;
263
314
  stderr?: NodeJS.WriteStream | Writable;
@@ -281,6 +332,14 @@ declare interface ParserOptions {
281
332
  positionalOnly?: boolean;
282
333
  }
283
334
 
335
+ /**
336
+ * Complete client registration against discovered authorization-server
337
+ * metadata. A Client ID Metadata Document needs no registration request: the
338
+ * authorization server retrieves it from the HTTPS client_id. The legacy DCR
339
+ * fallback is executed as an RFC 7591 JSON POST.
340
+ */
341
+ export declare function registerMcpClient(authorizationServer: OAuthAuthorizationServerMetadata, options: McpClientRegistrationOptions, fetchImpl?: typeof fetch): Promise<McpRegisteredClient>;
342
+
284
343
  /**
285
344
  * Render the response. Returns the desired exit code.
286
345
  */
@@ -331,6 +390,12 @@ export declare interface RequestJsonOptions {
331
390
  loadedConfig?: CliConfig;
332
391
  }
333
392
 
393
+ /**
394
+ * Select registration in MCP priority order: Client ID Metadata Documents
395
+ * first, then RFC 7591 DCR as a compatibility fallback.
396
+ */
397
+ export declare function resolveMcpClientRegistration(authorizationServer: OAuthAuthorizationServerMetadata, options: McpClientRegistrationOptions): McpClientRegistration;
398
+
334
399
  export declare type ResourceListResponse = ResourceListResponseBody;
335
400
 
336
401
  declare interface ResourceListResponseBody {
@@ -348,8 +413,8 @@ declare interface ResourceListResponseBody {
348
413
  */
349
414
  export declare function runMcpStdioBridge(options: McpStdioBridgeOptions): Promise<void>;
350
415
 
351
- /** Persist a login: writes both serverUrl and token to the config file. */
352
- export declare function saveAuth(context: CliConfigContext, serverUrl: string, token: string): Promise<void>;
416
+ /** Persist a login with the bearer token keyed by its exact issuer. */
417
+ export declare function saveAuth(context: CliConfigContext, serverUrl: string, token: string, issuer?: string): Promise<void>;
353
418
 
354
419
  /**
355
420
  * Write the CLI config to disk with 0600 permissions (the token is a bearer
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
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-DB4AMYT0.js";
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";
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  //#region src/discovery.ts
4
4
  /**
@@ -91,6 +91,79 @@ function splitPath(s) {
91
91
  return s.split("/").filter((p) => p.length > 0);
92
92
  }
93
93
  //#endregion
94
+ //#region src/mcp-oauth.ts
95
+ function createMcpRegistrationRequest(options) {
96
+ if (!options.clientName.trim()) throw new Error("MCP client_name must not be empty.");
97
+ if (options.redirectUris.length === 0) throw new Error("MCP client metadata requires at least one redirect URI.");
98
+ for (const redirectUri of options.redirectUris) new URL(redirectUri);
99
+ return {
100
+ application_type: options.applicationType,
101
+ client_name: options.clientName,
102
+ grant_types: ["authorization_code"],
103
+ redirect_uris: [...options.redirectUris],
104
+ response_types: ["code"],
105
+ token_endpoint_auth_method: "none"
106
+ };
107
+ }
108
+ /** Build and validate the document hosted at an HTTPS URL used as client_id. */
109
+ function createMcpClientIdMetadataDocument(options) {
110
+ if (!options.clientId) throw new Error("MCP client_id metadata URL is required for CIMD.");
111
+ const clientId = new URL(options.clientId);
112
+ if (clientId.protocol !== "https:" || clientId.pathname === "/") throw new Error("MCP client_id metadata URL must use HTTPS and include a path.");
113
+ if (clientId.search || clientId.hash) throw new Error("MCP client_id metadata URL must not include query or fragment.");
114
+ return {
115
+ ...createMcpRegistrationRequest(options),
116
+ client_id: options.clientId
117
+ };
118
+ }
119
+ /**
120
+ * Select registration in MCP priority order: Client ID Metadata Documents
121
+ * first, then RFC 7591 DCR as a compatibility fallback.
122
+ */
123
+ function resolveMcpClientRegistration(authorizationServer, options) {
124
+ if (authorizationServer.client_id_metadata_document_supported === true) {
125
+ const metadataDocument = createMcpClientIdMetadataDocument(options);
126
+ return {
127
+ clientId: metadataDocument.client_id,
128
+ kind: "client_id_metadata_document",
129
+ metadataDocument
130
+ };
131
+ }
132
+ if (authorizationServer.registration_endpoint) return {
133
+ endpoint: authorizationServer.registration_endpoint,
134
+ kind: "dynamic_client_registration",
135
+ request: createMcpRegistrationRequest(options)
136
+ };
137
+ throw new Error("Authorization server supports neither Client ID Metadata Documents nor dynamic client registration.");
138
+ }
139
+ /**
140
+ * Complete client registration against discovered authorization-server
141
+ * metadata. A Client ID Metadata Document needs no registration request: the
142
+ * authorization server retrieves it from the HTTPS client_id. The legacy DCR
143
+ * fallback is executed as an RFC 7591 JSON POST.
144
+ */
145
+ async function registerMcpClient(authorizationServer, options, fetchImpl = fetch) {
146
+ const registration = resolveMcpClientRegistration(authorizationServer, options);
147
+ if (registration.kind === "client_id_metadata_document") return {
148
+ clientId: registration.clientId,
149
+ kind: registration.kind,
150
+ metadataDocument: registration.metadataDocument
151
+ };
152
+ const response = await fetchImpl(registration.endpoint, {
153
+ body: JSON.stringify(registration.request),
154
+ headers: { "content-type": "application/json" },
155
+ method: "POST"
156
+ });
157
+ if (!response.ok) throw new Error(`Dynamic client registration failed: HTTP ${response.status}`);
158
+ const body = await response.json();
159
+ if (typeof body !== "object" || body === null || typeof body.client_id !== "string" || !body.client_id) throw new Error("Dynamic client registration response omitted client_id.");
160
+ return {
161
+ clientId: body.client_id,
162
+ kind: registration.kind,
163
+ registrationResponse: body
164
+ };
165
+ }
166
+ //#endregion
94
167
  //#region src/output.ts
95
168
  var JSON_BUFFER_LIMIT = 10 * 1024 * 1024;
96
169
  /**
@@ -558,7 +631,7 @@ async function runAuthLogin(options, args) {
558
631
  serverUrl: targetServer
559
632
  });
560
633
  if (token.status === "approved" && token.accessToken) {
561
- await saveAuth(options.context, targetServer, token.accessToken);
634
+ await saveAuth(options.context, targetServer, token.accessToken, start.issuer ?? targetServer);
562
635
  stdout.write(`Authenticated to ${targetServer}\n`);
563
636
  return;
564
637
  }
@@ -774,7 +847,7 @@ function createAppCli(options) {
774
847
  return {
775
848
  run: (argv) => runCli(context, options, extraByName, argv),
776
849
  startMcpBridge: async (serverInfo) => {
777
- const { runMcpStdioBridge } = await import("./bridge-sTKlA6Hz.js");
850
+ const { runMcpStdioBridge } = await import("./bridge-D0LJc3mN.js");
778
851
  await runMcpStdioBridge({
779
852
  ...context,
780
853
  serverInfo: {
@@ -927,4 +1000,4 @@ function similar(a, b) {
927
1000
  return true;
928
1001
  }
929
1002
  //#endregion
930
- export { buildFlagParser, buildUrl, classifySchema, clearStoredToken, createAppCli, createMcpStdioBridge, fetchResourceList, findCommand, findResourceBySlug, getServerUrl, getStoredToken, invokeCommand, loadCliConfig, renderResponse, requestJson, runMcpStdioBridge, saveAuth, saveCliConfig };
1003
+ export { buildFlagParser, buildUrl, classifySchema, clearStoredToken, createAppCli, createMcpClientIdMetadataDocument, createMcpStdioBridge, fetchResourceList, findCommand, findResourceBySlug, getServerUrl, getStoredToken, invokeCommand, loadCliConfig, registerMcpClient, renderResponse, requestJson, 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.48",
3
+ "version": "0.40.50",
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.48"
24
+ "@happyvertical/smrt-users": "0.40.50"
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.48"
32
+ "@happyvertical/smrt-core": "0.40.50"
33
33
  },
34
34
  "engines": {
35
35
  "node": ">=24.18.0"
@@ -1,2 +0,0 @@
1
- import { l as runMcpStdioBridge } from "./config-DB4AMYT0.js";
2
- export { runMcpStdioBridge };