@ultimat3/cli 22.13.0 → 22.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "22.13.0",
3
+ "version": "22.14.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,33 +38,33 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@babel/core": "^8.0.1",
41
- "@ultimat3/action": "22.13.0",
42
- "@ultimat3/admin": "22.13.0",
43
- "@ultimat3/ai": "22.13.0",
44
- "@ultimat3/auth": "22.13.0",
45
- "@ultimat3/cache": "22.13.0",
46
- "@ultimat3/core": "22.13.0",
47
- "@ultimat3/db": "22.13.0",
48
- "@ultimat3/entity": "22.13.0",
49
- "@ultimat3/flags": "22.13.0",
50
- "@ultimat3/http": "22.13.0",
51
- "@ultimat3/i18n": "22.13.0",
52
- "@ultimat3/jobs": "22.13.0",
53
- "@ultimat3/mail": "22.13.0",
54
- "@ultimat3/manifest": "22.13.0",
55
- "@ultimat3/mcp": "22.13.0",
56
- "@ultimat3/money": "22.13.0",
57
- "@ultimat3/notify": "22.13.0",
58
- "@ultimat3/policy": "22.13.0",
59
- "@ultimat3/pwa": "22.13.0",
60
- "@ultimat3/query": "22.13.0",
61
- "@ultimat3/realtime": "22.13.0",
62
- "@ultimat3/render": "22.13.0",
63
- "@ultimat3/schema": "22.13.0",
64
- "@ultimat3/seo": "22.13.0",
65
- "@ultimat3/storage": "22.13.0",
66
- "@ultimat3/testing": "22.13.0",
67
- "@ultimat3/time": "22.13.0",
41
+ "@ultimat3/action": "22.14.0",
42
+ "@ultimat3/admin": "22.14.0",
43
+ "@ultimat3/ai": "22.14.0",
44
+ "@ultimat3/auth": "22.14.0",
45
+ "@ultimat3/cache": "22.14.0",
46
+ "@ultimat3/core": "22.14.0",
47
+ "@ultimat3/db": "22.14.0",
48
+ "@ultimat3/entity": "22.14.0",
49
+ "@ultimat3/flags": "22.14.0",
50
+ "@ultimat3/http": "22.14.0",
51
+ "@ultimat3/i18n": "22.14.0",
52
+ "@ultimat3/jobs": "22.14.0",
53
+ "@ultimat3/mail": "22.14.0",
54
+ "@ultimat3/manifest": "22.14.0",
55
+ "@ultimat3/mcp": "22.14.0",
56
+ "@ultimat3/money": "22.14.0",
57
+ "@ultimat3/notify": "22.14.0",
58
+ "@ultimat3/policy": "22.14.0",
59
+ "@ultimat3/pwa": "22.14.0",
60
+ "@ultimat3/query": "22.14.0",
61
+ "@ultimat3/realtime": "22.14.0",
62
+ "@ultimat3/render": "22.14.0",
63
+ "@ultimat3/schema": "22.14.0",
64
+ "@ultimat3/seo": "22.14.0",
65
+ "@ultimat3/storage": "22.14.0",
66
+ "@ultimat3/testing": "22.14.0",
67
+ "@ultimat3/time": "22.14.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/app-mcp.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  // `ai: { mcp: { expose: true, path: '/mcp' } }` by DEFAULT, and nothing between the two served it:
4
4
  // neither `x dev` nor `runRole` mounted the route, so `POST /mcp` answered `X_ROUTE_NOT_FOUND` in
5
5
  // every app ever scaffolded (measured 2026-09-05). The contract is one file: `apps/<app>/mcp.ts`
6
- // exports `mcp`, an `AppMcp`; this module finds it, and both boots mount what it carries.
6
+ // exports `mcp` — one `AppMcp`, or an array of them, one per population (customers, staff,
7
+ // affiliates) — this module finds it, and both boots mount what it carries: endpoint #0 at
8
+ // `ai.mcp.path`, every other at its own `defineAppMcp({ path })`.
7
9
 
8
10
  // why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
9
11
  import { existsSync } from 'node:fs';
@@ -12,23 +14,38 @@ import { join } from 'node:path';
12
14
  import { logger } from '@ultimat3/core';
