@ultimat3/mcp 23.0.0 → 24.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 CHANGED
@@ -29,8 +29,7 @@ import. The CLI wires it.
29
29
  | `projectable.ts` | a real `action`/`query` → `ProjectablePrimitive`; the ONE adapter both the sweep and the written-out list use |
30
30
  | `exposed.ts` | `include: 'exposed'` — the action/query registries → primitives |
31
31
  | `scopes.ts` | the `scopes:` map — outcome 2's declaration surface; boot-time refusal of an unknown or doubly-claimed tool |
32
- | `input-schema.ts` | Standard Schema → the `JsonSchema` subset `validate-args.ts` enforces |
33
- | `readonly-sql.ts` | layer 3 of `db.query` — the single-read parse — and `db.migrate`'s branch check |
32
+ | `readonly-sql.ts` · `readonly-sql-calls.ts` | layer 3 of `db.query` — the single-read parse — and `db.migrate`'s branch check · the banned call families |
34
33
  | `query-limits.ts` | layer 4 of `db.query` — the row, byte and timeout ceilings, and what truncation reports |
35
34
  | `meta-surface.ts` | `surface: 'meta'` catalog: `list_resources` / `describe_resource`, `groups:` boot checks; dispatch stays in `server.ts` |
36
35
  | `list-params.ts` | `listParams` whitelist → the schema `manage_resource` enforces (flat keys: `status_eq`) |
@@ -102,15 +101,17 @@ import. The CLI wires it.
102
101
  - **A declared `pattern` is compiled once per schema NODE, never per `tools/call`** — a `WeakMap`
103
102
  keyed on the node (never on the pattern string, which grows forever), caching an uncompilable
104
103
  pattern's `null` too. `compiledPatternCount()` is the test-only probe; `validate-args.test.ts`
