@ultimat3/mcp 23.0.0 → 25.0.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,37 +268,81 @@ 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,
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.
302
346
  kind: tool.destructive === false ? 'query' : 'action',
303
347
  description: tool.description,
304
348
  ...(tool.confirms === true ? { confirms: true } : {}),
@@ -310,27 +354,44 @@ function sorted<V>(map: ReadonlyMap<string, V>): readonly [string, V][] {
310
354
  return [...map.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
311
355
  }
312
356
 
313
- /** Past this, the hint says "see describe_resource" instead of pretending to be complete. */
314
- 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;
315
363
 
316
364
  /**
317
365
  * `name: type` per property, `?` when optional, an enum as its literals — the hint
318
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.
319
372
  */
320
373
  export function oneLineParams(schema: JsonSchema): string {
321
374
  const required = new Set(schema.required ?? []);
322
- const parts = Object.entries(schema.properties ?? {}).map(
323
- ([key, child]) => `${key}${required.has(key) ? '' : '?'}: ${typeHint(child)}`,
324
- );
325
- const line = parts.join(', ');
326
- if (line.length <= HINT_MAX) return line;
327
- 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(', ');
328
389
  }
329
390
 
330
391
  function typeHint(schema: JsonSchema): string {
331
- 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)));
332
393
  if (schema.const !== undefined) return JSON.stringify(schema.const);
333
- if (schema.anyOf !== undefined) return schema.anyOf.map(typeHint).join('|');
394
+ if (schema.anyOf !== undefined) return literals(schema.anyOf.map(typeHint));
334
395
  if (schema.type === 'array') {
335
396
  if (schema.items === undefined) return 'array';
336
397
  const item = typeHint(schema.items);
@@ -338,3 +399,9 @@ function typeHint(schema: JsonSchema): string {
338
399
  }
339
400
  return schema.type ?? 'any';
340
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,34 +4,28 @@
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
- sourceFor,
21
- } from '@ultimat3/query';
16
+ import { guardQueryBeforeInput, isQuery, queryName, readAnswer, sourceFor } from '@ultimat3/query';
17
+ import { toWireOutputSchema, toWireSchema } from '@ultimat3/schema';
22
18
  import { asCallerContext } from './caller-context';
23
- import type { McpExposure, ProjectablePrimitive } from './from-action';
19
+ import type { ProjectablePrimitive } from './from-action';
24
20
  import { takeIdempotencyKeyArg, withIdempotencyKeyArg } from './idempotency-arg';
25
- import { toOutputSchema, toRowsOutputSchema, toWireSchema } from './input-schema';
26
- import type { McpListParams } from './list-params';
27
- import type { McpToolAnnotations } from './registry';
21
+ import type { JsonSchema } from './wire';
28
22
 
