@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.
- package/CLAUDE.md +54 -51
- package/README.md +153 -21
- package/package.json +12 -11
- package/src/app-tool.ts +7 -8
- package/src/app-tools.ts +38 -13
- package/src/audit-hook.ts +108 -0
- package/src/audit.ts +17 -0
- package/src/caller-context.ts +2 -4
- package/src/confirmation-decide.ts +116 -0
- package/src/confirmation-errors.ts +137 -0
- package/src/confirmation-gate.ts +101 -0
- package/src/confirmation-postgres.ts +143 -0
- package/src/confirmation-schema.ts +34 -0
- package/src/confirmation-store.ts +136 -0
- package/src/confirmations.ts +147 -0
- package/src/dev-host.ts +4 -4
- package/src/errors-transport.ts +94 -0
- package/src/errors.ts +44 -102
- package/src/framework-error.ts +31 -3
- package/src/from-action.ts +30 -58
- package/src/index.ts +58 -19
- package/src/list-params.ts +9 -4
- package/src/meta-call.ts +5 -4
- package/src/meta-surface.ts +86 -19
- package/src/mount-errors.ts +1 -1
- package/src/oauth-metadata.ts +1 -1
- package/src/projectable.ts +44 -40
- package/src/readonly-sql-calls.ts +106 -0
- package/src/readonly-sql.ts +26 -97
- package/src/registry.ts +6 -5
- package/src/scopes.ts +15 -4
- package/src/server-voice.ts +1 -1
- package/src/server.ts +38 -36
- package/src/tool-thrown.ts +5 -3
- package/src/transport-http.ts +119 -17
- package/src/transport-stdio.ts +1 -1
- package/src/type-pins.ts +52 -0
- package/src/validate-args.ts +12 -4
- package/src/wire.ts +21 -40
- package/src/input-schema.ts +0 -140
package/src/meta-surface.ts
CHANGED
|
@@ -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
|
|
274
|
-
* closeCase (action; confirms
|
|
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
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
/**
|
|
314
|
-
const
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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))
|
|
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)
|
|
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
|
+
}
|
package/src/mount-errors.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/oauth-metadata.ts
CHANGED
|
@@ -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';
|
package/src/projectable.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
actionName,
|
|
9
|
+
type Actor,
|
|
10
10
|
actorOf,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
isMcpExposed,
|
|
12
|
+
type McpExposureDeclaration,
|
|
13
|
+
useContext,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
15
|
import type { AnyQuery } from '@ultimat3/query';
|
|
16
|
-
import {
|
|
17
|
-
|
|
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 {
|
|
19
|
+
import type { ProjectablePrimitive } from './from-action';
|
|
24
20
|
import { takeIdempotencyKeyArg, withIdempotencyKeyArg } from './idempotency-arg';
|
|
25
|
-
import {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
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(
|
|
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
|
+
}
|
package/src/readonly-sql.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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 ===
|
|
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: `
|
|
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
|