@ultimat3/mcp 27.10.4 → 27.12.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
@@ -34,6 +34,7 @@ import. The CLI wires it.
34
34
  | `readonly-sql.ts` · `readonly-sql-calls.ts` | layer 3 of `db.query` — the single-read parse — and `db.migrate`'s branch check · the banned call families |
35
35
  | `query-limits.ts` | layer 4 of `db.query` — the row, byte and timeout ceilings, and what truncation reports |
36
36
  | `meta-surface.ts` | `surface: 'meta'` catalog: `list_resources` / `describe_resource`, `groups:` boot checks; dispatch stays in `server.ts` |
37
+ | `meta-split.ts` · `meta-split-call.ts` · `meta-host.ts` | `surface: 'meta-split'`: the three dispatchers, the effect boot check · their dispatch (wrong tool, preview, `confirmed_by_user`) · the server half both meta doors call |
37
38
  | `list-params.ts` | `listParams` whitelist → the schema `manage_resource` enforces (flat keys: `status_eq`) |
38
39
  | `meta-errors.ts` · `framework-error.ts` | meta boot refusals · a thrown value read and rendered (split from `server.ts`) |
39
40
  | `server-voice.ts` · `surface-budget.ts` | `instructions`, `errorAudience`, the invalid-args RESULT · `tools/list`/`list_resources` size, measured and budgeted |
@@ -45,6 +46,10 @@ import. The CLI wires it.
45
46
  audited under the inner tool. `tools/list` shows what `tools/call` answers PER CALLER: a meta caller
46
47
  cannot call a grouped tool by name, a flat caller never sees the meta tools, and every not-found a
47
48
  meta caller gets carries `META_UNKNOWN_FIX`. Default `'flat'` builds no `MetaSurface`.
49
+ **`'meta-split'` is three more doors on the same path** (`As of 2026-10-18`): the effect picks the
50
+ door and is never defaulted (`X_MCP_EFFECT_UNDECLARED` at boot); a wrong door runs nothing
51
+ (`X_MCP_WRONG_TOOL`, after visibility, before scope); `delete_resource` previews unless `live:
52
+ true` and commits only on `confirmed_by_user: true`, both after the policy's actor half.
48
53
 
49
54
  - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a `defineAppMcp` file
50
55
  imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on
@@ -202,19 +207,8 @@ import. The CLI wires it.
202
207
  second claim, exactly as `ToolRegistry.register` throws on a second tool name — one package
203
208
  cannot answer "this name is taken" two ways. `Map.set` made the answer whichever provider was
204
209
  wired last, and a `ultimate://` URI is quoted in AGENTS.md files.
205
- - **`\'` inside a string literal is refused** (`readonly-sql.ts`). `E'\''` is one quote to
206
- Postgres and `standard_conforming_strings` decides the plain spelling, so the two readings
207
- disagree about where the string ends — which is exactly where a `;` hides.
208
- `select E'\'' ; drop table posts --'` was accepted as one read-only statement and handed back
209
- verbatim to run. Only inside a single-quoted run: a comment, a `$tag$` body and a quoted
210
- identifier are unambiguous and still read fine.
211
- - **An unterminated run is refused, never swallowed** (`readonly-sql.ts`). The stripper blanks what
212
- it believes is inside a literal, so an opening delimiter that never closes hid the whole tail:
213
- `select '; delete from members` counted one statement with no mutating keyword and was handed
214
- back to run. All four forms now throw `X_MCP_QUERY_REJECTED` — `'`, `"`, `$tag$`, `/* */`. Not
215
- exploitable through Postgres (a syntax error either way); the point is that this layer must not
216
- be the thing that waves it through. `@ultimat3/admin`'s `/_x` panel failed CLOSED here where this
217
- failed open, which is how it was found — a second, differently-behaved copy of one rule.
210
+ - **`\'` inside a string literal is refused** (`readonly-sql.ts`) — why: `docs/history/mcp.md`.
211
+ - **An unterminated run is refused, never swallowed** (`readonly-sql.ts`, all four delimiters, `X_MCP_QUERY_REJECTED`) — why: `docs/history/mcp.md`.
218
212
  - **`into` is a write keyword.** `SELECT ... INTO <table>` is `CREATE TABLE AS` in another spelling:
219
213
  a DDL write with a read LEADER, so nothing else in the scan sees it. Layer 2's `BEGIN READ ONLY`
220
214
  refuses it on the wired path — the point is that this layer must not be the thing that waves it
package/README.md CHANGED
@@ -250,6 +250,58 @@ else, an optional input key the whitelist left out included (an `isError` result
250
250
  `X_INPUT_INVALID`), before the query runs; a
251
251
  whitelisted key the input does not declare is `X_MCP_LIST_PARAMS_INVALID` at boot.
252
252
 
253
+ ### Split by effect: `surface: 'meta-split'`
254
+
255
+ `As of 2026-10-18`. `manage_resource` runs reads and writes alike, so it cannot carry an honest
256
+ `readOnlyHint`, and a directory that refuses a read+write dispatcher refuses it. `'meta-split'`
257
+ serves the same catalog pair plus three dispatchers, each running only its own effect class:
258
+
259
+ | Tool | Runs | Input | Hints |
260
+ |---|---|---|---|
261
+ | `read_resource` | every query (`destructive: false`) | `{ resource, action, params? }` | `readOnlyHint: true` |
262
+ | `write_resource` | actions declaring `mcp: { effect: 'write' }` | `{ resource, action, params? }` | `readOnlyHint: false`, `destructiveHint: true` |
263
+ | `delete_resource` | actions declaring `mcp: { effect: 'delete' }` — archive, cancel, delete, an external send, a spend | `{ resource, action, params?, live?, confirmed_by_user }` | `readOnlyHint: false`, `destructiveHint: true` |
264
+
265
+ ```ts
266
+ import { action } from '@ultimat3/action';
267
+ import { defineAppMcp } from '@ultimat3/mcp';
268
+ import { can } from '@ultimat3/policy';
269
+ import { t } from '@ultimat3/schema';
270
+
271
+ // An action declares its effect in the one mcp block; a query declares none.
272
+ export const archivePost = action({
273
+ input: t.object({ postId: t.string }),
274
+ output: t.object({ ok: t.boolean }),
275
+ policy: can('post:archive'),
276
+ mcp: { expose: true, description: 'Archive a post', effect: 'delete' },
277
+ handle: () => ({ ok: true }),
278
+ });
279
+
280
+ defineAppMcp({
281
+ include: 'exposed',
282
+ surface: 'meta-split',
283
+ groups: { posts: { description: 'Blog posts', tools: ['archivePost'] } },
284
+ });
285
+ ```
286
+
287
+ - **No default.** A grouped write without `effect` is `X_MCP_EFFECT_UNDECLARED` at boot, naming
288
+ every one; a read declaring one is `X_MCP_EFFECT_CONFLICT`. A `surface` function is held to this
289
+ once any grouped tool declares an effect; until then its `'meta-split'` answer serves `'meta'`, so
290
+ an app written before the split boots as it did.
291
+ - **Wrong dispatcher → `X_MCP_WRONG_TOOL`** (an `isError` result, audited `invalid-args`): the cause
292
+ says `wrong_tool_for_action` and names the tool that runs it (`meta.use_tool`). Nothing ran.
293
+ - **`delete_resource`**: `live` absent or `false` answers the framework's preview, which runs nothing —
294
+ `{ preview: true, ran: false, resource, action, params, commit_with: { live: true,
295
+ confirmed_by_user: true } }`, audited `previewed`. An action whose own input declares `live`
296
+ authors its preview instead: it is handed `live: false` and runs. `live: true` runs only with
297
+ `confirmed_by_user: true` (the user approved the change, directly or by authorizing the task);
298
+ anything else is `X_MCP_USER_CONFIRMATION_REQUIRED`, audited `unconfirmed`. Both flags are read
299
+ top-level or from `params` (nested wins) and never handed to an action that does not declare them.
300
+ `confirmed_by_user` is required by the schema, so a client sees it at connect time.
301
+ - Order: visibility (absent answer) → effect (`X_MCP_WRONG_TOOL`) → scope → arguments → the policy's
302
+ actor half → preview / confirmation → `dispatch`, the flat path — same audit line, same bucket.
303
+ - Ungrouped tools (`docs`, `whoami`) stay flat beside the five, exactly as on `'meta'`.
304
+
253
305
  ### What a client is told beyond the schema