105
- asserts it does not climb over 100 calls.
104
+ asserts it does not climb over 100 calls. Compiled WITH `x-ultimate-pattern-flags` (schema's),
105
+ `lastIndex` reset.
106
106
  - **`format` is NOT in the wire subset**, and `wire.ts` types it `never` so re-adding it does not
107
107
  compile. It names a rule whose meaning lives in `@ultimat3/schema` (`uuid`, `email`,
108
108
  `iana-time-zone`), and this package cannot check it without a second definition of each that can
109
109
  only drift from the action's own parse. `tools/list` published it and `validate-args.ts` ignored
110
- it, so a tool declaring `t.uuid` accepted `"not-a-uuid"` with `ok: true` — the silent pass
111
- `input-schema.ts` exists to prevent. `pattern` is the opposite case and is kept: the rule travels
112
- with the schema. `input-schema.test.ts` asserts every published keyword is one this server
113
- enforces, at any depth.
110
+ it, so a tool declaring `t.uuid` accepted `"not-a-uuid"` with `ok: true`. `pattern` is the
111
+ opposite case and is kept: the rule travels with the schema. The narrowing is `@ultimat3/schema`'s
112
+ `toWireSchema` (tier 0, so `@ultimat3/action`'s `.tool()` publishes the same document) and the
113
+ type is its `WireJsonSchema`, which `wire.ts` names `JsonSchema`; `validate-args-subset.test.ts`
114
+ asserts every published keyword is one this server enforces, at any depth.
114
115
  - **A hand-written app tool parses its own input**, in the slot `invoke` puts it: parse, then
115
116
  `guard()`, then `handle`. A projected action re-parses inside `invoke`; `app-tool.ts` had no second
116
117
  parse, so `handle` was handed whatever the wire subset let through — typed `InferOutput<TInput>`,
@@ -131,7 +132,9 @@ import. The CLI wires it.
131
132
  `-32603` the transport promises for a genuine bug.
132
133
  - A framework error rendered into a tool result is **byte-identical to
133
134
  `UltimateError.format()`** — one denial must not read one way over MCP and another in the
134
- terminal. `server.ts` renders it; the test pins it against `format()`, never a literal.
135
+ terminal. `server.ts` renders it; the test pins it against `format()`, never a literal. Except a
136
+ hidden 5xx for `'caller'` (`forAudience`, `As of 2026-10`): `statusFor(code) >= 500` and not core's
137
+ `hasPublicCause` → `HIDDEN_CAUSE` + `x errors explain`, tool results and resource reads alike.
135
138
  - Every outcome is audited via `audit.ts`, hidden included, at `warn`. Never log arguments or row
136
139
  data. **On both surfaces**: `mcp.tool-call.<outcome>` and `mcp.resource-read.<outcome>` — two
137
140
  events, one `LEVEL` table, so an alert can tell a URI walk from a tool-name walk. `resources/list`
@@ -223,7 +226,9 @@ import. The CLI wires it.
223
226
  `pg_terminate_backend`, `pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote`,
224
227
  `pg_wal_replay_*`); replication (`pg_logical_*`, `pg_create_*`, `pg_drop_*`, `pg_replication_*` —
225
228
  advancing a slot is a write no `ROLLBACK` undoes); `pg_file_*`; `txid_current` /
226
- `pg_current_xact_id`. Catalog VIEWS (`pg_replication_slots`) are read `from`, never called.
229
+ `pg_current_xact_id`; `pg_import_*`; SQL run from a string (`*_to_xml*`, `ts_stat`, `ts_rewrite`). `U&"…"` is
230
+ refused, never decoded; a keyword is a whole identifier (`set2` is a column). Catalog VIEWS
231
+ (`pg_replication_slots`) are read `from`, never called.
227
232
  - Banned SQL functions are matched as a **prefix of a CALLED function name** — add a family, never
228
233
  a name (`pg_sleep_for` passed an exact `pg_sleep` ban). The unit is the call (`name(`), so a
229
234
  column `pg_sleep_for_seconds` is fine; the call scan keeps quoted-identifier content
@@ -251,12 +256,13 @@ import. The CLI wires it.
251
256
  `resolveToken` rejects and a non-agent actor all return before the body is read: parsing first
252
257
  answered `400 parse error` for a malformed payload and `401` for a well-formed one under the
253
258
  SAME rejected token, which is precisely the oracle the pre-parse 401 exists to remove. The parse
254
- error still exists — it is what an authenticated agent gets.
259
+ error still exists — it is what an authenticated agent gets. Failures spend a per-ADDRESS bucket
260
+ (`seen.address`), and an exhausted address is `429` BEFORE `resolveToken` — a valid guess too.
255
261
  - **`transport-stdio.ts`'s default `write` is AWAITED** — fd 1 is a pipe, and an unawaited
256
262
  `Bun.stdout.write` lost the tail of a 4 MB frame at exit. It is also the loop's only
257
263
  back-pressure; only a child-process test can see it.
258
- - **A schema property lands through `Object.defineProperty` too** — `input-schema.ts`'s
259
- `narrowProperties`, the twin of `validate-args.ts`'s `put` on the schema side. `out[key] = …` for
264
+ - **A schema property lands through `Object.defineProperty` too** — `@ultimat3/schema`'s
265
+ `wire-schema.ts`, the twin of `validate-args.ts`'s `put` on the schema side. `out[key] = …` for
260
266
  a property named `__proto__` runs `Object.prototype`'s setter and re-prototypes the published
261
267
  `properties` record instead of adding a key, so a field the tool author declared vanishes from
262
268
  `tools/list` and from what the arg validator reads back.
package/README.md CHANGED
@@ -226,7 +226,9 @@ meta tool name taken by an app tool (`X_MCP_TOOL_DUPLICATE`).
226
226
  maxLimit: 100 } }`. Keys are FLAT — `status_eq`, `createdAt_gt`, `sort: '-createdAt'`, `fields`,
227
227
  `cursor` (opaque keyset), `limit` — because a query's input travels as a query string; the query's
228
228
  own `input` declares them and implements them. `describe_resource` publishes the whitelist and
229
- `manage_resource` refuses anything outside it (an `isError` result carrying `X_INPUT_INVALID`) before the query runs; a
229
+ `manage_resource` admits the whitelist plus the keys the input `required`s and refuses anything
230
+ else, an optional input key the whitelist left out included (an `isError` result carrying
231
+ `X_INPUT_INVALID`), before the query runs; a
230
232
  whitelisted key the input does not declare is `X_MCP_LIST_PARAMS_INVALID` at boot.
231
233
 
232
234
  ### What a client is told beyond the schema
@@ -239,8 +241,8 @@ whitelisted key the input does not declare is `X_MCP_LIST_PARAMS_INVALID` at boo
239
241
  | `initialize.instructions` | `defineAppMcp({ instructions })` / `createMcpServer({ instructions })` — a string, or `(caller) => string \| undefined` | none; a function that throws or answers blank sends none |
240
242
  | `title` | `mcp: { title }` · hand-written `title` | none |
241
243
  | `annotations` | `deriveAnnotations(primitive)`, then `mcp: { annotations }` key by key | query `{ readOnlyHint: true }`; action `{ readOnlyHint: false, destructiveHint: true, idempotentHint: <idempotent> }`; `openWorldHint` only when declared |
242
- | `outputSchema` | an action's `output` with an object root; a query's `rows` as `{ rows: [<row>] }` — `toOutputSchema` / `toRowsOutputSchema`, structure only (no bounds, patterns or `additionalProperties`: a client refuses a result that misses its schema) | none |
243
- | `structuredContent` | `structuredResult(value, wrap?)`: the serialized answer read back, beside the text block | absent |
244
+ | `outputSchema` | an action's `output` with an object root; a query's `rows` as `{ rows: [<row>] }`, or as the row itself for a `single: true` read (which answers one row or `X_NOT_FOUND`, as its route does) — `toWireOutputSchema` (from `@ultimat3/schema`) / `toRowsOutputSchema`, structure only (no bounds, patterns or `additionalProperties`: a client refuses a result that misses its schema) | none |
245
+ | `structuredContent` | `structuredResult(value, wrap?)`: the serialized answer read back, beside the text block — a list query's rows under `rows`, a `single: true` query's row unwrapped | absent |
244
246
 
245
247
  A tool's text is **compact JSON** (`jsonResult`; the 2-space form until 22.10). The meta tools
246
248
  carry their own hints: `list_resources` and `describe_resource` read-only, `manage_resource` a
@@ -290,6 +292,7 @@ did not.
290
292
  |---|---|---|
291
293
  | the body cap | `mcpHttpRoute({ bodyLimitBytes })` · `defineAppMcp({ bodyLimitBytes })` | `DEFAULT_MCP_BODY_LIMIT_BYTES`, 1 MiB |
292
294
  | the numbers | `mcpHttpRoute({ rateLimits })` · `defineAppMcp({ rateLimits })` | `MCP_RATE_LIMITS` |
295
+ | failed authentications per address | `rateLimits: { unauthenticated }`, keyed on `handle(request, { address })` | `MCP_UNAUTHENTICATED_LIMIT`, 20 / minute; past it the address is `429` before `resolveToken` runs. No `address`, not metered here |
293
296
  | where they are counted | `mcpHttpRoute({ rateLimitStore })` · `defineAppMcp({ rateLimitStore })` | a per-**process** memory store — N replicas behind one URL each enforce the full allowance, so a fleet passes `postgresRateLimitStore({ executor })` |
294
297
  | OAuth discovery | `mcpHttpRoute({ oauth })` · `defineAppMcp({ oauth })` | absent: the 401 is `Bearer realm="ultimate-mcp"` |
295
298
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "23.0.0",
3
+ "version": "24.0.0",
4
4
  "description": "MCP server, dev tools, and the action-to-tool projection \u2014 one authz system, two surfaces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,13 +32,13 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "23.0.0",
36
- "@ultimat3/core": "23.0.0",
37
- "@ultimat3/entity": "23.0.0",
38
- "@ultimat3/http": "23.0.0",
39
- "@ultimat3/jobs": "23.0.0",
40
- "@ultimat3/policy": "23.0.0",
41
- "@ultimat3/query": "23.0.0",
42
- "@ultimat3/schema": "23.0.0"
35
+ "@ultimat3/action": "24.0.0",
36
+ "@ultimat3/core": "24.0.0",
37
+ "@ultimat3/entity": "24.0.0",
38
+ "@ultimat3/http": "24.0.0",
39
+ "@ultimat3/jobs": "24.0.0",
40
+ "@ultimat3/policy": "24.0.0",
41
+ "@ultimat3/query": "24.0.0",
42
+ "@ultimat3/schema": "24.0.0"
43
43
  }
44
44
  }
package/src/app-tool.ts CHANGED
@@ -16,11 +16,10 @@ import { useContext } from '@ultimat3/core';
16
16
  import type { KnownPermission } from '@ultimat3/policy';
17
17
  import { can } from '@ultimat3/policy';
18
18
  import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
19
- import { formatIssues, toValidationIssues, validateAsync } from '@ultimat3/schema';
19
+ import { formatIssues, toValidationIssues, toWireSchema, validateAsync } from '@ultimat3/schema';
20
20
  import { asCallerContext } from './caller-context';
21
21
  import { McpToolUnsafeError } from './errors';
22
22
  import type { ProjectablePrimitive } from './from-action';
23
- import { toWireSchema } from './input-schema';
24
23
  import type { McpRole, McpToolAnnotations } from './registry';
25
24
 
26
25
  export interface AppToolArgs<TInput extends StandardSchemaV1> {
package/src/app-tools.ts CHANGED
@@ -20,7 +20,7 @@ import type { McpResourceGroups, McpSurfaceOption } from './meta-surface';
20
20
  import type { McpOAuth } from './oauth-metadata';
21
21
  import type { ListedPrimitive } from './projectable';
22
22
  import { asProjectable } from './projectable';
23
- import type { AnyMcpTool, McpVerbClass } from './registry';
23
+ import type { AnyMcpTool } from './registry';
24
24
  import type { McpPrompt, McpResource } from './resources';
25
25
  import { toPrompts } from './resources';
26
26
  import type { McpScopes } from './scopes';
@@ -28,7 +28,7 @@ import { withScopes } from './scopes';
28
28
  import type { CreateMcpServerInput } from './server';
29
29
  import { createMcpServer, type McpServer } from './server';
30
30
  import type { McpInstructions } from './server-voice';
31
- import type { McpRouteDescriptor, ResolvedToken } from './transport-http';
31
+ import type { McpRateLimits, McpRouteDescriptor, ResolvedToken } from './transport-http';
32
32
  import { mcpHttpRoute } from './transport-http';
33
33
 
34
34
  /** Schema map behind the authored `tools` record; inferred per tool, never written by hand. */
@@ -133,10 +133,11 @@ export interface DefineAppMcpInput<TSchemas extends AppToolSchemas = AppToolSche
133
133
  */
134
134
  readonly bodyLimitBytes?: number | undefined;
135
135
  /**
136
- * Requests per minute per caller, by class. Defaults to `MCP_RATE_LIMITS` and is ENFORCED by the
137
- * route, so this is the app's one knob over what an agent may spend here.
136
+ * Requests per minute per caller, by class — defaults to `MCP_RATE_LIMITS` — and failed
137
+ * authentications per minute per address (`unauthenticated`). ENFORCED by the route, so this is
138
+ * the app's one knob over what an agent, or a stranger guessing tokens, may spend here.
138
139
  */
139
- readonly rateLimits?: Readonly<Record<McpVerbClass, number>> | undefined;
140
+ readonly rateLimits?: McpRateLimits | undefined;
140
141
  /**
141
142
  * Where those buckets are counted. Forwarded rather than left to the route's per-process default
142
143
  * because an app behind N replicas has no other way to reach it — and N processes each holding
@@ -1,16 +1,20 @@
1
1
  // A value a tool THREW, read as a framework error (code, title, cause, fix) or rejected as a bug —
2
2
  // and its rendering, byte-identical to `UltimateError.format()`. Split from `server.ts`, whose two
3
3
  // dispatch paths (a flat `tools/call` and `manage_resource`) and resource reads all share it.
4
+ // Also what a REMOTE caller may read of it: a 5xx cause only when core's `hasPublicCause` says so.
4
5
 
5
6
  import type { ErrorAudience } from '@ultimat3/core';
6
7
  import {
7
8
  ERROR_DOCS_URL,
8
9
  FRAMEWORK_CODE,
9
10
  fixFor,
11
+ hasPublicCause,
10
12
  isUltimateError,
13
+ renderFixShellArg,
11
14
  singleLine,
12
15
  stringField,
13
16
  } from '@ultimat3/core';
17
+ import { statusFor } from '@ultimat3/http';
14
18
 
15
19
  export interface FrameworkError {
16
20
  readonly code: string;
@@ -64,6 +68,29 @@ export function asFrameworkError(error: unknown): FrameworkError | undefined {
64
68
  };
65
69
  }
66
70
 
71
+ /**
72
+ * The fixed sentence a remote caller reads in place of a hidden cause. The real one is in the
73
+ * audit line this call wrote and in the error monitor, never on the wire.
74
+ */
75
+ export const HIDDEN_CAUSE =
76
+ 'the server failed while handling this call; the details are in this process\u2019s logs';
77
+
78
+ /**
79
+ * What THIS audience may read of a thrown error — the verdict `@ultimat3/http`'s `toProblem` gives
80
+ * the problem document, asked of the same two tables. A remote caller (`'caller'`) of a code whose
81
+ * status is 5xx gets the cause only when core's `hasPublicCause` says so: `X_DB_STATEMENT_FAILED`
82
+ * carries the server's message and the statement, and an MCP client is a remote client. The
83
+ * developer's `fix` is withheld with it — a driver's fix is built from the same statement — and
84
+ * `callerFix`, authored for the caller, still goes. `'developer'` (the dev server) keeps both, as
85
+ * HTTP's `dev: true` does: the reader is the author fixing their own SQL.
86
+ */
87
+ export function forAudience(error: FrameworkError, audience: ErrorAudience): FrameworkError {
88
+ if (audience === 'developer') return error;
89
+ if (statusFor(error.code) < 500 || hasPublicCause(error.code)) return error;
90
+ const fix = `x errors explain ${renderFixShellArg(error.code, '<code>')} --json`;
91
+ return { ...error, cause: HIDDEN_CAUSE, fix };
92
+ }
93
+
67
94
  /**
68
95
  * The agent-readable form, BYTE-IDENTICAL to `UltimateError.format({ audience })` — plus
69
96
  * `{ docs: true }` when the error names a page of its own — so one denial reads the same over MCP
@@ -85,10 +112,11 @@ export function renderFrameworkError(
85
112
  error.title === ''
86
113
  ? singleLine(error.code)
87
114
  : `${singleLine(error.code)}: ${singleLine(error.title)}`;
115
+ const shown = forAudience(error, audience);
88
116
  const lines = [
89
117
  head,
90
- ` cause: ${singleLine(error.cause)}`,
91
- ` fix: ${singleLine(fixFor(error, audience))}`,
118
+ ` cause: ${singleLine(shown.cause)}`,
119
+ ` fix: ${singleLine(fixFor(shown, audience))}`,
92
120
  ];
93
121
  if (error.docs !== undefined) lines.push(` docs: ${singleLine(error.docs)}`);
94
122
  return lines.join('\n');
@@ -13,16 +13,10 @@
13
13
  // tool in `defineAppMcp`'s `scopes:` map (see `scopes.ts`) — declared once, next to the
14
14
  // other tools that same token capability covers, never guessed from the action.
15
15
  //
16
- // `toWireSchema` (`input-schema.ts`), reached through `projectable.ts`, owns the schema half of
17
- // what THIS server publishes; this file owns the execution half. It is deliberately not
18
- // `toMcpTool` in @ultimat3/action — which this comment claimed until 2026-08, while
19
- // `projectable.ts` had always called `toWireSchema`. The two are not interchangeable:
20
- // `toMcpTool` emits the full draft-07 vocabulary and `toWireSchema` narrows to the subset
21
- // `validate-args.ts` can ENFORCE, so a keyword in one and not the other is a contract an agent
22
- // is judged against and was never shown. `pattern` was exactly that until 2026-08.
23
- // `action` is tier 3 and this package is tier 4, so the two cannot share one function today; the
24
- // shared home would be `@ultimat3/schema`. `packages/mcp/src/cross-surface.test.ts` is what keeps
25
- // them from diverging again.
16
+ // `toWireSchema` from `@ultimat3/schema`, reached through `projectable.ts`, owns the schema half
17
+ // of what THIS server publishes; this file owns the execution half. `@ultimat3/action`'s
18
+ // `.tool()` publishes through the same function, so the two cannot describe one declaration
19
+ // differently — `packages/mcp/src/cross-surface.test.ts` holds them to it.
26
20
 
27
21
  import type { Actor } from '@ultimat3/core';
28
22
  import { isMcpExposed } from '@ultimat3/core';
@@ -85,7 +79,7 @@ export interface ProjectablePrimitive {
85
79
  /** The action honours an idempotency key — a repeat is answered, not re-run. */
86
80
  readonly idempotent?: boolean;
87
81
  /**
88
- * The `outputSchema` to publish, already narrowed (`toOutputSchema`) and with an OBJECT root.
82
+ * The `outputSchema` to publish, already narrowed (`toWireOutputSchema`) with an OBJECT root.
89
83
  * Absent: no `outputSchema`, no `structuredContent` — only the text block.
90
84
  */
91
85
  readonly outputJsonSchema?: JsonSchema;
package/src/index.ts CHANGED
@@ -90,7 +90,6 @@ export {
90
90
  toolsListed,
91
91
  } from './from-action';
92
92
  export { MCP_IDEMPOTENCY_KEY_ARG } from './idempotency-arg';
93
- export { toOutputSchema, toRowsOutputSchema, toWireSchema } from './input-schema';
94
93
  export type { ListFilterOp, McpListParams } from './list-params';
95
94
  export { DEFAULT_LIST_MAX_LIMIT, listParamsSchema } from './list-params';
96
95
  export {
@@ -126,7 +125,7 @@ export {
126
125
  protectedResourceMetadata,
127
126
  } from './oauth-metadata';
128
127
  export type { ListedPrimitive } from './projectable';
129
- export { asProjectable } from './projectable';
128
+ export { asProjectable, toRowsOutputSchema } from './projectable';
130
129
  export type { QueryLimits, QueryResult, QueryRows } from './query-limits';
131
130
  export {
132
131
  capQueryRows,
@@ -182,6 +181,7 @@ export { assertMcpSurfaceBudget, measureMcpSurface } from './surface-budget';
182
181
  export type {
183
182
  McpHttpTransportInput,
184
183
  McpProtectedResource,
184
+ McpRateLimits,
185
185
  McpRequestOrigin,
186
186
  McpRouteDescriptor,
187
187
  ResolvedToken,
@@ -189,6 +189,7 @@ export type {
189
189
  export {
190
190
  DEFAULT_MCP_BODY_LIMIT_BYTES,
191
191
  MCP_RATE_LIMITS,
192
+ MCP_UNAUTHENTICATED_LIMIT,
192
193
  mcpHttpRoute,
193
194
  } from './transport-http';
194
195
  export type { StdioTransportInput } from './transport-stdio';
@@ -90,13 +90,18 @@ export function listParamsProperties(spec: McpListParams): readonly [string, Jso
90
90
  }
91
91
 
92
92
  /**
93
- * The schema `manage_resource` validates a list call against: the tool's own input with every
94
- * list key NARROWED to the whitelist, and nothing else admitted. `Object.fromEntries` (a define,
95
- * never `[k] =`) because a field name is the app's string and `__proto__` must land as a key.
93
+ * The schema `manage_resource` validates a list call against: the whitelist, NARROWED, plus the
94
+ * input keys the query REQUIRES (a `projectId` the list cannot run without), and nothing else
95
+ * admitted. Decided by provenance — the author's `required` and the author's whitelist — never by
96
+ * whether a key looks like a filter: an optional `status_in` or `includeDeleted` the whitelist
97
+ * left out was admitted until 2026-10. `Object.fromEntries` (a define, never `[k] =`) because a
98
+ * field name is the app's string and `__proto__` must land as a key.
96
99
  */
97
100
  export function listParamsSchema(spec: McpListParams, base: JsonSchema): JsonSchema {
101
+ const declared = base.properties ?? {};
102
+ const required = (base.required ?? []).filter((key) => Object.hasOwn(declared, key));
98
103
  const properties = Object.fromEntries([
99
- ...Object.entries(base.properties ?? {}),
104
+ ...required.map((key): [string, JsonSchema] => [key, declared[key] ?? {}]),
100
105
  ...listParamsProperties(spec),
101
106
  ]);
102
107
  return {
@@ -299,7 +299,9 @@ const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim();
299
299
  function headOf(tool: AnyMcpTool): Omit<MetaAction, 'params'> {
300
300
  return {
301
301
  name: tool.name,
302
- kind: tool.destructive === false ? 'query' : 'action',
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',
303
305
  description: tool.description,
304
306
  ...(tool.confirms === true ? { confirms: true } : {}),
305
307
  ...(tool.scope === undefined ? {} : { scope: tool.scope }),
@@ -17,14 +17,16 @@ import {
17
17
  isQuery,
18
18
  guardBeforeInput as queryGuardBeforeInput,
19
19
  queryName,
20
+ readAnswer,
20
21
  sourceFor,
21
22
  } from '@ultimat3/query';
23
+ import { toWireOutputSchema, toWireSchema } from '@ultimat3/schema';
22
24
  import { asCallerContext } from './caller-context';
23
25
  import type { McpExposure, ProjectablePrimitive } from './from-action';
24
26
  import { takeIdempotencyKeyArg, withIdempotencyKeyArg } from './idempotency-arg';
25
- import { toOutputSchema, toRowsOutputSchema, toWireSchema } from './input-schema';
26
27
  import type { McpListParams } from './list-params';
27
28
  import type { McpToolAnnotations } from './registry';
29
+ import type { JsonSchema } from './wire';
28
30
 
29
31
  /**
30
32
  * What `defineAppMcp`'s `actions:`/`queries:` accept.
@@ -60,7 +62,7 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
60
62
  // `idempotent: true` is honoured on this surface exactly as over HTTP: the caller's key reaches
61
63
  // `invoke`, which files it under the action, the caller and the key (`idempotencyKeyFor`).
62
64
  const keyed = target.describe().idempotent;
63
- const output = toOutputSchema(target.output);
65
+ const output = toWireOutputSchema(target.output);
64
66
  return {
65
67
  name,
66
68
  ...(exposure === undefined ? {} : { mcp: exposure }),
@@ -87,17 +89,22 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
87
89
 
88
90
  export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
89
91
  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);
92
+ // 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".
94
+ const single = target.single === true;
95
+ // Only a query that DECLARED its rows (`rows: Post.$schema`) has a known answer shape. A list
96
+ // read answers the rows as a list, so the structured copy is `{ rows }` — `structuredContent`
97
+ // is an object by the spec; a single read's answer already IS the object.
98
+ const output = single ? toWireOutputSchema(target.rows) : toRowsOutputSchema(target.rows);
94
99
  const name = queryName(target);
95
100
  return {
96
101
  name,
97
102
  ...(exposure === undefined ? {} : { mcp: exposure }),
98
103
  ...(exposure?.description === undefined ? {} : { description: exposure.description }),
99
104
  inputJsonSchema: toWireSchema(target.input),
100
- ...(output === undefined ? {} : { outputJsonSchema: output, outputWrap: 'rows' }),
105
+ ...(output === undefined
106
+ ? {}
107
+ : { outputJsonSchema: output, ...(single ? {} : { outputWrap: 'rows' }) }),
101
108
  mutates: false,
102
109
  // `sourceFor`'s opening gate, on the surface it runs on ('server'), asked before `invalid-args`.
103
110
  admit: (actor: Actor) =>
@@ -112,11 +119,24 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
112
119
  // cache tiers on purpose: an agent diffing two tool calls must be reading the rows,
113
120
  // not a TTL.
114
121
  const source = await sourceFor(target, input);
115
- return source.execute();
122
+ // `@ultimat3/query`'s one rule for what a read answers: rows, or a single read's row.
123
+ return readAnswer(target, await source.execute());
116
124
  }),
117
125
  };
118
126
  }
119
127
 
128
+ /** `{ rows: [<row>] }` — a query's answer as `structuredContent`. `undefined` when no row schema. */
129
+ export function toRowsOutputSchema(rows: unknown): JsonSchema | undefined {
130
+ if (rows === undefined) return undefined;
131
+ const row = toWireOutputSchema(rows);
132
+ if (row === undefined) return undefined;
133
+ return {
134
+ type: 'object',
135
+ properties: { rows: { type: 'array', items: row } },
136
+ required: ['rows'],
137
+ };
138
+ }
139
+
120
140
  /**
121
141
  * An action and a query declare MCP exposure with the same fields, so one typed path reads
122
142
  * both. Narrow on purpose, through `@ultimat3/core`'s `isMcpExposed`: only a literal
@@ -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/server.ts CHANGED
@@ -7,7 +7,7 @@ import type { ErrorAudience } from '@ultimat3/core';
7
7
  import { fixFor } from '@ultimat3/core';
8
8
  import { auditResourceRead, auditToolCall, outcomeForResult } from './audit';
9
9
  import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
10
- import { asFrameworkError } from './framework-error';
10
+ import { asFrameworkError, forAudience } from './framework-error';
11
11
  import { metaCall } from './meta-call';
12
12
  import { META_UNKNOWN_FIX } from './meta-errors';
13
13
  import type { McpResourceGroups, McpSurfaceOption, MetaResource } from './meta-surface';
@@ -450,8 +450,9 @@ export class McpServer {
450
450
  auditResourceRead({ uri, outcome: 'ok', caller });
451
451
  return resultResponse(id, { contents: [contents] });
452
452
  } catch (error) {
453
- const framework = asFrameworkError(error);
454
- if (framework !== undefined) {
453
+ const thrown = asFrameworkError(error);
454
+ if (thrown !== undefined) {
455
+ const framework = forAudience(thrown, this.voice.errorAudience);
455
456
  auditResourceRead({ uri, outcome: 'failed', caller, code: framework.code });
456
457
  return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`, {
457
458
  code: framework.code,
@@ -21,7 +21,7 @@
21
21
 
22
22
  import type { Actor, Clock } from '@ultimat3/core';
23
23
  import { finiteCount, readWithinLimit, systemClock } from '@ultimat3/core';
24
- import type { RateLimitStore } from '@ultimat3/http';
24
+ import type { Bucket, RateLimitStore } from '@ultimat3/http';
25
25
  import { memoryRateLimitStore, toBucket } from '@ultimat3/http';
26
26
  import { McpBodyTooLargeError, McpRateLimitedError } from './errors';
27
27
  import type { McpOAuth } from './oauth-metadata';
@@ -57,6 +57,18 @@ export const MCP_RATE_LIMITS: Readonly<Record<McpVerbClass, number>> = {
57
57
  write: 20,
58
58
  };
59
59
 
60
+ /**
61
+ * Failed authentications per minute per ADDRESS. An address past it is refused before
62
+ * `resolveToken` runs, so guessing tokens is bounded and a valid guess is answered the same 429.
63
+ */
64
+ export const MCP_UNAUTHENTICATED_LIMIT = 20;
65
+
66
+ /** `rateLimits` as declared: the two per-actor classes, and the per-address failure allowance. */
67
+ export type McpRateLimits = Readonly<Record<McpVerbClass, number>> & {
68
+ /** Failed authentications per minute per address. Defaults to `MCP_UNAUTHENTICATED_LIMIT`. */
69
+ readonly unauthenticated?: number | undefined;
70
+ };
71
+
60
72
  /** What a token resolves to. `null` = unauthenticated, answered 401 with no catalog. */
61
73
  export interface ResolvedToken {
62
74
  readonly actor: Actor;
@@ -75,8 +87,11 @@ export interface McpHttpTransportInput {
75
87
  readonly path?: string;
76
88
  /** Bytes this transport will hold for one request. Defaults to `DEFAULT_MCP_BODY_LIMIT_BYTES`. */
77
89
  readonly bodyLimitBytes?: number | undefined;
78
- /** Requests per minute per caller, by class. Defaults to `MCP_RATE_LIMITS`. */
79
- readonly rateLimits?: Readonly<Record<McpVerbClass, number>> | undefined;
90
+ /**
91
+ * Requests per minute per caller, by class — defaults to `MCP_RATE_LIMITS` — and failed
92
+ * authentications per minute per address (`unauthenticated`, `MCP_UNAUTHENTICATED_LIMIT`).
93
+ */
94
+ readonly rateLimits?: McpRateLimits | undefined;
80
95
  /**
81
96
  * Where the buckets are counted. Defaults to a per-PROCESS memory store, which is the honest
82
97
  * default for `x mcp serve` and a lie for N replicas behind one URL — each would enforce the
@@ -100,6 +115,12 @@ export interface McpHttpTransportInput {
100
115
  export interface McpRequestOrigin {
101
116
  /** The PUBLIC origin, `https://www.example.com`. Defaults to the request URL's own. */
102
117
  readonly origin?: string | undefined;
118
+ /**
119
+ * The caller's address as the host resolved it (a trusted proxy's `x-forwarded-for` honoured) —
120
+ * what failed authentications are counted against. Absent, they are not counted here: one
121
+ * shared key would let a stranger's failures lock every agent out.
122
+ */
123
+ readonly address?: string | undefined;
103
124
  }
104
125
 
105
126
  /** The metadata half of an `oauth` route: where to serve it and what to answer. */
@@ -116,7 +137,7 @@ export interface McpRouteDescriptor {
116
137
  /** Bucket for one already-parsed body. Metering only — never an authz decision. */
117
138
  rateLimitClass(body: unknown): McpVerbClass;
118
139
  /** Requests per minute per caller, by class — what `handle` actually spends against. */
119
- readonly limits: Readonly<Record<McpVerbClass, number>>;
140
+ readonly limits: McpRateLimits;
120
141
  /** Present exactly when the route was built with `oauth`. */
121
142
  readonly protectedResource?: McpProtectedResource;
122
143
  handle(request: Request, seen?: McpRequestOrigin): Promise<Response>;
@@ -144,7 +165,7 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
144
165
  input.bodyLimitBytes ?? DEFAULT_MCP_BODY_LIMIT_BYTES,
145
166
  0,
146
167
  );
147
- const limits = input.rateLimits ?? MCP_RATE_LIMITS;
168
+ const limits: McpRateLimits = input.rateLimits ?? MCP_RATE_LIMITS;
148
169
  const clock = input.clock ?? systemClock;
149
170
  const store = input.rateLimitStore ?? memoryRateLimitStore();
150
171
  // Built once, at construction: `toBucket` refuses an unusable pair (`X_RATE_LIMIT_INVALID`), and
@@ -159,6 +180,20 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
159
180
  windowMs: MCP_RATE_LIMIT_WINDOW_MS,
160
181
  });
