@ultimat3/admin 30.8.0 → 30.9.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/CLAUDE.md CHANGED
@@ -95,7 +95,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
95
95
  | `batch.ts` / `batch-queue.ts` / `batch-bar.tsx` / `batch-job.ts` | a batch through the gate / its queued half / the bar and row checkboxes / the `admin.batch` job |
96
96
  | `resource-fields.ts` / `resource-layout.ts` / `related.ts` / `list-columns.tsx` | one column → one field / sections and form groups / related lists / a resource's table columns |
97
97
  | `audit.ts` / `audit-pg.ts` / `json-text.ts` / `audit-request.ts` | the log interface + memory log / the Postgres log / readable JSON for a cell or a diff / the trail's URL ⇄ `AuditRequest`, the history cursor |
98
- | `mcp-tools.ts` / `mcp-scopes.ts` / `mcp.ts` | tool derivation, each tool's scope (pure) / scope → tools for `defineAppMcp`, a token's expanded scopes / transport wiring |
98
+ | `mcp-tools.ts` / `mcp-scopes.ts` / `mcp.ts` / `mcp-dispatch.ts` | tool derivation, each tool's scope (pure) / scope → tools for `defineAppMcp`, a token's scopes / transport / one call |
99
99
  | `test-ctx.ts` | `adminTestCtx({ actor?, granted? })` — the `CrudCtx` a test hands a page or a CRUD call; `staticAuthz` over a grant list, its own audit log. What `x g admin:page`'s emitted render test calls |
100
100
  | `jobs/` | the jobs dashboard: entities, repos, the where translation, actions, labels, resources, the overview and its `/_x` tab |
101
101
  | `batch-matching.ts` / `audit-schema.ts` | a set-based "all matching" / the audit table's DDL, a leaf |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "30.8.0",
3
+ "version": "30.9.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.8.0",
49
- "@ultimat3/ai": "30.8.0",
50
- "@ultimat3/cache": "30.8.0",
51
- "@ultimat3/core": "30.8.0",
52
- "@ultimat3/db": "30.8.0",
53
- "@ultimat3/entity": "30.8.0",
54
- "@ultimat3/http": "30.8.0",
55
- "@ultimat3/i18n": "30.8.0",
56
- "@ultimat3/jobs": "30.8.0",
57
- "@ultimat3/mcp": "30.8.0",
58
- "@ultimat3/money": "30.8.0",
59
- "@ultimat3/policy": "30.8.0",
60
- "@ultimat3/query": "30.8.0",
61
- "@ultimat3/render": "30.8.0",
62
- "@ultimat3/schema": "30.8.0",
63
- "@ultimat3/ui": "30.8.0"
48
+ "@ultimat3/action": "30.9.0",
49
+ "@ultimat3/ai": "30.9.0",
50
+ "@ultimat3/cache": "30.9.0",
51
+ "@ultimat3/core": "30.9.0",
52
+ "@ultimat3/db": "30.9.0",
53
+ "@ultimat3/entity": "30.9.0",
54
+ "@ultimat3/http": "30.9.0",
55
+ "@ultimat3/i18n": "30.9.0",
56
+ "@ultimat3/jobs": "30.9.0",
57
+ "@ultimat3/mcp": "30.9.0",
58
+ "@ultimat3/money": "30.9.0",
59
+ "@ultimat3/policy": "30.9.0",
60
+ "@ultimat3/query": "30.9.0",
61
+ "@ultimat3/render": "30.9.0",
62
+ "@ultimat3/schema": "30.9.0",
63
+ "@ultimat3/ui": "30.9.0"
64
64
  }
65
65
  }
@@ -5,6 +5,8 @@
5
5
  // physical columns. And a foreign key is read back from `$describe()`, because the binding
6
6
  // that turns `() => orgs.id` into `"orgs.id"` is private to @ultimat3/entity.
7
7
 
8
+ import { isUltimateError } from '@ultimat3/core';
9
+ import { SchemaUnsupportedError, toWireSchema, type WireJsonSchema } from '@ultimat3/schema';
8
10
  import { AdminFieldUnsupportedError } from './errors';
9
11
  import type { AdminColumnMeta, AdminEntity } from './registry';
10
12
 
