@ultimat3/mcp 22.3.6 → 22.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -31,9 +31,18 @@ import. The CLI wires it.
31
31
  | `input-schema.ts` | Standard Schema → the `JsonSchema` subset `validate-args.ts` enforces |
32
32
  | `readonly-sql.ts` | layer 3 of `db.query` — the single-read parse — and `db.migrate`'s branch check |
33
33
  | `query-limits.ts` | layer 4 of `db.query` — the row, byte and timeout ceilings, and what truncation reports |
34
+ | `meta-surface.ts` | `surface: 'meta'` catalog: `list_resources` / `describe_resource`, `groups:` boot checks; dispatch stays in `server.ts` |
35
+ | `list-params.ts` | `listParams` whitelist → the schema `manage_resource` enforces (flat keys: `status_eq`) |
36
+ | `meta-errors.ts` · `framework-error.ts` | meta boot refusals · a thrown value read and rendered (split from `server.ts`) |
34
37
 
35
38
  ## Invariants
36
39
 
40
+ - **`manage_resource` is a second DOOR, never a second PATH** (`As of 2026-09-26`): locate (visibility,
41
+ else the absent answer) → the shared `resolve` (scope → args; a list whitelist's issues win) → `dispatch`,
42
+ audited under the inner tool. `tools/list` shows what `tools/call` answers PER CALLER: a meta caller
43
+ cannot call a grouped tool by name, a flat caller never sees the meta tools, and every not-found a
44
+ meta caller gets carries `META_UNKNOWN_FIX`. Default `'flat'` builds no `MetaSurface`.
45
+
37
46
  - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a `defineAppMcp` file
38
47
  imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on
39
48
  every access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
package/README.md CHANGED
@@ -178,6 +178,49 @@ beside the action, because a scope is a capability of the CONNECTION's token —
178
178
  stays the only rule that reads the input. A name this server does not project is
179
179
  `X_MCP_SCOPE_UNKNOWN` at boot; one tool claimed by two scopes is `X_MCP_SCOPE_CONFLICT`.
180
180
 
181
+ ### A constant surface: `surface: 'meta'`
182
+
183
+ A large or sparsely used catalog blows an agent's context one tool per primitive. `surface: 'meta'`
184
+ serves three tools instead, however many primitives sit behind them; tools named in no group
185
+ (`docs`, `whoami`) stay flat beside them. The default is `'flat'` and changes nothing.
186
+
187
+ ```ts
188
+ import { defineAppMcp } from '@ultimat3/mcp';
189
+
190
+ defineAppMcp({
191
+ include: 'exposed',
192
+ surface: (caller) => (caller.role?.startsWith('staff') ? 'meta' : 'flat'), // or 'meta'
193
+ groups: {
194
+ payouts: {
195
+ description: 'Affiliate payout batches',
196
+ tools: ['previewPayoutBatch', 'listPayoutBatches'],
197
+ },
198
+ },
199
+ });
200
+ ```
201
+
202
+ | Tool | Answers |
203
+ |---|---|
204
+ | `list_resources` | `{ resources: [{ name, description, actions: [{ name, kind: 'query'\|'action', description, params, confirms?, scope? }] }] }` — `params` is a one-line hint (`batchId: string, note?: string`) |
205
+ | `describe_resource` | `{ resources: string[] }` (batched) → each action's full `inputSchema` |
206
+ | `manage_resource` | `{ resource, action, params }` → the flat tool's answer, byte for byte |
207
+
208
+ `manage_resource` is the flat `tools/call` through another door: visibility (a hidden or misaddressed
209
+ pair answers what an absent one does), then the shared resolver's scope and argument gates, then the
210
+ tool's own policy, audited under the tool's name and metered in its bucket. A meta caller cannot call
211
+ a grouped tool by name, and a flat caller never sees the meta tools. `confirms: true` on an
212
+ `McpTool` marks a write a human finishes. Boot refuses `X_MCP_GROUP_UNKNOWN`,
213
+ `X_MCP_GROUP_CONFLICT`, `X_MCP_SURFACE_INVALID` (meta without groups, groups nobody is served) and a
214
+ meta tool name taken by an app tool (`X_MCP_TOOL_DUPLICATE`).
215
+
216
+ **List params.** A list query declares its whitelist, `mcp: { expose: true, listParams: { filters:
217
+ { status: ['_eq', '_in'], createdAt: ['_gt', '_lt'] }, sort: ['createdAt'], fields: ['id', 'status'],
218
+ maxLimit: 100 } }`. Keys are FLAT — `status_eq`, `createdAt_gt`, `sort: '-createdAt'`, `fields`,
219
+ `cursor` (opaque keyset), `limit` — because a query's input travels as a query string; the query's
220
+ own `input` declares them and implements them. `describe_resource` publishes the whitelist and
221
+ `manage_resource` refuses anything outside it (`X_MCP_ARGS_INVALID`) before the query runs; a
222
+ whitelisted key the input does not declare is `X_MCP_LIST_PARAMS_INVALID` at boot.
223
+
181
224
  ## Transports
182
225
 
183
226
  | Transport | Entry | Auth |
@@ -244,6 +287,9 @@ inside their own handler.
244
287
  | `X_MCP_QUERY_REJECTED` | `db.query` given anything but one read-only statement |
245
288
  | `X_MCP_NOT_BRANCH_DB` | `db.migrate` aimed at a production or otherwise non-branch database |
246
289
  | `X_MCP_RESOURCE_DUPLICATE` | two resources claim one `ultimate://` URI — refused at registration, as a duplicate tool name is |
290
+ | `X_MCP_GROUP_UNKNOWN` · `X_MCP_GROUP_CONFLICT` | `groups:` names a tool not projected · lists one tool twice |
291
+ | `X_MCP_SURFACE_INVALID` | `surface` and `groups` disagree |
292
+ | `X_MCP_LIST_PARAMS_INVALID` | `listParams` whitelists a key the tool's input does not declare |
247
293
  | `X_MCP_RATE_LIMITED` | the caller spent its per-minute allowance for this request's class. Its own code rather than `@ultimat3/http`'s `X_RATE_LIMITED` because the KNOB differs — `rateLimits` on the route, never `rateLimit.buckets` in `app.config.ts` |
248
294
 
249
295
  ### Error classes
@@ -256,8 +302,12 @@ a job boundary the class is gone and the `code` is what survives — match on th
256
302
  | `McpAppUnmountedError` | `X_MCP_APP_UNMOUNTED` | `src/errors.ts` |
257
303
  | `McpArgsInvalidError` | `X_MCP_ARGS_INVALID` | `src/errors.ts` |
258
304
  | `McpBodyTooLargeError` | `X_MCP_BODY_TOO_LARGE` | `src/errors.ts` |
305
+ | `McpGroupConflictError` | `X_MCP_GROUP_CONFLICT` | `src/meta-errors.ts` |
306
+ | `McpGroupUnknownError` | `X_MCP_GROUP_UNKNOWN` | `src/meta-errors.ts` |
307
+ | `McpListParamsInvalidError` | `X_MCP_LIST_PARAMS_INVALID` | `src/meta-errors.ts` |
259
308
  | `McpNotBranchDbError` | `X_MCP_NOT_BRANCH_DB` | `src/errors.ts` |
260
309
  | `McpProtocolError` | `X_MCP_PROTOCOL` | `src/errors.ts` |
310
+ | `McpSurfaceInvalidError` | `X_MCP_SURFACE_INVALID` | `src/meta-errors.ts` |
261
311
  | `McpQueryRejectedError` | `X_MCP_QUERY_REJECTED` | `src/errors.ts` |
262
312
  | `McpRateLimitedError` | `X_MCP_RATE_LIMITED` | `src/errors.ts` |
263
313
  | `McpResourceDuplicateError` | `X_MCP_RESOURCE_DUPLICATE` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "22.3.6",
3
+ "version": "22.5.0",
4
4
  "description": "MCP server, dev tools, and the action-to-tool projection \u2014 one authz system, two surfaces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,13 +31,13 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/action": "22.3.6",
35
- "@ultimat3/core": "22.3.6",
36
- "@ultimat3/entity": "22.3.6",
37
- "@ultimat3/http": "22.3.6",
38
- "@ultimat3/jobs": "22.3.6",
39
- "@ultimat3/policy": "22.3.6",
40
- "@ultimat3/query": "22.3.6",
41
- "@ultimat3/schema": "22.3.6"
34
+ "@ultimat3/action": "22.5.0",
35
+ "@ultimat3/core": "22.5.0",
36
+ "@ultimat3/entity": "22.5.0",
37
+ "@ultimat3/http": "22.5.0",
38
+ "@ultimat3/jobs": "22.5.0",
39
+ "@ultimat3/policy": "22.5.0",
40
+ "@ultimat3/query": "22.5.0",
41
+ "@ultimat3/schema": "22.5.0"
42
42
  }
