@ultimat3/admin 30.3.0 → 30.4.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 +7 -1
- package/package.json +17 -17
- package/src/mcp-scopes.ts +20 -2
- package/src/mcp.ts +27 -6
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
|
-
|
|
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
|
+
"version": "30.4.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.4.0",
|
|
49
|
+
"@ultimat3/ai": "30.4.0",
|
|
50
|
+
"@ultimat3/cache": "30.4.0",
|
|
51
|
+
"@ultimat3/core": "30.4.0",
|
|
52
|
+
"@ultimat3/db": "30.4.0",
|
|
53
|
+
"@ultimat3/entity": "30.4.0",
|
|
54
|
+
"@ultimat3/http": "30.4.0",
|
|
55
|
+
"@ultimat3/i18n": "30.4.0",
|
|
56
|
+
"@ultimat3/jobs": "30.4.0",
|
|
57
|
+
"@ultimat3/mcp": "30.4.0",
|
|
58
|
+
"@ultimat3/money": "30.4.0",
|
|
59
|
+
"@ultimat3/policy": "30.4.0",
|
|
60
|
+
"@ultimat3/query": "30.4.0",
|
|
61
|
+
"@ultimat3/render": "30.4.0",
|
|
62
|
+
"@ultimat3/schema": "30.4.0",
|
|
63
|
+
"@ultimat3/ui": "30.4.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
|
-
/**
|
|
254
|
-
|
|
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:
|
|
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
|
//
|