@ultimat3/admin 30.3.0 → 30.5.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
@@ -482,7 +482,13 @@ The admin exposes **its own MCP surface**, derived from the same resources and g
482
482
  ```ts
483
483
  import { adminMcp, adminMcpTools } from '@ultimat3/admin';
484
484
 
485
- export const mcp = adminMcp({ app: admin, actor: (session) => actorFor(session.token) });
485
+ // apps/admin/mcp.ts — module scope: the mount serves every apps/<app>/mcp.ts, after the scan
486
+ export const mcp = adminMcp({
487
+ app: admin,
488
+ path: '/admin/mcp', // beside the web endpoint's /mcp
489
+ include: 'exposed', // the app's exposed actions (admin:write) and queries (admin:read) too
490
+ actor: ({ token, facts }) => actorFor(token, facts),
491
+ });
486
492
  adminMcpTools(admin, ctx); // admin.post.list · admin.post.read · admin.search · admin.action.post.publish
487
493
  ```
488
494
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "30.3.0",
3
+ "version": "30.5.0",
4
4
  "description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -45,21 +45,21 @@
45
45
  "test": "bun test"
46
46
  },
47
47
  "dependencies": {
48
- "@ultimat3/action": "30.3.0",
49
- "@ultimat3/ai": "30.3.0",
50
- "@ultimat3/cache": "30.3.0",
51
- "@ultimat3/core": "30.3.0",
52
- "@ultimat3/db": "30.3.0",
53
- "@ultimat3/entity": "30.3.0",
54
- "@ultimat3/http": "30.3.0",
55
- "@ultimat3/i18n": "30.3.0",
56
- "@ultimat3/jobs": "30.3.0",
57
- "@ultimat3/mcp": "30.3.0",
58
- "@ultimat3/money": "30.3.0",
59
- "@ultimat3/policy": "30.3.0",
60
- "@ultimat3/query": "30.3.0",
61
- "@ultimat3/render": "30.3.0",
62
- "@ultimat3/schema": "30.3.0",
63
- "@ultimat3/ui": "30.3.0"
48
+ "@ultimat3/action": "30.5.0",
49
+ "@ultimat3/ai": "30.5.0",
50
+ "@ultimat3/cache": "30.5.0",
51
+ "@ultimat3/core": "30.5.0",
52
+ "@ultimat3/db": "30.5.0",
53
+ "@ultimat3/entity": "30.5.0",
54
+ "@ultimat3/http": "30.5.0",
55
+ "@ultimat3/i18n": "30.5.0",
56
+ "@ultimat3/jobs": "30.5.0",
57
+ "@ultimat3/mcp": "30.5.0",
58
+ "@ultimat3/money": "30.5.0",
59
+ "@ultimat3/policy": "30.5.0",
60
+ "@ultimat3/query": "30.5.0",
61
+ "@ultimat3/render": "30.5.0",
62
+ "@ultimat3/schema": "30.5.0",
63
+ "@ultimat3/ui": "30.5.0"
64
64
  }
65
65
  }
package/src/mcp-scopes.ts CHANGED
@@ -2,10 +2,10 @@
2
2
  // what a resolved token carries. The gate itself is `@ultimat3/mcp`'s registry — this file only
3
3
  // declares the cut (`defineAppMcp({ scopes })`) and the token's half of it.
4
4
 
5
- import type { McpScopes } from '@ultimat3/mcp';
5
+ import type { McpScopes, ProjectablePrimitive } from '@ultimat3/mcp';
6
6
  import { expandPermissions } from './authz';
7
7
  import type { AdminMcpTool } from './mcp-tools';
8
- import { ADMIN_DESTROY, ADMIN_PERMISSIONS } from './permissions';
8
+ import { ADMIN_DESTROY, ADMIN_PERMISSIONS, ADMIN_READ, ADMIN_WRITE } from './permissions';
9
9
 
10
10
  const isAdminPermission = (scope: string): boolean =>
11
11
  (ADMIN_PERMISSIONS as readonly string[]).includes(scope);
@@ -34,3 +34,21 @@ export function adminToolScopes(tools: readonly AdminMcpTool[]): McpScopes {
34
34
  for (const tool of tools) by.set(tool.scope, [...(by.get(tool.scope) ?? []), tool.name]);
35
35
  return Object.fromEntries(by);
36
36
  }
