@ultimat3/admin 1.0.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/LICENSE +21 -0
- package/README.md +115 -0
- package/package.json +48 -0
- package/src/action-gate.ts +202 -0
- package/src/actions.tsx +94 -0
- package/src/admin.ts +186 -0
- package/src/ai-panes.ts +139 -0
- package/src/audit.ts +183 -0
- package/src/authz.ts +129 -0
- package/src/crud.ts +278 -0
- package/src/detail.tsx +121 -0
- package/src/dev/data.ts +344 -0
- package/src/dev/facts.ts +180 -0
- package/src/dev/index.ts +47 -0
- package/src/dev/panel-cache.ts +47 -0
- package/src/dev/panel-db.ts +59 -0
- package/src/dev/panel-jobs.ts +66 -0
- package/src/dev/panel-live.ts +46 -0
- package/src/dev/panel-mail.ts +51 -0
- package/src/dev/panel-manifest.ts +39 -0
- package/src/dev/panel-policy.ts +61 -0
- package/src/dev/panel-routes.ts +43 -0
- package/src/dev/panel-timeline.ts +82 -0
- package/src/dev/panel.ts +58 -0
- package/src/dev/server.ts +189 -0
- package/src/entity-columns.ts +95 -0
- package/src/errors.ts +145 -0
- package/src/fields.ts +151 -0
- package/src/form.tsx +97 -0
- package/src/index.ts +214 -0
- package/src/layout.tsx +104 -0
- package/src/list.tsx +120 -0
- package/src/mcp-tools.ts +201 -0
- package/src/mcp.ts +304 -0
- package/src/nav.ts +97 -0
- package/src/pagination.ts +152 -0
- package/src/permissions.ts +92 -0
- package/src/policy-bridge.ts +64 -0
- package/src/registry.ts +181 -0
- package/src/resource.ts +321 -0
- package/src/routes.ts +35 -0
- package/src/search.ts +108 -0
- package/src/theme.ts +59 -0
- package/src/validate.ts +65 -0
- package/src/widget-value.ts +217 -0
- package/src/widgets.tsx +262 -0
package/src/layout.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The admin shell: skip link, header, permission-filtered nav, main region. Landmarks and a
|
|
2
|
+
// visible focus order are the accessibility contract; the theme attributes come from the
|
|
3
|
+
// token system, so nothing here knows a colour.
|
|
4
|
+
|
|
5
|
+
import { type Locale, registeredLocales, t } from '@ultimat3/i18n';
|
|
6
|
+
import { LocaleSwitcher, ThemeToggle } from '@ultimat3/ui';
|
|
7
|
+
import type { JSX } from 'solid-js';
|
|
8
|
+
import type { AdminApp } from './admin';
|
|
9
|
+
import type { NavGroup } from './nav';
|
|
10
|
+
|
|
11
|
+
export interface AdminLayoutProps {
|
|
12
|
+
readonly app: AdminApp;
|
|
13
|
+
/** Already filtered by `app.navFor(ctx)`. The layout does not decide visibility. */
|
|
14
|
+
readonly nav: readonly NavGroup[];
|
|
15
|
+
readonly currentPath: string;
|
|
16
|
+
readonly onSearch?: (term: string) => void;
|
|
17
|
+
readonly onLocaleChange?: (locale: Locale) => void;
|
|
18
|
+
readonly children: JSX.Element;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AdminLayout(props: AdminLayoutProps): JSX.Element {
|
|
22
|
+
const theme = props.app.theme;
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
class="x-admin"
|
|
26
|
+
data-theme={theme['data-theme']}
|
|
27
|
+
data-density={theme['data-density']}
|
|
28
|
+
style={theme.style}
|
|
29
|
+
>
|
|
30
|
+
<a class="x-admin-skip" href="#x-admin-main">
|
|
31
|
+
{t('admin.a11y.skip-to-content')}
|
|
32
|
+
</a>
|
|
33
|
+
|
|
34
|
+
<header class="x-admin-header">
|
|
35
|
+
<a class="x-admin-brand" href={props.app.basePath}>
|
|
36
|
+
{props.app.branding.logo === undefined ? null : (
|
|
37
|
+
<img
|
|
38
|
+
src={props.app.branding.logo.src}
|
|
39
|
+
alt={t(props.app.branding.logo.altKey)}
|
|
40
|
+
width={props.app.branding.logo.width ?? 24}
|
|
41
|
+
/>
|
|
42
|
+
)}
|
|
43
|
+
<span>{t(props.app.branding.nameKey)}</span>
|
|
44
|
+
</a>
|
|
45
|
+
|
|
46
|
+
<search class="x-admin-search">
|
|
47
|
+
<form
|
|
48
|
+
onSubmit={(event: SubmitEvent) => {
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
const field = (event.currentTarget as HTMLFormElement).elements.namedItem('term');
|
|
51
|
+
props.onSearch?.(field instanceof HTMLInputElement ? field.value : '');
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<label class="x-admin-visually-hidden" for="x-admin-search-input">
|
|
55
|
+
{t('admin.search.label')}
|
|
56
|
+
</label>
|
|
57
|
+
<input
|
|
58
|
+
id="x-admin-search-input"
|
|
59
|
+
name="term"
|
|
60
|
+
type="search"
|
|
61
|
+
placeholder={t('admin.search.placeholder')}
|
|
62
|
+
/>
|
|
63
|
+
</form>
|
|
64
|
+
</search>
|
|
65
|
+
|
|
66
|
+
<div class="x-admin-header-tools">
|
|
67
|
+
{/* The locales with a catalog: an option nobody translated is a broken page. */}
|
|
68
|
+
<LocaleSwitcher locales={registeredLocales()} onLocaleChange={props.onLocaleChange} />
|
|
69
|
+
<ThemeToggle />
|
|
70
|
+
</div>
|
|
71
|
+
</header>
|
|
72
|
+
|
|
73
|
+
<div class="x-admin-body">
|
|
74
|
+
<nav class="x-admin-nav" aria-label={t('admin.nav.label')}>
|
|
75
|
+
{props.nav.map((group) => (
|
|
76
|
+
<section>
|
|
77
|
+
<h2>{t(group.labelKey)}</h2>
|
|
78
|
+
<ul>
|
|
79
|
+
{group.items.map((item) => (
|
|
80
|
+
<li>
|
|
81
|
+
<a
|
|
82
|
+
href={`${props.app.basePath}${item.href}`}
|
|
83
|
+
aria-current={
|
|
84
|
+
props.currentPath === `${props.app.basePath}${item.href}`
|
|
85
|
+
? 'page'
|
|
86
|
+
: undefined
|
|
87
|
+
}
|
|
88
|
+
>
|
|
89
|
+
{t(item.labelKey)}
|
|
90
|
+
</a>
|
|
91
|
+
</li>
|
|
92
|
+
))}
|
|
93
|
+
</ul>
|
|
94
|
+
</section>
|
|
95
|
+
))}
|
|
96
|
+
</nav>
|
|
97
|
+
|
|
98
|
+
<main id="x-admin-main" class="x-admin-main" tabindex={-1}>
|
|
99
|
+
{props.children}
|
|
100
|
+
</main>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
package/src/list.tsx
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The list view: derived columns, keyset pagination, and the three states a table actually
|
|
2
|
+
// has (loading, empty, error) instead of the one it usually ships with. Prev/Next are the
|
|
3
|
+
// only navigation — there is no page number, because there is no offset.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/i18n';
|
|
6
|
+
import { Card, DataTable, ErrorState } from '@ultimat3/ui';
|
|
7
|
+
import type { JSX } from 'solid-js';
|
|
8
|
+
import { AdminActions } from './actions';
|
|
9
|
+
import type { AdminActor, AdminAuthz } from './authz';
|
|
10
|
+
import { type AdminErrorParts, adminErrorFrom } from './errors';
|
|
11
|
+
import type { AdminPage } from './pagination';
|
|
12
|
+
import type { AdminRow, AdminSort } from './registry';
|
|
13
|
+
import { rowId } from './registry';
|
|
14
|
+
import type { AdminResource } from './resource';
|
|
15
|
+
import type { WidgetContext } from './widget-value';
|
|
16
|
+
import { Widget } from './widgets';
|
|
17
|
+
|
|
18
|
+
export interface AdminListProps<Row extends AdminRow> {
|
|
19
|
+
readonly resource: AdminResource<Row>;
|
|
20
|
+
readonly page: AdminPage<Row> | null;
|
|
21
|
+
readonly loading: boolean;
|
|
22
|
+
readonly error: AdminErrorParts | null;
|
|
23
|
+
readonly ctx: WidgetContext;
|
|
24
|
+
readonly actor: AdminActor;
|
|
25
|
+
readonly authz: AdminAuthz;
|
|
26
|
+
readonly basePath: string;
|
|
27
|
+
readonly onCursor: (cursor: string | null) => void;
|
|
28
|
+
readonly onOpen: (id: string) => void;
|
|
29
|
+
/** Absent means the table renders its current order without sortable headers. */
|
|
30
|
+
readonly onSort?: (sort: AdminSort) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function AdminList<Row extends AdminRow>(props: AdminListProps<Row>): JSX.Element {
|
|
34
|
+
const href = (id: string): string => `${props.basePath}${props.resource.path}/${id}`;
|
|
35
|
+
const idOf = (row: Row): string => rowId(row, props.resource.idField);
|
|
36
|
+
|
|
37
|
+
if (props.error !== null) {
|
|
38
|
+
return <ErrorState error={adminErrorFrom(props.error)} />;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (props.loading || props.page === null) {
|
|
42
|
+
return (
|
|
43
|
+
<Card header={<h2>{t(props.resource.titleKey)}</h2>}>
|
|
44
|
+
<p aria-busy="true">{t('admin.list.loading')}</p>
|
|
45
|
+
</Card>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const page = props.page;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Card header={<h2>{t(props.resource.titleKey)}</h2>}>
|
|
53
|
+
<AdminActions
|
|
54
|
+
actions={props.resource.actions}
|
|
55
|
+
actor={props.actor}
|
|
56
|
+
authz={props.authz}
|
|
57
|
+
subject={{ entity: props.resource.name }}
|
|
58
|
+
onRun={() => props.onCursor(null)}
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
{page.rows.length === 0 ? (
|
|
62
|
+
<p class="x-admin-empty">{t('admin.list.empty')}</p>
|
|
63
|
+
) : (
|
|
64
|
+
<DataTable
|
|
65
|
+
caption={t(props.resource.titleKey)}
|
|
66
|
+
columns={[
|
|
67
|
+
// A real link, not a row click handler: the detail view has to survive a
|
|
68
|
+
// middle-click, a keyboard, and a crawler that never runs the onClick.
|
|
69
|
+
{
|
|
70
|
+
key: 'open',
|
|
71
|
+
header: t('admin.list.open'),
|
|
72
|
+
cell: (row: Row) => (
|
|
73
|
+
<a
|
|
74
|
+
href={href(idOf(row))}
|
|
75
|
+
onClick={(event) => {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
props.onOpen(idOf(row));
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{idOf(row)}
|
|
81
|
+
</a>
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
...props.resource.listFields.map((field) => ({
|
|
85
|
+
key: field.name,
|
|
86
|
+
header: t(field.labelKey),
|
|
87
|
+
sortable: field.sortable && props.onSort !== undefined,
|
|
88
|
+
cell: (row: Row) => (
|
|
89
|
+
<Widget field={field} value={row[field.name]} ctx={props.ctx} mode="read" />
|
|
90
|
+
),
|
|
91
|
+
})),
|
|
92
|
+
]}
|
|
93
|
+
rows={page.rows}
|
|
94
|
+
rowKey={idOf}
|
|
95
|
+
sort={{ key: page.sort.field, direction: page.sort.direction }}
|
|
96
|
+
onSortChange={(next) => {
|
|
97
|
+
if (next !== undefined) props.onSort?.({ field: next.key, direction: next.direction });
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
<nav class="x-admin-pager" aria-label={t('admin.list.pagination')}>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
disabled={page.prevCursor === null}
|
|
106
|
+
onClick={() => props.onCursor(page.prevCursor)}
|
|
107
|
+
>
|
|
108
|
+
{t('admin.list.previous')}
|
|
109
|
+
</button>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
disabled={!page.hasMore}
|
|
113
|
+
onClick={() => props.onCursor(page.nextCursor)}
|
|
114
|
+
>
|
|
115
|
+
{t('admin.list.next')}
|
|
116
|
+
</button>
|
|
117
|
+
</nav>
|
|
118
|
+
</Card>
|
|
119
|
+
);
|
|
120
|
+
}
|
package/src/mcp-tools.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// The admin's MCP surface, derived from the same resources and actions the UI renders and
|
|
2
|
+
// gated by the same authz. An agent therefore sees exactly the tools its actor could have
|
|
3
|
+
// clicked — no more, and never a tool whose call would then 403.
|
|
4
|
+
//
|
|
5
|
+
// Tool descriptions are literal English on purpose: they are protocol payload read by a
|
|
6
|
+
// model, not UI copy read by a person, so they are not `t()` keys.
|
|
7
|
+
|
|
8
|
+
import { decideAction, permissionsForAction } from './action-gate';
|
|
9
|
+
import type { AdminApp } from './admin';
|
|
10
|
+
import { type AdminDecision, decideAll } from './authz';
|
|
11
|
+
import { type CrudCtx, decideOperation, permissionsForOperation } from './crud';
|
|
12
|
+
import type { AdminFieldType } from './fields';
|
|
13
|
+
import type { AdminOperation } from './permissions';
|
|
14
|
+
import type { AdminResource } from './resource';
|
|
15
|
+
|
|
16
|
+
export type AdminToolKind = 'list' | 'read' | 'search' | 'create' | 'update' | 'delete' | 'action';
|
|
17
|
+
|
|
18
|
+
export interface AdminToolField {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly type: AdminFieldType;
|
|
21
|
+
readonly required: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AdminMcpTool {
|
|
25
|
+
/** `admin.<entity>.<kind>`, or `admin.action.<name>`. Stable: agents cache tool names. */
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly kind: AdminToolKind;
|
|
28
|
+
readonly description: string;
|
|
29
|
+
readonly entity: string | null;
|
|
30
|
+
readonly action: string | null;
|
|
31
|
+
readonly permissions: readonly string[];
|
|
32
|
+
/** Destructive tools require the confirmation token; agents must read before they delete. */
|
|
33
|
+
readonly destructive: boolean;
|
|
34
|
+
readonly input: readonly AdminToolField[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const CURSOR_FIELD: AdminToolField = { name: 'cursor', type: 'text', required: false };
|
|
38
|
+
const ID_FIELD: AdminToolField = { name: 'id', type: 'text', required: true };
|
|
39
|
+
|
|
40
|
+
const formFields = (resource: AdminResource): readonly AdminToolField[] =>
|
|
41
|
+
resource.formFields.map((field) => ({
|
|
42
|
+
name: field.name,
|
|
43
|
+
type: field.type,
|
|
44
|
+
required: field.required,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | null {
|
|
48
|
+
switch (op) {
|
|
49
|
+
case 'list':
|
|
50
|
+
return {
|
|
51
|
+
name: `admin.${resource.name}.list`,
|
|
52
|
+
kind: 'list',
|
|
53
|
+
description: `List ${resource.name} rows, cursor-paginated, newest first.`,
|
|
54
|
+
entity: resource.name,
|
|
55
|
+
action: null,
|
|
56
|
+
permissions: permissionsForOperation(resource.name, 'list'),
|
|
57
|
+
destructive: false,
|
|
58
|
+
input: [CURSOR_FIELD, { name: 'limit', type: 'number', required: false }],
|
|
59
|
+
};
|
|
60
|
+
case 'detail':
|
|
61
|
+
return {
|
|
62
|
+
name: `admin.${resource.name}.read`,
|
|
63
|
+
kind: 'read',
|
|
64
|
+
description: `Read one ${resource.name} row by id.`,
|
|
65
|
+
entity: resource.name,
|
|
66
|
+
action: null,
|
|
67
|
+
permissions: permissionsForOperation(resource.name, 'detail'),
|
|
68
|
+
destructive: false,
|
|
69
|
+
input: [ID_FIELD],
|
|
70
|
+
};
|
|
71
|
+
case 'create':
|
|
72
|
+
return {
|
|
73
|
+
name: `admin.${resource.name}.create`,
|
|
74
|
+
kind: 'create',
|
|
75
|
+
description: `Create a ${resource.name} row. Validated by the entity's schema.`,
|
|
76
|
+
entity: resource.name,
|
|
77
|
+
action: null,
|
|
78
|
+
permissions: permissionsForOperation(resource.name, 'create'),
|
|
79
|
+
destructive: false,
|
|
80
|
+
input: formFields(resource),
|
|
81
|
+
};
|
|
82
|
+
case 'update':
|
|
83
|
+
return {
|
|
84
|
+
name: `admin.${resource.name}.update`,
|
|
85
|
+
kind: 'update',
|
|
86
|
+
description: `Update fields of one ${resource.name} row.`,
|
|
87
|
+
entity: resource.name,
|
|
88
|
+
action: null,
|
|
89
|
+
permissions: permissionsForOperation(resource.name, 'update'),
|
|
90
|
+
destructive: false,
|
|
91
|
+
input: [ID_FIELD, ...formFields(resource)],
|
|
92
|
+
};
|
|
93
|
+
case 'delete':
|
|
94
|
+
return {
|
|
95
|
+
name: `admin.${resource.name}.delete`,
|
|
96
|
+
kind: 'delete',
|
|
97
|
+
description: `Delete one ${resource.name} row. Requires confirmation "<entity>:<id>".`,
|
|
98
|
+
entity: resource.name,
|
|
99
|
+
action: null,
|
|
100
|
+
permissions: permissionsForOperation(resource.name, 'delete'),
|
|
101
|
+
destructive: true,
|
|
102
|
+
input: [ID_FIELD, { name: 'confirmation', type: 'text', required: true }],
|
|
103
|
+
};
|
|
104
|
+
case 'search':
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One exposable tool and the decision that gates it, for whichever actor asks. */
|
|
110
|
+
interface ToolGate {
|
|
111
|
+
readonly tool: AdminMcpTool;
|
|
112
|
+
gate(ctx: CrudCtx): AdminDecision;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The catalog and its gates, derived once. Two callers need different halves of it — the
|
|
117
|
+
* transport needs every tool's name and schema before any caller exists, an actor needs the
|
|
118
|
+
* subset it may call — and they must not be two derivations that can disagree.
|
|
119
|
+
*/
|
|
120
|
+
function toolGates(app: AdminApp): readonly ToolGate[] {
|
|
121
|
+
const out: ToolGate[] = [];
|
|
122
|
+
|
|
123
|
+
for (const resource of app.resources) {
|
|
124
|
+
for (const op of resource.operations) {
|
|
125
|
+
const tool = toolFor(resource, op);
|
|
126
|
+
if (tool === null) continue;
|
|
127
|
+
out.push({ tool, gate: (ctx) => decideOperation(resource, op, ctx) });
|
|
128
|
+
}
|
|
129
|
+
for (const action of resource.actions) {
|
|
130
|
+
if (action.mcp?.expose === false) continue;
|
|
131
|
+
out.push({
|
|
132
|
+
tool: {
|
|
133
|
+
name: `admin.action.${action.name}`,
|
|
134
|
+
kind: 'action',
|
|
135
|
+
description: action.mcp?.description ?? `Run the ${action.name} action.`,
|
|
136
|
+
entity: action.entity ?? null,
|
|
137
|
+
action: action.name,
|
|
138
|
+
permissions: permissionsForAction(action),
|
|
139
|
+
destructive: action.destructive === true,
|
|
140
|
+
input: [],
|
|
141
|
+
},
|
|
142
|
+
gate: (ctx) => decideAction(action, ctx.actor, ctx.authz),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Search is one tool over every resource, not one per entity: an agent looking for a row
|
|
148
|
+
// should not have to fan out over the registry itself.
|
|
149
|
+
out.push({
|
|
150
|
+
tool: {
|
|
151
|
+
name: 'admin.search',
|
|
152
|
+
kind: 'search',
|
|
153
|
+
description: 'Search every readable entity by its text fields. Returns ids and labels.',
|
|
154
|
+
entity: null,
|
|
155
|
+
action: null,
|
|
156
|
+
permissions: permissionsForOperation('admin', 'search'),
|
|
157
|
+
destructive: false,
|
|
158
|
+
input: [{ name: 'term', type: 'text', required: true }],
|
|
159
|
+
},
|
|
160
|
+
gate: (ctx) => decideAll(ctx.authz, permissionsForOperation('admin', 'search'), ctx.actor),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
for (const action of app.globalActions) {
|
|
164
|
+
if (action.mcp?.expose === false) continue;
|
|
165
|
+
out.push({
|
|
166
|
+
tool: {
|
|
167
|
+
name: `admin.action.${action.name}`,
|
|
168
|
+
kind: 'action',
|
|
169
|
+
description: action.mcp?.description ?? `Run the ${action.name} action.`,
|
|
170
|
+
entity: null,
|
|
171
|
+
action: action.name,
|
|
172
|
+
permissions: permissionsForAction(action),
|
|
173
|
+
destructive: action.destructive === true,
|
|
174
|
+
input: [],
|
|
175
|
+
},
|
|
176
|
+
gate: (ctx) => decideAction(action, ctx.actor, ctx.authz),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Every tool the surface could expose, ungated. The transport's `tools/list` payload. */
|
|
184
|
+
export function adminToolCatalog(app: AdminApp): readonly AdminMcpTool[] {
|
|
185
|
+
return toolGates(app).map(({ tool }) => tool);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Every tool the surface could expose, each with the decision that would gate it. */
|
|
189
|
+
export function adminToolDecisions(
|
|
190
|
+
app: AdminApp,
|
|
191
|
+
ctx: CrudCtx,
|
|
192
|
+
): readonly { readonly tool: AdminMcpTool; readonly decision: AdminDecision }[] {
|
|
193
|
+
return toolGates(app).map(({ tool, gate }) => ({ tool, decision: gate(ctx) }));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The tools this actor may call. The UI's visibility rule, applied to the MCP surface. */
|
|
197
|
+
export function adminMcpTools(app: AdminApp, ctx: CrudCtx): readonly AdminMcpTool[] {
|
|
198
|
+
return adminToolDecisions(app, ctx)
|
|
199
|
+
.filter(({ decision }) => decision.allowed)
|
|
200
|
+
.map(({ tool }) => tool);
|
|
201
|
+
}
|