@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 +7 -13
- package/README.md +53 -1
- package/package.json +10 -10
- package/src/app-tool.ts +8 -1
- package/src/audit.ts +10 -2
- package/src/errors.ts +9 -0
- package/src/from-action.ts +2 -0
- package/src/index.ts +13 -0
- package/src/meta-call.ts +22 -39
- package/src/meta-errors.ts +66 -0
- package/src/meta-host.ts +43 -0
- package/src/meta-split-call.ts +129 -0
- package/src/meta-split.ts +116 -0
- package/src/meta-surface.ts +78 -13
- package/src/projectable.ts +1 -0
- package/src/registry.ts +6 -1
- package/src/server.ts +12 -6
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`)
|
|
206
|
-
|
|
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.
|
|
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.
|
|
37
|
-
"@ultimat3/core": "27.
|
|
38
|
-
"@ultimat3/db": "27.
|
|
39
|
-
"@ultimat3/entity": "27.
|
|
40
|
-
"@ultimat3/http": "27.
|
|
41
|
-
"@ultimat3/jobs": "27.
|
|
42
|
-
"@ultimat3/policy": "27.
|
|
43
|
-
"@ultimat3/query": "27.
|
|
44
|
-
"@ultimat3/schema": "27.
|
|
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
|
package/src/from-action.ts
CHANGED
|
@@ -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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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 {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
}
|
package/src/meta-errors.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/meta-host.ts
ADDED
|
@@ -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
|
+
];
|
package/src/meta-surface.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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'
|
|
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 }),
|
package/src/projectable.ts
CHANGED
|
@@ -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,
|
|
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
|
|
218
|
-
|
|
219
|
-
(
|
|
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)
|
|
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 },
|