254
306
 
255
307
  `As of 22.10`, MCP 2025-06-18. The projection derives each from the declaration; the primitive's
@@ -265,7 +317,7 @@ whitelisted key the input does not declare is `X_MCP_LIST_PARAMS_INVALID` at boo
265
317
 
266
318
  A tool's text is **compact JSON** (`jsonResult`; the 2-space form until 22.10). The meta tools
267
319
  carry their own hints: `list_resources` and `describe_resource` read-only, `manage_resource` a
268
- write.
320
+ write; on `'meta-split'`, `read_resource` read-only and the other two destructive.
269
321
 
270
322
  **Budget test.** `measureMcpSurface(server, caller)` answers `{ toolsList, listResources,
271
323
  instructions }` in characters, as serialized on the wire; `assertMcpSurfaceBudget(server, caller,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "27.10.4",
3
+ "version": "27.12.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",
@@ -33,14 +33,14 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/action": "27.10.4",
37
- "@ultimat3/core": "27.10.4",
38
- "@ultimat3/db": "27.10.4",
39
- "@ultimat3/entity": "27.10.4",
40
- "@ultimat3/http": "27.10.4",
41
- "@ultimat3/jobs": "27.10.4",
42
- "@ultimat3/policy": "27.10.4",
43
- "@ultimat3/query": "27.10.4",
44
- "@ultimat3/schema": "27.10.4"
36
+ "@ultimat3/action": "27.12.0",
37
+ "@ultimat3/core": "27.12.0",
38
+ "@ultimat3/db": "27.12.0",
39
+ "@ultimat3/entity": "27.12.0",
40
+ "@ultimat3/http": "27.12.0",
41
+ "@ultimat3/jobs": "27.12.0",
42
+ "@ultimat3/policy": "27.12.0",
43
+ "@ultimat3/query": "27.12.0",
44
+ "@ultimat3/schema": "27.12.0"
45
45
  }
46
46
  }
package/src/app-tool.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // apply to routes and miss tools.
12
12
 
13
13
  import { guardAction, guardActionBeforeInput, InputInvalidError } from '@ultimat3/action';
14
- import type { Ctx } from '@ultimat3/core';
14
+ import type { Ctx, McpEffect } from '@ultimat3/core';
15
15
  import { actorOf, useContext } from '@ultimat3/core';
16
16
  import type { KnownPermission } from '@ultimat3/policy';
17
17
  import { can } from '@ultimat3/policy';
@@ -42,6 +42,11 @@ export interface AppToolDefinition<TInput extends StandardSchemaV1 = StandardSch
42
42
  * rate-limit bucket, so forgetting the flag costs throughput rather than safety.
43
43
  */
44
44
  readonly destructive?: boolean;
45
+ /**
46
+ * `'write'` | `'delete'` — which dispatcher runs this tool on a `'meta-split'` surface, where a
47
+ * grouped write without one is `X_MCP_EFFECT_UNDECLARED`. Ignored by every other surface.
48
+ */
49
+ readonly effect?: McpEffect;
45
50
  /** Display name for a client's UI. Absent: none published. */
46
51
  readonly title?: string;
47
52
  /**
@@ -60,6 +65,7 @@ export interface AnyAppToolDefinition {
60
65
  readonly policy: KnownPermission;
61
66
  readonly visibleTo?: readonly McpRole[];
62
67
  readonly destructive?: boolean;
68
+ readonly effect?: McpEffect;
63
69
  readonly title?: string;
64
70
  readonly annotations?: McpToolAnnotations;
65
71
  handle(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
@@ -98,6 +104,7 @@ export function appToolPrimitive(name: string, def: AnyAppToolDefinition): Proje
98
104
  description: def.description,
99
105
  ...(visibleTo !== undefined ? { visibleTo } : {}),
100
106
  ...(def.title !== undefined ? { title: def.title } : {}),
107
+ ...(def.effect !== undefined ? { effect: def.effect } : {}),
101
108
  ...(def.annotations !== undefined ? { annotations: def.annotations } : {}),
102
109
  },
103
110
  inputJsonSchema: toWireSchema(def.input),
package/src/audit.ts CHANGED
@@ -28,6 +28,11 @@ export type McpOutcome =
28
28
  * one there was expired. Expected — `mcpConfirmations` answers it by design — so `info`.
29
29
  */
30
30
  | 'unconfirmed'
31
+ /**
32
+ * `delete_resource` without `live: true`: resolved and admitted, and answered with a preview of
33
+ * the call instead of running it. Its own outcome so "previewed" never reads as "committed".
34
+ */
35
+ | 'previewed'
31
36
  /**
32
37
  * The handler threw something that is not an authz denial: a non-authz framework refusal
33
38
  * (`db.migrate` aimed at prod), or a bug. Both want a human — a projected action rejecting
@@ -52,13 +57,14 @@ const DENIAL_CODES: ReadonlySet<string> = new Set([
52
57
  * Codes that mean the CALLER got the arguments wrong, in a way the published JSON Schema could
53
58
  * not have refused. `X_ADMIN_INVALID` is the case: `@ultimat3/admin` publishes a `type` per field
54
59
  * and nothing else, so an entity's own rules are the first thing a value meets, and a mistyped
55
- * `admin.create` is a client misreading a schema — `invalid-args`, at `info`.
60
+ * `admin.create` is a client misreading a schema — `invalid-args`, at `info`. `X_MCP_WRONG_TOOL`
61
+ * is the same kind of slip one level up: the right action on the wrong `meta-split` dispatcher.
56
62
  *
57
63
  * `X_INPUT_INVALID` deliberately stays OUT: a projected action publishes its whole schema, so
58
64
  * input this server already validated failing inside the action means the two have drifted, and
59
65
  * that wants a human.
60
66
  */
61
- const ARGUMENT_CODES: ReadonlySet<string> = new Set(['X_ADMIN_INVALID']);
67
+ const ARGUMENT_CODES: ReadonlySet<string> = new Set(['X_ADMIN_INVALID', 'X_MCP_WRONG_TOOL']);
62
68
 
63
69
  /**
64
70
  * `mcpConfirmations`' answers for a call no live approval covers. A rejection is NOT here: a human
@@ -67,6 +73,7 @@ const ARGUMENT_CODES: ReadonlySet<string> = new Set(['X_ADMIN_INVALID']);
67
73
  const UNCONFIRMED_CODES: ReadonlySet<string> = new Set([
68
74
  'X_MCP_CONFIRMATION_PENDING',
69
75
  'X_MCP_CONFIRMATION_EXPIRED',
76
+ 'X_MCP_USER_CONFIRMATION_REQUIRED',
70
77
  ]);
71
78
 
72
79
  /** Classify a code a tool threw. Denials are outcome 3; everything else wants a human. */
@@ -125,6 +132,7 @@ const LEVEL: ReadonlyMap<McpOutcome, 'info' | 'warn' | 'error'> = new Map([
125
132
  ['policy-denied', 'warn'],
126
133
  ['invalid-args', 'info'],
127
134
  ['unconfirmed', 'info'],
135
+ ['previewed', 'info'],
128
136
  ['failed', 'error'],
129
137
  ]);
130
138
  // An outcome is a closed union, so the `Map` is total; `?? 'error'` is the type's witness, not a
package/src/errors.ts CHANGED
@@ -36,6 +36,10 @@ export const MCP_ERROR_CODES = [
36
36
  'X_MCP_CONFIRMATION_TOOL_UNKNOWN',
37
37
  'X_MCP_CONFIRMATION_CONTESTED',
38
38
  'X_MCP_CONFIRMATION_ARGUMENTS_MISMATCH',
39
+ 'X_MCP_EFFECT_UNDECLARED',
40
+ 'X_MCP_EFFECT_CONFLICT',
41
+ 'X_MCP_WRONG_TOOL',
42
+ 'X_MCP_USER_CONFIRMATION_REQUIRED',
39
43
  ] as const;
40
44
 
41
45
  export type McpErrorCode = (typeof MCP_ERROR_CODES)[number];
@@ -75,6 +79,11 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
75
79
  X_MCP_CONFIRMATION_CONTESTED: 'identical concurrent MCP calls kept racing one confirmation',
76
80
  X_MCP_CONFIRMATION_ARGUMENTS_MISMATCH:
77
81
  'an MCP confirmation approval did not carry the arguments the agent sent',
82
+ X_MCP_EFFECT_UNDECLARED: 'an MCP tool on a meta-split surface declares no effect',
83
+ X_MCP_EFFECT_CONFLICT: 'a read-only MCP tool declares an effect',
84
+ X_MCP_WRONG_TOOL: 'a meta-split action was called through the wrong dispatcher',
85
+ X_MCP_USER_CONFIRMATION_REQUIRED:
86
+ 'a delete_resource commit did not carry confirmed_by_user: true',
78
87
  };
79
88
 
80
89
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -98,6 +98,7 @@ export function toolFrom(listed: ListedPrimitive): AnyMcpTool {
98
98
  const mutates = primitive.mutates ?? true;
99
99
  const visibleTo = primitive.mcp?.visibleTo;
100
100
  const listParams = primitive.mcp?.listParams;
101
+ const effect = primitive.mcp?.effect;
101
102
  const title = primitive.mcp?.title;
102
103
  const outputSchema = primitive.outputJsonSchema;
103
104
  const wrap = primitive.outputWrap;
@@ -112,6 +113,7 @@ export function toolFrom(listed: ListedPrimitive): AnyMcpTool {
112
113
  destructive: mutates,
113
114
  ...(visibleTo !== undefined ? { visibleTo } : {}),
114
115
  ...(listParams !== undefined ? { listParams } : {}),
116
+ ...(effect !== undefined ? { effect } : {}),
115
117
  ...(primitive.admit === undefined
116
118
  ? {}
117
119
  : { admit: (caller: McpCaller) => primitive.admit?.(caller.actor) }),
package/src/index.ts CHANGED
@@ -131,12 +131,25 @@ export { MCP_IDEMPOTENCY_KEY_ARG } from './idempotency-arg';
131
131
  export type { ListFilterOp, McpListParams } from './list-params';
132
132
  export { DEFAULT_LIST_MAX_LIMIT, listParamsSchema } from './list-params';
133
133
  export {
134
+ McpEffectConflictError,
135
+ McpEffectUndeclaredError,
134
136
  McpGroupConflictError,
135
137
  McpGroupUnknownError,
136
138
  McpListParamsInvalidError,
137
139
  McpSurfaceInvalidError,
140
+ McpUserConfirmationRequiredError,
141
+ McpWrongToolError,
138
142
  META_UNKNOWN_FIX,
139
143
  } from './meta-errors';
144
+ export type { McpEffectClass } from './meta-split';
145
+ export {
146
+ DELETE_RESOURCE,
147
+ effectOf,
148
+ READ_RESOURCE,
149
+ SPLIT_DISPATCHERS,
150
+ toolForEffect,
151
+ WRITE_RESOURCE,
152
+ } from './meta-split';
140
153
  export type {
141
154
  DescribedAction,
142
155
  McpResourceGroup,
package/src/meta-call.ts CHANGED
@@ -1,35 +1,20 @@
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.
1
+ // A META caller's `tools/call`: the meta tools, and the refusal of a grouped tool called by its
2
+ // flat name; the split surface's dispatchers continue in `meta-split-call.ts`. Split from
3
+ // `server.ts` for the size ceiling only — it holds no gate of its own: every call it runs goes back
4
+ // through the server's `resolve` → `dispatch`, the flat path.
4
5
 
5
- import type { McpAuditor } from './audit-hook';
6
6
  import { META_UNKNOWN_FIX } from './meta-errors';
7
- import type { MetaSurface } from './meta-surface';
8
- import {
9
- DESCRIBE_RESOURCE,
10
- LIST_RESOURCES,
11
- META_TOOL_ENTRIES,
12
- renderCatalog,
13
- schemaOf,
14
- } from './meta-surface';
15
- import type { McpCaller, ToolRegistry, ToolResolution } from './registry';
7
+ import type { MetaHost } from './meta-host';
8
+ import { resolveAddressed } from './meta-host';
9
+ import { splitCall } from './meta-split-call';
10
+ import { DESCRIBE_RESOURCE, LIST_RESOURCES, MetaSurface, renderCatalog } from './meta-surface';
11
+ import type { McpCaller } from './registry';
16
12
  import { jsonResult, textResult } from './registry';
17
13
  import { validateArgs } from './validate-args';
18
14
  import type { JsonRpcId, JsonRpcResponse } from './wire';
19
15
  import { errorResponse, METHOD_NOT_FOUND, resultResponse } from './wire';
20
16
 
21
- /** The server's own two steps, handed in so this file cannot grow a second path. */
22
- export interface MetaHost {
23
- readonly tools: ToolRegistry;
24
- readonly audit: McpAuditor;
25
- dispatch(
26
- id: JsonRpcId,
27
- name: string,
28
- resolved: ToolResolution,
29
- caller: McpCaller,
30
- ): Promise<JsonRpcResponse>;
31
- notFound(id: JsonRpcId, name: string, caller: McpCaller, fix: string): JsonRpcResponse;
32
- }
17
+ export type { MetaHost } from './meta-host';
33
18
 
34
19
  export interface MetaRequest {
35
20
  readonly id: JsonRpcId;
@@ -45,7 +30,8 @@ export async function metaCall(
45
30
  request: MetaRequest,
46
31
  ): Promise<JsonRpcResponse | undefined> {
47
32
  const { id, name, caller } = request;
48
- const entry = META_TOOL_ENTRIES.find((tool) => tool.name === name);
33
+ const surface = meta.surfaceFor(caller) === 'meta-split' ? 'meta-split' : 'meta';
34
+ const entry = MetaSurface.entriesFor(surface).find((tool) => tool.name === name);
49
35
  if (entry === undefined) {
50
36
  return meta.isGrouped(name) ? host.notFound(id, name, caller, META_UNKNOWN_FIX) : undefined;
51
37
  }
@@ -55,7 +41,7 @@ export async function metaCall(
55
41
  }
56
42
  if (name === LIST_RESOURCES) {
57
43
  host.audit.toolCall({ tool: name, outcome: 'ok', caller });
58
- return resultResponse(id, textResult(renderCatalog(meta.listResources(caller))));
44
+ return resultResponse(id, textResult(renderCatalog(meta.listResources(caller), surface)));
59
45
  }
60
46
  if (name === DESCRIBE_RESOURCE) {
61
47
  const described = meta.describe(args.value['resources'] as readonly string[], caller);
@@ -71,22 +57,19 @@ export async function metaCall(
71
57
  host.audit.toolCall({ tool: name, outcome: 'ok', caller });
72
58
  return resultResponse(id, jsonResult({ resources: described.resources }));
73
59
  }
60
+ if (surface === 'meta-split')
61
+ return splitCall(host, meta, { id, name, caller, args: args.value });
74
62
  // `manage_resource`: address → the SAME resolve and dispatch a flat call takes, audited under
75
63
  // the tool it reached. Visibility first (a hidden pair is an absent pair), then the shared
76
- // resolver's scope → args. A list whitelist's issues REPLACE the tool schema's: both are pure
77
- // checks with nothing run between them, so which is evaluated first is invisible — the answer
78
- // names the whitelist, which is the contract `describe_resource` published.
64
+ // resolver's scope → args (`resolveAddressed`).
79
65
  const resource = args.value['resource'] as string;
80
66
  const action = args.value['action'] as string;
81
67
  const tool = meta.locate(resource, action, caller);
82
68
  if (tool === undefined) return host.notFound(id, action, caller, META_UNKNOWN_FIX);
83
- const input = args.value['params'] ?? {};
84
- let resolved = host.tools.resolve(action, input, caller);
85
- if (resolved.kind !== 'scope-denied' && resolved.kind !== 'not-found') {
86
- const listed = validateArgs(schemaOf(tool), input);
87
- if (!listed.ok) {
88
- resolved = { kind: 'invalid-args', name: action, issues: listed.issues, tool };
89
- }
90
- }
91
- return host.dispatch(id, action, resolved, caller);
69
+ return host.dispatch(
70
+ id,
71
+ action,
72
+ resolveAddressed(host, tool, args.value['params'] ?? {}, caller),
73
+ caller,
74
+ );
92
75
  }
@@ -66,3 +66,69 @@ export class McpSurfaceInvalidError extends UltimateError {
66
66
  });
67
67
  }
68
68
  }
69
+
70
+ /** A grouped write on a `'meta-split'` surface that names no effect: no dispatcher can run it. */
71
+ export class McpEffectUndeclaredError extends UltimateError {
72
+ readonly names: readonly string[];
73
+
74
+ constructor(input: { names: readonly string[] }) {
75
+ const names = input.names.join(', ');
76
+ super({
77
+ code: 'X_MCP_EFFECT_UNDECLARED',
78
+ cause: `surface can be 'meta-split', and ${input.names.length} grouped write(s) declare no effect (${names}), so neither write_resource nor delete_resource can be picked for them`,
79
+ fix: `mcp: { expose: true, effect: 'write' } # on ${names} — 'delete' for an archive, a cancel, a send or a spend; a hand-written tool takes tools: { <name>: { effect } }`,
80
+ });
81
+ this.names = input.names;
82
+ }
83
+ }
84
+
85
+ /** A grouped READ that declares an effect: a read is `read_resource`'s, whatever it says. */
86
+ export class McpEffectConflictError extends UltimateError {
87
+ constructor(input: { name: string; effect: string }) {
88
+ super({
89
+ code: 'X_MCP_EFFECT_CONFLICT',
90
+ cause: `"${input.name}" is a read (a query, or destructive: false) and declares effect: '${input.effect}'; on a meta-split surface a read is always run by read_resource`,
91
+ fix: `mcp: { expose: true } # on "${input.name}", without effect — or, if it changes state, make it an action (or a tool with destructive: true) and keep effect: '${input.effect}'`,
92
+ });
93
+ }
94
+ }
95
+
96
+ /** The right action, the wrong dispatcher. Names the dispatcher that runs it; nothing ran. */
97
+ export class McpWrongToolError extends UltimateError {
98
+ readonly useTool: string;
99
+
100
+ constructor(input: { resource: string; action: string; called: string; useTool: string }) {
101
+ const call = `${input.useTool}({ resource: "${input.resource}", action: "${input.action}", params })`;
102
+ super({
103
+ code: 'X_MCP_WRONG_TOOL',
104
+ cause: `${input.resource}.${input.action} is not run by ${input.called}; it is run by ${input.useTool} (wrong_tool_for_action). Nothing ran`,
105
+ fix: `${call} # the same params, on ${input.useTool}`,
106
+ callerFix: `call ${call} with the same params`,
107
+ meta: {
108
+ resource: input.resource,
109
+ action: input.action,
110
+ called: input.called,
111
+ use_tool: input.useTool,
112
+ },
113
+ });
114
+ this.useTool = input.useTool;
115
+ }
116
+ }
117
+
118
+ /** A `delete_resource` commit (`live: true`) without the user's approval. Nothing ran. */
119
+ export class McpUserConfirmationRequiredError extends UltimateError {
120
+ constructor(input: { resource: string; action: string }) {
121
+ const call = `delete_resource({ resource: "${input.resource}", action: "${input.action}", params, live: true, confirmed_by_user: true })`;
122
+ super({
123
+ code: 'X_MCP_USER_CONFIRMATION_REQUIRED',
124
+ cause: `${input.resource}.${input.action} commits only with confirmed_by_user: true; nothing ran`,
125
+ fix: `${call} # once the user approved this change, directly or by authorizing the task that includes it; live: false previews it without running`,
126
+ callerFix: `ask the user to approve ${input.resource}.${input.action}, then call ${call} with the same params`,
127
+ meta: {
128
+ resource: input.resource,
129
+ action: input.action,
130
+ retry_with: { confirmed_by_user: true },
131
+ },
132
+ });
133
+ }
134
+ }
@@ -0,0 +1,43 @@
1
+ // The server's half of a meta dispatch, handed to `meta-call.ts` and `meta-split-call.ts` so neither
2
+ // can grow a second path: its `dispatch` IS the flat `tools/call`'s resolve → run → audit.
3
+
4
+ import type { McpAuditor } from './audit-hook';
5
+ import { schemaOf } from './meta-surface';
6
+ import type { AnyMcpTool, McpCaller, ToolRegistry, ToolResolution } from './registry';
7
+ import { validateArgs } from './validate-args';
8
+ import type { JsonRpcId, JsonRpcResponse } from './wire';
9
+
10
+ /** The server's own two steps, handed in so this file cannot grow a second path. */
11
+ export interface MetaHost {
12
+ readonly tools: ToolRegistry;
13
+ readonly audit: McpAuditor;
14
+ dispatch(
15
+ id: JsonRpcId,
16
+ name: string,
17
+ resolved: ToolResolution,
18
+ caller: McpCaller,
19
+ ): Promise<JsonRpcResponse>;
20
+ notFound(id: JsonRpcId, name: string, caller: McpCaller, fix: string): JsonRpcResponse;
21
+ /** A coded refusal rendered and audited exactly as a thrown one — `tool-thrown.ts`. */
22
+ refuse(id: JsonRpcId, name: string, error: unknown, caller: McpCaller): JsonRpcResponse;
23
+ }
24
+
25
+ /**
26
+ * The shared resolver's scope → args for a tool a dispatcher located, with a list whitelist's
27
+ * issues REPLACING the tool schema's: both are pure checks with nothing run between them, so which
28
+ * is evaluated first is invisible — the answer names the whitelist, which is the contract
29
+ * `describe_resource` published.
30
+ */
31
+ export function resolveAddressed(
32
+ host: MetaHost,
33
+ tool: AnyMcpTool,
34
+ input: unknown,
35
+ caller: McpCaller,
36
+ ): ToolResolution {
37
+ const resolved = host.tools.resolve(tool.name, input, caller);
38
+ if (resolved.kind === 'scope-denied' || resolved.kind === 'not-found') return resolved;
39
+ const listed = validateArgs(schemaOf(tool), input);
40
+ return listed.ok
41
+ ? resolved
42
+ : { kind: 'invalid-args', name: tool.name, issues: listed.issues, tool };
43
+ }
@@ -0,0 +1,129 @@
1
+ // `read_resource` / `write_resource` / `delete_resource` on a `'meta-split'` surface: locate (the
2
+ // visibility gate) → the tool its effect names, else `X_MCP_WRONG_TOOL` → the shared resolver →
3
+ // for `delete_resource`, preview unless `live: true`, and commit only with `confirmed_by_user:
4
+ // true` → the server's `dispatch`. Every call that runs goes through the flat path's one dispatch.
5
+
6
+ import {
7
+ McpUserConfirmationRequiredError,
8
+ McpWrongToolError,
9
+ META_UNKNOWN_FIX,
10
+ } from './meta-errors';
11
+ import type { MetaHost } from './meta-host';
12
+ import { resolveAddressed } from './meta-host';
13
+ import { DELETE_RESOURCE, effectOf, toolForEffect } from './meta-split';
14
+ import type { MetaSurface } from './meta-surface';
15
+ import { schemaOf } from './meta-surface';
16
+ import type { AnyMcpTool, McpCaller, ToolArgs } from './registry';
17
+ import { jsonResult } from './registry';
18
+ import { admitted } from './tool-thrown';
19
+ import type { JsonRpcId, JsonRpcResponse } from './wire';
20
+ import { resultResponse } from './wire';
21
+
22
+ /** The tool-level arguments of `delete_resource`, accepted top-level or (where models put them) in `params`. */
23
+ const LIVE = 'live';
24
+ const CONFIRMED = 'confirmed_by_user';
25
+
26
+ export interface SplitRequest {
27
+ readonly id: JsonRpcId;
28
+ /** The dispatcher called: `read_resource`, `write_resource` or `delete_resource`. */
29
+ readonly name: string;
30
+ readonly caller: McpCaller;
31
+ /** The envelope, already validated against the dispatcher's own schema. */
32
+ readonly args: ToolArgs;
33
+ }
34
+
35
+ export async function splitCall(
36
+ host: MetaHost,
37
+ meta: MetaSurface,
38
+ request: SplitRequest,
39
+ ): Promise<JsonRpcResponse> {
40
+ const { id, name, caller, args } = request;
41
+ const resource = args['resource'] as string;
42
+ const action = args['action'] as string;
43
+ const tool = meta.locate(resource, action, caller);
44
+ if (tool === undefined) return host.notFound(id, action, caller, META_UNKNOWN_FIX);
45
+ // `assertEffects` refused an undeclared write at boot, so `effectOf` answers for a served tool.
46
+ const effect = effectOf(tool) ?? 'delete';
47
+ const useTool = toolForEffect(effect);
48
+ if (useTool !== name) {
49
+ // Visibility already passed, and the effect is what `list_resources` shows this caller: the
50
+ // refusal names nothing the catalog did not.
51
+ const wrong = new McpWrongToolError({ resource, action, called: name, useTool });
52
+ return host.refuse(id, action, wrong, caller);
53
+ }
54
+ const params = isRecord(args['params']) ? args['params'] : {};
55
+ if (name !== DELETE_RESOURCE) {
56
+ return host.dispatch(id, action, resolveAddressed(host, tool, params, caller), caller);
57
+ }
58
+ const lifted = liftToolArgs(args, params, tool);
59
+ const resolved = resolveAddressed(host, tool, lifted.params, caller);
60
+ if (resolved.kind !== 'ok') return host.dispatch(id, action, resolved, caller);
61
+ // The policy's actor half before any preview or confirmation prompt: a caller who may never run
62
+ // this gets the denial, never an answer that rehearses the call.
63
+ const refused = admitted(tool, caller);
64
+ if (refused !== undefined) return host.refuse(id, action, refused, caller);
65
+ if (lifted.live && lifted.confirmed !== true) {
66
+ return host.refuse(
67
+ id,
68
+ action,
69
+ new McpUserConfirmationRequiredError({ resource, action }),
70
+ caller,
71
+ );
72
+ }
73
+ // An action that declares `live` authors its own preview: it is handed `live: false` and runs.
74
+ if (lifted.live || lifted.ownsPreview) return host.dispatch(id, action, resolved, caller);
75
+ host.audit.toolCall({ tool: action, outcome: 'previewed', caller });
76
+ return resultResponse(
77
+ id,
78
+ jsonResult({
79
+ preview: true,
80
+ ran: false,
81
+ resource,
82
+ action,
83
+ params: resolved.args,
84
+ commit_with: { live: true, [CONFIRMED]: true },
85
+ }),
86
+ );
87
+ }
88
+
89
+ interface Lifted {
90
+ readonly params: Readonly<Record<string, unknown>>;
91
+ readonly live: boolean;
92
+ readonly confirmed: unknown;
93
+ /** The action's own input declares `live`, so the preview is the action's to answer. */
94
+ readonly ownsPreview: boolean;
95
+ }
96
+
97
+ /**
98
+ * `live` and `confirmed_by_user` are the dispatcher's, not the action's: read top-level or from
99
+ * `params` (nested wins, the precedence every other argument has) and removed from what the action
100
+ * is handed — except `live` for an action whose own input declares it, which is then always
101
+ * handed an explicit boolean so its preview branch never depends on its own default.
102
+ */
103
+ function liftToolArgs(
104
+ args: ToolArgs,
105
+ params: Readonly<Record<string, unknown>>,
106
+ tool: AnyMcpTool,
107
+ ): Lifted {
108
+ const declared = schemaOf(tool).properties ?? {};
109
+ const ownsPreview = Object.hasOwn(declared, LIVE);
110
+ // Nested wins, ALWAYS — a declared field too: a nested `confirmed_by_user: false` beside a
111
+ // top-level `true` is a refusal, and reading the top level there would commit anyway.
112
+ const pick = (key: string): unknown => (Object.hasOwn(params, key) ? params[key] : args[key]);
113
+ const live = pick(LIVE) === true;
114
+ const rest: Record<string, unknown> = {};
115
+ for (const [key, value] of Object.entries(params)) {
116
+ if ((key === LIVE || key === CONFIRMED) && !Object.hasOwn(declared, key)) continue;
117
+ Object.defineProperty(rest, key, {
118
+ value,
119
+ enumerable: true,
120
+ writable: true,
121
+ configurable: true,
122
+ });
123
+ }
124
+ if (ownsPreview) rest[LIVE] = live;
125
+ return { params: rest, live, confirmed: pick(CONFIRMED), ownsPreview };
126
+ }
127
+
128
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
129
+ typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -0,0 +1,116 @@
1
+ // The `'meta-split'` surface's dispatchers: `read_resource`, `write_resource`, `delete_resource`
2
+ // in place of the one `manage_resource`, each picked from the action's declared effect. A client
3
+ // decides whether to ask its user from a TOOL's annotations alone, and a directory refuses a tool
4
+ // that both reads and writes — so the split is by tool, never by a flag inside the envelope.
5
+ // Catalog and boot check here; the dispatch is `meta-split-call.ts`, on the shared path.
6
+
7
+ import type { McpEffect } from '@ultimat3/core';
8
+ import { McpEffectConflictError, McpEffectUndeclaredError } from './meta-errors';
9
+ import type { AnyMcpTool, ToolListEntry } from './registry';
10
+ import type { JsonSchema } from './wire';
11
+
12
+ export const READ_RESOURCE = 'read_resource';
13
+ export const WRITE_RESOURCE = 'write_resource';
14
+ export const DELETE_RESOURCE = 'delete_resource';
15
+ export const SPLIT_DISPATCHERS: readonly string[] = [
16
+ READ_RESOURCE,
17
+ WRITE_RESOURCE,
18
+ DELETE_RESOURCE,
19
+ ];
20
+
21
+ /** What a grouped tool does, as the split surface reads it: a read, or its declared effect. */
22
+ export type McpEffectClass = 'read' | McpEffect;
23
+
24
+ /** The dispatcher that runs each class. */
25
+ export function toolForEffect(effect: McpEffectClass): string {
26
+ if (effect === 'read') return READ_RESOURCE;
27
+ return effect === 'write' ? WRITE_RESOURCE : DELETE_RESOURCE;
28
+ }
29
+
30
+ /**
31
+ * `destructive === false` is a read, as `headOf` and `verbClass` read it; anything else is its
32
+ * declared effect, or `undefined` — which `assertEffects` refuses at boot, so a served tool always
33
+ * has one.
34
+ */
35
+ export function effectOf(tool: AnyMcpTool): McpEffectClass | undefined {
36
+ return tool.destructive === false ? 'read' : tool.effect;
37
+ }
38
+
39
+ /**
40
+ * Boot: every grouped write names its effect, and no read names one. No default either way — a
41
+ * wrong one hides a deletion behind `write_resource`, or asks deletion-grade consent for an
42
+ * update. Every undeclared name is collected first, so one boot lists them all.
43
+ */
44
+ export function assertEffects(tools: Iterable<AnyMcpTool>): void {
45
+ const undeclared: string[] = [];
46
+ for (const tool of tools) {
47
+ if (tool.destructive === false) {
48
+ if (tool.effect !== undefined) {
49
+ throw new McpEffectConflictError({ name: tool.name, effect: tool.effect });
50
+ }
51
+ } else if (tool.effect !== 'write' && tool.effect !== 'delete') {
52
+ undeclared.push(tool.name);
53
+ }
54
+ }
55
+ if (undeclared.length > 0) throw new McpEffectUndeclaredError({ names: undeclared.sort() });
56
+ }
57
+
58
+ const ENVELOPE: Readonly<Record<string, JsonSchema>> = {
59
+ resource: { type: 'string', description: 'Resource name, as list_resources shows it.' },
60
+ action: { type: 'string', description: 'Action name, as list_resources shows it.' },
61
+ params: {
62
+ type: 'object',
63
+ description: "The action's parameters, as describe_resource lists them.",
64
+ },
65
+ };
66
+
67
+ const envelope = (extra: Readonly<Record<string, JsonSchema>> = {}, required: string[] = []) =>
68
+ ({
69
+ type: 'object',
70
+ properties: { ...ENVELOPE, ...extra },
71
+ required: ['resource', 'action', ...required],
72
+ additionalProperties: false,
73
+ }) satisfies JsonSchema;
74
+
75
+ /** The three dispatchers as `tools/list` publishes them. Descriptions state facts, never orders. */
76
+ export const SPLIT_TOOL_ENTRIES: readonly ToolListEntry[] = [
77
+ {
78
+ name: READ_RESOURCE,
79
+ title: 'Read',
80
+ description:
81
+ 'Runs one read-only action of one resource: { resource, action, params }. Changes nothing. list_resources shows which actions it runs (untagged ones); any other action is refused with X_MCP_WRONG_TOOL naming its tool.',
82
+ inputSchema: envelope(),
83
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
84
+ },
85
+ {
86
+ name: WRITE_RESOURCE,
87
+ title: 'Write',
88
+ description:
89
+ 'Runs one action of one resource that creates or changes state: { resource, action, params }. Same policies, scopes and audit as calling the action directly. Runs the actions list_resources tags (write); any other is refused with X_MCP_WRONG_TOOL naming its tool.',
90
+ inputSchema: envelope(),
91
+ // An update can overwrite state, so the honest hint for a write is still destructive.
92
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
93
+ },
94
+ {
95
+ name: DELETE_RESOURCE,
96
+ title: 'Delete, send or spend',
97
+ description:
98
+ "Runs one action that archives, cancels or deletes, sends something outside the app, or commits money: { resource, action, params, live, confirmed_by_user }. live: false or absent answers a preview — the framework's, which runs nothing, or the action's own when its input declares live; live: true runs it, only with confirmed_by_user: true. Runs the actions list_resources tags (delete).",
99
+ inputSchema: envelope(
100
+ {
101
+ live: {
102
+ type: 'boolean',
103
+ description:
104
+ "true runs the action; false or absent answers a preview: the framework's runs nothing, and an action whose params declare live answers its own.",
105
+ },
106
+ confirmed_by_user: {
107
+ type: 'boolean',
108
+ description:
109
+ 'true when the user approved this change, directly or by authorizing the task that includes it. A live call without true runs nothing and answers X_MCP_USER_CONFIRMATION_REQUIRED.',
110
+ },
111
+ },
112
+ ['confirmed_by_user'],
113
+ ),
114
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
115
+ },
116
+ ];
@@ -8,20 +8,26 @@
8
8
  // A grouped tool is reachable ONLY through `manage_resource` for a meta caller, and a flat caller