161
182
 
183
+ // A COUNT of failures, screened where it is declared: `toBucket` alone would read `NaN` as a
184
+ // bound only at its own check, and `??` never fires for one.
185
+ const unauthenticated = finiteCount(
186
+ 'mcpHttpRoute',
187
+ 'rateLimits.unauthenticated',
188
+ limits.unauthenticated ?? MCP_UNAUTHENTICATED_LIMIT,
189
+ 1,
190
+ );
191
+ const failureBucket = toBucket('mcpHttpRoute rateLimits.unauthenticated', {
192
+ limit: unauthenticated,
193
+ windowMs: MCP_RATE_LIMIT_WINDOW_MS,
194
+ });
195
+ const failures = failureMeter(store, failureBucket, clock);
196
+
162
197
  const path = input.path ?? '/mcp';
163
198
  const oauth = input.oauth;
164
199
  if (oauth !== undefined) assertMcpOAuth(oauth);
@@ -193,12 +228,26 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
193
228
  // remove, and it costs nothing to close: the body is not an input to any of these.
194
229
  const resource = resourceOf(request, seen);
195
230
  const metadataUrl = resource === undefined ? undefined : metadataUrlFor(resource);
231
+ // An address that has spent its failures is refused BEFORE the credential store is asked:
232
+ // counting only after the fact turned a 401 into a 429 and still answered a valid guess.
233
+ const address = seen?.address;
234
+ const exhausted = await failures.refusing(address);
235
+ if (exhausted !== undefined) return exhausted;
196
236
  const token = bearerToken(request);
