@ultimat3/mcp 24.0.0 → 25.1.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.
@@ -268,40 +268,82 @@ export function schemaOf(tool: AnyMcpTool): JsonSchema {
268
268
  * every quote — a staff catalog measured 32.5k characters — and `JSON.parse` was never its reader's
269
269
  * job: the model's is. Same facts, same order; `describe_resource` stays JSON because a schema is.
270
270
  *
271
+ * Every per-action tag costs once per action, so each is stated where it is cheapest and still
272
+ * exact (#590): a scope shared by every action of the resource — or of one kind — is said once on
273
+ * the resource line (`hoistScopes`); only `action` is tagged, because untagged means `query` and
274
+ * the header says so. The kind comes from `destructive` (`headOf`), never from the name.
275
+ *
271
276
  * ```text
272
- * cases — Court cases of the account.
273
- * listCases (query) {radicado?: string} — List the account's cases.
274
- * closeCase (action; confirms; scope cases:write) {id: string} — Close a case.
277
+ * cases (scope cases) — Court cases of the account.
278
+ * listCases {radicado?: string} — List the account's cases.
279
+ * closeCase (action; confirms) {id: string} — Close a case.
275
280
  * ```
276
281
  */
277
282
  export function renderCatalog(resources: readonly MetaResource[]): string {
278
283
  if (resources.length === 0) return 'No resources are available to this caller.';
279
284
  const lines = [
280
- `${resources.length} resource(s). Run one with manage_resource({resource, action, params}); describe_resource({resources:["<name>"]}) has the full input schemas.`,
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.`,
281
286
  ];
282
287
  for (const resource of resources) {
283
- lines.push('', `${resource.name} — ${oneLine(resource.description)}`);
288
+ const hoisted = hoistScopes(resource.actions);
289
+ const said = hoisted.line === '' ? '' : ` (${hoisted.line})`;
290
+ lines.push('', `${resource.name}${said} — ${oneLine(resource.description)}`);
284
291
  for (const action of resource.actions) {
285
292
  const tags = [
286
- action.kind,
293
+ ...(action.kind === 'action' ? ['action'] : []),
287
294
  ...(action.confirms === true ? ['confirms'] : []),
288
- ...(action.scope === undefined ? [] : [`scope ${action.scope}`]),
289
- ].join('; ');
290
- lines.push(` ${action.name} (${tags}) {${action.params}} — ${oneLine(action.description)}`);
295
+ ...(action.scope === undefined || hoisted.kinds.has(action.kind)
296
+ ? []
297
+ : [`scope ${action.scope}`]),
298
+ ];
299
+ const tagged = tags.length === 0 ? '' : ` (${tags.join('; ')})`;
300
+ lines.push(` ${action.name}${tagged} {${action.params}} — ${oneLine(action.description)}`);
291
301
  }
292
302
  }
293
303
  return lines.join('\n');
294
304
  }
295
305
 
306
+ /**
307
+ * The scopes said once on the resource line, and the kinds whose actions no longer repeat theirs.
308
+ * One scope for every action → `scope cases`. Otherwise per kind, because a token split into
309
+ * `cases:read` / `cases:write` is the common shape: `query scope cases:read; action scope
310
+ * cases:write`. A kind is hoisted only when EVERY action of it carries the SAME scope — one
311
+ * unscoped action among scoped ones must not inherit a gate the registry does not enforce.
312
+ * Computed over what THIS caller sees: the catalog is per caller.
313
+ */
314
+ function hoistScopes(actions: readonly MetaAction[]): {
315
+ readonly line: string;
316
+ readonly kinds: ReadonlySet<MetaAction['kind']>;
317
+ } {
318
+ const all = sharedScope(actions);
319
+ if (all !== undefined) return { line: `scope ${all}`, kinds: new Set(['query', 'action']) };
320
+ const parts: string[] = [];
321
+ const kinds = new Set<MetaAction['kind']>();
322
+ for (const kind of ['query', 'action'] as const) {
323
+ const scope = sharedScope(actions.filter((action) => action.kind === kind));
324
+ if (scope === undefined) continue;
325
+ parts.push(`${kind} scope ${scope}`);
326
+ kinds.add(kind);
327
+ }
328
+ return { line: parts.join('; '), kinds };
329
+ }
330
+
331
+ /** The scope every action carries, or `undefined` when there is none, one lacks it or two differ. */
332
+ function sharedScope(actions: readonly MetaAction[]): string | undefined {
333
+ const first = actions[0]?.scope;
334
+ if (first === undefined) return undefined;
335
+ return actions.every((action) => action.scope === first) ? first : undefined;
336
+ }
337
+
296
338
  /** A description authored across lines is still one catalog line. */
297
339
  const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim();
298
340
 
299
341
  function headOf(tool: AnyMcpTool): Omit<MetaAction, 'params'> {
300
342
  return {
301
343
  name: tool.name,
302
- // `=== true`, as `ToolRegistry.verbClass` meters it: an omitted `destructive` is a read in the
303
- // rate limiter, so listing it as an action gave one tool two answers.
304
- kind: tool.destructive === true ? 'action' : 'query',
344
+ // `=== false`, as `ToolRegistry.verbClass` meters it — one answer per tool. An omitted flag is
345
+ // an action: the header's "untagged = read-only query" holds only for a DECLARED read.
346
+ kind: tool.destructive === false ? 'query' : 'action',
305
347
  description: tool.description,
306
348
  ...(tool.confirms === true ? { confirms: true } : {}),
307
349
  ...(tool.scope === undefined ? {} : { scope: tool.scope }),
@@ -312,27 +354,44 @@ function sorted<V>(map: ReadonlyMap<string, V>): readonly [string, V][] {
312
354
  return [...map.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
313
355
  }
314
356
 
315
- /** Past this, the hint says "see describe_resource" instead of pretending to be complete. */
316
- const HINT_MAX = 240;
357
+ /** Fields a hint names before `…` — every required one is named regardless. */
358
+ const HINT_FIELDS = 4;
359
+ /** Optional fields stop being added once the hint is this long; required ones never stop. */
360
+ const HINT_MAX = 100;
361
+ /** Literals an enum hint names before `|…`. */
362
+ const HINT_LITERALS = 8;
317
363
 
318
364
  /**
319
365
  * `name: type` per property, `?` when optional, an enum as its literals — the hint
320
366
  * `list_resources` carries so a common call needs no `describe_resource` round-trip.
367
+ *
368
+ * Cut by FIELDS, never by characters (#590): a character cut sliced `limit?: integer` into
369
+ * `limit?: i…`. Every required field is named, in declared order, because a call without one
370
+ * fails; optional fields follow until `HINT_FIELDS` names or `HINT_MAX` characters, and a trailing
371
+ * `…` says some were left out. `describe_resource` always has the full schema.
321
372
  */
322
373
  export function oneLineParams(schema: JsonSchema): string {
323
374
  const required = new Set(schema.required ?? []);
324
- const parts = Object.entries(schema.properties ?? {}).map(
325
- ([key, child]) => `${key}${required.has(key) ? '' : '?'}: ${typeHint(child)}`,
326
- );
327
- const line = parts.join(', ');
328
- if (line.length <= HINT_MAX) return line;
329
- return `${line.slice(0, HINT_MAX - 1)}… (describe_resource has the rest)`;
375
+ const fields = Object.entries(schema.properties ?? {}).map(([key, child]) => ({
376
+ required: required.has(key),
377
+ text: `${key}${required.has(key) ? '' : '?'}: ${typeHint(child)}`,
378
+ }));
379
+ const named = new Set(fields.filter((field) => field.required));
380
+ let length = [...named].reduce((sum, field) => sum + field.text.length + 2, 0);
381
+ for (const field of fields) {
382
+ if (field.required) continue;
383
+ if (named.size >= HINT_FIELDS || length + field.text.length > HINT_MAX) break;
384
+ named.add(field);
385
+ length += field.text.length + 2;
386
+ }
387
+ const parts = fields.filter((field) => named.has(field)).map((field) => field.text);
388
+ return named.size < fields.length ? [...parts, '…'].join(', ') : parts.join(', ');
330
389
  }
331
390
 
332
391
  function typeHint(schema: JsonSchema): string {
333
- if (schema.enum !== undefined) return schema.enum.map((v) => JSON.stringify(v)).join('|');
392
+ if (schema.enum !== undefined) return literals(schema.enum.map((v) => JSON.stringify(v)));
334
393
  if (schema.const !== undefined) return JSON.stringify(schema.const);
335
- if (schema.anyOf !== undefined) return schema.anyOf.map(typeHint).join('|');
394
+ if (schema.anyOf !== undefined) return literals(schema.anyOf.map(typeHint));
336
395
  if (schema.type === 'array') {
337
396
  if (schema.items === undefined) return 'array';
338
397
  const item = typeHint(schema.items);
@@ -340,3 +399,9 @@ function typeHint(schema: JsonSchema): string {
340
399
  }
341
400
  return schema.type ?? 'any';
342
401
  }
402
+
403
+ /** A union cut at an alternative, never inside one. */
404
+ function literals(alternatives: readonly string[]): string {
405
+ if (alternatives.length <= HINT_LITERALS) return alternatives.join('|');
406
+ return [...alternatives.slice(0, HINT_LITERALS), '…'].join('|');
407
+ }
@@ -23,7 +23,7 @@ export class McpPathDuplicateError extends UltimateError {
23
23
  super({
24
24
  code: 'X_MCP_PATH_DUPLICATE',
25
25
  cause: `${input.file} exports mcp endpoints #${first} and #${second}, and both claim ${input.method} ${input.path}`,
26
- fix: `give endpoint #${second} in ${input.file} its own path — defineAppMcp({ path: '/mcp/<population>', ... }); endpoint #0 mounts at ai.mcp.path and every other at its own defineAppMcp path`,
26
+ fix: `give endpoint #${second} in ${input.file} its own path — defineAppMcp({ path: '/mcp/<population>', ... }); every endpoint mounts at its own defineAppMcp path (default /mcp)`,
27
27
  });
28
28
  this.path = input.path;
29
29
  this.endpoints = input.endpoints;
@@ -4,7 +4,7 @@
4
4
  // authorization server(s) a client goes to for a token. The authorization server itself is the
5
5
  // APP's — its consent page, its token endpoint, its revocation list — never this package's.
6
6
 
7
- import { McpOAuthInvalidError } from './errors';
7
+ import { McpOAuthInvalidError } from './errors-transport';
8
8
 
9
9
  /** The well-known suffix RFC 9728 §3 defines. */
10
10
  export const PROTECTED_RESOURCE_WELL_KNOWN = '/.well-known/oauth-protected-resource';
@@ -4,28 +4,20 @@
4
4
  // primitive out is a different way to NAME a tool, never a second way to run one.
5
5
 
6
6
  import type { AnyAction } from '@ultimat3/action';
7
+ import { actionName, guardActionBeforeInput, invoke, isAction } from '@ultimat3/action';
7
8
  import {
8
- guardBeforeInput as actionGuardBeforeInput,
9
- actionName,
9
+ type Actor,
10
10
  actorOf,
11
- invoke,
12
- isAction,
13
- } from '@ultimat3/action';
14
- import { type Actor, isMcpExposed, useContext } from '@ultimat3/core';
11
+ isMcpExposed,
12
+ type McpExposureDeclaration,
13
+ useContext,
14
+ } from '@ultimat3/core';
15
15
  import type { AnyQuery } from '@ultimat3/query';
16
- import {
17
- isQuery,
18
- guardBeforeInput as queryGuardBeforeInput,
19
- queryName,
20
- readAnswer,
21
- sourceFor,
22
- } from '@ultimat3/query';
16
+ import { guardQueryBeforeInput, isQuery, queryName, readAnswer, sourceFor } from '@ultimat3/query';
23
17
  import { toWireOutputSchema, toWireSchema } from '@ultimat3/schema';
24
18
  import { asCallerContext } from './caller-context';
25
- import type { McpExposure, ProjectablePrimitive } from './from-action';
19
+ import type { ProjectablePrimitive } from './from-action';
26
20
  import { takeIdempotencyKeyArg, withIdempotencyKeyArg } from './idempotency-arg';
27
- import type { McpListParams } from './list-params';
28
- import type { McpToolAnnotations } from './registry';
29
21
  import type { JsonSchema } from './wire';
30
22
 
31
23
  /**
@@ -33,7 +25,7 @@ import type { JsonSchema } from './wire';
33
25
  *
34
26
  * The real primitives come first because they are what an app writes: `actions: [publishPost]`.
35
27
  * Until 2026-08 this list took `ProjectablePrimitive` alone, which no `action()` or `query()`
36
- * structurally satisfies — they carry `as`/`tool`, never `run` — so the only value that could
28
+ * structurally satisfies — they carry `as` and their projections, never `run` — so the only value that could
37
29
  * reach `X_MCP_TOOL_UNDECLARED` was a hand-built fake, and the gate refused nothing an app could
38
30
  * actually declare. `ProjectablePrimitive` stays in the union for surfaces that build their
39
31
  * catalog programmatically (`@ultimat3/admin`) and for tests that project a stand-in.
@@ -75,7 +67,7 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
75
67
  admit: (actor: Actor) =>
76
68
  asCallerContext(actor, () => {
77
69
  const ctx = useContext();
78
- actionGuardBeforeInput(target.policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
70
+ guardActionBeforeInput(target.policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
79
71
  }),
80
72
  // The actor rides in on the options: `invoke` swaps it inside the one execution path.
81
73
  run: keyed
@@ -90,7 +82,7 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
90
82
  export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
91
83
  const exposure = exposureOf(target.mcp);
92
84
  // A `single: true` read answers ONE row, or not-found — what its route answers (404) and what
93
- // `tool().read()` answers. Handing an agent `[]` for a missing id said "found, and empty".
85
+ // `@ultimat3/query`'s `readAnswer` makes every read answer. Handing an agent `[]` for a missing id said "found, and empty".
94
86
  const single = target.single === true;
95
87
  // Only a query that DECLARED its rows (`rows: Post.$schema`) has a known answer shape. A list
96
88
  // read answers the rows as a list, so the structured copy is `{ rows }` — `structuredContent`
@@ -110,7 +102,7 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
110
102
  admit: (actor: Actor) =>
111
103
  asCallerContext(actor, () => {
112
104
  const ctx = useContext();
113
- queryGuardBeforeInput(target.policy, { actor: actorOf(ctx), ctx, query: name }, 'server');
105
+ guardQueryBeforeInput(target.policy, { actor: actorOf(ctx), ctx, query: name }, 'server');
114
106
  }),
115
107
  run: ({ input, actor }) =>
116
108
  asCallerContext(actor, async () => {
@@ -118,7 +110,8 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
118
110
  // and is what `live`, `paginate` and `explain` build on too. Executed without the
119
111
  // cache tiers on purpose: an agent diffing two tool calls must be reading the rows,
120
112
  // not a TTL.
121
- const source = await sourceFor(target, input);
113
+ // `'mcp'`: an agent's call spends the read's declared `rateLimit:`, the one bucket its route draws on.
114
+ const source = await sourceFor(target, input, { surface: 'mcp' });
122
115
  // `@ultimat3/query`'s one rule for what a read answers: rows, or a single read's row.
123
116
  return readAnswer(target, await source.execute());
124
117
  }),
@@ -138,7 +131,7 @@ export function toRowsOutputSchema(rows: unknown): JsonSchema | undefined {
138
131
  }
139
132
 
140
133
  /**
141
- * An action and a query declare MCP exposure with the same fields, so one typed path reads
134
+ * An action and a query declare core's ONE `McpExposureDeclaration`, so one typed path reads
142
135
  * both. Narrow on purpose, through `@ultimat3/core`'s `isMcpExposed`: only a literal
143
136
  * `expose: true` counts, so nothing is exposed by accident — an undeclared `mcp` block yields
144
137
  * no exposure at all.
@@ -149,7 +142,9 @@ export function toRowsOutputSchema(rows: unknown): JsonSchema | undefined {
149
142
  * projected tool was visible to every caller and the first outcome existed only for the
150
143
  * hand-written tools that build their own `McpTool`.
151
144
  */
152
- function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined {
145
+ function exposureOf(
146
+ declared: McpExposureDeclaration | undefined,
147
+ ): McpExposureDeclaration | undefined {
153
148
  if (declared === undefined) return undefined;
154
149
  return {
155
150
  expose: isMcpExposed(declared),
@@ -160,14 +155,3 @@ function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined
160
155
  ...(declared.annotations === undefined ? {} : { annotations: declared.annotations }),
161
156
  };
162
157
  }
163
-
164
- /** `ActionMcp` and `QueryMcp` are the same shape; restating it binds to neither. */
165
- interface DeclaredMcp {
166
- readonly expose: boolean;
167
- readonly description?: string;
168
- readonly visibleTo?: readonly string[];
169
- /** `QueryMcp` only; an action has no list to compose. */
170
- readonly listParams?: McpListParams;
171
- readonly title?: string;
172
- readonly annotations?: McpToolAnnotations;
173
- }
@@ -6,6 +6,7 @@
6
6
  // anywhere at statement level (which also catches a data-modifying CTE — `WITH x AS
7
7
  // (INSERT ...) SELECT`, which reads like a SELECT and is not one).
8
8
 
9
+ import { dollarTagAt, endOfBlockComment } from '@ultimat3/db';
9
10
  import { McpNotBranchDbError, McpQueryRejectedError } from './errors';
10
11
  import { calledFunctions, forbiddenFamily } from './readonly-sql-calls';
11
12
 
@@ -216,8 +217,9 @@ function notBranch(cause: string, fix: string): McpNotBranchDbError {
216
217
  /**
217
218
  * Replace string/identifier literals and comments with spaces so keyword scanning cannot be
218
219
  * fooled by `SELECT 'delete'` (a harmless literal that looks like a write) and cannot be
219
- * evaded by hiding a second statement behind a block comment. Only word boundaries matter
220
- * downstream, so collapsing each run to one space is enough.
220
+ * evaded by hiding a second statement behind a block comment — ended where Postgres ends it,
221
+ * nesting counted (`@ultimat3/db`'s `endOfBlockComment`), or a comment closed early hides one
222
+ * instead. Only word boundaries matter downstream, so collapsing each run to one space is enough.
221
223
  *
222
224
  * `identifiers: 'keep'` unwraps a double-quoted identifier to its content instead — the call
223
225
  * scan needs it, because `"pg_advisory_lock"(1)` calls the function the blanked form hides.
@@ -234,11 +236,13 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
234
236
  continue;
235
237
  }
236
238
  if (two === '/*') {
237
- const end = sql.indexOf('*/', i + 2);
238
- if (end === -1) {
239
+ // Nested, as Postgres reads it: ending at the FIRST `*/` let `/* /* */ ' */` open a literal
240
+ // here that swallowed the real statement after the comment.
241
+ const end = endOfBlockComment(sql, i);
242
+ if (end === null) {
239
243
  throw unterminated('a /* block comment', 'close it with */, or use -- to the end of line');
240
244
  }
241
- i = end + 2;
245
+ i = end;
242
246
  out += ' ';
243
247
  continue;
244
248
  }
@@ -260,26 +264,21 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
260
264
  i = end;
261
265
  continue;
262
266
  }
263
- // Glued to an identifier, `$` is part of the NAME (`a$b$` is one identifier to Postgres), so
264
- // it opens nothing — `@ultimat3/db`'s `dollarTagAt` makes the same check.
265
- if (char === '$' && !IDENTIFIER_CHAR.test(sql[i - 1] ?? '')) {
266
- // Postgres's own tag grammar: an identifier that does not start with a digit — letters,
267
- // `_`, digits after the first, and any non-ASCII character. `[a-z_]*` read `$a1$` as text,
268
- // and the `'` it then saw hid a `pg_sleep` Postgres runs outside every quote.
269
- const tag = DOLLAR_TAG.exec(sql.slice(i));
270
- if (tag !== null) {
271
- const marker = tag[0];
272
- const end = sql.indexOf(marker, i + marker.length);
273
- if (end === -1) {
274
- throw unterminated(
275
- `a ${marker} dollar-quoted body`,
276
- `close it with the same tag: ${marker} … ${marker}`,
277
- );
278
- }
279
- i = end + marker.length;
280
- out += ' ';
281
- continue;
267
+ // `@ultimat3/db`'s lexer rule, the one copy: Postgres's tag grammar (non-ASCII included, never
268
+ // a leading digit — `[a-z_]*` read `$a1$` as text and hid a `pg_sleep` behind the `'` after
269
+ // it), and a `$` that continues an identifier (`é$x$` is one name) opens nothing.
270
+ const marker = char === '$' ? dollarTagAt(sql, i) : null;
271
+ if (marker !== null) {
272
+ const end = sql.indexOf(marker, i + marker.length);
273
+ if (end === -1) {
274
+ throw unterminated(
275
+ `a ${marker} dollar-quoted body`,
276
+ `close it with the same tag: ${marker} … ${marker}`,
277
+ );
282
278
  }
279
+ i = end + marker.length;
280
+ out += ' ';
281
+ continue;
283
282
  }
284
283
  out += char;
285
284
  i += 1;
@@ -289,7 +288,6 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
289
288
 
290
289
  const IDENTIFIER_CHAR = /[A-Za-z0-9_$\u0080-\uffff]/;
291
290
  const WORD = /[a-z_\u0080-\uffff][a-z0-9_$\u0080-\uffff]*/g;
292
- const DOLLAR_TAG = /^\$(?:[A-Za-z_\u0080-\uffff][A-Za-z0-9_\u0080-\uffff]*)?\$/;
293
291
 
294
292
  /**
295
293
  * `U&"` opening at a token boundary — the lexer's `xuistart`, with no space inside. `menu&"m"` is
package/src/registry.ts CHANGED
@@ -131,9 +131,10 @@ export interface McpTool<A extends ToolArgs = ToolArgs> {
131
131
  /** Who may see and call this tool. Absent = everyone. See `McpVisibility`. */
132
132
  readonly visibleTo?: McpVisibility;
133
133
  /**
134
- * Marks a tool that changes state. Drives the transport's rate-limit bucket and is
135
- * asserted by tests over the dev server, so a new mutating tool cannot be metered as
136
- * cheap read chatter by omission.
134
+ * Marks a tool that changes state. Drives the transport's rate-limit bucket and the meta
135
+ * catalog's `(action)` tag. Only `false` declares a read: an OMITTED flag counts as a write
136
+ * (`As of 2026-10`), as a projected action and an app tool already default — so a new mutating
137
+ * tool cannot be metered or listed as cheap read chatter by omission.
137
138
  */
138
139
  readonly destructive?: boolean;
139
140
  /**
@@ -309,7 +310,7 @@ export class ToolRegistry {
309
310
  verbClass(name: string): McpVerbClass {
310
311
  const tool = this.#tools.get(name);
311
312
  if (tool === undefined) return 'write';
312
- return tool.destructive === true ? 'write' : 'read';
313
+ return tool.destructive === false ? 'read' : 'write';
313
314
  }
314
315
  }
315
316
 
@@ -326,7 +327,7 @@ export function textResult(text: string, isError = false): McpToolResult {
326
327
  * spent a third of a large answer on indentation (a staff catalog measured 32.5k characters).
327
328
  * `JSON.stringify` is deterministic for one value, so two calls still diff.
328
329
  *
329
- * TOTAL, because the value is an app's: `toolFromAction` hands an action's own return value
330
+ * TOTAL, because the value is an app's: `toolFrom` hands an action's own return value
330
331
  * straight here, and `JSON.stringify` answers `undefined` for a handler that returned nothing —
331
332
  * a `text` that is not a string is an invalid MCP frame — and THROWS on a bigint, a cycle or a
332
333
  * `toJSON` the value carries. A throw would leave the server's catch reporting a bug in the tool
package/src/scopes.ts CHANGED
@@ -4,13 +4,13 @@
4
4
  // other is its authz rule, and both belong beside the code they guard. A SCOPE is neither.
5
5
  // It is a capability of the CONNECTION — what the token was issued to do — so it is grouped
6
6
  // here, once per scope, naming the tools that capability covers. That is also why
7
- // `toolFromAction` never invents one: a projection cannot know what a token means.
7
+ // `toolFrom` never invents one: a projection cannot know what a token means.
8
8
  //
9
9
  // Without this map the second outcome is unreachable for a generated app. `ToolRegistry`
10
10
  // enforces `scope`, the framework's own dev tools declare one, and until 2026-08 nothing an
11
11
  // app could write ever set the field — an enforced gate no app declaration could engage.
12
12
 
13
- import { McpScopeConflictError, McpScopeUnknownError } from './errors';
13
+ import { McpScopeConflictError, McpScopeUncoveredError, McpScopeUnknownError } from './errors';
14
14
  import type { AnyMcpTool } from './registry';
15
15
 
16
16
  /**
@@ -25,14 +25,19 @@ export type McpScopes = Readonly<Record<string, readonly string[]>>;
25
25
  /**
26
26
  * Attach each declared scope to the tool it covers, refusing anything ambiguous at BOOT.
27
27
  *
28
- * Two refusals, because both silently ship an ungated tool otherwise:
28
+ * Three refusals, because each silently ships an ungated tool otherwise:
29
29
  *
30
30
  * - a name no tool in the catalog answers to (`X_MCP_SCOPE_UNKNOWN`) — a typo, or a primitive
31
31
  * that was renamed or never listed. Skipping it leaves the tool reachable with no scope at
32
32
  * all, which is the opposite of what the author wrote;
33
33
  * - one tool claimed by two scopes (`X_MCP_SCOPE_CONFLICT`) — a tool carries ONE scope, so
34
34
  * the second claim would either overwrite the first or be dropped, and which one wins would
35
- * be decided by object key order.
35
+ * be decided by object key order;
36
+ * - a projected tool no scope names (`X_MCP_SCOPE_UNCOVERED`). The registry gates a tool only
37
+ * when it carries a scope, so an unnamed one answered EVERY token — while `bearerMount`, handed
38
+ * the same map, serves an unnamed primitive to nobody. Giving a map is declaring the cut; a
39
+ * tool outside it is a mistake, not a public tool. A tool that arrived with its own `scope`
40
+ * (a ready `McpTool`) is covered by it.
36
41
  *
37
42
  * Runs AFTER duplicate names are refused, so a name resolves to exactly one tool.
38
43
  */
@@ -64,6 +69,12 @@ export function withScopes(
64
69
  }
65
70
  }
66
71
 
72
+ const uncovered = tools
73
+ .filter((tool) => tool.scope === undefined && !required.has(tool.name))
74
+ .map((tool) => tool.name)
75
+ .sort();
76
+ if (uncovered.length > 0) throw new McpScopeUncoveredError({ uncovered });
77
+
67
78
  return tools.map((tool) => {
68
79
  const scope = required.get(tool.name);
69
80
  return scope === undefined ? tool : { ...tool, scope };
@@ -8,7 +8,7 @@ import { formatIssues } from '@ultimat3/schema';
8
8
  import type { McpCaller, McpToolResult } from './registry';
9
9
  import type { ArgIssue } from './validate-args';
10
10
 
11
- /** See `CreateMcpServerInput.instructions`. */
11
+ /** See `McpServerInput.instructions`. */
12
12
  export type McpInstructions = string | ((caller: McpCaller) => string | undefined);
13
13
 
14
14
  /** The server's per-caller wording: `initialize`'s advice and the audience of every `fix:` line. */