9
9
  // never sees the meta tools: what `tools/list` shows is what `tools/call` answers, per caller.
10
10
 
11
+ import type { McpEffect } from '@ultimat3/core';
11
12
  import { McpToolDuplicateError } from './errors';
12
13
  import { assertListParams, listParamsSchema } from './list-params';
13
14
  import { McpGroupConflictError, McpGroupUnknownError, McpSurfaceInvalidError } from './meta-errors';
15
+ import { assertEffects, SPLIT_DISPATCHERS, SPLIT_TOOL_ENTRIES } from './meta-split';
14
16
  import type { AnyMcpTool, McpCaller, ToolListEntry } from './registry';
15
17
  import { visibleToCaller } from './registry';
16
18
  import type { JsonSchema } from './wire';
17
19
  import { NO_ARGS } from './wire';
18
20
 
19
- export type McpSurface = 'flat' | 'meta';
21
+ /**
22
+ * `'meta'`: one `manage_resource` runs every grouped tool. `'meta-split'`: `read_resource`,
23
+ * `write_resource` and `delete_resource` run them by declared effect (`meta-split.ts`).
24
+ */
25
+ export type McpSurface = 'flat' | 'meta' | 'meta-split';
20
26
 
21
27
  /**
22
28
  * Which surface a caller is served. A function decides per POPULATION — staff meta, customers flat
23
29
  * — 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
30
+ * literal `'meta'` or `'meta-split'` serves `'flat'`: the two surfaces reach the same tools under the same gates, so
25
31
  * the fallback changes the shape of the catalog, never what the caller may do.
26
32
  */