@@ -31,6 +33,12 @@ export interface AdminColumnFacts {
31
33
  readonly length?: number;
32
34
  readonly values?: readonly string[];
33
35
  readonly references?: AdminColumnReference;
36
+ /**
37
+ * What a `json()` or `arrayOf()` value is, as JSON Schema: the column's own schema, so an MCP
38
+ * argument is typed by the one judge of the value. `{}` (any) where none can be derived. Absent
39
+ * on every other kind, whose scalar type the field type already says. SQL NULL is `nullable`.
40
+ */
41
+ readonly valueSchema?: WireJsonSchema;
34
42
  }
35
43
 
36
44
  const parseReference = (entity: string, property: string, target: string): AdminColumnReference => {
@@ -61,25 +69,49 @@ const referencesOf = (entity: AdminEntity): ReadonlyMap<string, AdminColumnRefer
61
69
  return out;
62
70
  };
63
71
 
72
+ /**
73
+ * The declared schema as a tool argument, or `{}` when it cannot be said (`X_SCHEMA_UNSUPPORTED`)
74
+ * — permissive, so the entity's own parse still judges the value. Any other throw is a defect.
75
+ */
76
+ const declaredSchema = (schema: unknown): WireJsonSchema => {
77
+ if (schema === undefined) return {};
78
+ try {
79
+ return toWireSchema(schema);
80
+ } catch (error) {
81
+ if (isUltimateError(error) && error.code === SchemaUnsupportedError.code) return {};
82
+ throw error;
83
+ }
84
+ };
85
+
86
+ /** The VALUE's shape; whether the column also takes SQL NULL is `nullable`, beside it. */
87
+ const valueSchemaOf = (meta: AdminColumnMeta): WireJsonSchema | undefined => {
88
+ if (meta.kind !== 'jsonb' && meta.kind !== 'array') return undefined;
89
+ return meta.kind === 'array' ? { type: 'array' } : declaredSchema(meta.schema);
90
+ };
91
+
64
92
  const factsFor = (
65
93
  name: string,
66
94
  meta: AdminColumnMeta,
67
95
  primaryKey: boolean,
68
96
  references: AdminColumnReference | undefined,
69
- ): AdminColumnFacts => ({
70
- name,
71
- kind: meta.kind,
72
- nullable: !meta.notNull,
73
- primaryKey,
74
- unique: meta.unique,
75
- index: meta.index,
76
- // A value the writer never supplies. `.default('free')` is a starting value the operator
77
- // may still change, so it stays writable — only a generated one is read-only.
78
- generated: meta.default?.kind === 'generated' || meta.onUpdate !== undefined,
79
- ...(meta.length === undefined ? {} : { length: meta.length }),
80
- ...(meta.values === undefined ? {} : { values: meta.values }),
81
- ...(references === undefined ? {} : { references }),
82
- });
97
+ ): AdminColumnFacts => {
98
+ const valueSchema = valueSchemaOf(meta);
99
+ return {
100
+ name,
101
+ kind: meta.kind,
102
+ nullable: !meta.notNull,
103
+ primaryKey,
104
+ unique: meta.unique,
105
+ index: meta.index,
106
+ // A value the writer never supplies. `.default('free')` is a starting value the operator
107
+ // may still change, so it stays writable — only a generated one is read-only.
108
+ generated: meta.default?.kind === 'generated' || meta.onUpdate !== undefined,
109
+ ...(meta.length === undefined ? {} : { length: meta.length }),
110
+ ...(meta.values === undefined ? {} : { values: meta.values }),
111
+ ...(references === undefined ? {} : { references }),
112
+ ...(valueSchema === undefined ? {} : { valueSchema }),
113
+ };
114
+ };
83
115
 