197
- if (token === null) return unauthorized(bearerChallenge(metadataUrl, false, challengeScopes));
237
+ if (token === null) {
238
+ return (
239
+ (await failures.spend(address)) ??
240
+ unauthorized(bearerChallenge(metadataUrl, false, challengeScopes))
241
+ );
242
+ }
198
243
 
199
244
  const resolved = await input.resolveToken(token);
200
- if (resolved === null)
201
- return unauthorized(bearerChallenge(metadataUrl, true, challengeScopes));
245
+ if (resolved === null) {
246
+ return (
247
+ (await failures.spend(address)) ??
248
+ unauthorized(bearerChallenge(metadataUrl, true, challengeScopes))
249
+ );
250
+ }
202
251
  if (!isAgentActor(resolved.actor)) return notAnAgent();
203
252
 
204
253
  // Read through the counting reader, never `request.json()`: the cap has to be enforced
@@ -275,6 +324,31 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
275
324
  };
276
325
  }
277
326
 
327
+ /**
328
+ * The per-address failure allowance. `refusing` reads the bucket (`RateLimitStore.peek`, which
329
+ * writes nothing) and answers the 429 when not one more failure fits; `spend` charges one
330
+ * failure and answers the 429 when that one did not fit. `undefined` from either: carry on.
331
+ */
332
+ function failureMeter(store: RateLimitStore, bucket: Bucket, clock: Clock) {
333
+ const keyOf = (address: string): string => `mcp:unauthenticated|ip:${address}`;
334
+ const refusal = (retryAfterSeconds: number): Response =>
335
+ throttled('unauthenticated', bucket.capacity, retryAfterSeconds);
336
+ return {
337
+ async refusing(address: string | undefined): Promise<Response | undefined> {
338
+ if (address === undefined) return undefined;
339
+ // READ ONLY: this runs on every request, an agent's included, and a write per call would be
340
+ // a Postgres upsert on the hot path of every authenticated one.
341
+ const peek = await store.peek(keyOf(address), bucket, clock.now().getTime());
342
+ return peek.remaining >= 1 ? undefined : refusal(peek.retryAfterSeconds);
343
+ },
344
+ async spend(address: string | undefined): Promise<Response | undefined> {
345
+ if (address === undefined) return undefined;
346
+ const spent = await store.take(keyOf(address), bucket, 1, clock.now().getTime());
347
+ return spent.allowed ? undefined : refusal(spent.retryAfterSeconds);
348
+ },
349
+ };
350
+ }
351
+
278
352
  /** `Authorization: Bearer <token>`, the only accepted form. No query-string tokens. */