27
33
  export type McpSurfaceOption = McpSurface | ((caller: McpCaller) => McpSurface);
@@ -44,6 +50,7 @@ export const META_TOOL_NAMES: readonly string[] = [
44
50
  LIST_RESOURCES,
45
51
  DESCRIBE_RESOURCE,
46
52
  MANAGE_RESOURCE,
53
+ ...SPLIT_DISPATCHERS,
47
54
  ];
48
55
 
49
56
  /** The three constant entries, as `tools/list` publishes them. */
@@ -90,9 +97,31 @@ export const META_TOOL_ENTRIES: readonly ToolListEntry[] = [
90
97
  },
91
98
  ];
92
99
 
100
+ /**
101
+ * The catalog pair as the `'meta-split'` surface publishes it: the same tools, worded for three
102
+ * dispatchers instead of one. `'meta'` keeps its entries byte for byte.
103
+ */
104
+ const CATALOG_ENTRIES: readonly ToolListEntry[] = META_TOOL_ENTRIES.filter(
105
+ (entry) => entry.name !== MANAGE_RESOURCE,
106
+ ).map((entry) =>
107
+ entry.name === DESCRIBE_RESOURCE
108
+ ? {
109
+ ...entry,
110
+ description:
111
+ "Full input schemas for the actions of one or more resources (batched), each with the tool that runs it. For when list_resources' one-line params are not enough. Read-only.",
112
+ }
113
+ : {
114
+ ...entry,
115
+ description:
116
+ 'The catalog: every resource this caller may use, each action with a one-line parameter hint and the tool that runs it — read_resource, write_resource or delete_resource. Read-only.',
117
+ },
118
+ );
119
+
93
120
  export interface MetaAction {
94
121
  readonly name: string;
95
122
  readonly kind: 'query' | 'action';
123
+ /** An action's declared effect — the dispatcher that runs it on `'meta-split'`. A query has none. */
124
+ readonly effect?: McpEffect;
96
125
  readonly description: string;
97
126
  /** One line an agent can compose a call from: `radicado: string, note?: string`. */
98
127
  readonly params: string;
@@ -116,13 +145,17 @@ export class MetaSurface {
116
145
  /** Tool name → the resource that answers it. */
117
146
  readonly #home: ReadonlyMap<string, string>;
118
147
  readonly #tools: ReadonlyMap<string, AnyMcpTool>;
148
+ /** Every grouped tool's effect was checked at boot, so `'meta-split'` may be served. */
149
+ readonly #split: boolean;
119
150
 
120
151
  private constructor(
121
152
  option: McpSurfaceOption,
122
153
  groups: ReadonlyMap<string, McpResourceGroup>,
123
154
  home: ReadonlyMap<string, string>,
124
155
  tools: ReadonlyMap<string, AnyMcpTool>,
156
+ split: boolean,
125
157
  ) {
158
+ this.#split = split;
126
159
  this.#option = option;
127
160
  this.#groups = groups;
128
161
  this.#home = home;
@@ -151,13 +184,13 @@ export class MetaSurface {
151
184
  cause: `groups: declares ${entries.length} resource(s), and surface is 'flat' for every caller, so no caller would ever see them`,
152
185
  });
153
186
  }
154
- if (option !== 'meta' && typeof option !== 'function') {
187
+ if (option !== 'meta' && option !== 'meta-split' && typeof option !== 'function') {
155
188
  throw new McpSurfaceInvalidError({ cause: `surface is ${JSON.stringify(option)}` });
156
189
  }
157
190
  if (entries.length === 0) {
158
191
  throw new McpSurfaceInvalidError({
159
192
  cause:
160
- "surface can be 'meta', and groups: declares no resource, so list_resources would always answer empty",
193
+ "surface can be 'meta' or 'meta-split', and groups: declares no resource, so list_resources would always answer empty",
161
194
  });
162
195
  }
163
196
  const tools = new Map(input.tools.map((tool) => [tool.name, tool]));
@@ -177,21 +210,39 @@ export class MetaSurface {
177
210
  home.set(name, group);
178
211
  }
179
212
  }
180
- return new MetaSurface(option, new Map(entries), home, tools);
213
+ // 'meta-split' is held to the split's contract at boot. A FUNCTION is held to it once any
214
+ // grouped tool declares an effect — the app has opted in; until then it can only serve 'meta'
215
+ // (`surfaceFor`), so an app written before the split boots exactly as it did.
216
+ const grouped = [...home.keys()].flatMap((name) => tools.get(name) ?? []);
217
+ const split =
218
+ option === 'meta-split' ||
219
+ (typeof option === 'function' && grouped.some((tool) => tool.effect !== undefined));
220
+ if (split) assertEffects(grouped);
221
+ return new MetaSurface(option, new Map(entries), home, tools, split);
181
222
  }