43
43
  }
package/src/app-tools.ts CHANGED
@@ -15,6 +15,7 @@ import { appToolPrimitives } from './app-tool';
15
15
  import { McpToolDuplicateError } from './errors';
16
16
  import { exposedPrimitives } from './exposed';
17
17
  import { toolsFrom, toolsListed } from './from-action';
18
+ import type { McpResourceGroups, McpSurfaceOption } from './meta-surface';
18
19
  import type { ListedPrimitive } from './projectable';
19
20
  import { asProjectable } from './projectable';
20
21
  import type { AnyMcpTool, McpVerbClass } from './registry';
@@ -74,6 +75,26 @@ export interface DefineAppMcpInput<TSchemas extends AppToolSchemas = AppToolSche
74
75
  * ```
75
76
  */
76
77
  readonly scopes?: McpScopes;
78
+ /**
79
+ * `'flat'` (the default — nothing changes for an app that omits it): one tool per primitive.
80
+ * `'meta'`: a CONSTANT `tools/list` — `list_resources`, `describe_resource`,
81
+ * `manage_resource` — with every tool named in `groups` behind them; the rest (`docs`,
82
+ * `whoami`) stay flat beside them. A function decides per population, from the caller alone:
83
+ *
84
+ * ```ts
85
+ * surface: (caller) => (caller.role?.startsWith('staff') ? 'meta' : 'flat'),
86
+ * ```
87
+ *
88
+ * Same policies, scopes, visibility and audit either way: `manage_resource` resolves to the
89
+ * tool and runs it on the path a flat `tools/call` takes.
90
+ */
91
+ readonly surface?: McpSurfaceOption;
92
+ /**
93
+ * The meta catalog: resource name → `{ description, tools }`, by TOOL NAME like `scopes`.
94
+ * Required with a surface that can be `'meta'`; refused with one that cannot. A name no tool
95
+ * answers to is `X_MCP_GROUP_UNKNOWN`, a tool in two groups `X_MCP_GROUP_CONFLICT`.
96
+ */
97
+ readonly groups?: McpResourceGroups;
77
98
  /** Bearer-token resolution. Omit to expose no HTTP route (stdio/embedded only). */
78
99
  resolveToken?(token: string): Promise<ResolvedToken | null> | ResolvedToken | null;
79
100
  /** Mount path. Defaults to `/mcp`. */
@@ -164,6 +185,8 @@ export function defineAppMcp<TSchemas extends AppToolSchemas>(
164
185
  resources: input.resources ?? [],
165
186
  prompts: toPrompts(input.prompts ?? []),
166
187
  serverInfo: { name: input.name ?? 'ultimate-app', version: input.version ?? '0.0.0' },
188
+ ...(input.surface === undefined ? {} : { surface: input.surface }),
189
+ ...(input.groups === undefined ? {} : { groups: input.groups }),
167
190
  };
168
191
  const server = createMcpServer(config);
169
192
 
package/src/errors.ts CHANGED
@@ -19,6 +19,10 @@ export const MCP_ERROR_CODES = [
19
19
  'X_MCP_RATE_LIMITED',
20
20
  'X_MCP_APP_UNMOUNTED',
21
21
  'X_MCP_BODY_TOO_LARGE',
22
+ 'X_MCP_GROUP_UNKNOWN',
23
+ 'X_MCP_GROUP_CONFLICT',
24
+ 'X_MCP_SURFACE_INVALID',
25
+ 'X_MCP_LIST_PARAMS_INVALID',
22
26
  ] as const;
23
27
 
24
28
  export type McpErrorCode = (typeof MCP_ERROR_CODES)[number];
@@ -39,6 +43,10 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
39
43
  X_MCP_RATE_LIMITED: "the caller has spent its allowance for this request's class",
40
44
  X_MCP_APP_UNMOUNTED: "the app's MCP endpoint is exposed in config and nothing can be mounted",
41
45
  X_MCP_BODY_TOO_LARGE: 'one MCP message is larger than the transport holds',
46
+ X_MCP_GROUP_UNKNOWN: 'defineAppMcp groups a tool this server does not project',
47
+ X_MCP_GROUP_CONFLICT: 'two resource groups claim one MCP tool',
48
+ X_MCP_SURFACE_INVALID: 'the MCP meta surface is declared inconsistently',
49
+ X_MCP_LIST_PARAMS_INVALID: "a tool's listParams names a key its own input does not accept",
42
50
  };
43
51
 
44
52
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -0,0 +1,58 @@
1
+ // A value a tool THREW, read as a framework error (code, title, cause, fix) or rejected as a bug —
2
+ // and its rendering, byte-identical to `UltimateError.format()`. Split from `server.ts`, whose two
3
+ // dispatch paths (a flat `tools/call` and `manage_resource`) and resource reads all share it.
4
+
5
+ import { FRAMEWORK_CODE, singleLine, stringField } from '@ultimat3/core';
6
+
7
+ export interface FrameworkError {
8
+ readonly code: string;
9
+ /** `''` for a foreign thrown object that carries no title. See `renderFrameworkError`. */
10
+ readonly title: string;
11
+ readonly cause: string;
12
+ readonly fix: string;
13
+ }
14
+
15
+ /**
16
+ * Read a thrown framework error, or `undefined` when it is not one (a genuine bug, which
17
+ * becomes `-32603` with no internals leaked). Structural rather than `instanceof`: the
18
+ * transport must stay independent of which package threw.
19
+ */
20
+ export function asFrameworkError(error: unknown): FrameworkError | undefined {
21
+ // `stringField` from `@ultimat3/core`, never `typeof e.code === 'string'`: the value is whatever
22
+ // an app's handler, its driver or its SDK threw, so each read is a getter call or a `Proxy`
23
+ // trap. This runs inside the catch block that owes the caller an answer, and a probe that
24
+ // raises here leaves the JSON-RPC request with no response at all — not even the `-32603` the
25
+ // header promises for a genuine bug.
26
+ const code = stringField(error, 'code');
27
+ // `FRAMEWORK_CODE`, never `startsWith('X_')`: the substituted `fix:` below interpolates this
28
+ // value into a COMMAND an agent is told to run, and `X_$(id)` passes a prefix test. A code is
29
+ // `X_SCREAMING_SNAKE` and nothing else, so a value that is not one is not a framework error —
30
+ // it takes the `-32603` branch, which leaks nothing of the throw.
31
+ if (code === undefined || !FRAMEWORK_CODE.test(code)) return undefined;
32
+ return {
33
+ code,
34
+ title: stringField(error, 'title') ?? '',
35
+ cause: stringField(error, 'cause') ?? 'unknown',
36
+ // A substituted fix is still a fix an agent will act on, so it has to be runnable. `see docs`
37
+ // named no docs and no command; `code` is already narrowed to an `X_` string by the guard
38
+ // above, so the substitute is the one command that explains exactly this code.
39
+ fix: stringField(error, 'fix') ?? `x errors explain ${code}`,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * The agent-readable form, BYTE-IDENTICAL to `UltimateError.format()` — one denial reads the
45
+ * same over MCP as it does in the terminal, so an agent that learned the shape from `x` does
46
+ * not have to learn a second one here. Dropping the title would be a second rendering of the
47
+ * same contract, and the two would drift.
48
+ *
49
+ * The bare-`code` head is the fallback for a foreign thrown object that carries `code`/`cause`
50
+ * but no title; a real `UltimateError` always has one.
51
+ */
52
+ export function renderFrameworkError(error: FrameworkError): string {
53
+ const head =
54
+ error.title === ''
55
+ ? singleLine(error.code)
56
+ : `${singleLine(error.code)}: ${singleLine(error.title)}`;
57
+ return `${head}\n cause: ${singleLine(error.cause)}\n fix: ${singleLine(error.fix)}`;
58
+ }
@@ -27,6 +27,7 @@
27
27
  import type { Actor } from '@ultimat3/core';
28
28
  import { isMcpExposed } from '@ultimat3/core';
29
29
  import { McpToolUndeclaredError } from './errors';
30
+ import type { McpListParams } from './list-params';
30
31
  import type { AnyMcpTool, McpCaller, McpRole, McpToolResult, ToolArgs } from './registry';
31
32
  import { jsonResult } from './registry';
32
33
  import type { JsonSchema } from './wire';
@@ -49,6 +50,8 @@ export interface McpExposure {
49
50
  readonly visibleTo?: readonly McpRole[];
50
51
  /** Override the projected tool name. Defaults to the primitive's own name. */
51
52
  readonly name?: string;
53
+ /** A list query's whitelist, carried to the tool for the meta surface. See `McpListParams`. */
54
+ readonly listParams?: McpListParams;
52
55
  }
53
56
 
54
57
  /**
@@ -88,6 +91,7 @@ export function toolFromAction(primitive: ProjectablePrimitive): AnyMcpTool {
88
91
  primitive.mcp?.description ?? primitive.description ?? `Run the "${primitive.name}" action.`;
89
92
  const mutates = primitive.mutates ?? true;
90
93
  const visibleTo = primitive.mcp?.visibleTo;
94
+ const listParams = primitive.mcp?.listParams;
91
95
 
92
96
  return {
93
97
  name,
@@ -95,6 +99,7 @@ export function toolFromAction(primitive: ProjectablePrimitive): AnyMcpTool {
95
99
  inputSchema: primitive.inputJsonSchema ?? NO_ARGS,
96
100
  destructive: mutates,
97
101
  ...(visibleTo !== undefined ? { visibleTo } : {}),
102
+ ...(listParams !== undefined ? { listParams } : {}),
98
103
  // No `scope` from here: see the header. `defineAppMcp`'s `scopes:` map may add one.
99
104
  async handle(args: ToolArgs, caller: McpCaller): Promise<McpToolResult> {
100
105
  // ONE authz system, TWO surfaces. This `run` IS `invoke`; the HTTP route reaches the
package/src/index.ts CHANGED
@@ -86,6 +86,31 @@ export {
86
86
  toolsListed,
87
87
  } from './from-action';
88
88
  export { toWireSchema } from './input-schema';
89
+ export type { ListFilterOp, McpListParams } from './list-params';
90
+ export { DEFAULT_LIST_MAX_LIMIT, listParamsSchema } from './list-params';
91
+ export {
92
+ McpGroupConflictError,
93
+ McpGroupUnknownError,
94
+ McpListParamsInvalidError,
95
+ McpSurfaceInvalidError,
96
+ META_UNKNOWN_FIX,
97
+ } from './meta-errors';
98
+ export type {
99
+ DescribedAction,
100
+ McpResourceGroup,
101
+ McpResourceGroups,
102
+ McpSurface,
103
+ McpSurfaceOption,
104
+ MetaAction,
105
+ MetaResource,
106
+ } from './meta-surface';
107
+ export {
108
+ DESCRIBE_RESOURCE,
109
+ LIST_RESOURCES,
110
+ MANAGE_RESOURCE,
111
+ META_TOOL_NAMES,
112
+ oneLineParams,
113
+ } from './meta-surface';
89
114
  export type { ListedPrimitive } from './projectable';
90
115
  export { asProjectable } from './projectable';
91
116
  export type { QueryLimits, QueryResult, QueryRows } from './query-limits';
@@ -0,0 +1,120 @@
1
+ // `listParams` — composition pushed into params, not tools: one list query answers filters,
2
+ // `sort`, `fields` and a keyset `cursor` + `limit` over a WHITELIST it declares, instead of an app
3
+ // shipping `listRecentX`, `listPaidX`, `xByOrg`. This file turns the declaration into the schema
4
+ // `describe_resource` publishes and `manage_resource` enforces before the query's own parse.
5
+ //
6
+ // FLAT keys (`status_eq`, not `filters: { status: { eq } }`): a query's input is projected to a
7
+ // query string (`@ultimat3/query` refuses a nested object in `input-shape.ts`), so the one shape
8
+ // every surface can carry is the one this whitelist publishes.
9
+
10
+ import { finiteCount } from '@ultimat3/core';
11
+ import { McpListParamsInvalidError } from './meta-errors';
12
+ import type { JsonSchema } from './wire';
13
+
14
+ /** The five comparison operators. A filter key is `<field><op>`: `status_eq`, `amount_gt`. */
15
+ export type ListFilterOp = '_eq' | '_in' | '_gt' | '_lt' | '_cont';
16
+
17
+ /** The largest page `limit` may ask for when a query declares no `maxLimit`. */
18
+ export const DEFAULT_LIST_MAX_LIMIT = 100;
19
+
20
+ /**
21
+ * What a list query composes over. Every member is a whitelist: a filter field with the operators
22
+ * it takes, the sortable fields, the fields a sparse response may pick.
23
+ *
24
+ * The query's own `input` declares the same keys — `status_eq`, `sort`, `fields`, `cursor`,
25
+ * `limit` — and implements them; the dispatcher refuses anything outside the whitelist before the
26
+ * query runs, and a whitelisted key the input does not declare is refused at BOOT
27
+ * (`X_MCP_LIST_PARAMS_INVALID`), because the query would silently never read it. `cursor` is
28
+ * opaque and keyset, never an offset.
29
+ */
30
+ export interface McpListParams {
31
+ readonly filters?: Readonly<Record<string, readonly ListFilterOp[]>>;
32
+ readonly sort?: readonly string[];
33
+ readonly fields?: readonly string[];
34
+ /** Ceiling for `limit`. Defaults to `DEFAULT_LIST_MAX_LIMIT`. */
35
+ readonly maxLimit?: number;
36
+ }
37
+
38
+ const SCALAR: JsonSchema = {
39
+ anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }],
40
+ };
41
+
42
+ const ORDERED: JsonSchema = { anyOf: [{ type: 'string' }, { type: 'number' }] };
43
+
44
+ function operandSchema(op: ListFilterOp): JsonSchema {
45
+ switch (op) {
46
+ case '_in':
47
+ return { type: 'array', items: SCALAR };
48
+ case '_cont':
49
+ return { type: 'string', minLength: 1 };
50
+ case '_gt':
51
+ case '_lt':
52
+ return ORDERED;
53
+ case '_eq':
54
+ return SCALAR;
55
+ }
56
+ }
57
+
58
+ /** The whitelisted keys and their wire schemas, in declaration order. */
59
+ export function listParamsProperties(spec: McpListParams): readonly [string, JsonSchema][] {
60
+ const out: [string, JsonSchema][] = [];
61
+ for (const [field, ops] of Object.entries(spec.filters ?? {})) {
62
+ for (const op of new Set(ops)) out.push([`${field}${op}`, operandSchema(op)]);
63
+ }
64
+ if (spec.sort !== undefined && spec.sort.length > 0) {
65
+ out.push([
66
+ 'sort',
67
+ {
68
+ type: 'string',
69
+ description: 'a field, ascending; prefix "-" for descending',
70
+ enum: spec.sort.flatMap((field) => [field, `-${field}`]),
71
+ },
72
+ ]);
73
+ }
74
+ if (spec.fields !== undefined && spec.fields.length > 0) {
75
+ out.push(['fields', { type: 'array', items: { type: 'string', enum: spec.fields } }]);
76
+ }
77
+ out.push([
78
+ 'cursor',
79
+ { type: 'string', maxLength: 1024, description: 'opaque keyset cursor from the last page' },
80
+ ]);
81
+ // Refused rather than published: `maximum: NaN` is a ceiling `validateArgs` never trips.
82
+ const maxLimit = finiteCount(
83
+ 'listParams',
84
+ 'maxLimit',
85
+ spec.maxLimit ?? DEFAULT_LIST_MAX_LIMIT,
86
+ 1,
87
+ );
88
+ out.push(['limit', { type: 'integer', minimum: 1, maximum: maxLimit }]);
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * The schema `manage_resource` validates a list call against: the tool's own input with every
94
+ * list key NARROWED to the whitelist, and nothing else admitted. `Object.fromEntries` (a define,
95
+ * never `[k] =`) because a field name is the app's string and `__proto__` must land as a key.
96
+ */
97
+ export function listParamsSchema(spec: McpListParams, base: JsonSchema): JsonSchema {
98
+ const properties = Object.fromEntries([
99
+ ...Object.entries(base.properties ?? {}),
100
+ ...listParamsProperties(spec),
101
+ ]);
102
+ return {
103
+ type: 'object',
104
+ properties,
105
+ ...(base.required === undefined ? {} : { required: base.required }),
106
+ additionalProperties: false,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Boot-time: every whitelisted key must be one the tool's input declares. A key the input does not
112
+ * declare is either refused by the query's parse or stripped by it — the second is a filter an
113
+ * agent was promised and the query never applied, which is worse than no filter at all.
114
+ */
115
+ export function assertListParams(name: string, spec: McpListParams, base: JsonSchema): void {
116
+ const declared = base.properties ?? {};
117
+ for (const [key] of listParamsProperties(spec)) {
118
+ if (!Object.hasOwn(declared, key)) throw new McpListParamsInvalidError({ name, key });
119
+ }
120
+ }
@@ -0,0 +1,83 @@
1
+ // A META caller's `tools/call`: the three meta tools, and the refusal of a grouped tool called by
2
+ // its flat name. Split from `server.ts` for the size ceiling only — it holds no gate of its own:
3
+ // every call it runs goes back through the server's `resolve` → `dispatch`, the flat path.
4
+
5
+ import { auditToolCall } from './audit';
6
+ import { META_UNKNOWN_FIX } from './meta-errors';
7
+ import type { MetaSurface } from './meta-surface';
8
+ import { DESCRIBE_RESOURCE, LIST_RESOURCES, META_TOOL_ENTRIES, schemaOf } from './meta-surface';
9
+ import type { McpCaller, ToolRegistry, ToolResolution } from './registry';
10
+ import { jsonResult } from './registry';
11
+ import { validateArgs } from './validate-args';
12
+ import type { JsonRpcId, JsonRpcResponse } from './wire';
13
+ import { errorResponse, METHOD_NOT_FOUND, resultResponse } from './wire';
14
+
15
+ /** The server's own two steps, handed in so this file cannot grow a second path. */
16
+ export interface MetaHost {
17
+ readonly tools: ToolRegistry;
18
+ dispatch(
19
+ id: JsonRpcId,
20
+ name: string,
21
+ resolved: ToolResolution,
22
+ caller: McpCaller,
23
+ ): Promise<JsonRpcResponse>;
24
+ notFound(id: JsonRpcId, name: string, caller: McpCaller, fix: string): JsonRpcResponse;
25
+ }
26
+
27
+ export interface MetaRequest {
28
+ readonly id: JsonRpcId;
29
+ readonly name: string;
30
+ readonly rawArgs: unknown;
31
+ readonly caller: McpCaller;
32
+ }
33
+
34
+ /** `undefined` hands an ungrouped tool (`docs`, `whoami`) to the flat path. */
35
+ export async function metaCall(
36
+ host: MetaHost,
37
+ meta: MetaSurface,
38
+ request: MetaRequest,
39
+ ): Promise<JsonRpcResponse | undefined> {
40
+ const { id, name, caller } = request;
41
+ const entry = META_TOOL_ENTRIES.find((tool) => tool.name === name);
42
+ if (entry === undefined) {
43
+ return meta.isGrouped(name) ? host.notFound(id, name, caller, META_UNKNOWN_FIX) : undefined;
44
+ }
45
+ const args = validateArgs(entry.inputSchema, request.rawArgs ?? {});
46
+ if (!args.ok) {
47
+ return host.dispatch(id, name, { kind: 'invalid-args', name, issues: args.issues }, caller);
48
+ }
49
+ if (name === LIST_RESOURCES) {
50
+ auditToolCall({ tool: name, outcome: 'ok', caller });
51
+ return resultResponse(id, jsonResult({ resources: meta.listResources(caller) }));
52
+ }
53
+ if (name === DESCRIBE_RESOURCE) {
54
+ const described = meta.describe(args.value['resources'] as readonly string[], caller);
55
+ if (!described.ok) {
56
+ // Absent and hidden are one answer, with no `data`, exactly as for a tool.
57
+ auditToolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
58
+ return errorResponse(
59
+ id,
60
+ METHOD_NOT_FOUND,
61
+ `resource not found: ${described.name} — ${META_UNKNOWN_FIX}`,
62
+ );
63
+ }
64
+ auditToolCall({ tool: name, outcome: 'ok', caller });
65
+ return resultResponse(id, jsonResult({ resources: described.resources }));
66
+ }
67
+ // `manage_resource`: address → the SAME resolve and dispatch a flat call takes, audited under
68
+ // the tool it reached. Visibility first (a hidden pair is an absent pair), then the shared
69
+ // resolver's scope → args. A list whitelist's issues REPLACE the tool schema's: both are pure
70
+ // checks with nothing run between them, so which is evaluated first is invisible — the answer
71
+ // names the whitelist, which is the contract `describe_resource` published.
72
+ const resource = args.value['resource'] as string;
73
+ const action = args.value['action'] as string;
74
+ const tool = meta.locate(resource, action, caller);
75
+ if (tool === undefined) return host.notFound(id, action, caller, META_UNKNOWN_FIX);
76
+ const input = args.value['params'] ?? {};
77
+ let resolved = host.tools.resolve(action, input, caller);
78
+ if (resolved.kind !== 'scope-denied' && resolved.kind !== 'not-found') {
79
+ const listed = validateArgs(schemaOf(tool), input);
80
+ if (!listed.ok) resolved = { kind: 'invalid-args', name: action, issues: listed.issues };
81
+ }
82
+ return host.dispatch(id, action, resolved, caller);
83
+ }
@@ -0,0 +1,68 @@
1
+ // The boot-time refusals of the META surface (`surface: 'meta'` + `groups:` in `defineAppMcp`).
2
+ // Codes and titles are registered in `errors.ts` with the rest of the package's; these classes
3
+ // sit apart only so that file stays under the size ceiling.
4
+
5
+ import { UltimateError } from '@ultimat3/core';
6
+
7
+ /**
8
+ * The one instruction safe on both branches of outcome 1 for a META caller — the twin of
9
+ * `TOOL_UNKNOWN_FIX`. Every not-found a meta caller receives carries THIS sentence, grouped name or
10
+ * flat, absent or hidden: a second wording for one branch would be the oracle outcome 1 removes.
11
+ */
12
+ export const META_UNKNOWN_FIX =
13
+ 'call tools/list and list_resources to read the catalog this caller may use';
14
+
15
+ /** `groups:` names a tool no projected or hand-written tool answers to. */
16
+ export class McpGroupUnknownError extends UltimateError {
17
+ readonly projected: readonly string[];
18
+
19
+ constructor(input: { group: string; name: string; projected: readonly string[] }) {
20
+ const projected = input.projected.length > 0 ? input.projected.join(', ') : 'nothing';
21
+ super({
22
+ code: 'X_MCP_GROUP_UNKNOWN',
23
+ cause: `groups["${input.group}"] names "${input.name}", which this server does not project (projected: ${projected})`,
24
+ fix: `in defineAppMcp, spell it as one of the projected names above — or drop "${input.name}" from groups["${input.group}"].tools`,
25
+ });
26
+ this.projected = input.projected;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * One tool claimed by two groups. `manage_resource` addresses a tool as `{ resource, action }`, so
32
+ * a tool in two resources has two addresses — and which one `list_resources` shows would be
33
+ * decided by object key order.
34
+ */
35
+ export class McpGroupConflictError extends UltimateError {
36
+ readonly groups: readonly [string, string];
37
+
38
+ constructor(input: { name: string; groups: readonly [string, string] }) {
39
+ super({
40
+ code: 'X_MCP_GROUP_CONFLICT',
41
+ cause: `tool "${input.name}" is listed in two groups ("${input.groups[0]}" and "${input.groups[1]}"); a tool belongs to one resource`,
42
+ fix: `in defineAppMcp, keep "${input.name}" in the one group an agent should find it under, and remove the other entry`,
43
+ });
44
+ this.groups = input.groups;
45
+ }
46
+ }
47
+
48
+ /** A `listParams` key the tool's own input does not declare — a promise the query never keeps. */
49
+ export class McpListParamsInvalidError extends UltimateError {
50
+ constructor(input: { name: string; key: string }) {
51
+ super({
52
+ code: 'X_MCP_LIST_PARAMS_INVALID',
53
+ cause: `tool "${input.name}" whitelists "${input.key}" in listParams, and its input schema does not declare "${input.key}" — the query would never read it`,
54
+ fix: `add "${input.key}" to the input of "${input.name}" (the query implements it), or drop it from mcp.listParams`,
55
+ });
56
+ }
57
+ }
58
+
59
+ /** `surface` and `groups` disagree: a meta surface with nothing behind it, or groups nobody sees. */
60
+ export class McpSurfaceInvalidError extends UltimateError {
61
+ constructor(input: { cause: string }) {
62
+ super({
63
+ code: 'X_MCP_SURFACE_INVALID',
64
+ cause: input.cause,
65
+ fix: "in defineAppMcp, declare groups: { <resource>: { description, tools: [...] } } together with surface: 'meta' (or surface: (caller) => 'meta' | 'flat'), or drop both",
66
+ });
67
+ }
68
+ }
@@ -0,0 +1,298 @@
1
+ // The META surface: three tools — `list_resources`, `describe_resource`, `manage_resource` — in
2
+ // place of one tool per primitive, so `tools/list` stays the same size however many primitives an
3
+ // app grows. The catalog, the lazy schemas and the address book live here; the DISPATCH stays in
4
+ // `server.ts`, on the same resolve → run → audit path a flat `tools/call` takes, so the three
5
+ // security outcomes, the policy inside `invoke` and the synchronous audit line are one code path
6
+ // reached through two doors — never a second implementation.
7
+ //
8
+ // A grouped tool is reachable ONLY through `manage_resource` for a meta caller, and a flat caller
9
+ // never sees the meta tools: what `tools/list` shows is what `tools/call` answers, per caller.
10
+
11
+ import { McpToolDuplicateError } from './errors';
12
+ import { assertListParams, listParamsSchema } from './list-params';
13
+ import { McpGroupConflictError, McpGroupUnknownError, McpSurfaceInvalidError } from './meta-errors';
14
+ import type { AnyMcpTool, McpCaller, ToolListEntry } from './registry';
15
+ import { visibleToCaller } from './registry';
16
+ import type { JsonSchema } from './wire';
17
+ import { NO_ARGS } from './wire';
18
+
19
+ export type McpSurface = 'flat' | 'meta';
20
+
21
+ /**
22
+ * Which surface a caller is served. A function decides per POPULATION — staff meta, customers flat
23
+ * — from the caller alone, never the arguments. A function that throws or answers anything but the
24
+ * literal `'meta'` serves `'flat'`: the two surfaces reach the same tools under the same gates, so
25
+ * the fallback changes the shape of the catalog, never what the caller may do.
26
+ */
27
+ export type McpSurfaceOption = McpSurface | ((caller: McpCaller) => McpSurface);
28
+
29
+ /** One resource of the meta catalog: a slice or feature, and the tools it answers, by name. */
30
+ export interface McpResourceGroup {
31
+ /** What the resource is, for an agent choosing where to look. */
32
+ readonly description: string;
33
+ /** Tool names — a projected action/query or a hand-written tool. One group per tool. */
34
+ readonly tools: readonly string[];
35
+ }
36
+
37
+ /** Resource name → its group. The key is what `manage_resource({ resource })` names. */
38
+ export type McpResourceGroups = Readonly<Record<string, McpResourceGroup>>;
39
+
40
+ export const LIST_RESOURCES = 'list_resources';
41
+ export const DESCRIBE_RESOURCE = 'describe_resource';
42
+ export const MANAGE_RESOURCE = 'manage_resource';
43
+ export const META_TOOL_NAMES: readonly string[] = [
44
+ LIST_RESOURCES,
45
+ DESCRIBE_RESOURCE,
46
+ MANAGE_RESOURCE,
47
+ ];
48
+
49
+ /** The three constant entries, as `tools/list` publishes them. */
50
+ export const META_TOOL_ENTRIES: readonly ToolListEntry[] = [
51
+ {
52
+ name: DESCRIBE_RESOURCE,
53
+ description:
54
+ "Full input schemas for the actions of one or more resources (batched). Call it before manage_resource when list_resources' one-line params are not enough. Read-only.",
55
+ inputSchema: {
56
+ type: 'object',
57
+ properties: { resources: { type: 'array', items: { type: 'string' } } },
58
+ required: ['resources'],
59
+ additionalProperties: false,
60
+ },
61
+ },
62
+ {
63
+ name: LIST_RESOURCES,
64
+ description:
65
+ 'The catalog: every resource this caller may use, each with its actions (query or action), a one-line parameter hint and whether a write waits for a human to confirm. Read-only.',
66
+ inputSchema: NO_ARGS,
67
+ },
68
+ {
69
+ name: MANAGE_RESOURCE,
70
+ description:
71
+ 'Run one action of one resource: { resource, action, params }. Same policies, scopes and audit as calling the action directly. Writes may change state or send mail — read the action first.',
72
+ inputSchema: {
73
+ type: 'object',
74
+ properties: {
75
+ resource: { type: 'string' },
76
+ action: { type: 'string' },
77
+ params: { type: 'object' },
78
+ },
79
+ required: ['resource', 'action'],
80
+ additionalProperties: false,
81
+ },
82
+ },
83
+ ];
84
+
85
+ export interface MetaAction {
86
+ readonly name: string;
87
+ readonly kind: 'query' | 'action';
88
+ readonly description: string;
89
+ /** One line an agent can compose a call from: `radicado: string, note?: string`. */
90
+ readonly params: string;
91
+ readonly confirms?: true;
92
+ readonly scope?: string;
93
+ }
94
+
95
+ export interface MetaResource {
96
+ readonly name: string;
97
+ readonly description: string;
98
+ readonly actions: readonly MetaAction[];
99
+ }
100
+
101
+ export interface DescribedAction extends Omit<MetaAction, 'params'> {
102
+ readonly inputSchema: JsonSchema;
103
+ }
104
+
105
+ export class MetaSurface {
106
+ readonly #option: McpSurfaceOption;
107
+ readonly #groups: ReadonlyMap<string, McpResourceGroup>;
108
+ /** Tool name → the resource that answers it. */
109
+ readonly #home: ReadonlyMap<string, string>;
110
+ readonly #tools: ReadonlyMap<string, AnyMcpTool>;
111
+
112
+ private constructor(
113
+ option: McpSurfaceOption,
114
+ groups: ReadonlyMap<string, McpResourceGroup>,
115
+ home: ReadonlyMap<string, string>,
116
+ tools: ReadonlyMap<string, AnyMcpTool>,
117
+ ) {
118
+ this.#option = option;
119
+ this.#groups = groups;
120
+ this.#home = home;
121
+ this.#tools = tools;
122
+ }
123
+
124
+ /**
125
+ * Validate at BOOT and build, or `undefined` for a flat-only server. Every refusal is one an
126
+ * author would otherwise meet as a tool silently missing from, or doubly present in, a catalog.
127
+ */
128
+ static build(input: {
129
+ readonly surface?: McpSurfaceOption | undefined;
130
+ readonly groups?: McpResourceGroups | undefined;
131
+ readonly tools: readonly AnyMcpTool[];
132
+ }): MetaSurface | undefined {
133
+ for (const tool of input.tools) {
134
+ if (tool.listParams !== undefined) {
135
+ assertListParams(tool.name, tool.listParams, tool.inputSchema);
136
+ }
137
+ }
138
+ const option = input.surface ?? 'flat';
139
+ const entries = Object.entries(input.groups ?? {});
140
+ if (option === 'flat') {
141
+ if (entries.length === 0) return undefined;
142
+ throw new McpSurfaceInvalidError({
143
+ cause: `groups: declares ${entries.length} resource(s), and surface is 'flat' for every caller, so no caller would ever see them`,
144
+ });
145
+ }
146
+ if (option !== 'meta' && typeof option !== 'function') {
147
+ throw new McpSurfaceInvalidError({ cause: `surface is ${JSON.stringify(option)}` });
148
+ }
149
+ if (entries.length === 0) {
150
+ throw new McpSurfaceInvalidError({
151
+ cause:
152
+ "surface can be 'meta', and groups: declares no resource, so list_resources would always answer empty",
153
+ });
154
+ }
155
+ const tools = new Map(input.tools.map((tool) => [tool.name, tool]));
156
+ for (const name of META_TOOL_NAMES) {
157
+ if (tools.has(name)) throw new McpToolDuplicateError({ name });
158
+ }
159
+ const home = new Map<string, string>();
160
+ for (const [group, declared] of entries) {
161
+ for (const name of declared.tools) {
162
+ if (!tools.has(name)) {
163
+ throw new McpGroupUnknownError({ group, name, projected: [...tools.keys()].sort() });
164
+ }
165
+ const claimed = home.get(name);
166
+ if (claimed !== undefined) {
167
+ throw new McpGroupConflictError({ name, groups: [claimed, group] });
168
+ }
169
+ home.set(name, group);
170
+ }
171
+ }
172
+ return new MetaSurface(option, new Map(entries), home, tools);
173
+ }
174
+
175
+ /** The surface this caller is served. Fail-closed to `'flat'` — see `McpSurfaceOption`. */
176
+ surfaceFor(caller: McpCaller): McpSurface {
177
+ const option = this.#option;
178
+ if (typeof option !== 'function') return option;
179
+ try {
180
+ return option(caller) === 'meta' ? 'meta' : 'flat';
181
+ } catch {
182
+ return 'flat';
183
+ }
184
+ }
185
+
186
+ /** True when a META caller must reach this tool through `manage_resource`, not by its name. */
187
+ isGrouped(name: string): boolean {
188
+ return this.#home.has(name);
189
+ }
190
+
191
+ /** `list_resources`: only what this caller may see; a resource with nothing visible is absent. */
192
+ listResources(caller: McpCaller): readonly MetaResource[] {
193
+ const out: MetaResource[] = [];
194
+ for (const [name, group] of sorted(this.#groups)) {
195
+ const actions = this.#visibleActions(group, caller).map(
196
+ (tool): MetaAction => ({ ...headOf(tool), params: oneLineParams(schemaOf(tool)) }),
197
+ );
198
+ if (actions.length > 0) out.push({ name, description: group.description, actions });
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * `describe_resource`: the full schemas, or the FIRST name this caller cannot see. Absent and
205
+ * hidden are one answer, exactly as they are for a tool.
206
+ */
207
+ describe(
208
+ names: readonly string[],
209
+ caller: McpCaller,
210
+ ):
211
+ | { readonly ok: true; readonly resources: readonly unknown[] }
212
+ | { readonly ok: false; readonly name: string } {
213
+ const resources: unknown[] = [];
214
+ for (const name of new Set(names)) {
215
+ // A `Map`, so a name like `__proto__` or `constructor` is just a missing key.
216
+ const group = this.#groups.get(name);
217
+ const actions = group === undefined ? [] : this.#visibleActions(group, caller);
218
+ if (group === undefined || actions.length === 0) return { ok: false, name };
219
+ resources.push({
220
+ name,
221
+ description: group.description,
222
+ actions: actions.map(
223
+ (tool): DescribedAction => ({ ...headOf(tool), inputSchema: schemaOf(tool) }),
224
+ ),
225
+ });
226
+ }
227
+ return { ok: true, resources };
228
+ }
229
+
230
+ /**
231
+ * The tool `{ resource, action }` addresses, or `undefined` when that pair is not a visible
232
+ * member — the caller then gets the not-found an absent tool gets. Visibility is checked HERE so
233
+ * a hidden tool is never reached; scope, arguments and policy stay with the shared resolver.
234
+ */
235
+ locate(resource: string, action: string, caller: McpCaller): AnyMcpTool | undefined {
236
+ if (this.#home.get(action) !== resource) return undefined;
237
+ const tool = this.#tools.get(action);
238
+ if (tool === undefined || !visibleToCaller(tool, caller)) return undefined;
239
+ return tool;
240
+ }
241
+
242
+ #visibleActions(group: McpResourceGroup, caller: McpCaller): readonly AnyMcpTool[] {
243
+ return [...group.tools]
244
+ .sort()
245
+ .map((name) => this.#tools.get(name))
246
+ .filter((tool): tool is AnyMcpTool => tool !== undefined && visibleToCaller(tool, caller));
247
+ }
248
+ }
249
+
250
+ /** The schema a meta call is validated against: the list whitelist when one is declared. */
251
+ export function schemaOf(tool: AnyMcpTool): JsonSchema {
252
+ return tool.listParams === undefined
253
+ ? tool.inputSchema
254
+ : listParamsSchema(tool.listParams, tool.inputSchema);
255
+ }
256
+
257
+ function headOf(tool: AnyMcpTool): Omit<MetaAction, 'params'> {
258
+ return {
259
+ name: tool.name,
260
+ kind: tool.destructive === false ? 'query' : 'action',
261
+ description: tool.description,
262
+ ...(tool.confirms === true ? { confirms: true } : {}),
263
+ ...(tool.scope === undefined ? {} : { scope: tool.scope }),
264
+ };
265
+ }
266
+
267
+ function sorted<V>(map: ReadonlyMap<string, V>): readonly [string, V][] {
268
+ return [...map.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
269
+ }
270
+
271
+ /** Past this, the hint says "see describe_resource" instead of pretending to be complete. */
272
+ const HINT_MAX = 240;
273
+
274
+ /**
275
+ * `name: type` per property, `?` when optional, an enum as its literals — the hint
276
+ * `list_resources` carries so a common call needs no `describe_resource` round-trip.
277
+ */
278
+ export function oneLineParams(schema: JsonSchema): string {
279
+ const required = new Set(schema.required ?? []);
280
+ const parts = Object.entries(schema.properties ?? {}).map(
281
+ ([key, child]) => `${key}${required.has(key) ? '' : '?'}: ${typeHint(child)}`,
282
+ );
283
+ const line = parts.join(', ');
284
+ if (line.length <= HINT_MAX) return line;
285
+ return `${line.slice(0, HINT_MAX - 1)}… (describe_resource has the rest)`;
286
+ }
287
+
288
+ function typeHint(schema: JsonSchema): string {
289
+ if (schema.enum !== undefined) return schema.enum.map((v) => JSON.stringify(v)).join('|');
290
+ if (schema.const !== undefined) return JSON.stringify(schema.const);
291
+ if (schema.anyOf !== undefined) return schema.anyOf.map(typeHint).join('|');
292
+ if (schema.type === 'array') {
293
+ if (schema.items === undefined) return 'array';
294
+ const item = typeHint(schema.items);
295
+ return item.includes('|') ? `(${item})[]` : `${item}[]`;
296
+ }
297
+ return schema.type ?? 'any';
298
+ }
@@ -11,6 +11,7 @@ import { isQuery, queryName, sourceFor } from '@ultimat3/query';
11
11
  import { asCallerContext } from './caller-context';
12
12
  import type { McpExposure, ProjectablePrimitive } from './from-action';
13
13
  import { toWireSchema } from './input-schema';
14
+ import type { McpListParams } from './list-params';
14
15
 
15
16
  /**
16
17
  * What `defineAppMcp`'s `actions:`/`queries:` accept.
@@ -90,6 +91,7 @@ function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined
90
91
  expose: isMcpExposed(declared),
91
92
  ...(declared.description === undefined ? {} : { description: declared.description }),
92
93
  ...(declared.visibleTo === undefined ? {} : { visibleTo: declared.visibleTo }),
94
+ ...(declared.listParams === undefined ? {} : { listParams: declared.listParams }),
93
95
  };
94
96
  }
95
97
 
@@ -98,4 +100,6 @@ interface DeclaredMcp {
98
100
  readonly expose: boolean;
99
101
  readonly description?: string;
100
102
  readonly visibleTo?: readonly string[];
103
+ /** `QueryMcp` only; an action has no list to compose. */
104
+ readonly listParams?: McpListParams;
101
105
  }
package/src/registry.ts CHANGED
@@ -20,6 +20,7 @@
20
20
 
21
21
  import type { Actor } from '@ultimat3/core';
22
22
  import { McpToolDuplicateError } from './errors';
23
+ import type { McpListParams } from './list-params';
23
24
  import type { ArgIssue } from './validate-args';
24
25
  import { validateArgs } from './validate-args';
25
26
  import type { JsonSchema } from './wire';
@@ -95,6 +96,17 @@ export interface McpTool<A extends ToolArgs = ToolArgs> {
95
96
  * cheap read chatter by omission.
96
97
  */
97
98
  readonly destructive?: boolean;
99
+ /**
100
+ * A list query's whitelist of `filters` / `sort` / `fields` / `cursor` + `limit`. Published by
101
+ * `describe_resource` and enforced by `manage_resource` alongside the tool's own input schema;
102
+ * the flat surface ignores it (the tool's `inputSchema` is its contract there).
103
+ */
104
+ readonly listParams?: McpListParams;
105
+ /**
106
+ * A catalog hint: this write answers "awaiting confirmation" and a human finishes it. Shown by
107
+ * `list_resources` so an agent plans for the hand-off; it changes nothing about the call.
108
+ */
109
+ readonly confirms?: boolean;
98
110
  // Method syntax (not a property) so a tool declared with narrower args stays assignable.
99
111
  handle(args: A, caller: McpCaller): Promise<McpToolResult>;
100
112
  }
package/src/server.ts CHANGED
@@ -3,16 +3,27 @@
3
3
  // and an already-resolved caller, and returns a response or `null` for a notification.
4
4
  // Both transports (http, stdio) and every test drive this one function.
5
5
 
6
- import { FRAMEWORK_CODE, singleLine, stringField } from '@ultimat3/core';
7
6
  import { formatIssues } from '@ultimat3/schema';
8
7
  import { auditResourceRead, auditToolCall, outcomeForCode, outcomeForResult } from './audit';
9
8
  import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
9
+ import { asFrameworkError, renderFrameworkError } from './framework-error';
10
+ import { metaCall } from './meta-call';
11
+ import { META_UNKNOWN_FIX } from './meta-errors';
12
+ import type { McpResourceGroups, McpSurfaceOption } from './meta-surface';
13
+ import { MANAGE_RESOURCE, META_TOOL_ENTRIES, META_TOOL_NAMES, MetaSurface } from './meta-surface';
10
14
  import { promptListEntry, promptsGet } from './prompts-get';
11
- import type { AnyMcpTool, McpCaller, McpToolResult, McpVerbClass, ToolListEntry } from './registry';
15
+ import type {
16
+ AnyMcpTool,
17
+ McpCaller,
18
+ McpToolResult,
19
+ McpVerbClass,
20
+ ToolListEntry,
21
+ ToolResolution,
22
+ } from './registry';
12
23
  import { ToolRegistry } from './registry';
13
24
  import type { McpPrompt, McpResource } from './resources';
14
25
  import { ResourceRegistry } from './resources';
15
- import type { JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
26
+ import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
16
27
  import {
17
28
  defaultServerInfo,
18
29
  errorResponse,
@@ -49,6 +60,10 @@ export interface CreateMcpServerInput {
49
60
  readonly resources?: readonly McpResource[];
50
61
  readonly prompts?: readonly McpPrompt[];
51
62
  readonly serverInfo?: ServerInfo;
63
+ /** `'flat'` (the default): one tool per primitive. See `McpSurfaceOption`. */
64
+ readonly surface?: McpSurfaceOption | undefined;
65
+ /** The meta catalog's resources. Required with a surface that can be `'meta'`. */
66
+ readonly groups?: McpResourceGroups | undefined;
52
67
  }
53
68
 
54
69
  /** The set of JSON-RPC methods this server answers. Kept in sync with `classify`. */
@@ -66,11 +81,17 @@ const METHODS = [
66
81
  export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
67
82
  const tools = new ToolRegistry().registerAll(input.tools ?? []);
68
83
  const resources = new ResourceRegistry().registerAll(input.resources ?? []);
84
+ const meta = MetaSurface.build({
85
+ surface: input.surface,
86
+ groups: input.groups,
87
+ tools: input.tools ?? [],
88
+ });
69
89
  return new McpServer(
70
90
  tools,
71
91
  resources,
72
92
  input.prompts ?? [],
73
93
  input.serverInfo ?? defaultServerInfo(),
94
+ meta,
74
95
  );
75
96
  }
76
97
 
@@ -79,17 +100,21 @@ export class McpServer {
79
100
  readonly resources: ResourceRegistry;
80
101
  private readonly prompts: readonly McpPrompt[];
81
102
  private readonly serverInfo: ServerInfo;
103
+ /** `undefined` for a flat-only server — every existing app, byte for byte. */
104
+ private readonly meta: MetaSurface | undefined;
82
105
 
83
106
  constructor(
84
107
  tools: ToolRegistry,
85
108
  resources: ResourceRegistry,
86
109
  prompts: readonly McpPrompt[],
87
110
  serverInfo: ServerInfo,
111
+ meta?: MetaSurface,
88
112
  ) {
89
113
  this.tools = tools;
90
114
  this.resources = resources;
91
115
  this.prompts = prompts;
92
116
  this.serverInfo = serverInfo;
117
+ this.meta = meta;
93
118
  }
94
119
 
95
120
  async handle(
@@ -160,7 +185,19 @@ export class McpServer {
160
185
 
161
186
  /** Role-filtered catalog. Exposed so a transport can answer a cheap capability probe. */
162
187
  list(caller: McpCaller): readonly ToolListEntry[] {
163
- return this.tools.list(caller);
188
+ const flat = this.tools.list(caller);
189
+ const meta = this.metaFor(caller);
190
+ if (meta === undefined) return flat;
191
+ // Constant: the three meta tools plus whatever the app left ungrouped (`docs`, `whoami`).
192
+ return [...flat.filter((tool) => !meta.isGrouped(tool.name)), ...META_TOOL_ENTRIES].sort(
193
+ (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
194
+ );
195
+ }
196
+
197
+ /** The meta surface when THIS caller is served it, else `undefined`. */
198
+ private metaFor(caller: McpCaller): MetaSurface | undefined {
199
+ const meta = this.meta;
200
+ return meta !== undefined && meta.surfaceFor(caller) === 'meta' ? meta : undefined;
164
201
  }
165
202
 
166
203
  /**
@@ -174,10 +211,18 @@ export class McpServer {
174
211
  */
175
212
  classify(body: unknown): McpVerbClass {
176
213
  if (!isJsonRpcRequest(body) || body.method !== 'tools/call') return 'read';
177
- const name = paramsOf(body)?.['name'];
214
+ const params = paramsOf(body);
215
+ const name = params?.['name'];
178
216
  // An unresolvable call is refused before it runs, so charging it the strict bucket
179
217
  // only costs a broken client — it never hands an unproven verb the cheap one.
180
218
  if (typeof name !== 'string') return 'write';
219
+ if (this.meta !== undefined && name === MANAGE_RESOURCE) {
220
+ // Billed as the tool it reaches, so the dispatcher is never a cheap door to a write.
221
+ const args = params?.['arguments'];
222
+ const action = isRecord(args) ? args['action'] : undefined;
223
+ return typeof action === 'string' ? this.tools.verbClass(action) : 'write';
224
+ }
225
+ if (this.meta !== undefined && META_TOOL_NAMES.includes(name)) return 'read';
181
226
  return this.tools.verbClass(name);
182
227
  }
183
228
 
@@ -190,9 +235,45 @@ export class McpServer {
190
235
  return errorResponse(id, INVALID_PARAMS, 'tools/call params.name must be a string');
191
236
  }
192
237
 
238
+ const meta = this.metaFor(caller);
239
+ if (meta !== undefined) {
240
+ const answered = await metaCall(
241
+ {
242
+ tools: this.tools,
243
+ dispatch: (at, tool, resolved, who) => this.dispatch(at, tool, resolved, who),
244
+ notFound: (at, tool, who, fix) => this.notFound(at, tool, who, fix),
245
+ },
246
+ meta,
247
+ { id, name, rawArgs: params['arguments'], caller },
248
+ );
249
+ if (answered !== undefined) return answered;
250
+ }
251
+ return this.dispatch(
252
+ id,
253
+ name,
254
+ this.tools.resolve(name, params['arguments'] ?? {}, caller),
255
+ caller,
256
+ );
257
+ }
258
+
259
+ /** OUTCOME 1, the one answer for absent and hidden alike. See `dispatch`. */
260
+ private notFound(id: JsonRpcId, name: string, caller: McpCaller, fix: string): JsonRpcResponse {
261
+ auditToolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
262
+ return errorResponse(id, METHOD_NOT_FOUND, `tool not found: ${name} — ${fix}`);
263
+ }
264
+
265
+ /**
266
+ * Resolve → run → audit, for a flat `tools/call` and for `manage_resource` alike: ONE path, so
267
+ * the two surfaces cannot answer the same call differently.
268
+ */
269
+ private async dispatch(
270
+ id: JsonRpcId,
271
+ name: string,
272
+ resolved: ToolResolution,
273
+ caller: McpCaller,
274
+ ): Promise<JsonRpcResponse> {
193
275
  // Three outcomes, deliberately different — and every one of them audited, including the
194
276
  // one that tells the caller nothing. See `audit.ts`.
195
- const resolved = this.tools.resolve(name, params['arguments'] ?? {}, caller);
196
277
  switch (resolved.kind) {
197
278
  // OUTCOME 1. Absent AND role-hidden collapse to the same answer, with no `data` at
198
279
  // all: any extra field would be the difference a prober is looking for. The message
@@ -200,8 +281,12 @@ export class McpServer {
200
281
  // nothing about whether the name exists: the same sentence for a stale name and for a
201
282
  // tool this role may never see, so the hint is not a second oracle.
202
283
  case 'not-found':
203
- auditToolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
204
- return errorResponse(id, METHOD_NOT_FOUND, `tool not found: ${name} — ${TOOL_UNKNOWN_FIX}`);
284
+ return this.notFound(
285
+ id,
286
+ name,
287
+ caller,
288
+ this.metaFor(caller) ? META_UNKNOWN_FIX : TOOL_UNKNOWN_FIX,
289
+ );
205
290
  // OUTCOME 2. The caller can already see this tool, so naming the missing scope leaks
206
291
  // nothing — and the fix travels with it, built by the error that owns the wording.
207
292
  case 'scope-denied': {
@@ -369,55 +454,5 @@ function protocolRefusal(message: string, error: McpProtocolError): JsonRpcRespo
369
454
  });
370
455
  }
371
456
 
372
- interface FrameworkError {
373
- readonly code: string;
374
- /** `''` for a foreign thrown object that carries no title. See `renderFrameworkError`. */
375
- readonly title: string;
376
- readonly cause: string;
377
- readonly fix: string;
378
- }
379
-
380
- /**
381
- * Read a thrown framework error, or `undefined` when it is not one (a genuine bug, which
382
- * becomes `-32603` with no internals leaked). Structural rather than `instanceof`: the
383
- * transport must stay independent of which package threw.
384
- */
385
- function asFrameworkError(error: unknown): FrameworkError | undefined {
386
- // `stringField` from `@ultimat3/core`, never `typeof e.code === 'string'`: the value is whatever
387
- // an app's handler, its driver or its SDK threw, so each read is a getter call or a `Proxy`
388
- // trap. This runs inside the catch block that owes the caller an answer, and a probe that
389
- // raises here leaves the JSON-RPC request with no response at all — not even the `-32603` the
390
- // header promises for a genuine bug.
391
- const code = stringField(error, 'code');
392
- // `FRAMEWORK_CODE`, never `startsWith('X_')`: the substituted `fix:` below interpolates this
393
- // value into a COMMAND an agent is told to run, and `X_$(id)` passes a prefix test. A code is
394
- // `X_SCREAMING_SNAKE` and nothing else, so a value that is not one is not a framework error —
395
- // it takes the `-32603` branch, which leaks nothing of the throw.
396
- if (code === undefined || !FRAMEWORK_CODE.test(code)) return undefined;
397
- return {
398
- code,
399
- title: stringField(error, 'title') ?? '',
400
- cause: stringField(error, 'cause') ?? 'unknown',
401
- // A substituted fix is still a fix an agent will act on, so it has to be runnable. `see docs`
402
- // named no docs and no command; `code` is already narrowed to an `X_` string by the guard
403
- // above, so the substitute is the one command that explains exactly this code.
404
- fix: stringField(error, 'fix') ?? `x errors explain ${code}`,
405
- };
406
- }
407
-
408
- /**
409
- * The agent-readable form, BYTE-IDENTICAL to `UltimateError.format()` — one denial reads the
410
- * same over MCP as it does in the terminal, so an agent that learned the shape from `x` does
411
- * not have to learn a second one here. Dropping the title would be a second rendering of the
412
- * same contract, and the two would drift.
413
- *
414
- * The bare-`code` head is the fallback for a foreign thrown object that carries `code`/`cause`
415
- * but no title; a real `UltimateError` always has one.
416
- */
417
- function renderFrameworkError(error: FrameworkError): string {
418
- const head =
419
- error.title === ''
420
- ? singleLine(error.code)
421
- : `${singleLine(error.code)}: ${singleLine(error.title)}`;
422
- return `${head}\n cause: ${singleLine(error.cause)}\n fix: ${singleLine(error.fix)}`;
423
- }
457
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
458
+ typeof value === 'object' && value !== null && !Array.isArray(value);