279
353
  export function bearerToken(request: Request): string | null {
280
354
  const header = request.headers.get('authorization');
@@ -298,7 +372,11 @@ export function isAgentActor(actor: Actor): boolean {
298
372
  * call to answer. `retry-after` carries the same number the `fix:` line names, because an agent
299
373
  * reads one of the two and must not get different answers from them.
300
374
  */
301
- function throttled(verbClass: McpVerbClass, limit: number, retryAfterSeconds: number): Response {
375
+ function throttled(
376
+ verbClass: keyof McpRateLimits,
377
+ limit: number,
378
+ retryAfterSeconds: number,
379
+ ): Response {
302
380
  const error = new McpRateLimitedError({ verbClass, limit, retryAfterSeconds });
303
381
  return new Response(JSON.stringify({ code: error.code, cause: error.cause, fix: error.fix }), {
304
382
  status: 429,
@@ -174,7 +174,8 @@ export const compiledPatternCount = (): number => compilations;
174
174
  * A pattern this server cannot COMPILE is refused, not skipped. `tools/list` published it, so an
175
175
  * agent has already been told the rule — passing a call the server cannot check is the silent-pass
176
176
  * this whole module exists to prevent. Every framework-projected pattern is a `RegExp.source` and
177
- * compiles; only a hand-written tool can reach the second branch, and that is its author's bug.
177
+ * its own flags and compiles; only a hand-written tool can reach the second branch, and that is its
178
+ * author's bug.
178
179
  */
179
180
  function matchesPattern(
180
181
  schema: JsonSchema,
@@ -183,21 +184,28 @@ function matchesPattern(
183
184
  path: string,
184
185
  issues: ArgIssue[],
185
186
  ): void {
187
+ // The flags `@ultimat3/schema` published beside the source. Without them `/^[a-z]+$/i` refused
188
+ // `ABC` and `/^\p{L}+$/u` refused `é` here, while the action's own parse accepted both.
189
+ const flags = schema['x-ultimate-pattern-flags'] ?? '';
190
+ const shown = flags === '' ? pattern : `/${pattern}/${flags}`;
186
191
  let compiled = compiledPatterns.get(schema);
187
192
  if (compiled === undefined) {
188
193
  compilations += 1;
189
194
  try {
190
- compiled = new RegExp(pattern);
195
+ compiled = new RegExp(pattern, flags);
191
196
  } catch {
192
197
  compiled = null;
193
198
  }
194
199
  compiledPatterns.set(schema, compiled);
195
200
  }
196
201
  if (compiled === null) {
197
- issues.push({ path, message: `declares a pattern this server cannot compile: ${pattern}` });
202
+ issues.push({ path, message: `declares a pattern this server cannot compile: ${shown}` });
198
203
  return;
199
204
  }
200
- if (!compiled.test(input)) issues.push({ path, message: `must match ${pattern}` });
205
+ // A `g` or `y` RegExp carries `lastIndex` between calls, and this one is cached per node: the
206
+ // second `.test()` of an accepted value would answer false. Reset, as the schema's tester does.
207
+ compiled.lastIndex = 0;
208
+ if (!compiled.test(input)) issues.push({ path, message: `must match ${shown}` });
201
209
  }
202
210
 
203
211
  function number(schema: JsonSchema, input: unknown, path: string, issues: ArgIssue[]): unknown {
package/src/wire.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // Reference: https://modelcontextprotocol.io/specification (2025-06-18).
7
7
 
8
8
  import { frameworkVersion } from '@ultimat3/core';
9
+ import type { WireJsonSchema } from '@ultimat3/schema';
9
10
 
10
11
  /** MCP protocol version advertised on `initialize`. */
11
12
  export const MCP_PROTOCOL_VERSION = '2025-06-18';
@@ -62,47 +63,10 @@ export const INVALID_PARAMS = -32602;
62
63
  export const INTERNAL_ERROR = -32603;
63
64
 
64
65
  /**
65
- * The JSON Schema subset the framework emits and `validate-args.ts` enforces. Narrow on
66
- * purpose: a tool schema an agent cannot fully understand is a tool it will call wrong.
66
+ * The JSON Schema subset a tool publishes and `validate-args.ts` enforces — `@ultimat3/schema`'s
67
+ * `WireJsonSchema`, declared there so `@ultimat3/action`'s `.tool()` speaks the same one.
67
68
  */
68
- export interface JsonSchema {
69
- readonly type?: 'object' | 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'null';
70
- readonly title?: string;
71
- readonly description?: string;
72
- readonly properties?: Readonly<Record<string, JsonSchema>>;
73
- readonly required?: readonly string[];
74
- readonly additionalProperties?: boolean;
75
- readonly items?: JsonSchema;
76
- readonly enum?: readonly (string | number | boolean | null)[];
77
- readonly const?: string | number | boolean | null;
78
- readonly default?: unknown;
79
- /**
80
- * NOT in the subset, and deliberately absent: `format` NAMES a rule whose meaning lives in
81
- * `@ultimat3/schema` (`uuid`, `email`, `iana-time-zone`, `bcp47-locale`), and this package
82
- * cannot enforce it without a second definition of each one — which would drift from the parse
83
- * the action itself runs and refuse values that surface accepts. So it is dropped by
84
- * `input-schema.ts` rather than published unchecked: an agent told `format: 'uuid'` by a server
85
- * that accepted `"not-a-uuid"` obeyed a rule nothing checked. `pattern` below is the opposite
86
- * case — the rule travels WITH the schema as a source string, so it is enforceable and is kept.
87
- *
88
- * `readonly format?: never;` states it in the type: a narrow() that starts copying `format`
89
- * again fails to compile rather than shipping a silent pass.
90
- */
91
- readonly format?: never;
92
- readonly minimum?: number;
93
- readonly maximum?: number;
94
- readonly minLength?: number;
95
- readonly maxLength?: number;
96
- /**
97
- * A `RegExp.source` string, JSON Schema semantics (a partial match unless it anchors itself).
98
- * In the subset because `validate-args.ts` enforces it: without it `tools/list` published
99
- * `{minLength,maxLength}` for a field whose OpenAPI component carried the pattern, so an HTTP
100
- * client and an MCP agent held different contracts for one declaration and the agent's only
101
- * way to learn the format was `X_INPUT_INVALID` from the action's own parse.
102
- */
103
- readonly pattern?: string;
104
- readonly anyOf?: readonly JsonSchema[];
105
- }
69
+ export type JsonSchema = WireJsonSchema;
106
70
 
107
71
  /** An empty-object schema — the honest shape for a no-argument tool. */
108
72
  export const NO_ARGS: JsonSchema = { type: 'object', properties: {}, additionalProperties: false };
@@ -1,140 +0,0 @@
1
- // A Standard Schema (`t.object({...})`, Zod, Valibot) -> the JSON Schema subset the wire speaks.
2
- //
3
- // Two `JsonSchema` types exist on purpose. `@ultimat3/schema` emits the full draft-07 vocabulary;
4
- // `wire.ts` declares the narrow subset `validate-args.ts` can actually ENFORCE. Handing an agent a
5
- // keyword the server ignores is worse than omitting it — the agent obeys a rule nothing checks and
6
- // gets a silent pass. So this is a real projection, not a cast: a keyword outside the subset is
7
- // dropped here, and `tools/list` publishes only what the resolver will hold a call to.
8
- //
9
- // `format` is the keyword that rule is easiest to get wrong on: it is expressive, and it is a NAME
10
- // whose meaning lives in `@ultimat3/schema` — enforcing it here would be a second definition of
11
- // `uuid`/`email`/`iana-time-zone` that can only drift from the parse the action itself runs. So it
12
- // is dropped, and `wire.ts` types it `never` so re-adding it does not compile. `pattern` is kept
13
- // for the mirror-image reason: the rule travels with the schema, so this server can hold a call
14
- // to it. A tool needing a format enforced declares a `pattern` beside it.
15
-
16
- import type { JsonSchema as RichJsonSchema } from '@ultimat3/schema';
17
- import { toMcpInputSchema } from '@ultimat3/schema';
18
- import type { JsonSchema } from './wire';
19
-
20
- /** Introspect any Standard Schema and narrow the result to the wire subset. */
21
- export function toWireSchema(schema: unknown): JsonSchema {
22
- return narrow(toMcpInputSchema(schema));
23
- }
24
-
25
- function narrow(source: RichJsonSchema): JsonSchema {
26
- // exactOptionalPropertyTypes: every field is attached only when it is present, never as
27
- // an explicit `undefined` — `tools/list` serialises this object verbatim.
28
- return {
29
- ...(source.type === undefined ? {} : { type: source.type }),
30
- ...(source.title === undefined ? {} : { title: source.title }),
31
- ...(source.description === undefined ? {} : { description: source.description }),
32
- ...(source.properties === undefined ? {} : { properties: narrowProperties(source.properties) }),
33
- ...(source.required === undefined ? {} : { required: source.required }),
34
- ...narrowAdditional(source.additionalProperties),
35
- ...(source.items === undefined ? {} : { items: narrow(source.items) }),
36
- ...(source.enum === undefined ? {} : { enum: source.enum }),
37
- ...(source.const === undefined ? {} : { const: source.const }),
38
- ...(source.default === undefined ? {} : { default: source.default }),
39
- ...(source.minimum === undefined ? {} : { minimum: source.minimum }),
40
- ...(source.maximum === undefined ? {} : { maximum: source.maximum }),
41
- ...(source.minLength === undefined ? {} : { minLength: source.minLength }),
42
- ...(source.maxLength === undefined ? {} : { maxLength: source.maxLength }),
43
- ...(source.pattern === undefined ? {} : { pattern: source.pattern }),
44
- ...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(narrow) }),
45
- };
46
- }
47
-
48
- function narrowProperties(
49
- properties: Readonly<Record<string, RichJsonSchema>>,
50
- ): Readonly<Record<string, JsonSchema>> {
51
- const out: Record<string, JsonSchema> = {};
52
- for (const [key, child] of Object.entries(properties)) {
53
- // `out[key] = …` is not an assignment for exactly one name — `__proto__` runs
54
- // `Object.prototype`'s setter and RE-PROTOTYPES this record instead of adding a key, so a
55
- // field an author declared disappears from `tools/list` and from what `validate-args.ts`
56
- // reads back. The twin of `validate-args.ts`'s `put`, on the schema side.
57
- Object.defineProperty(out, key, {
58
- value: narrow(child),
59
- writable: true,
60
- enumerable: true,
61
- configurable: true,
62
- });
63
- }
64
- return out;
65
- }
66
-
67
- /**
68
- * A record schema says "extra keys, shaped like this". The wire subset can only say yes or no,
69
- * and `validate-args.ts` reads `false` as "reject unknown keys" — so an unrepresentable value
70
- * schema becomes `true` (permit) rather than a rejection the agent was never warned about.
71
- */
72
- function narrowAdditional(value: boolean | RichJsonSchema | undefined): {
73
- readonly additionalProperties?: boolean;
74
- } {
75
- if (value === undefined) return {};
76
- return { additionalProperties: typeof value === 'boolean' ? value : true };
77
- }
78
-
79
- /**
80
- * A Standard Schema → the `outputSchema` a tool publishes, or `undefined` when its root is not an
81
- * object (MCP's `structuredContent` is one) or it cannot be introspected.
82
- *
83
- * STRUCTURE ONLY — type, properties, required, items, enum, const, anyOf, title, description. A
84
- * client validates `structuredContent` against this document and refuses the call on a miss, so
85
- * every keyword here is a promise about a value the server produced: a bound, a pattern or
86
- * `additionalProperties: false` on a query's rows (read from the database, never re-parsed) is a
87
- * promise nothing enforces on the way out. Fewer constraints is the honest projection of an output.
88
- */
89
- export function toOutputSchema(schema: unknown): JsonSchema | undefined {
90
- let rich: RichJsonSchema;
91
- try {
92
- rich = toMcpInputSchema(schema);
93
- } catch {
94
- return undefined;
95
- }
96
- if (rich.type !== 'object') return undefined;
97
- return shape(rich);
98
- }
99
-
100
- /** `{ rows: [<row>] }` — a query's answer as `structuredContent`. `undefined` when no row schema. */
101
- export function toRowsOutputSchema(rows: unknown): JsonSchema | undefined {
102
- if (rows === undefined) return undefined;
103
- const row = toOutputSchema(rows);
104
- if (row === undefined) return undefined;
105
- return {
106
- type: 'object',
107
- properties: { rows: { type: 'array', items: row } },
108
- required: ['rows'],
109
- };
110
- }
111
-
112
- function shape(source: RichJsonSchema): JsonSchema {
113
- return {
114
- ...(source.type === undefined ? {} : { type: source.type }),
115
- ...(source.title === undefined ? {} : { title: source.title }),
116
- ...(source.description === undefined ? {} : { description: source.description }),
117
- ...(source.properties === undefined ? {} : { properties: shapeProperties(source.properties) }),
118
- ...(source.required === undefined ? {} : { required: source.required }),
119
- ...(source.items === undefined ? {} : { items: shape(source.items) }),
120
- ...(source.enum === undefined ? {} : { enum: source.enum }),
121
- ...(source.const === undefined ? {} : { const: source.const }),
122
- ...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(shape) }),
123
- };
124
- }
125
-
126
- function shapeProperties(
127
- properties: Readonly<Record<string, RichJsonSchema>>,
128
- ): Readonly<Record<string, JsonSchema>> {
129
- const out: Record<string, JsonSchema> = {};
130
- for (const [key, child] of Object.entries(properties)) {
131
- // `narrowProperties`' reason: `out['__proto__'] = …` re-prototypes instead of adding a key.
132
- Object.defineProperty(out, key, {
133
- value: shape(child),
134
- writable: true,
135
- enumerable: true,
136
- configurable: true,
137
- });
138
- }
139
- return out;
140
- }