182
223
 
183
- /** The surface this caller is served. Fail-closed to `'flat'` — see `McpSurfaceOption`. */
224
+ /**
225
+ * The surface this caller is served. Fail-closed to `'flat'` — see `McpSurfaceOption`. A function's
226
+ * `'meta-split'` serves `'meta'` while no grouped tool declares an effect (see `build`).
227
+ */
184
228
  surfaceFor(caller: McpCaller): McpSurface {
185
229
  const option = this.#option;
186
230
  if (typeof option !== 'function') return option;
187
231
  try {
188
- return option(caller) === 'meta' ? 'meta' : 'flat';
232
+ const chosen = option(caller);
233
+ if (chosen === 'meta-split') return this.#split ? 'meta-split' : 'meta';
234
+ return chosen === 'meta' ? 'meta' : 'flat';
189
235
  } catch {
190
236
  return 'flat';
191
237
  }
192
238
  }
193
239
 
194
- /** True when a META caller must reach this tool through `manage_resource`, not by its name. */
240
+ /** The constant `tools/list` entries of a meta surface: the catalog pair plus its dispatcher(s). */
241
+ static entriesFor(surface: Exclude<McpSurface, 'flat'>): readonly ToolListEntry[] {
242
+ return surface === 'meta' ? META_TOOL_ENTRIES : [...CATALOG_ENTRIES, ...SPLIT_TOOL_ENTRIES];
243
+ }
244
+
245
+ /** True when a META caller must reach this tool through a dispatcher, not by its name. */
195
246
  isGrouped(name: string): boolean {
196
247
  return this.#home.has(name);
197
248
  }