13
15
  import type { Route } from '@ultimat3/http';
14
16
  import { json, selfOrigin } from '@ultimat3/http';
15
- import { type AppMcp, McpAppUnmountedError } from '@ultimat3/mcp';
17
+ import {
18
+ type AppMcp,
19
+ McpAppUnmountedError,
20
+ McpPathDuplicateError,
21
+ PROTECTED_RESOURCE_WELL_KNOWN,
22
+ } from '@ultimat3/mcp';
16
23
  import { APP_CONFIG_EXPORT } from './app-auth';
17
24
  import { APP_CONFIG_FILE } from './app-root';
18
25
 
19
26
  /** The one file an app writes, per app directory. */
20
27
  export const APP_MCP_GLOB = 'apps/*/mcp.ts';
21
- /** The export that file makes — an `AppMcp`, the value `defineAppMcp` returns. */
28
+ /**
29
+ * The export that file makes — an `AppMcp`, the value `defineAppMcp` returns, or a non-empty array
30
+ * of them. ONE export name either way: a second named export per endpoint would be a second way
31
+ * to say the same thing, and the array's order is the one fact the mount needs (#0 is the default).
32
+ */
22
33
  export const APP_MCP_EXPORT = 'mcp';
23
- /** What the boot line and `/_x` call the route. */
34
+ /** What the boot line and `/_x` call endpoint #0's route; endpoint #n is `mcp:<its path>`. */
24
35
  export const APP_MCP_ROUTE_NAME = 'mcp';
25
36
 
26
37
  export interface AppMcpMount {
27
- /** `[]` when `expose` is false, when nothing exports `mcp`, or when the export has no route. */
38
+ /** `[]` when `expose` is false, when nothing exports `mcp`, or when no endpoint has a route. */
28
39
  readonly routes: readonly Route[];
29
- /** `POST <path>` when mounted, else `null` — the boot line prints it. */
40
+ /** Endpoint #0's `POST <path>` when mounted, else `null` — the default, at `ai.mcp.path`. */
30
41
  readonly path: string | null;
31
- /** Set exactly when `expose` is true and `routes` is empty: the reason, as an instruction. */
42
+ /** Every mounted endpoint's `POST <path>`, in export order — the boot line prints one each. */
43
+ readonly paths: readonly string[];
44
+ /**
45
+ * Set when `expose` is true and an endpoint could not be mounted — nothing exports `mcp`, or the
46
+ * first endpoint built without `resolveToken`: the reason, as an instruction. The endpoints that
47
+ * could be mounted still are.
48
+ */
32
49
  readonly warning: McpAppUnmountedError | undefined;
33
50
  }
34
51
 
@@ -65,6 +82,13 @@ async function exposeDeclaration(root: string): Promise<ExposeDeclaration | unde
65
82
  const isAppMcp = (value: unknown): value is AppMcp =>
66
83
  isRecord(value) && 'server' in value && 'tools' in value && 'route' in value;
67
84
 
85
+ /** The export as a list of endpoints, or `undefined` when it is not one (the "missing" case). */
86
+ function endpointsOf(exported: unknown): readonly AppMcp[] | undefined {
87
+ if (isAppMcp(exported)) return [exported];
88
+ if (!Array.isArray(exported) || exported.length === 0) return undefined;
89
+ return exported.every(isAppMcp) ? (exported as readonly AppMcp[]) : undefined;
90
+ }
91
+
68
92
  /** Every `apps/<app>/mcp.ts`, app-root-relative and sorted, so two apps answer in one order. */