29
23
  /**
30
24
  * What `defineAppMcp`'s `actions:`/`queries:` accept.
31
25
  *
32
26
  * The real primitives come first because they are what an app writes: `actions: [publishPost]`.
33
27
  * Until 2026-08 this list took `ProjectablePrimitive` alone, which no `action()` or `query()`
34
- * 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
35
29
  * reach `X_MCP_TOOL_UNDECLARED` was a hand-built fake, and the gate refused nothing an app could
36
30
  * actually declare. `ProjectablePrimitive` stays in the union for surfaces that build their
37
31
  * catalog programmatically (`@ultimat3/admin`) and for tests that project a stand-in.
@@ -60,7 +54,7 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
60
54
  // `idempotent: true` is honoured on this surface exactly as over HTTP: the caller's key reaches
61
55
  // `invoke`, which files it under the action, the caller and the key (`idempotencyKeyFor`).
62
56
  const keyed = target.describe().idempotent;
63
- const output = toOutputSchema(target.output);
57
+ const output = toWireOutputSchema(target.output);
64
58
  return {
65
59
  name,
66
60
  ...(exposure === undefined ? {} : { mcp: exposure }),
@@ -73,7 +67,7 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
73
67
  admit: (actor: Actor) =>
74
68
  asCallerContext(actor, () => {
75
69
  const ctx = useContext();
76
- actionGuardBeforeInput(target.policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
70
+ guardActionBeforeInput(target.policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
77
71
  }),
78
72
  // The actor rides in on the options: `invoke` swaps it inside the one execution path.
79
73
  run: keyed
@@ -87,23 +81,28 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
87
81
 
88
82
  export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
89
83
  const exposure = exposureOf(target.mcp);
90
- // Only a query that DECLARED its rows (`rows: Post.$schema`) has a known answer shape; the tool
91
- // answers the rows as a list, so the structured copy is `{ rows }` — `structuredContent` is an
92
- // object by the spec.
93
- const output = toRowsOutputSchema(target.rows);
84
+ // A `single: true` read answers ONE row, or not-found — what its route answers (404) and what
85
+ // `@ultimat3/query`'s `readAnswer` makes every read answer. Handing an agent `[]` for a missing id said "found, and empty".
86
+ const single = target.single === true;
87
+ // Only a query that DECLARED its rows (`rows: Post.$schema`) has a known answer shape. A list
88
+ // read answers the rows as a list, so the structured copy is `{ rows }` — `structuredContent`
89
+ // is an object by the spec; a single read's answer already IS the object.
90
+ const output = single ? toWireOutputSchema(target.rows) : toRowsOutputSchema(target.rows);
94
91
  const name = queryName(target);
95
92
  return {
96
93
  name,
97
94
  ...(exposure === undefined ? {} : { mcp: exposure }),
98
95
  ...(exposure?.description === undefined ? {} : { description: exposure.description }),
99
96
  inputJsonSchema: toWireSchema(target.input),
100
- ...(output === undefined ? {} : { outputJsonSchema: output, outputWrap: 'rows' }),
97
+ ...(output === undefined
98
+ ? {}
99
+ : { outputJsonSchema: output, ...(single ? {} : { outputWrap: 'rows' }) }),
101
100
  mutates: false,
102
101
  // `sourceFor`'s opening gate, on the surface it runs on ('server'), asked before `invalid-args`.
103
102
  admit: (actor: Actor) =>
104
103
  asCallerContext(actor, () => {
105
104
  const ctx = useContext();
106
- queryGuardBeforeInput(target.policy, { actor: actorOf(ctx), ctx, query: name }, 'server');
105
+ guardQueryBeforeInput(target.policy, { actor: actorOf(ctx), ctx, query: name }, 'server');
107
106
  }),
108
107
  run: ({ input, actor }) =>
109
108
  asCallerContext(actor, async () => {
@@ -111,14 +110,28 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
111
110
  // and is what `live`, `paginate` and `explain` build on too. Executed without the
112
111
  // cache tiers on purpose: an agent diffing two tool calls must be reading the rows,
113
112
  // not a TTL.
114
- const source = await sourceFor(target, input);
115
- return source.execute();
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' });
115
+ // `@ultimat3/query`'s one rule for what a read answers: rows, or a single read's row.
116
+ return readAnswer(target, await source.execute());
116
117
  }),
117
118
  };
118
119
  }
119
120
 
121
+ /** `{ rows: [<row>] }` — a query's answer as `structuredContent`. `undefined` when no row schema. */
122
+ export function toRowsOutputSchema(rows: unknown): JsonSchema | undefined {
123
+ if (rows === undefined) return undefined;
124
+ const row = toWireOutputSchema(rows);
125
+ if (row === undefined) return undefined;
126
+ return {
127
+ type: 'object',
128
+ properties: { rows: { type: 'array', items: row } },
129
+ required: ['rows'],
130
+ };
131
+ }
132
+
120
133
  /**
121
- * 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
122
135
  * both. Narrow on purpose, through `@ultimat3/core`'s `isMcpExposed`: only a literal
123
136
  * `expose: true` counts, so nothing is exposed by accident — an undeclared `mcp` block yields
124
137
  * no exposure at all.
@@ -129,7 +142,9 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
129
142
  * projected tool was visible to every caller and the first outcome existed only for the
130
143
  * hand-written tools that build their own `McpTool`.
131
144
  */