84
116
  const factsOf = (entity: AdminEntity, sealed: boolean): readonly AdminColumnFacts[] => {
85
117
  const references = referencesOf(entity);
package/src/index.ts CHANGED
@@ -244,14 +244,8 @@ export {
244
244
  scopeNamed,
245
245
  } from './list-scope';
246
246
  export { AdminScopeTabs, type AdminScopeTabsProps } from './list-scope-tabs';
247
- export {
248
- type AdminMcpActor,
249
- type AdminMcpOptions,
250
- type AdminToolResult,
251
- adminMcp,
252
- callAdminTool,
253
- type McpInput,
254
- } from './mcp';
247
+ export { type AdminMcpActor, type AdminMcpOptions, adminMcp } from './mcp';
248
+ export { type AdminToolResult, callAdminTool, type McpInput } from './mcp-dispatch';
255
249
  export { adminTokenScopes, adminToolScopes } from './mcp-scopes';
256
250
  export {
257
251
  type AdminMcpTool,
@@ -0,0 +1,221 @@
1
+ // One admin MCP tool call, dispatched: the tool resolved against the actor's own allowed list, then
2
+ // handed to the CRUD call, action gate, batch or search its button uses. No transport here — that is
3
+ // `mcp.ts`, which runs this AS the caller; a direct `callAdminTool` is the same path.
4
+
5
+ import { invokeAdminAction } from './action-gate';
6
+ import { invokeRowAction } from './action-row';
7
+ import type { AdminApp } from './admin';
8
+ import { batchPlan, runAdminBatch } from './batch';
9
+ import { batchEnqueue } from './batch-job';
10
+ import {
11
+ adminCreate,
12
+ adminDestroy,
13
+ adminDetail,
14
+ adminList,
15
+ adminUpdate,
16
+ type CrudCtx,
17
+ type CrudResult,
18
+ } from './crud';
19
+ import { AdminFilterInvalidError } from './errors';
20
+ import { type AskedFilter, checkedFilter } from './list-filters';
21
+ import { type AdminMcpTool, adminMcpTools } from './mcp-tools';
22
+ import type { AdminAction, AdminRow } from './registry';
23
+ import { adminSearch } from './search';
24
+ import type { ValidationIssue } from './validate';
25
+
26
+ export type McpInput = Readonly<Record<string, unknown>>;
27
+
28
+ export type AdminToolResult =
29
+ | { readonly ok: true; readonly data: unknown }
30
+ | { readonly ok: false; readonly error: string; readonly reason: string };
31
+
32
+ const str = (input: McpInput, key: string): string => {
33
+ const value = input[key];
34
+ return typeof value === 'string' ? value : '';
35
+ };
36
+
37
+ const num = (input: McpInput, key: string): number | undefined => {
38
+ const value = input[key];
39
+ return typeof value === 'number' ? value : undefined;
40
+ };
41
+
42
+ const withoutKeys = (input: McpInput, keys: readonly string[]): Record<string, unknown> => {
43
+ const out: Record<string, unknown> = {};
44
+ for (const [key, value] of Object.entries(input)) {
45
+ if (!keys.includes(key)) out[key] = value;
46
+ }
47
+ return out;
48
+ };
49
+
50
+ /** The `where` argument as a list of asked filters; anything else in it is refused by name. */
51
+ const askedFilters = (where: unknown): readonly AskedFilter[] => {
52
+ if (where === undefined || where === null) return [];
53
+ const list: readonly unknown[] = Array.isArray(where) ? where : [where];
54
+ return list.map((one) => {
55
+ const bag = (typeof one === 'object' && one !== null ? one : {}) as Record<string, unknown>;
56
+ const op = bag['op'];
57
+ return {
58
+ // A predicate with no `field` names the empty one, which no resource derives.
59
+ field: typeof bag['field'] === 'string' ? bag['field'] : '',
60
+ ...(typeof op === 'string' ? { op } : {}),
61
+ value: bag['value'],
62
+ };
63
+ });
64
+ };
65
+
66
+ const actionByName = (app: AdminApp, name: string): AdminAction | undefined =>
67
+ [...app.resources.flatMap((resource) => resource.actions), ...app.globalActions].find(
68
+ (action) => action.name === name,
69
+ );
70
+
71
+ /**
72
+ * Dispatch one tool call. The tool must be in this actor's allowed list — resolving the name
73
+ * against `adminMcpTools()` is what makes "the agent sees what it may do" true rather than
74
+ * aspirational.
75
+ */
76
+ export async function callAdminTool(
77
+ app: AdminApp,
78
+ ctx: CrudCtx,
79
+ name: string,
80
+ input: McpInput,
81
+ ): Promise<AdminToolResult> {
82
+ const tool = adminMcpTools(app, ctx).find((candidate) => candidate.name === name);
83
+ // NOT dead code, and not the only gate: the registry's `visibleTo` predicate already hides
84
+ // this tool from a caller who may not use it, so an MCP call cannot reach here refused.
85
+ // Every other entry point (a direct `callAdminTool`, a future transport) can, so this stays
86
+ // as defence in depth — the authz decision must not live only in the catalog filter.
87
+ if (tool === undefined) {
88
+ return {
89
+ ok: false,
90
+ error: 'X_ADMIN_TOOL_FORBIDDEN',
91
+ reason: `tool "${name}" is not available to actor ${ctx.actor.id}`,
92
+ };
93
+ }
94
+ return dispatch(app, ctx, tool, input);
95
+ }
96
+
97
+ async function dispatch(
98
+ app: AdminApp,
99
+ ctx: CrudCtx,
100
+ tool: AdminMcpTool,
101
+ input: McpInput,
102
+ ): Promise<AdminToolResult> {
103
+ if (tool.kind === 'search') {
104
+ const result = await adminSearch({ term: str(input, 'term'), resources: app.resources, ctx });
105
+ return { ok: true, data: result };
106
+ }
107
+
108
+ if (tool.kind === 'action') {
109
+ const action = actionByName(app, tool.action ?? '');
110
+ if (action === undefined) {
111
+ return { ok: false, error: 'X_ADMIN_TOOL_FORBIDDEN', reason: 'action is not registered' };
112
+ }
113
+ const owner = app.resources.find((resource) => resource.actions.includes(action));
114
+ const own = withoutKeys(input, ['id', 'ids', 'confirmation']);
115
+ const confirmation = str(input, 'confirmation');
116
+ // `ids`: the same tool as a batch — the bar's own path, row by row through the gate.
117
+ if (owner !== undefined && action.batch !== undefined && Array.isArray(input['ids'])) {
118
+ const ids = (input['ids'] as readonly unknown[]).filter(
119
+ (one): one is string => typeof one === 'string',
120
+ );
121
+ const result = await runAdminBatch({
122
+ resource: owner,
123
+ action,
124
+ ctx,
125
+ selection: { kind: 'ids', ids },
126
+ input: own,
127
+ confirmation,
128
+ ...(batchPlan(action) === null ? {} : { enqueue: batchEnqueue(app.basePath) }),
129
+ });
130
+ if (result.ok) return { ok: true, data: result };
131
+ if (result.kind === 'invalid') return invalidIssues(result.issues);
132
+ return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
133
+ }
134
+ const id = str(input, 'id');
135
+ // The row a row-level rule — and the action's own `when` — decides about, loaded through the
136
+ // resource that OWNS the action: what the UI's button does. A global action names no row.
137
+ const result =
138
+ owner !== undefined && id !== ''
139
+ ? await invokeRowAction({ resource: owner, action, id, ctx, input: own, confirmation })
140
+ : await invokeAdminAction({
141
+ action,
142
+ input: { ...own, ...(id === '' ? {} : { id }) },
143
+ actor: ctx.actor,
144
+ authz: ctx.authz,
145
+ audit: ctx.audit,
146
+ requestId: ctx.requestId,
147
+ subject: {
148
+ ...(action.entity === undefined ? {} : { entity: action.entity }),
149
+ ...(id === '' ? {} : { id }),
150
+ },
151
+ // The agent must echo the token, exactly as the UI makes an operator type it.
152
+ confirmation,
153
+ });
154
+ if (result.ok) return { ok: true, data: result.value };
155
+ if (result.kind === 'not-applicable') {
156
+ return { ok: false, error: result.error.code, reason: result.error.cause };
157
+ }
158
+ if (result.kind === 'invalid') return invalidIssues(result.issues);
159
+ return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
160
+ }
161
+
162
+ const resource = app.resource(tool.entity ?? '');
163
+ switch (tool.kind) {
164
+ case 'list': {
165
+ const limit = num(input, 'limit');
166
+ const scope = str(input, 'scope');
167
+ try {
168
+ // `where` through the same validator a URL's `f.<field>` passes, and the scope by name:
169
+ // an agent filters by exactly what an operator can, and the resource's row scope rides
170
+ // the read inside `adminList` whatever the agent sends.
171
+ const result = await adminList(resource, ctx, {
172
+ cursor: str(input, 'cursor'),
173
+ ...(limit === undefined ? {} : { limit }),
174
+ ...(scope === '' ? {} : { scope }),
175
+ filters: askedFilters(input['where']).map((asked) => checkedFilter(resource, asked)),
176
+ });
177
+ return result.ok
178
+ ? { ok: true, data: result.page }
179
+ : { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
180
+ } catch (error) {
181
+ if (!(error instanceof AdminFilterInvalidError)) throw error;
182
+ return { ok: false, error: error.code, reason: error.cause };
183
+ }
184
+ }
185
+ case 'read':
186
+ return crudResult(await adminDetail(resource, ctx, str(input, 'id')));
187
+ case 'create':
188
+ return crudResult(await adminCreate(resource, ctx, input));
189
+ case 'update':
190
+ return crudResult(
191
+ await adminUpdate(resource, ctx, str(input, 'id'), withoutKeys(input, ['id'])),
192
+ );
193
+ case 'delete':
194
+ return crudResult(
195
+ await adminDestroy(resource, ctx, str(input, 'id'), str(input, 'confirmation')),
196
+ );
197
+ }
198
+ }
199
+
200
+ /** The schema's issues, as the one string an agent's result carries. */
201
+ const invalidIssues = (issues: readonly ValidationIssue[]): AdminToolResult => ({
202
+ ok: false,
203
+ error: 'X_ADMIN_INVALID',
204
+ reason: JSON.stringify(issues),
205
+ });
206
+
207
+ function crudResult(result: CrudResult<AdminRow>): AdminToolResult {
208
+ if (result.ok) return { ok: true, data: result.row };
209
+ if (result.kind === 'denied') {
210
+ return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
211
+ }
212
+ // No row by that id — or none this caller's row scope lets them see, which reads the same.
213
+ if (result.kind === 'missing') {
214
+ return { ok: false, error: 'X_ADMIN_INVALID', reason: 'no row has that id' };
215
+ }
216
+ // Reached only by a caller that sent a version; the update tool sends none.
217
+ if (result.kind === 'stale') {
218
+ return { ok: false, error: 'X_ADMIN_INVALID', reason: 'the row changed since it was read' };
219
+ }
220
+ return invalidIssues(result.issues);
221
+ }
package/src/mcp-tools.ts CHANGED
@@ -6,10 +6,12 @@
6
6
  // model, not UI copy read by a person, so they are not `t()` keys.
7
7
 
8
8
  import { toolDescription } from '@ultimat3/core';
9
+ import type { WireJsonSchema } from '@ultimat3/schema';
9
10
  import { adminPermissionForAction, decideAction, permissionsForAction } from './action-gate';
10
11
  import type { AdminApp } from './admin';
11
12
  import { type AdminDecision, decideAll } from './authz';
12
13
  import { type CrudCtx, decideOperation, permissionsForOperation } from './crud';
14
+ import { adminColumnsOf } from './entity-columns';
13
15
  import type { AdminFieldType } from './fields';
14
16
  import { type AdminOperation, type AdminPermission, adminPermissionFor } from './permissions';
15
17
  import type { AdminAction } from './registry';
@@ -23,6 +25,13 @@ export interface AdminToolField {
23
25
  readonly required: boolean;
24
26
  /** The argument is a LIST of `type`: the list tool's `where` is a list of predicates. */
25
27
  readonly list?: true;
28
+ /**
29
+ * The argument's exact JSON Schema, where `type` cannot say it: a `json()` or `arrayOf()`
30
+ * column's value is an array, an object, a scalar or `null` by its own schema, never "object".
31
+ */
32
+ readonly schema?: WireJsonSchema;
33
+ /** The column takes SQL NULL: the argument admits `null` beside its type (`mcp.ts`). */
34
+ readonly nullable?: true;
26
35
  }
27
36
 
28
37
  export interface AdminMcpTool {
@@ -83,12 +92,23 @@ const actionDescription = (action: AdminAction): string =>
83
92
  : ` Pass ids: [...] to run it once per row; the answer counts done, refused and failed${action.destructive === true ? ', and confirmation is "<entity>:<n> rows"' : ''}.`,
84
93
  ].join('');
85
94
 
86
- const formFields = (resource: AdminResource): readonly AdminToolField[] =>
87
- resource.formFields.map((field) => ({
88
- name: field.name,
89
- type: field.type,
90
- required: field.required,
91
- }));
95
+ /**
96
+ * Every form field, with what its column says beyond the type: a `json` one its value schema (`{}`,
97
+ * any, where none exists), a nullable one that it takes `null` — the form's empty box, over MCP.
98
+ */
99
+ const formFields = (resource: AdminResource): readonly AdminToolField[] => {
100
+ const facts = new Map(adminColumnsOf(resource.entity).map((column) => [column.name, column]));
101
+ return resource.formFields.map((field) => {
102
+ const column = facts.get(field.name);
103
+ return {
104
+ name: field.name,
105
+ type: field.type,
106
+ required: field.required,
107
+ ...(field.type === 'json' ? { schema: column?.valueSchema ?? {} } : {}),
108
+ ...(column?.nullable === true ? { nullable: true as const } : {}),
109
+ };
110
+ });
111
+ };
92
112
 
93
113
  /**
94
114
  * The list tool says what it filters by, because a schema cannot: `where` is a list of
package/src/mcp.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // The AI-first surface: the admin's resources and actions as MCP tools, wired through
2
2
  // `defineAppMcp` so the user's agents drive the user's app. Same authz, same audit, same
3
3
  // confirmation rules as the buttons — this file adds a transport, not a second back door.
4
+ // What one call DOES is `mcp-dispatch.ts`; this file is the catalog, the schemas and the caller.
4
5
 
5
6
  import type { Actor } from '@ultimat3/core';
6
7
  import { agentActor, ctxOf, hasContext, runWithContext, withChildContext } from '@ultimat3/core';
@@ -17,226 +18,17 @@ import {
17
18
  type ResolvedToken,
18
19
  type ToolArgs,
19
20
  } from '@ultimat3/mcp';
20
- import { invokeAdminAction } from './action-gate';
21
- import { invokeRowAction } from './action-row';
22
21
  import type { AdminApp } from './admin';
23
22
  import type { AdminActor } from './authz';
24
- import { batchPlan, runAdminBatch } from './batch';
25
- import { batchEnqueue } from './batch-job';
26
- import {
27
- adminCreate,
28
- adminDestroy,
29
- adminDetail,
30
- adminList,
31
- adminUpdate,
32
- type CrudCtx,
33
- type CrudResult,
34
- } from './crud';
35
- import { AdminFilterInvalidError } from './errors';
36
23
  import type { AdminFieldType } from './fields';
37
- import { type AskedFilter, checkedFilter } from './list-filters';
24
+ import { callAdminTool } from './mcp-dispatch';
38
25
  import { adminTokenScopes, adminToolScopes, includedToolScopes } from './mcp-scopes';
39
- import { type AdminMcpTool, adminMcpTools, adminToolCatalog } from './mcp-tools';
40
- import type { AdminAction, AdminRow } from './registry';
41
- import { adminSearch } from './search';
42
- import type { ValidationIssue } from './validate';
43
-
44
- export type McpInput = Readonly<Record<string, unknown>>;
45
-
46
- export type AdminToolResult =
47
- | { readonly ok: true; readonly data: unknown }
48
- | { readonly ok: false; readonly error: string; readonly reason: string };
49
-
50
- const str = (input: McpInput, key: string): string => {
51
- const value = input[key];
52
- return typeof value === 'string' ? value : '';
53
- };
54
-
55
- const num = (input: McpInput, key: string): number | undefined => {
56
- const value = input[key];
57
- return typeof value === 'number' ? value : undefined;
58
- };
59
-
60
- const withoutKeys = (input: McpInput, keys: readonly string[]): Record<string, unknown> => {
61
- const out: Record<string, unknown> = {};
62
- for (const [key, value] of Object.entries(input)) {
63
- if (!keys.includes(key)) out[key] = value;
64
- }
65
- return out;
66
- };
67
-
68
- /** The `where` argument as a list of asked filters; anything else in it is refused by name. */
69
- const askedFilters = (where: unknown): readonly AskedFilter[] => {
70
- if (where === undefined || where === null) return [];
71
- const list: readonly unknown[] = Array.isArray(where) ? where : [where];
72
- return list.map((one) => {
73
- const bag = (typeof one === 'object' && one !== null ? one : {}) as Record<string, unknown>;
74
- const op = bag['op'];
75
- return {
76
- // A predicate with no `field` names the empty one, which no resource derives.
77
- field: typeof bag['field'] === 'string' ? bag['field'] : '',
78
- ...(typeof op === 'string' ? { op } : {}),
79
- value: bag['value'],
80
- };
81
- });
82
- };
83
-
84
- const actionByName = (app: AdminApp, name: string): AdminAction | undefined =>
85
- [...app.resources.flatMap((resource) => resource.actions), ...app.globalActions].find(
86
- (action) => action.name === name,
87
- );
88
-
89
- /**
90
- * Dispatch one tool call. The tool must be in this actor's allowed list — resolving the name
91
- * against `adminMcpTools()` is what makes "the agent sees what it may do" true rather than
92
- * aspirational.
93
- */
94
- export async function callAdminTool(
95
- app: AdminApp,
96
- ctx: CrudCtx,
97
- name: string,
98
- input: McpInput,
99
- ): Promise<AdminToolResult> {
100
- const tool = adminMcpTools(app, ctx).find((candidate) => candidate.name === name);
101
- // NOT dead code, and not the only gate: the registry's `visibleTo` predicate already hides
102
- // this tool from a caller who may not use it, so an MCP call cannot reach here refused.
103
- // Every other entry point (a direct `callAdminTool`, a future transport) can, so this stays
104
- // as defence in depth — the authz decision must not live only in the catalog filter.
105
- if (tool === undefined) {
106
- return {
107
- ok: false,
108
- error: 'X_ADMIN_TOOL_FORBIDDEN',
109
- reason: `tool "${name}" is not available to actor ${ctx.actor.id}`,
110
- };
111
- }
112
- return dispatch(app, ctx, tool, input);
113
- }
114
-
115
- async function dispatch(
116
- app: AdminApp,
117
- ctx: CrudCtx,
118
- tool: AdminMcpTool,
119
- input: McpInput,
120
- ): Promise<AdminToolResult> {
121
- if (tool.kind === 'search') {
122
- const result = await adminSearch({ term: str(input, 'term'), resources: app.resources, ctx });
123
- return { ok: true, data: result };
124
- }
125
-
126
- if (tool.kind === 'action') {
127
- const action = actionByName(app, tool.action ?? '');
128
- if (action === undefined) {
129
- return { ok: false, error: 'X_ADMIN_TOOL_FORBIDDEN', reason: 'action is not registered' };
130
- }
131
- const owner = app.resources.find((resource) => resource.actions.includes(action));
132
- const own = withoutKeys(input, ['id', 'ids', 'confirmation']);
133
- const confirmation = str(input, 'confirmation');
134
- // `ids`: the same tool as a batch — the bar's own path, row by row through the gate.
135
- if (owner !== undefined && action.batch !== undefined && Array.isArray(input['ids'])) {
136
- const ids = (input['ids'] as readonly unknown[]).filter(
137
- (one): one is string => typeof one === 'string',
138
- );
139
- const result = await runAdminBatch({
140
- resource: owner,
141
- action,
142
- ctx,
143
- selection: { kind: 'ids', ids },
144
- input: own,
145
- confirmation,
146
- ...(batchPlan(action) === null ? {} : { enqueue: batchEnqueue(app.basePath) }),
147
- });
148
- if (result.ok) return { ok: true, data: result };
149
- if (result.kind === 'invalid') return invalidIssues(result.issues);
150
- return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
151
- }
152
- const id = str(input, 'id');
153
- // The row a row-level rule — and the action's own `when` — decides about, loaded through the
154
- // resource that OWNS the action: what the UI's button does. A global action names no row.
155
- const result =
156
- owner !== undefined && id !== ''
157
- ? await invokeRowAction({ resource: owner, action, id, ctx, input: own, confirmation })
158
- : await invokeAdminAction({
159
- action,
160
- input: { ...own, ...(id === '' ? {} : { id }) },
161
- actor: ctx.actor,
162
- authz: ctx.authz,
163
- audit: ctx.audit,
164
- requestId: ctx.requestId,
165
- subject: {
166
- ...(action.entity === undefined ? {} : { entity: action.entity }),
167
- ...(id === '' ? {} : { id }),
168
- },
169
- // The agent must echo the token, exactly as the UI makes an operator type it.
170
- confirmation,
171
- });
172
- if (result.ok) return { ok: true, data: result.value };
173
- if (result.kind === 'not-applicable') {
174
- return { ok: false, error: result.error.code, reason: result.error.cause };
175
- }
176
- if (result.kind === 'invalid') return invalidIssues(result.issues);
177
- return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
178
- }
179
-
180
- const resource = app.resource(tool.entity ?? '');
181
- switch (tool.kind) {
182
- case 'list': {
183
- const limit = num(input, 'limit');
184
- const scope = str(input, 'scope');
185
- try {
186
- // `where` through the same validator a URL's `f.<field>` passes, and the scope by name:
187
- // an agent filters by exactly what an operator can, and the resource's row scope rides
188
- // the read inside `adminList` whatever the agent sends.
189
- const result = await adminList(resource, ctx, {
190
- cursor: str(input, 'cursor'),
191
- ...(limit === undefined ? {} : { limit }),
192
- ...(scope === '' ? {} : { scope }),
193
- filters: askedFilters(input['where']).map((asked) => checkedFilter(resource, asked)),
194
- });
195
- return result.ok
196
- ? { ok: true, data: result.page }
197
- : { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
198
- } catch (error) {
199
- if (!(error instanceof AdminFilterInvalidError)) throw error;
200
- return { ok: false, error: error.code, reason: error.cause };
201
- }
202
- }
203
- case 'read':
204
- return crudResult(await adminDetail(resource, ctx, str(input, 'id')));
205
- case 'create':
206
- return crudResult(await adminCreate(resource, ctx, input));
207
- case 'update':
208
- return crudResult(
209
- await adminUpdate(resource, ctx, str(input, 'id'), withoutKeys(input, ['id'])),
210
- );
211
- case 'delete':
212
- return crudResult(
213
- await adminDestroy(resource, ctx, str(input, 'id'), str(input, 'confirmation')),
214
- );
215
- }
216
- }
217
-
218
- /** The schema's issues, as the one string an agent's result carries. */
219
- const invalidIssues = (issues: readonly ValidationIssue[]): AdminToolResult => ({
220
- ok: false,
221
- error: 'X_ADMIN_INVALID',
222
- reason: JSON.stringify(issues),
223
- });
224
-
225
- function crudResult(result: CrudResult<AdminRow>): AdminToolResult {
226
- if (result.ok) return { ok: true, data: result.row };
227
- if (result.kind === 'denied') {
228
- return { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
229
- }
230
- // No row by that id — or none this caller's row scope lets them see, which reads the same.
231
- if (result.kind === 'missing') {
232
- return { ok: false, error: 'X_ADMIN_INVALID', reason: 'no row has that id' };
233
- }
234
- // Reached only by a caller that sent a version; the update tool sends none.
235
- if (result.kind === 'stale') {
236
- return { ok: false, error: 'X_ADMIN_INVALID', reason: 'the row changed since it was read' };
237
- }
238
- return invalidIssues(result.issues);
239
- }
26
+ import {
27
+ type AdminMcpTool,
28
+ type AdminToolField,
29
+ adminMcpTools,
30
+ adminToolCatalog,
31
+ } from './mcp-tools';
240
32
 
241
33
  /**
242
34
  * What a session token resolves to: the actor, and optionally the admin scopes the TOKEN was
@@ -301,11 +93,30 @@ const JSON_TYPE: Readonly<Record<AdminFieldType, NonNullable<JsonSchema['type']>
301
93
  * exactly the arguments the action needs; a made-up field list would describe a contract nobody
302
94
  * validates against. Open lets the action's own validation be the one that decides.
303
95
  */
96
+ const NULL_SCHEMA: JsonSchema = { type: 'null' };
97
+
98
+ /** `{}` admits anything; otherwise only a `null` type or a `null` branch says so. */
99
+ const admitsNull = (schema: JsonSchema): boolean =>
100
+ Object.keys(schema).length === 0 ||
101
+ schema.type === 'null' ||
102
+ (schema.anyOf?.some((branch) => branch.type === 'null') ?? false);
103
+
104
+ /**
105
+ * One argument. A field's own schema first — `JSON_TYPE` is the kind's scalar, and a `json()`
106
+ * column's value is whatever its schema says, where a blanket `object` refused every array and
107
+ * every JSON `null`. A nullable column takes `null` beside it, which the server's argument check
108
+ * otherwise refused before the entity was asked.
109
+ */
110
+ const argumentSchema = (field: AdminToolField): JsonSchema => {
111
+ const value = field.schema ?? { type: JSON_TYPE[field.type] };
112
+ return field.nullable === true && !admitsNull(value) ? { anyOf: [value, NULL_SCHEMA] } : value;
113
+ };
114
+
304
115
  const inputSchema = (tool: AdminMcpTool): JsonSchema => ({
305
116
  type: 'object',
306
117
  properties: Object.fromEntries(
307
118
  tool.input.map((field) => {
308
- const one = { type: JSON_TYPE[field.type] };
119
+ const one = argumentSchema(field);
309
120
  return [field.name, field.list === true ? { type: 'array', items: one } : one];
310
121
  }),
311
122
  ),
package/src/registry.ts CHANGED
@@ -42,6 +42,8 @@ export interface AdminColumnMeta {
42
42
  * server. Present means the admin derives NO readable field for it — see `entity-columns.ts`.
43
43
  */
44
44
  readonly sealed?: { readonly lookup: boolean };
45
+ /** `json(schema)`'s schema: what a `json()` column's MCP argument is typed by (`entity-columns.ts`). */
46
+ readonly schema?: unknown;
45
47
  }
46
48
 
47
49
  /** One column of a registered entity. An `@ultimat3/entity` `Column` satisfies this. */