69
93
  async function candidates(root: string): Promise<readonly string[]> {
70
94
  // A root with no `apps/` is an app with no MCP file, never a boot failure — the scan's ENOENT
@@ -75,8 +99,10 @@ async function candidates(root: string): Promise<readonly string[]> {
75
99
  return files.sort();
76
100
  }
77
101
 
102
+ const NOTHING: AppMcpMount = { routes: [], path: null, paths: [], warning: undefined };
103
+
78
104
  /**
79
- * The route to mount, or the reason there is none. Pure over the filesystem it is pointed at;
105
+ * The routes to mount, or the reason there are none. Pure over the filesystem it is pointed at;
80
106
  * `mountAppMcp` below is the one place the warning becomes a log line.
81
107
  *
82
108
  * `meta.auth: 'public'` and `enforcedBy: 'handler'` — the http pipeline must not pre-judge:
@@ -87,72 +113,17 @@ async function candidates(root: string): Promise<readonly string[]> {
87
113
  */
88
114
  export async function appMcpMount(root: string): Promise<AppMcpMount> {
89
115
  const declared = await exposeDeclaration(root);
90
- if (declared === undefined || !declared.expose)
91
- return { routes: [], path: null, warning: undefined };
116
+ if (declared === undefined || !declared.expose) return NOTHING;
92
117
  const files = await candidates(root);
93
118
  const fallbackFile = 'apps/web/mcp.ts';
94
- if (files.length === 0) {
95
- return {
96
- routes: [],
97
- path: null,
98
- warning: new McpAppUnmountedError({
99
- reason: 'missing',
100
- path: declared.path,
101
- file: fallbackFile,
102
- }),
103
- };
104
- }
105
119
  for (const file of files) {
106
120
  const module = (await import(join(root, file))) as Record<string, unknown>;
107
- const exported = module[APP_MCP_EXPORT];
108
- if (!isAppMcp(exported)) continue;
109
- const route = exported.route;
110
- if (route === undefined) {
111
- return {
112
- routes: [],
113
- path: null,
114
- warning: new McpAppUnmountedError({ reason: 'no-route', path: declared.path, file }),
115
- };
116
- }
117
- // The PUBLIC origin, as the pipeline resolved it (`ctx.https` honours a trusted proxy's
118
- // `x-forwarded-proto`): behind a TLS-terminating ingress the raw request URL is `http://` on
119
- // an internal host, and a `resource_metadata` naming that is a URL no client can reach.
120
- const resource = route.protectedResource;
121
- return {
122
- routes: [
123
- {
124
- method: 'POST',
125
- path: declared.path,
126
- handler: (request, ctx) =>
127
- route.handle(request.raw, { origin: selfOrigin(ctx.url, ctx.https) }),
128
- meta: { name: APP_MCP_ROUTE_NAME, auth: 'public', enforcedBy: 'handler' },
129
- },
130
- // RFC 9728 protected-resource metadata, when the app declared `oauth`: path-inserted and
131
- // at the root, so a client probing either finds the authorization server.
132
- ...(resource === undefined
133
- ? []
134
- : resource.paths.map(
135
- (path): Route => ({
136
- method: 'GET',
137
- path,
138
- handler: (_request, ctx) => {
139
- const response = json(resource.document(selfOrigin(ctx.url, ctx.https)));
140
- // A browser-hosted MCP client reads this cross-origin; it holds nothing secret.
141
- response.headers.set('access-control-allow-origin', '*');
142
- response.headers.set('cache-control', 'public, max-age=3600');
143
- return response;
144
- },
145
- meta: { name: `${APP_MCP_ROUTE_NAME}.oauth-protected-resource`, auth: 'public' },
146
- }),
147
- )),
148
- ],
149
- path: declared.path,
150
- warning: undefined,
151
- };
121
+ const endpoints = endpointsOf(module[APP_MCP_EXPORT]);
122
+ if (endpoints === undefined) continue;
123
+ return mountEndpoints(endpoints, declared.path, file);
152
124
  }
153
125
  return {
154
- routes: [],
155
- path: null,
126
+ ...NOTHING,
156
127
  warning: new McpAppUnmountedError({
157
128
  reason: 'missing',
158
129
  path: declared.path,
@@ -161,6 +132,95 @@ export async function appMcpMount(root: string): Promise<AppMcpMount> {
161
132
  };
162
133
  }
163
134
 
135
+ /**
136
+ * One file's endpoints onto one route table. An endpoint built without `resolveToken` has no
137
+ * route: it is skipped and the FIRST such is the warning, so one unfinished population never takes
138
+ * the others down with it. Two endpoints on one route is thrown — see `McpPathDuplicateError`.
139
+ */
140
+ function mountEndpoints(
141
+ endpoints: readonly AppMcp[],
142
+ defaultPath: string,
143
+ file: string,
144
+ ): AppMcpMount {
145
+ const routes: Route[] = [];
146
+ const paths: string[] = [];
147
+ const claimed = new Map<string, number>();
148
+ let warning: McpAppUnmountedError | undefined;
149
+ endpoints.forEach((endpoint, index) => {
150
+ const route = endpoint.route;
151
+ if (route === undefined) {
152
+ warning ??= new McpAppUnmountedError({
153
+ reason: 'no-route',
154
+ path: defaultPath,
155
+ file,
156
+ ...(index === 0 ? {} : { endpoint: index }),
157
+ });
158
+ return;
159
+ }
160
+ const path = index === 0 ? defaultPath : route.path;
161
+ for (const mounted of endpointRoutes(route, path, index)) {
162
+ const key = `${mounted.method} ${mounted.path}`;
163
+ const first = claimed.get(key);
164
+ if (first !== undefined) {
165
+ throw new McpPathDuplicateError({
166
+ method: mounted.method,
167
+ path: mounted.path,
168
+ endpoints: [first, index],
169
+ file,
170
+ });
171
+ }
172
+ claimed.set(key, index);
173
+ routes.push(mounted);
174
+ }
175
+ paths.push(path);
176
+ });
177
+ const path = endpoints[0]?.route === undefined ? null : defaultPath;
178
+ return { routes, path, paths, warning };
179
+ }
180
+
181
+ type Descriptor = NonNullable<AppMcp['route']>;
182
+
183
+ /** One endpoint's `POST` and, when it declared `oauth`, its RFC 9728 metadata `GET`s. */
184
+ function endpointRoutes(route: Descriptor, path: string, index: number): readonly Route[] {
185
+ const name = index === 0 ? APP_MCP_ROUTE_NAME : `${APP_MCP_ROUTE_NAME}:${path}`;
186
+ const resource = route.protectedResource;
187
+ // The ROOT document belongs to endpoint #0 alone: a client that probes the bare well-known URL
188
+ // is asking about the default resource, and a second endpoint answering there would collide.
189
+ // Every endpoint keeps its path-inserted document (RFC 9728 §3.1), which its own 401 names.
190
+ const metadata: readonly Route[] =
191
+ resource === undefined
192
+ ? []
193
+ : resource.paths
194
+ .filter((metadataPath) => index === 0 || metadataPath !== PROTECTED_RESOURCE_WELL_KNOWN)
195
+ .map(
196
+ (metadataPath): Route => ({
197
+ method: 'GET',
198
+ path: metadataPath,
199
+ handler: (_request, ctx) => {
200
+ const response = json(resource.document(selfOrigin(ctx.url, ctx.https)));
201
+ // A browser-hosted MCP client reads this cross-origin; it holds nothing secret.
202
+ response.headers.set('access-control-allow-origin', '*');
203
+ response.headers.set('cache-control', 'public, max-age=3600');
204
+ return response;
205
+ },
206
+ meta: { name: `${name}.oauth-protected-resource`, auth: 'public' },
207
+ }),
208
+ );
209
+ return [
210
+ {
211
+ method: 'POST',
212
+ path,
213
+ // The PUBLIC origin, as the pipeline resolved it (`ctx.https` honours a trusted proxy's
214
+ // `x-forwarded-proto`): behind a TLS-terminating ingress the raw request URL is `http://`
215
+ // on an internal host, and a `resource_metadata` naming that is a URL no client can reach.
216
+ handler: (request, ctx) =>
217
+ route.handle(request.raw, { origin: selfOrigin(ctx.url, ctx.https) }),
218
+ meta: { name, auth: 'public', enforcedBy: 'handler' },
219
+ },
220
+ ...metadata,
221
+ ];
222
+ }
223
+
164
224
  /**
165
225
  * The boot's call: the routes to spread into the table, with the warning already logged ONCE and
166
226
  * the mount announced. Both `x dev` and `runRole` go through here, so a developer's terminal and a
@@ -171,6 +231,6 @@ export async function mountAppMcp(root: string): Promise<AppMcpMount> {
171
231
  if (mount.warning !== undefined) {
172
232
  logger.warn(`${mount.warning.code}: ${mount.warning.cause} — fix: ${mount.warning.fix}`);
173
233
  }
174
- if (mount.path !== null) logger.info('app mcp mounted', { method: 'POST', path: mount.path });
234
+ for (const path of mount.paths) logger.info('app mcp mounted', { method: 'POST', path });
175
235
  return mount;
176
236
  }
package/src/cmd-dev.ts CHANGED
@@ -79,8 +79,10 @@ export interface DevServer {
79
79
  readonly runtime: RunningServices;
80
80
  /** Panel keys `/_x` mounted, in tab order. Reported so `--json` names what is reachable. */
81
81
  readonly panels: readonly string[];
82
- /** `POST <path>` of the app's own MCP endpoint, or `null` when nothing was mounted. */
82
+ /** `POST <path>` of the app's default MCP endpoint, or `null` when nothing was mounted. */
83
83
  readonly mcp: string | null;
84
+ /** `POST <path>` of every mounted MCP endpoint, default first — `[]` when none was. */
85
+ readonly mcpPaths: readonly string[];
84
86
  stop(): Promise<void>;
85
87
  }
86
88
 
@@ -240,7 +242,7 @@ async function bootDev(
240
242
  // `x dev`'s own, so a reload can empty it: an `isr` page's first render was otherwise served for
241
243
  // its whole ttl after every save, whatever the modules behind it now said.
242
244
  const isr = createIsrController({ buildId });
243
- const { routes, theme, errorStyles, mcpPath } = await devRouteTable({
245
+ const { routes, theme, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
244
246
  isr,
245
247
  root: options.root,
246
248
  env: options.env,
@@ -328,6 +330,7 @@ async function bootDev(
328
330
  services,
329
331
  roles: running.roles,
330
332
  mcp: mcpPath,
333
+ mcpPaths,
331
334
  get buildId(): string {
332
335
  return stamped ?? state.manifest.buildId;
333
336
  },
@@ -447,6 +450,7 @@ export const devCommand: CliCommand = {
447
450
  introspect: `${server.url}/_x`,
448
451
  panels: [...server.panels],
449
452
  mcp: server.mcp,
453
+ mcpPaths: [...server.mcpPaths],
450
454
  },
451
455
  lines: [
452
456
  // A hard kill leaves the lock behind; clearing it is normal and worth one line, never a
@@ -458,7 +462,8 @@ export const devCommand: CliCommand = {
458
462
  msg('cli.dev.introspect', { url: `${server.url}/_x` }),
459
463
  // Only when something was mounted: the unmounted case has already said why, once, as a
460
464
  // warning with a fix, and a summary line reading `mcp none` would be a second copy of it.
461
- ...(server.mcp === null ? [] : [msg('cli.dev.mcp', { path: server.mcp })]),
465
+ // One line per endpoint, so an app serving customers, staff and affiliates sees all three.
466
+ ...server.mcpPaths.map((path) => msg('cli.dev.mcp', { path })),
462
467
  ],
463
468
  };
464
469
  await writeLock(services.stateDir, {
@@ -58,8 +58,10 @@ export interface DevRouteTable {
58
58
  readonly theme: ThemeBoot;
59
59
  /** The app's own error pages' inline styles, admitted the same way. */
60
60
  readonly errorStyles: readonly string[];
61
- /** Where the app's MCP endpoint was mounted, or `undefined`. */
61
+ /** Where the app's default MCP endpoint was mounted, or `null`. */
62
62
  readonly mcpPath: string | null;
63
+ /** Every mounted MCP endpoint's path, default first — one per population the app serves. */
64
+ readonly mcpPaths: readonly string[];
63
65
  }
64
66
 
65
67
  export async function devRouteTable(input: DevRouteTableInput): Promise<DevRouteTable> {
@@ -156,5 +158,5 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
156
158
  }),
157
159
  ];
158
160
 
159
- return { routes, theme, errorStyles, mcpPath: mcpMount.path };
161
+ return { routes, theme, errorStyles, mcpPath: mcpMount.path, mcpPaths: mcpMount.paths };
160
162
  }