@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 +1 -1
- package/package.json +17 -17
- package/src/entity-columns.ts +46 -14
- package/src/index.ts +2 -8
- package/src/mcp-dispatch.ts +221 -0
- package/src/mcp-tools.ts +26 -6
- package/src/mcp.ts +28 -217
- package/src/registry.ts +2 -0
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
|
|
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.
|
|
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.
|
|
49
|
-
"@ultimat3/ai": "30.
|
|
50
|
-
"@ultimat3/cache": "30.
|
|
51
|
-
"@ultimat3/core": "30.
|
|
52
|
-
"@ultimat3/db": "30.
|
|
53
|
-
"@ultimat3/entity": "30.
|
|
54
|
-
"@ultimat3/http": "30.
|
|
55
|
-
"@ultimat3/i18n": "30.
|
|
56
|
-
"@ultimat3/jobs": "30.
|
|
57
|
-
"@ultimat3/mcp": "30.
|
|
58
|
-
"@ultimat3/money": "30.
|
|
59
|
-
"@ultimat3/policy": "30.
|
|
60
|
-
"@ultimat3/query": "30.
|
|
61
|
-
"@ultimat3/render": "30.
|
|
62
|
-
"@ultimat3/schema": "30.
|
|
63
|
-
"@ultimat3/ui": "30.
|
|
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
|
}
|
package/src/entity-columns.ts
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 {
|
|
24
|
+
import { callAdminTool } from './mcp-dispatch';
|
|
38
25
|
import { adminTokenScopes, adminToolScopes, includedToolScopes } from './mcp-scopes';
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 =
|
|
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. */
|