132
- function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined {
145
+ function exposureOf(
146
+ declared: McpExposureDeclaration | undefined,
147
+ ): McpExposureDeclaration | undefined {
133
148
  if (declared === undefined) return undefined;
134
149
  return {
135
150
  expose: isMcpExposed(declared),
@@ -140,14 +155,3 @@ function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined
140
155
  ...(declared.annotations === undefined ? {} : { annotations: declared.annotations }),
141
156
  };
142
157
  }
143
-
144
- /** `ActionMcp` and `QueryMcp` are the same shape; restating it binds to neither. */
145
- interface DeclaredMcp {
146
- readonly expose: boolean;
147
- readonly description?: string;
148
- readonly visibleTo?: readonly string[];
149
- /** `QueryMcp` only; an action has no list to compose. */
150
- readonly listParams?: McpListParams;
151
- readonly title?: string;
152
- readonly annotations?: McpToolAnnotations;
153
- }
@@ -0,0 +1,106 @@
1
+ // The function families `db.query` may not call, and the scan that finds a call. Split from
2
+ // `readonly-sql.ts` for the size ceiling. On embedded PGlite — `x dev`'s database — this ban is the
3
+ // only layer holding: `statement_timeout` cannot interrupt it, and no SELECT-only role need exist
4
+ // there. So what this scan cannot read is refused by the caller, never decoded.
5
+
6
+ /**
7
+ * Function families a read may not call, matched as a PREFIX of a CALLED function name.
8
+ *
9
+ * The family is the unit, never the name. Refusing `pg_sleep` while admitting `pg_sleep_for` is a
10
+ * distinction only this parser draws, and an exact-name list admits by default: every spelling
11
+ * nobody thought to write down passes. A prefix refuses by default instead, so a member Postgres
12
+ * adds next release is covered on the day it ships.
13
+ *
14
+ * Each family is a ban `readonly-sql.ts` already makes in some other spelling:
15
+ * - reach outside the database — the original list (`pg_read_*`, `pg_ls_*`, `lo_*`, `dblink`);
16
+ * - hold a lock, which `FOR UPDATE` is refused for. The call is the worse of the two: a SESSION
17
+ * advisory lock is not released by the `ROLLBACK` layer 2 always runs, so it outlives the read
18
+ * on a pooled connection the app's own writers use;
19
+ * - mutate the server or the session — `set_config` is `SET` spelled as a call, and `SET` is a
20
+ * write keyword;
21
+ * - burn the wall clock — layer 2's `statement_timeout` cannot interrupt embedded PGlite
22
+ * (single-threaded WASM), which is the database `x dev` runs;
23
+ * - ADVANCE A SEQUENCE (`nextval`, `setval`) — a write that leaves no keyword behind, and one
24
+ * `ROLLBACK` does not undo: a consumed sequence value is gone, so a read can silently burn the
25
+ * next id a real insert would have taken. `currval`/`lastval` read the session and stay legal.
26
+ * `txid_current`/`pg_current_xact_id` are the same ban one level down: they ASSIGN a real
27
+ * transaction id to a read, and a rollback does not give it back;
28
+ * - PUBLISH A MESSAGE — `pg_notify` is `NOTIFY` spelled as a call. The keyword scan cannot see
29
+ * it: it is one token;
30
+ * - CONTROL THE SERVER (`pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote`,
31
+ * `pg_wal_replay_*`) — the family `pg_cancel_backend`/`pg_terminate_backend` established;
32
+ * - CONSUME THE REPLICATION STREAM (`pg_create_*`, `pg_drop_*`, `pg_replication_*`,
33
+ * `pg_logical_*`) — `pg_logical_slot_get_changes` advances a slot's confirmed position. The
34
+ * catalog VIEWS beside them (`pg_replication_slots`) are read `from`, never called;
35
+ * - WRITE A FILE (`pg_file_*`) — the other half of `pg_read_*`;
36
+ * - WRITE THE CATALOG (`pg_import_*`) — `pg_import_system_collations` inserts `pg_collation` rows;
37
+ * - RUN A QUERY HANDED TO IT AS TEXT (`query_to_xml*`, `cursor_to_xml*`, `table_to_xml*`,
38
+ * `schema_to_xml*`, `database_to_xml*`, `ts_stat`, `ts_rewrite`) — the statement is a string
39
+ * literal, which every pass of the scan blanks by design, so whatever it calls is invisible.
40
+ *
41
+ * The prefix is applied to a CALL — a name followed by `(` — and never to a bare word, so a
42
+ * column called `pg_sleep_for_seconds` stays readable. Quoting does not evade it: the scan reads
43
+ * a form where a quoted identifier keeps its content, because `"pg_advisory_lock"(1)` is the same
44
+ * call as `pg_advisory_lock(1)`.
45
+ */
46
+ const FORBIDDEN_FUNCTIONS = [
47
+ 'cursor_to_xml',
48
+ 'database_to_xml',
49
+ 'dblink',
50
+ 'lo_',
51
+ 'nextval',
52
+ 'pg_advisory_',
53
+ 'pg_cancel_backend',
54
+ 'pg_create_',
55
+ 'pg_current_xact_id',
56
+ 'pg_drop_',
57
+ 'pg_file_',
58
+ 'pg_import_',
59
+ 'pg_logical_',
60
+ 'pg_ls_',
61
+ 'pg_notify',
62
+ 'pg_promote',
63
+ 'pg_read_',
64
+ 'pg_reload_',
65
+ 'pg_replication_',
66
+ 'pg_rotate_',
67
+ 'pg_sleep',
68
+ 'pg_stat_file',
69
+ 'pg_stat_reset',
70
+ // Not reachable from `pg_stat_reset`: the extension spells the same reset with the statistics
71
+ // view's name in the middle, so a prefix of one is not a prefix of the other.
72
+ 'pg_stat_statements_reset',
73
+ 'pg_switch_',
74
+ 'pg_terminate_backend',
75
+ 'pg_try_advisory_',
76
+ 'pg_wal_replay_',
77
+ 'query_to_xml',
78
+ 'schema_to_xml',
79
+ 'set_config',
80
+ 'setval',
81
+ 'table_to_xml',
82
+ 'ts_rewrite',
83
+ 'ts_stat',
84
+ 'txid_current',
85
+ ];
86
+
87
+ /** The family refusing `called`, or `undefined`. A prefix, so a new member is refused by default. */
88
+ export function forbiddenFamily(called: string): string | undefined {
89
+ return FORBIDDEN_FUNCTIONS.find((family) => called.startsWith(family));
90
+ }
91
+
92
+ /**
93
+ * A call: an identifier immediately before `(`. Schema qualification falls out of the scan —
94
+ * `pg_catalog.set_config(` matches on the last segment, which is the function being called.
95
+ */
96
+ const CALL_PATTERN = /([a-z_][a-z0-9_$]*)\s*\(/g;
97
+
98
+ /** Every function `sql` calls, lowercased. Read from the identifier-preserving strip. */
99
+ export function calledFunctions(sql: string): readonly string[] {
100
+ const names: string[] = [];
101
+ for (const match of sql.toLowerCase().matchAll(CALL_PATTERN)) {
102
+ const name = match[1];
103
+ if (name !== undefined) names.push(name);
104
+ }
105
+ return names;
106
+ }
@@ -7,6 +7,7 @@
7
7
  // (INSERT ...) SELECT`, which reads like a SELECT and is not one).
8
8
 
9
9
  import { McpNotBranchDbError, McpQueryRejectedError } from './errors';
10
+ import { calledFunctions, forbiddenFamily } from './readonly-sql-calls';
10
11
 
11
12
  /** Named in `db.query`'s `guards`, so a caller can see that layer 3 actually ran. */
12
13
  export const PARSE_GUARD = 'parse:single-read';
@@ -68,102 +69,6 @@ const WRITE_KEYWORDS = new Set([
68
69
  'vacuum',
69
70
  ]);
70
71
 
71
- /**
72
- * Function families a read may not call, matched as a PREFIX of a CALLED function name.
73
- *
74
- * The family is the unit, never the name. Refusing `pg_sleep` while admitting `pg_sleep_for` is a
75
- * distinction only this parser draws, and an exact-name list admits by default: every spelling
76
- * nobody thought to write down passes. A prefix refuses by default instead, so a member Postgres
77
- * adds next release is covered on the day it ships.
78
- *
79
- * Each family is a ban this file already makes in some other spelling:
80
- * - reach outside the database — the original list (`pg_read_*`, `pg_ls_*`, `lo_*`, `dblink`);
81
- * - hold a lock, which `FOR UPDATE` is refused for below. The call is the worse of the two: a
82
- * SESSION advisory lock is not released by the `ROLLBACK` layer 2 always runs, so it outlives
83
- * the read on a pooled connection the app's own writers use;
84
- * - mutate the server or the session — `set_config` is `SET` spelled as a call, and `SET` is a
85
- * write keyword above;
86
- * - burn the wall clock — layer 2's `statement_timeout` cannot interrupt embedded PGlite
87
- * (single-threaded WASM), which is the database `x dev` runs, so this ban is the only one
88
- * that holds there;
89
- * - ADVANCE A SEQUENCE (`nextval`, `setval`) — a write that leaves no keyword behind, and one
90
- * `ROLLBACK` does not undo: a consumed sequence value is gone, so a read can silently burn the
91
- * next id a real insert would have taken. `currval`/`lastval` read the session and stay legal.
92
- * `txid_current`/`pg_current_xact_id` are the same ban one level down: they ASSIGN a real
93
- * transaction id to a read, and a rollback does not give it back;
94
- * - PUBLISH A MESSAGE — `pg_notify` is `NOTIFY` spelled as a call, and `notify`, `listen` and
95
- * `unlisten` are all write keywords above. The keyword scan cannot see it: it is one token;
96
- * - CONTROL THE SERVER (`pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote`,
97
- * `pg_wal_replay_*`) — the family `pg_cancel_backend`/`pg_terminate_backend` already
98
- * established, in the spellings that reconfigure or fail over the server rather than a backend;
99
- * - CONSUME THE REPLICATION STREAM (`pg_create_*`, `pg_drop_*`, `pg_replication_*`,
100
- * `pg_logical_*`) — `pg_logical_slot_get_changes` advances a slot's confirmed position, so the
101
- * changes it returned are gone for the real consumer. Exactly the `nextval` argument: a write
102
- * with no keyword, and no `ROLLBACK` undoes it. The catalog VIEWS beside them
103
- * (`pg_replication_slots`, `pg_stat_replication`) are read `from`, never called, so the call
104
- * scan never sees them;
105
- * - WRITE A FILE (`pg_file_*`) — the other half of `pg_read_*`, which was banned from the start.
106
- *
107
-
108
- * The prefix is applied to a CALL — a name followed by `(` — and never to a bare word, so a
109
- * column called `pg_sleep_for_seconds` stays readable. Quoting does not evade it: the scan reads
110
- * a form where a quoted identifier keeps its content, because `"pg_advisory_lock"(1)` is the same
111
- * call as `pg_advisory_lock(1)`.
112
- */
113
- const FORBIDDEN_FUNCTIONS = [
114
- 'dblink',
115
- 'lo_',
116
- 'nextval',
117
- 'pg_advisory_',
118
- 'pg_cancel_backend',
119
- 'pg_create_',
120
- 'pg_current_xact_id',
121
- 'pg_drop_',
122
- 'pg_file_',
123
- 'pg_logical_',
124
- 'pg_ls_',
125
- 'pg_notify',
126
- 'pg_promote',
127
- 'pg_read_',
128
- 'pg_reload_',
129
- 'pg_replication_',
130
- 'pg_rotate_',
131
- 'pg_sleep',
132
- 'pg_stat_file',
133
- 'pg_stat_reset',
134
- // Not reachable from `pg_stat_reset`: the extension spells the same reset with the statistics
135
- // view's name in the middle, so a prefix of one is not a prefix of the other.
136
- 'pg_stat_statements_reset',
137
- 'pg_switch_',
138
- 'pg_terminate_backend',
139
- 'pg_try_advisory_',
140
- 'pg_wal_replay_',
141
- 'set_config',
142
- 'setval',
143
- 'txid_current',
144
- ];
145
-
146
- /** The family refusing `called`, or `undefined`. A prefix, so a new member is refused by default. */
147
- function forbiddenFamily(called: string): string | undefined {
148
- return FORBIDDEN_FUNCTIONS.find((family) => called.startsWith(family));
149
- }
150
-
151
- /**
152
- * A call: an identifier immediately before `(`. Schema qualification falls out of the scan —
153
- * `pg_catalog.set_config(` matches on the last segment, which is the function being called.
154
- */
155
- const CALL_PATTERN = /([a-z_][a-z0-9_$]*)\s*\(/g;
156
-
157
- /** Every function `sql` calls, lowercased. Read from the identifier-preserving strip. */
158
- function calledFunctions(sql: string): readonly string[] {
159
- const names: string[] = [];
160
- for (const match of sql.toLowerCase().matchAll(CALL_PATTERN)) {
161
- const name = match[1];
162
- if (name !== undefined) names.push(name);
163
- }
164
- return names;
165
- }
166
-
167
72
  /**
168
73
  * Throw unless `sql` is a single read-only statement. Every check runs on the *stripped* form
169
74
  * (literals and comments blanked) so a keyword hiding in a string cannot fool it — but the
@@ -189,7 +94,9 @@ export function assertReadOnlyQuery(sql: string): string {
189
94
  }
190
95
 
191
96
  const statement = statements[0] ?? '';
192
- const words: readonly string[] = statement.toLowerCase().match(/[a-z_]+/g) ?? [];
97
+ // A word is what Postgres lexes as ONE identifier — a letter, `_` or non-ASCII first, then
98
+ // digits and `$` too. `[a-z_]+` split `set2` at the digit and refused a column as `SET`.
99
+ const words: readonly string[] = statement.toLowerCase().match(WORD) ?? [];
193
100
  const leader = words[0] ?? '';
194
101
  if (!READ_LEADERS.has(leader)) {
195
102
  throw rejected(
@@ -336,6 +243,15 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
336
243
  continue;
337
244
  }
338
245
  const char = sql[i];
246
+ if (char === '"' && unicodeEscaped(sql, i)) {
247
+ // Refused, never decoded: `U&"pg\005fsleep"` is `pg_sleep` to Postgres, and a decoder here
248
+ // is a second reading of the lexer's escape grammar (`UESCAPE` included) that can drift.
249
+ throw rejected(
250
+ 'the statement quotes an identifier with Unicode escapes (U&"…"), which this check ' +
251
+ 'cannot read',
252
+ 'spell the identifier plainly: select "column name" from posts',
253
+ );
254
+ }
339
255
  if (char === "'" || char === '"') {
340
256
  const end = char === "'" ? skipSingleQuoted(sql, i) : skipQuoted(sql, i, char);
341
257
  // Padded, never spliced in place: `select"pg_advisory_lock"(1)` must not fuse into one
@@ -372,8 +288,21 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
372
288
  }
373
289
 
374
290
  const IDENTIFIER_CHAR = /[A-Za-z0-9_$\u0080-\uffff]/;
291
+ const WORD = /[a-z_\u0080-\uffff][a-z0-9_$\u0080-\uffff]*/g;
375
292
  const DOLLAR_TAG = /^\$(?:[A-Za-z_\u0080-\uffff][A-Za-z0-9_\u0080-\uffff]*)?\$/;
376
293
 
294
+ /**
295
+ * `U&"` opening at a token boundary — the lexer's `xuistart`, with no space inside. `menu&"m"` is
296
+ * the column `menu` and an operator, so the `u` must not continue an identifier.
297
+ */
298
+ function unicodeEscaped(sql: string, quote: number): boolean {
299
+ return (
300
+ sql[quote - 1] === '&' &&
301
+ (sql[quote - 2] === 'u' || sql[quote - 2] === 'U') &&
302
+ !IDENTIFIER_CHAR.test(sql[quote - 3] ?? '')
303
+ );
304
+ }
305
+
377
306
  /**
378
307
  * Where a `--` comment ends: the first CR **or** LF, or the end of the input.
379
308
  *
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