37
+
38
+ /**
39
+ * The app's exposed primitives an admin endpoint serves (`adminMcp({ include: 'exposed' })`),
40
+ * each under the admin scope its KIND needs — a query reads, an action writes; the framework
41
+ * cannot tell an insert from a delete, so never `admin:destroy`. Merged into the admin's own cut,
42
+ * so the map stays total and a read-only token stays read-only on the app's tools too.
43
+ */
44
+ export function includedToolScopes(
45
+ primitives: readonly ProjectablePrimitive[],
46
+ scopes: McpScopes,
47
+ ): McpScopes {
48
+ const by = new Map(Object.entries(scopes).map(([scope, names]) => [scope, [...names]]));
49
+ for (const primitive of primitives) {
50
+ const scope = primitive.mutates === false ? ADMIN_READ : ADMIN_WRITE;
51
+ by.set(scope, [...(by.get(scope) ?? []), primitive.name]);
52
+ }
53
+ return Object.fromEntries(by);
54
+ }
package/src/mcp.ts CHANGED
@@ -8,9 +8,11 @@ import {
8
8
  type AnyMcpTool,
9
9
  type AppMcp,
10
10
  defineAppMcp,
11
+ exposedPrimitives,
11
12
  type JsonSchema,
12
13
  jsonResult,
13
14
  type McpCaller,
15
+ type McpRequestFacts,
14
16
  type McpToolResult,
15
17
  type ResolvedToken,
16
18
  type ToolArgs,
@@ -33,7 +35,7 @@ import {
33
35
  import { AdminFilterInvalidError } from './errors';
34
36
  import type { AdminFieldType } from './fields';
35
37
  import { type AskedFilter, checkedFilter } from './list-filters';
36
- import { adminTokenScopes, adminToolScopes } from './mcp-scopes';
38
+ import { adminTokenScopes, adminToolScopes, includedToolScopes } from './mcp-scopes';
37
39
  import { type AdminMcpTool, adminMcpTools, adminToolCatalog } from './mcp-tools';
38
40
  import type { AdminAction, AdminRow } from './registry';
39
41
  import { adminSearch } from './search';
@@ -250,9 +252,23 @@ export interface AdminMcpActor extends AdminActor {
250
252
 
251
253
  export interface AdminMcpOptions {
252
254
  readonly app: AdminApp;
253
- /** Resolve the MCP session's actor. The same hook the HTTP surface uses, never a bypass. */
254
- actor(session: { readonly token?: string }): Promise<AdminMcpActor | null> | AdminMcpActor | null;
255
+ /**
256
+ * Resolve the MCP session's actor. The same hook the HTTP surface uses, never a bypass. `facts`:
257
+ * how the request arrived (address, `Origin`) — what a development token is decided on.
258
+ */
259
+ actor(session: {
260
+ readonly token?: string;
261
+ readonly facts?: McpRequestFacts;
262
+ }): Promise<AdminMcpActor | null> | AdminMcpActor | null;
255
263
  readonly requestId?: () => string;
264
+ /** Mount path, `defineAppMcp`'s own. Default `/mcp`; an admin surface beside the app's says `/admin/mcp`. */
265
+ readonly path?: string;
266
+ /**
267
+ * `'exposed'`: the app's own MCP tools here too, projected by `defineAppMcp` as on `/mcp`, each
268
+ * under its kind's admin scope (`includedToolScopes`). Call `adminMcp` at module scope in
269
+ * `apps/<app>/mcp.ts`: the mount refuses a catalog built before the scan ended (`X_MCP_INCLUDE_EARLY`).
270
+ */
271
+ readonly include?: 'exposed';
256
272
  }
257
273
 
258
274
  /** A field type an agent can actually send. Anything richer is a JSON object on the wire. */
@@ -436,15 +452,20 @@ export function adminMcp(opts: AdminMcpOptions): AppMcp {
436
452
  const requestId = opts.requestId ?? ((): string => crypto.randomUUID());
437
453
  const catalog = adminToolCatalog(opts.app);
438
454
 
455
+ const own = adminToolScopes(catalog);
456
+
439
457
  return defineAppMcp({
440
458
  name: 'admin',
441
459
  tools: catalog.map((tool) => toMcpTool(opts, requestId, tool)),
460
+ ...(opts.path === undefined ? {} : { path: opts.path }),
461
+ // The app's tools are `defineAppMcp`'s projection, never a copy; the scopes read the same registry.
462
+ ...(opts.include === undefined ? {} : { include: opts.include }),
442
463
  // The connection gate: each tool needs its admin-level permission as a token scope, checked
443
464
  // by the registry BEFORE the actor's policy. A stdio host builds its caller's scopes with
444
465
  // `adminTokenScopes`, as `resolveToken` does below.
445
- scopes: adminToolScopes(catalog),
446
- async resolveToken(token: string): Promise<ResolvedToken | null> {
447
- const actor = await opts.actor({ token });
466
+ scopes: opts.include === 'exposed' ? includedToolScopes(exposedPrimitives(), own) : own,
467
+ async resolveToken(token: string, facts: McpRequestFacts): Promise<ResolvedToken | null> {
468
+ const actor = await opts.actor({ token, facts });
448
469
  // `kind: 'agent'` — the same actor shape an agent gets everywhere else, so a policy
449
470
  // that distinguishes agents from people keeps working on this surface.
450
471
  //