@@ -278,19 +329,25 @@ export function schemaOf(tool: AnyMcpTool): JsonSchema {
278
329
  * listCases {radicado?: string} — List the account's cases.
279
330
  * closeCase (action; confirms) {id: string} — Close a case.
280
331
  * ```
332
+ *
333
+ * `'meta-split'` tags a write with the tool that runs it instead — `(write)` or `(delete)` — and
334
+ * its header names the three dispatchers; untagged still means a read.
281
335
  */
282
- export function renderCatalog(resources: readonly MetaResource[]): string {
336
+ export function renderCatalog(
337
+ resources: readonly MetaResource[],
338
+ surface: 'meta' | 'meta-split' = 'meta',
339
+ ): string {
283
340
  if (resources.length === 0) return 'No resources are available to this caller.';
284
- const lines = [
285
- `${resources.length} resource(s). Run one with manage_resource({resource, action, params}); describe_resource({resources:["<name>"]}) has the full input schemas. Untagged = read-only query; (action) may write; (confirms) waits for a human; … = more in describe_resource.`,
286
- ];
341
+ const lines = [surface === 'meta' ? metaHeader(resources.length) : splitHeader(resources.length)];
287
342
  for (const resource of resources) {
288
343
  const hoisted = hoistScopes(resource.actions);
289
344
  const said = hoisted.line === '' ? '' : ` (${hoisted.line})`;
290
345
  lines.push('', `${resource.name}${said} — ${oneLine(resource.description)}`);
291
346
  for (const action of resource.actions) {
292
347
  const tags = [
293
- ...(action.kind === 'action' ? ['action'] : []),
348
+ ...(action.kind === 'action'
349
+ ? [surface === 'meta' ? 'action' : (action.effect ?? 'action')]
350
+ : []),
294
351
  ...(action.confirms === true ? ['confirms'] : []),
295
352
  ...(action.scope === undefined || hoisted.kinds.has(action.kind)
296
353
  ? []
@@ -303,6 +360,12 @@ export function renderCatalog(resources: readonly MetaResource[]): string {
303
360
  return lines.join('\n');
304
361
  }
305
362
 
363
+ const metaHeader = (count: number): string =>
364
+ `${count} resource(s). Run one with manage_resource({resource, action, params}); describe_resource({resources:["<name>"]}) has the full input schemas. Untagged = read-only query; (action) may write; (confirms) waits for a human; … = more in describe_resource.`;
365
+
366
+ const splitHeader = (count: number): string =>
367
+ `${count} resource(s). Untagged = read-only, run with read_resource({resource, action, params}); (write) runs with write_resource({resource, action, params}); (delete) archives, sends or spends, run with delete_resource({resource, action, params, live, confirmed_by_user}) — live: false previews it. describe_resource({resources:["<name>"]}) has the full input schemas. (confirms) waits for a human; … = more in describe_resource.`;
368
+
306
369
  /**
307
370
  * The scopes said once on the resource line, and the kinds whose actions no longer repeat theirs.
308
371
  * One scope for every action → `scope cases`. Otherwise per kind, because a token split into
@@ -339,11 +402,13 @@ function sharedScope(actions: readonly MetaAction[]): string | undefined {
339
402
  const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim();
340
403
 
341
404
  function headOf(tool: AnyMcpTool): Omit<MetaAction, 'params'> {
405
+ const effect = tool.destructive === false ? undefined : tool.effect;
342
406
  return {
343
407
  name: tool.name,
344
408
  // `=== false`, as `ToolRegistry.verbClass` meters it — one answer per tool. An omitted flag is
345
409
  // an action: the header's "untagged = read-only query" holds only for a DECLARED read.
346
410
  kind: tool.destructive === false ? 'query' : 'action',
411
+ ...(effect === undefined ? {} : { effect }),
347
412
  description: tool.description,
348
413
  ...(tool.confirms === true ? { confirms: true } : {}),
349
414
  ...(tool.scope === undefined ? {} : { scope: tool.scope }),
@@ -151,6 +151,7 @@ function exposureOf(
151
151
  ...(declared.description === undefined ? {} : { description: declared.description }),
152
152
  ...(declared.visibleTo === undefined ? {} : { visibleTo: declared.visibleTo }),
153
153
  ...(declared.listParams === undefined ? {} : { listParams: declared.listParams }),
154
+ ...(declared.effect === undefined ? {} : { effect: declared.effect }),
154
155
  ...(declared.title === undefined ? {} : { title: declared.title }),
155
156
  ...(declared.annotations === undefined ? {} : { annotations: declared.annotations }),
156
157
  };
package/src/registry.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  // The outcomes are orthogonal on purpose. A tool can be visible and still refused for a
19
19
  // narrow token; a token holding every scope still cannot see a tool its role may not.
20
20
 
21
- import type { Actor } from '@ultimat3/core';
21
+ import type { Actor, McpEffect } from '@ultimat3/core';
22
22
  import { McpToolDuplicateError } from './errors';
23
23
  import type { McpListParams } from './list-params';
24
24
  import type { ArgIssue } from './validate-args';
@@ -143,6 +143,11 @@ export interface McpTool<A extends ToolArgs = ToolArgs> {
143
143
  * the flat surface ignores it (the tool's `inputSchema` is its contract there).
144
144
  */
145
145
  readonly listParams?: McpListParams;
146
+ /**
147
+ * The action's effect class (`'write'` | `'delete'`), as its `mcp` block declared it. Picks the
148
+ * dispatcher on a `'meta-split'` surface; a read (`destructive: false`) carries none.
149
+ */
150
+ readonly effect?: McpEffect;
146
151
  /**
147
152
  * A catalog hint: this write answers "awaiting confirmation" and a human finishes it. Shown by
148
153
  * `list_resources` so an agent plans for the hand-off; it changes nothing about the call.
package/src/server.ts CHANGED
@@ -12,8 +12,9 @@ import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './error
12
12
  import { asFrameworkError, forAudience } from './framework-error';
13
13
  import { metaCall } from './meta-call';
14
14
  import { META_UNKNOWN_FIX } from './meta-errors';
15
+ import { SPLIT_DISPATCHERS } from './meta-split';
15
16
  import type { McpResourceGroups, McpSurfaceOption, MetaResource } from './meta-surface';
16
- import { MANAGE_RESOURCE, META_TOOL_ENTRIES, META_TOOL_NAMES, MetaSurface } from './meta-surface';
17
+ import { MANAGE_RESOURCE, META_TOOL_NAMES, MetaSurface } from './meta-surface';
17
18
  import { promptListEntry, promptsGet, visiblePrompts } from './prompts-get';
18
19
  import type {
19
20
  AnyMcpTool,
@@ -214,9 +215,12 @@ export class McpServer {
214
215
  const flat = this.tools.list(caller);
215
216
  const meta = this.metaFor(caller);
216
217
  if (meta === undefined) return flat;
217
- // Constant: the three meta tools plus whatever the app left ungrouped (`docs`, `whoami`).
218
- return [...flat.filter((tool) => !meta.isGrouped(tool.name)), ...META_TOOL_ENTRIES].sort(
219
- (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
218
+ // Constant: the meta tools plus whatever the app left ungrouped (`docs`, `whoami`).
219
+ const entries = MetaSurface.entriesFor(
220
+ meta.surfaceFor(caller) === 'meta' ? 'meta' : 'meta-split',
221
+ );
222
+ return [...flat.filter((tool) => !meta.isGrouped(tool.name)), ...entries].sort((a, b) =>
223
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
220
224
  );
221
225
  }
222
226
 
@@ -231,7 +235,7 @@ export class McpServer {
231
235
  /** The meta surface when THIS caller is served it, else `undefined`. */
232
236
  private metaFor(caller: McpCaller): MetaSurface | undefined {
233
237
  const meta = this.meta;
234
- return meta !== undefined && meta.surfaceFor(caller) === 'meta' ? meta : undefined;
238
+ return meta !== undefined && meta.surfaceFor(caller) !== 'flat' ? meta : undefined;
235
239
  }
236
240
 
237
241
  /**
@@ -250,7 +254,7 @@ export class McpServer {
250
254
  // An unresolvable call is refused before it runs, so charging it the strict bucket
251
255
  // only costs a broken client — it never hands an unproven verb the cheap one.
252
256
  if (typeof name !== 'string') return 'write';
253
- if (this.meta !== undefined && name === MANAGE_RESOURCE) {
257
+ if (this.meta !== undefined && (name === MANAGE_RESOURCE || SPLIT_DISPATCHERS.includes(name))) {
254
258
  // Billed as the tool it reaches, so the dispatcher is never a cheap door to a write.
255
259
  const args = params?.['arguments'];
256
260
  const action = isRecord(args) ? args['action'] : undefined;
@@ -277,6 +281,8 @@ export class McpServer {
277
281
  audit: this.audit,
278
282
  dispatch: (at, tool, resolved, who) => this.dispatch(at, tool, resolved, who),
279
283
  notFound: (at, tool, who, fix) => this.notFound(at, tool, who, fix),
284
+ refuse: (at, tool, error, who) =>
285
+ thrownResponse(at, tool, error, who, this.voice.errorAudience, this.audit),
280
286
  },
281
287
  meta,
282
288
  { id, name, rawArgs: params['arguments'], caller },