@ultimat3/mcp 2.0.0 → 4.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
@@ -49,6 +49,56 @@ import. The CLI wires it.
49
49
  fails, every caller simply sees every tool.
50
50
  - Resolve order is visibility → scope → args → policy. Validating first leaks a schema;
51
51
  running the policy first decides a refusal from attacker-supplied input.
52
+ - **A key's membership of a declared schema is `Object.hasOwn(properties, key)`, never
53
+ `properties[key] === undefined`.** The arguments of a `tools/call` are NAMED by the caller, and
54
+ `Object.prototype` supplies a value for `constructor`, `toString`, `hasOwnProperty` and
55
+ `__proto__` on every plain object — so the index read answered "declared" for four names no
56
+ schema declares, and `validate-args.ts` accepted them past an `additionalProperties: false` that
57
+ forbids them and then dropped them. Third instance of the class in the framework, after
58
+ `@ultimat3/i18n`'s catalog lookup and `@ultimat3/schema`'s `coerce`. Its twin: a validated key
59
+ lands on the result through `Object.defineProperty`, because `out[key] = v` for `__proto__` runs
60
+ the setter on `Object.prototype` and re-prototypes the record instead of adding a key.
61
+ - **The RESOURCE surface owes the same three outcomes as the tool surface.** `resources/list` and
62
+ `resources/read` take the `McpCaller`; `McpResource` carries `visibleTo` and `scope`, and
63
+ `ResourceRegistry.resolve` applies them in the same order `ToolRegistry.resolve` does, through the
64
+ same `visibleToCaller`. Both took no caller at all until 2026-08: any token `resolveToken`
65
+ accepted could enumerate every URI and read every document — the manifest, the OpenAPI document,
66
+ the route table and the entity schema, which together are an app's whole policy and data map. The
67
+ not-found branch answered `data.available` with the full catalog, so one wrong guess enumerated
68
+ it; it now carries no `data`, exactly as `tool not found` does. `resource-security.test.ts` is the
69
+ contract, in both halves — hand-built resources AND `defineAppMcp({ resources })`.
70
+ - **Every provider call is inside a `try`.** A resource's `read` is an INJECTED THUNK —
71
+ `frameworkResources` wires it to a file read, and `Bun.file(...).text()` on a missing
72
+ `x.manifest.json` throws ENOENT. Outside the try it escaped `handle()`: `serveStdio` REJECTED with
73
+ the raw error, zero frames written, the request unanswered and every later request on that buffer
74
+ never processed. Same shape `toolsCall` uses — a framework error keeps its code/cause/fix, anything
75
+ else is `-32603` with no internals.
76
+ - **`format` is NOT in the wire subset**, and `wire.ts` types it `never` so re-adding it does not
77
+ compile. It names a rule whose meaning lives in `@ultimat3/schema` (`uuid`, `email`,
78
+ `iana-time-zone`), and this package cannot check it without a second definition of each that can
79
+ only drift from the action's own parse. `tools/list` published it and `validate-args.ts` ignored
80
+ it, so a tool declaring `t.uuid` accepted `"not-a-uuid"` with `ok: true` — the silent pass
81
+ `input-schema.ts` exists to prevent. `pattern` is the opposite case and is kept: the rule travels
82
+ with the schema. `input-schema.test.ts` asserts every published keyword is one this server
83
+ enforces, at any depth.
84
+ - **A hand-written app tool parses its own input**, in the slot `invoke` puts it: parse, then
85
+ `guard()`, then `handle`. A projected action re-parses inside `invoke`; `app-tool.ts` had no second
86
+ parse, so `handle` was handed whatever the wire subset let through — typed `InferOutput<TInput>`,
87
+ past the policy. One code either way, `X_INPUT_INVALID`, built from `@ultimat3/action`'s own
88
+ `InputInvalidError`.
89
+ - **Anything a tool RETURNS is rendered totally.** `jsonResult` is handed an action's own return
90
+ value, and `JSON.stringify` answers `undefined` for a handler that returned nothing (a
91
+ `ContentBlock.text` that is not a string is an invalid frame) and THROWS on a bigint, a cycle or
92
+ a `toJSON` the value carries. Unreadable is an ordinary `isError` result, never an escape past
93
+ `server.ts`'s catch, which would report a bug in the tool for a fault in the rendering.
94
+ `query-limits.ts`' `rowBytes` holds the same line one layer earlier: a row the driver decoded
95
+ into a bigint costs `Infinity` and is cut by the byte ceiling, rather than raising inside the cap
96
+ that exists to protect the answer.
97
+ - **A thrown value is read with `stringField` from `@ultimat3/core`, never `typeof e.code ===
98
+ 'string'`.** `asFrameworkError` reads four fields off whatever an app's handler threw; each read
99
+ is a getter call, or a `Proxy` trap, inside the catch block that owes the caller a response — and
100
+ a probe that raises there leaves the JSON-RPC request with no answer at all, not even the
101
+ `-32603` the transport promises for a genuine bug.
52
102
  - A framework error rendered into a tool result is **byte-identical to
53
103
  `UltimateError.format()`** — one denial must not read one way over MCP and another in the
54
104
  terminal. `server.ts` renders it; the test pins it against `format()`, never a literal.
@@ -125,6 +175,10 @@ import. The CLI wires it.
125
175
  exploitable through Postgres (a syntax error either way); the point is that this layer must not
126
176
  be the thing that waves it through. `@ultimat3/admin`'s `/_x` panel failed CLOSED here where this
127
177
  failed open, which is how it was found — a second, differently-behaved copy of one rule.
178
+ - **`into` is a write keyword.** `SELECT ... INTO <table>` is `CREATE TABLE AS` in another spelling:
179
+ a DDL write with a read LEADER, so nothing else in the scan sees it. Layer 2's `BEGIN READ ONLY`
180
+ refuses it on the wired path — the point is that this layer must not be the thing that waves it
181
+ through, and `@ultimat3/admin`'s `/_x` panel is a second caller whose layer 2 is its own.
128
182
  - `db.query` / `db.migrate` refuse structurally, in `readonly-sql.ts`, before the host runs
129
183
  (`X_MCP_QUERY_REJECTED` / `X_MCP_NOT_BRANCH_DB` — one code each, because they want different
130
184
  next commands).
@@ -134,11 +188,14 @@ import. The CLI wires it.
134
188
  keyword. Add a family, never a name. The unit is the call (`name` before `(`), never a bare word:
135
189
  a word scan refused a column named `pg_sleep_for_seconds`. The call scan reads a strip that KEEPS
136
190
  quoted-identifier content, because `"pg_advisory_lock"(1)` is the same call as the bare spelling —
137
- the keyword scan still reads the blanked form, so `select "update" from t` stays a column. Two of the families exist because the same ban is already
138
- made elsewhere in another spelling: `pg_advisory_*` is `FOR UPDATE`'s ban and the worse breach
139
- (a session lock survives layer 2's `ROLLBACK`, so it outlives the read on a pooled connection
140
- proved live in `packages/testing/src/db-integration.test.ts`), and `pg_sleep*` is the one ban
141
- that still holds on embedded PGlite, whose single WASM thread cannot honour a statement timeout.
191
+ the keyword scan still reads the blanked form, so `select "update" from t` stays a column. Two of
192
+ the families exist because the same ban is already made elsewhere in another spelling:
193
+ `pg_advisory_*` is `FOR UPDATE`'s ban and the worse breach (a session lock survives layer 2's
194
+ `ROLLBACK`, so it outlives the read on a pooled connection — proved live in
195
+ `packages/testing/src/db-integration.test.ts`), and `pg_sleep*` is the one ban that still holds on
196
+ embedded PGlite, whose single WASM thread cannot honour a statement timeout. `nextval`/`setval`
197
+ are a family no keyword can reach: they advance a SEQUENCE, which is a write, and one `ROLLBACK`
198
+ does not undo — a consumed id is gone, so a read can burn the next id a real insert would take.
142
199
  - `db.query` is defended four ways: a SELECT-only role and `BEGIN READ ONLY` in `@ultimat3/db`
143
200
  (the CLI wires them — this package must never import `db`), the parse here, and the caps here.
144
201
  `limit` is a request, never a permission: `resolveQueryLimits` clamps it into a hard 1000.
@@ -150,7 +207,12 @@ import. The CLI wires it.
150
207
  answered `400 parse error` for a malformed payload and `401` for a well-formed one under the
151
208
  SAME rejected token, which is precisely the oracle the pre-parse 401 exists to remove. The parse
152
209
  error still exists — it is what an authenticated agent gets.
153
- - `transport-stdio.ts` never writes stdout except the wire. Diagnostics → stderr.
210
+ - `transport-stdio.ts` never writes stdout except the wire. Diagnostics → stderr. It also **caps one
211
+ message** at `DEFAULT_STDIO_LINE_LIMIT` (1 MiB, the same figure `transport-http.ts` enforces with
212
+ `readWithinLimit`) — the peer launched this process and is trusted, a bug in it is not, and a
213
+ stream with no newline grew the buffer until the process died. Over-long is one `-32600` frame
214
+ carrying a `fix`, then the rest of that line is DISCARDED to the next newline: what follows an
215
+ over-long message on the same line is its tail, never a message of its own.
154
216
  - New mutating tool ⇒ set `destructive: true`, or it is metered as cheap read chatter.
155
217
 
156
218
  ## Commands
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "2.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "MCP server, dev tools, and the action-to-tool projection — one authz system, two surfaces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,12 +31,12 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/action": "2.0.0",
35
- "@ultimat3/core": "2.0.0",
36
- "@ultimat3/entity": "2.0.0",
37
- "@ultimat3/jobs": "2.0.0",
38
- "@ultimat3/policy": "2.0.0",
39
- "@ultimat3/query": "2.0.0",
40
- "@ultimat3/schema": "2.0.0"
34
+ "@ultimat3/action": "4.0.0",
35
+ "@ultimat3/core": "4.0.0",
36
+ "@ultimat3/entity": "4.0.0",
37
+ "@ultimat3/jobs": "4.0.0",
38
+ "@ultimat3/policy": "4.0.0",
39
+ "@ultimat3/query": "4.0.0",
40
+ "@ultimat3/schema": "4.0.0"
41
41
  }
42
42
  }
package/src/app-tool.ts CHANGED
@@ -10,12 +10,14 @@
10
10
  // tool therefore has no authz of its own — it borrows the action tier's, so a rule change cannot
11
11
  // apply to routes and miss tools.
12
12
 
13
- import { actorOf, guard } from '@ultimat3/action';
13
+ import { actorOf, guard, InputInvalidError } from '@ultimat3/action';
14
14
  import type { Ctx } from '@ultimat3/core';
15
- import { useContext, withChildContext } from '@ultimat3/core';
15
+ 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, validateAsync } from '@ultimat3/schema';
20
+ import { asCallerContext } from './caller-context';
19
21
  import { McpToolUnsafeError } from './errors';
20
22
  import type { ProjectablePrimitive } from './from-action';
21
23
  import { toWireSchema } from './input-schema';
@@ -94,10 +96,34 @@ export function appToolPrimitive(name: string, def: AnyAppToolDefinition): Proje
94
96
  // The caller is the actor for the WHOLE call. A child context is how the framework
95
97
  // impersonates, so the policy subject and whatever `handle` reads off `ctx.actor` are
96
98
  // the same identity by construction rather than by two call sites agreeing.
97
- withChildContext({ actor }, async () => {
99
+ asCallerContext(actor, async () => {
98
100
  const ctx = useContext();
99
- guard(policy, { actor: actorOf(ctx), input, ctx, action: name }, 'mcp');
100
- return def.handle({ input, ctx });
101
+ // The AUTHORITATIVE parse, in the same slot `invoke` puts it: before the policy, before
102
+ // the handler. A projected action re-parses inside `invoke`; a hand-written tool has no
103
+ // second parse, so `handle` was typed `InferOutput<TInput>` and handed whatever
104
+ // `validate-args.ts` let past the wire subset — which carries no `format`, so a `t.uuid`
105
+ // arrived as any string at all. `InputInvalidError` because a tool argument is an action
106
+ // input by another name: one code, X_INPUT_INVALID, whichever surface the call came in on.
107
+ const parsed = await parseInput(def.input, input, name);
108
+ guard(policy, { actor: actorOf(ctx), input: parsed, ctx, action: name }, 'mcp');
109
+ return def.handle({ input: parsed, ctx });
101
110
  }),
102
111
  };
103
112
  }
113
+
114
+ /**
115
+ * `@ultimat3/action`'s `validateInput` is not exported from its index, so this is the same three
116
+ * lines against the same helpers and the same error class — never a second failure shape. Delete
117
+ * it the day `validateInput` is exported.
118
+ */
119
+ async function parseInput(
120
+ schema: StandardSchemaV1,
121
+ raw: unknown,
122
+ name: string,
123
+ ): Promise<InferOutput<StandardSchemaV1>> {
124
+ const result = await validateAsync(schema, raw);
125
+ if (result.issues !== undefined) {
126
+ throw new InputInvalidError(name, formatIssues(result.issues).join('; '));
127
+ }
128
+ return result.value;
129
+ }
package/src/app-tools.ts CHANGED
@@ -125,10 +125,18 @@ export function defineAppMcp<TSchemas extends AppToolSchemas>(
125
125
  // so `include` fills the gaps rather than colliding with what the caller already spelled out.
126
126
  const included =
127
127
  input.include === 'exposed' ? notNamed(toolsFrom(exposedPrimitives()), listed) : [];
128
- const named = [...listed, ...included, ...handWritten(input.tools)];
128
+ const written = handWritten(input.tools);
129
+ const named = [...listed, ...included, ...written];
129
130
  // Unique names FIRST: the scope map addresses tools by name, so a duplicate would make
130
131
  // "which tool did this scope gate?" unanswerable before the question is worth asking.
131
- assertUniqueNames(named);
132
+ //
133
+ // Each list is tagged with the option that produced it, because "rename one" is only an
134
+ // instruction if the reader knows which two declarations to look at.
135
+ assertUniqueNames([
136
+ { source: 'actions:/queries:', tools: listed },
137
+ { source: "include: 'exposed'", tools: included },
138
+ { source: 'tools:', tools: written },
139
+ ]);
132
140
  const projected = withScopes(named, input.scopes);
133
141
 
134
142
  const config: CreateMcpServerInput = {
@@ -179,12 +187,20 @@ function notNamed(
179
187
  * to one name means the agent silently reaches the wrong one, which is the worst failure
180
188
  * mode available. Boot-time, loud, and named.
181
189
  */
182
- function assertUniqueNames(tools: readonly AnyMcpTool[]): void {
183
- const seen = new Set<string>();
184
- for (const tool of tools) {
185
- if (seen.has(tool.name)) {
186
- throw new McpToolDuplicateError({ name: tool.name });
190
+ function assertUniqueNames(
191
+ groups: readonly { readonly source: string; readonly tools: readonly AnyMcpTool[] }[],
192
+ ): void {
193
+ const seen = new Map<string, string>();
194
+ for (const group of groups) {
195
+ for (const tool of group.tools) {
196
+ const first = seen.get(tool.name);
197
+ if (first !== undefined) {
198
+ throw new McpToolDuplicateError({
199
+ name: tool.name,
200
+ declaredBy: first === group.source ? [first] : [first, group.source],
201
+ });
202
+ }
203
+ seen.set(tool.name, group.source);
187
204
  }
188
- seen.add(tool.name);
189
205
  }
190
206
  }
@@ -0,0 +1,24 @@
1
+ // The one place this package installs an ambient identity for a tool call.
2
+ //
3
+ // Its own file because both callers — a hand-written app tool and a projected action — must answer
4
+ // the same way on both transports, and two call sites each choosing `withChildContext` is how they
5
+ // came to answer differently by transport.
6
+
7
+ import type { Actor } from '@ultimat3/core';
8
+ import { createContext, hasContext, runWithContext, withChildContext } from '@ultimat3/core';
9
+
10
+ /**
11
+ * Run `fn` with `actor` as the ambient identity, whether or not a request is already in flight.
12
+ *
13
+ * `withChildContext` alone is wrong here, and was: it calls `useContext()`, which throws
14
+ * `X_NO_CONTEXT` when nothing is in flight — and NOTHING in this package installs a root context.
15
+ * Over `x mcp serve` (stdio) there is no surrounding request, so every app tool call and every
16
+ * projected action answered `X_NO_CONTEXT` instead of running. Over HTTP the transport's request
17
+ * supplies the parent, and the child is what keeps the policy subject and whatever the handler
18
+ * reads off `ctx.actor` the same identity by construction rather than by two call sites agreeing.
19
+ */
20
+ export function asCallerContext<T>(actor: Actor, fn: () => T): T {
21
+ return hasContext()
22
+ ? withChildContext({ actor }, fn)
23
+ : runWithContext(createContext({ actor }), fn);
24
+ }
package/src/errors.ts CHANGED
@@ -73,11 +73,20 @@ export class McpToolUnknownError extends UltimateError {
73
73
  export class McpScopeDeniedError extends UltimateError {
74
74
  readonly scope: string;
75
75
 
76
- constructor(input: { name: string; scope: string }) {
76
+ /**
77
+ * `subject` names WHICH surface refused, because the two are declared in different places: a
78
+ * tool's scope comes from `defineAppMcp({ scopes })`, a resource's is a field on the resource.
79
+ * A fix line naming the wrong declaration is an instruction that cannot be followed.
80
+ */
81
+ constructor(input: { name: string; scope: string; subject?: 'tool' | 'resource' }) {
82
+ const subject = input.subject ?? 'tool';
77
83
  super({
78
84
  code: 'X_MCP_SCOPE_DENIED',
79
- cause: `tool "${input.name}" requires scope "${input.scope}", which this connection's token does not carry`,
80
- fix: `reconnect with a token whose scopes include "${input.scope}" — the app's resolveToken(token) is what returns them — or drop "${input.scope}" from defineAppMcp({ scopes }); scopes are fixed for the life of a connection`,
85
+ cause: `${subject} "${input.name}" requires scope "${input.scope}", which this connection's token does not carry`,
86
+ fix:
87
+ subject === 'tool'
88
+ ? `reconnect with a token whose scopes include "${input.scope}" — the app's resolveToken(token) is what returns them — or drop "${input.scope}" from defineAppMcp({ scopes }); scopes are fixed for the life of a connection`
89
+ : `reconnect with a token whose scopes include "${input.scope}" — the app's resolveToken(token) is what returns them — or drop scope: '${input.scope}' from the resource declaring "${input.name}"; scopes are fixed for the life of a connection`,
81
90
  docs: docsFor('X_MCP_SCOPE_DENIED'),
82
91
  });
83
92
  this.scope = input.scope;
@@ -146,13 +155,30 @@ export class McpToolUndeclaredError extends UltimateError {
146
155
  * a call that succeeds against the wrong handler and reports nothing.
147
156
  */
148
157
  export class McpToolDuplicateError extends UltimateError {
149
- constructor(input: { name: string }) {
158
+ /**
159
+ * Which declaration each copy came from, when the projector knows — carried the way
160
+ * `McpScopeUnknownError` carries `projected`, so a caller can show it without re-parsing
161
+ * `cause`.
162
+ */
163
+ readonly declaredBy: readonly string[];
164
+
165
+ constructor(input: { name: string; declaredBy?: readonly string[] | undefined }) {
166
+ // The old `fix:` named "the primitive's export name, or the `tools` record key" for every
167
+ // raiser. On `@ultimat3/admin`'s path the colliding string is an `AdminAction.name` and
168
+ // neither of those exists, so the reader was sent to two places that do not hold it. Where
169
+ // the projector knows the sources, the fix names THEM instead of guessing.
170
+ const sites = input.declaredBy ?? [];
171
+ const from = sites.length > 0 ? ` (declared by ${sites.join(' and ')})` : '';
150
172
  super({
151
173
  code: 'X_MCP_TOOL_DUPLICATE',
152
- cause: `two primitives project to the MCP tool "${input.name}"`,
153
- fix: "rename one: the tool name is the primitive's export name, or the `tools` record key",
174
+ cause: `two primitives project to the MCP tool "${input.name}"${from}`,
175
+ fix:
176
+ sites.length > 0
177
+ ? `rename one — "${input.name}" is projected by ${sites.join(' and ')}; change the name at one of them`
178
+ : "rename one: the tool name is the primitive's export name, the `tools` record key, or an admin action's `name`",
154
179
  docs: docsFor('X_MCP_TOOL_DUPLICATE'),
155
180
  });
181
+ this.declaredBy = sites;
156
182
  }
157
183
  }
158
184
 
package/src/index.ts CHANGED
@@ -88,6 +88,7 @@ export type {
88
88
  McpResource,
89
89
  ResourceContents,
90
90
  ResourceListEntry,
91
+ ResourceResolution,
91
92
  } from './resources';
92
93
  export {
93
94
  frameworkResources,
@@ -5,6 +5,13 @@
5
5
  // keyword the server ignores is worse than omitting it — the agent obeys a rule nothing checks and
6
6
  // gets a silent pass. So this is a real projection, not a cast: a keyword outside the subset is
7
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.
8
15
 
9
16
  import type { JsonSchema as RichJsonSchema } from '@ultimat3/schema';
10
17
  import { toMcpInputSchema } from '@ultimat3/schema';
@@ -29,7 +36,6 @@ function narrow(source: RichJsonSchema): JsonSchema {
29
36
  ...(source.enum === undefined ? {} : { enum: source.enum }),
30
37
  ...(source.const === undefined ? {} : { const: source.const }),
31
38
  ...(source.default === undefined ? {} : { default: source.default }),
32
- ...(source.format === undefined ? {} : { format: source.format }),
33
39
  ...(source.minimum === undefined ? {} : { minimum: source.minimum }),
34
40
  ...(source.maximum === undefined ? {} : { maximum: source.maximum }),
35
41
  ...(source.minLength === undefined ? {} : { minLength: source.minLength }),
@@ -5,9 +5,10 @@
5
5
 
6
6
  import type { AnyAction } from '@ultimat3/action';
7
7
  import { actionName, invoke, isAction } from '@ultimat3/action';
8
- import { isMcpExposed, withChildContext } from '@ultimat3/core';
8
+ import { isMcpExposed } from '@ultimat3/core';
9
9
  import type { AnyQuery } from '@ultimat3/query';
10
10
  import { isQuery, queryName, sourceFor } from '@ultimat3/query';
11
+ import { asCallerContext } from './caller-context';
11
12
  import type { McpExposure, ProjectablePrimitive } from './from-action';
12
13
  import { toWireSchema } from './input-schema';
13
14
 
@@ -60,7 +61,7 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
60
61
  inputJsonSchema: toWireSchema(target.input),
61
62
  mutates: false,
62
63
  run: ({ input, actor }) =>
63
- withChildContext({ actor }, async () => {
64
+ asCallerContext(actor, async () => {
64
65
  // `sourceFor` is the authorized front half of `runQuery` — validate, guard, build —
65
66
  // and is what `live`, `paginate` and `explain` build on too. Executed without the
66
67
  // cache tiers on purpose: an agent diffing two tool calls must be reading the rows,
@@ -56,9 +56,21 @@ export function resolveQueryLimits(requested: unknown): QueryLimits {
56
56
 
57
57
  const encoder = new TextEncoder();
58
58
 
59
- /** Serialised size of one row, plus the separator it costs inside the JSON array. */
59
+ /**
60
+ * Serialised size of one row, plus the separator it costs inside the JSON array.
61
+ *
62
+ * A row the driver decoded into something JSON cannot hold — a bigint from an `int8` column, a
63
+ * cycle — costs `Infinity`, which is not a fudge: the row cannot be returned to the agent at all,
64
+ * and the ceiling it blows is already the one whose answer ("select fewer columns") is the right
65
+ * one. Raising instead would take down the tool that owes the agent a reply, from inside the cap
66
+ * that exists to protect it.
67
+ */
60
68
  function rowBytes(row: readonly unknown[]): number {
61
- return encoder.encode(JSON.stringify(row)).length + 1;
69
+ try {
70
+ return encoder.encode(JSON.stringify(row)).length + 1;
71
+ } catch {
72
+ return Number.POSITIVE_INFINITY;
73
+ }
62
74
  }
63
75
 
64
76
  /**
@@ -40,6 +40,10 @@ const WRITE_KEYWORDS = new Set([
40
40
  'grant',
41
41
  'import',
42
42
  'insert',
43
+ // `SELECT ... INTO <table>` is `CREATE TABLE AS` in another spelling: a DDL write with a read
44
+ // leader, so nothing above catches it. The word, never the shape — `insert into` is already
45
+ // refused by `insert`, and a bare `into` cannot appear in a read.
46
+ 'into',
43
47
  'listen',
44
48
  'lock',
45
49
  'merge',
@@ -81,7 +85,10 @@ const WRITE_KEYWORDS = new Set([
81
85
  * write keyword above;
82
86
  * - burn the wall clock — layer 2's `statement_timeout` cannot interrupt embedded PGlite
83
87
  * (single-threaded WASM), which is the database `x dev` runs, so this ban is the only one
84
- * that holds there.
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.
85
92
  *
86
93
  * The prefix is applied to a CALL — a name followed by `(` — and never to a bare word, so a
87
94
  * column called `pg_sleep_for_seconds` stays readable. Quoting does not evade it: the scan reads
@@ -91,6 +98,7 @@ const WRITE_KEYWORDS = new Set([
91
98
  const FORBIDDEN_FUNCTIONS = [
92
99
  'dblink',
93
100
  'lo_',
101
+ 'nextval',
94
102
  'pg_advisory_',
95
103
  'pg_cancel_backend',
96
104
  'pg_ls_',
@@ -101,6 +109,7 @@ const FORBIDDEN_FUNCTIONS = [
101
109
  'pg_terminate_backend',
102
110
  'pg_try_advisory_',
103
111
  'set_config',
112
+ 'setval',
104
113
  ];
105
114
 
106
115
  /** The family refusing `called`, or `undefined`. A prefix, so a new member is refused by default. */
package/src/registry.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  // narrow token; a token holding every scope still cannot see a tool its role may not.
20
20
 
21
21
  import type { Actor } from '@ultimat3/core';
22
+ import { McpToolDuplicateError } from './errors';
22
23
  import type { ArgIssue } from './validate-args';
23
24
  import { validateArgs } from './validate-args';
24
25
  import type { JsonSchema } from './wire';
@@ -117,8 +118,14 @@ export type McpVerbClass = 'read' | 'write';
117
118
  * oracle OUTCOME 1 exists to remove. It would also break `list` outright for that
118
119
  * caller, turning one broken audience into an empty catalog.
119
120
  */
120
- export function visibleToCaller(tool: AnyMcpTool, caller: McpCaller): boolean {
121
- const visibility = tool.visibleTo;
121
+ export function visibleToCaller(
122
+ // The SUBJECT of the gate, not a tool: a resource carries the same `visibleTo` and owes the same
123
+ // answer, and two implementations of one fail-closed rule is how one of them stops failing
124
+ // closed. Structural, so `AnyMcpTool` and `McpResource` both satisfy it with no adapter.
125
+ subject: { readonly visibleTo?: McpVisibility },
126
+ caller: McpCaller,
127
+ ): boolean {
128
+ const visibility = subject.visibleTo;
122
129
  if (visibility === undefined) return true;
123
130
  if (typeof visibility === 'function') {
124
131
  try {
@@ -142,7 +149,10 @@ export class ToolRegistry {
142
149
 
143
150
  register(tool: AnyMcpTool): this {
144
151
  if (this.#tools.has(tool.name)) {
145
- throw new McpDuplicateToolError(tool.name);
152
+ // The SAME code the resource twin throws. One package cannot answer "this name is taken"
153
+ // two ways, and a bare `Error` here reached the CLI as X_CLI_UNEXPECTED with
154
+ // `fix: x doctor --json` — the actual cause discarded at the last hop.
155
+ throw new McpToolDuplicateError({ name: tool.name });
146
156
  }
147
157
  this.#tools.set(tool.name, tool);
148
158
  return this;
@@ -208,14 +218,6 @@ export class ToolRegistry {
208
218
  }
209
219
  }
210
220
 
211
- /** Registration is a boot-time programming error, so it throws rather than returning. */
212
- class McpDuplicateToolError extends Error {
213
- constructor(name: string) {
214
- super(`MCP tool already registered: ${name}`);
215
- this.name = 'McpDuplicateToolError';
216
- }
217
- }
218
-
219
221
  /** Convenience constructor for a one-block text result. */
220
222
  export function textResult(text: string, isError = false): McpToolResult {
221
223
  const content: readonly ContentBlock[] = [{ type: 'text', text }];
@@ -223,7 +225,25 @@ export function textResult(text: string, isError = false): McpToolResult {
223
225
  return isError ? { content, isError: true } : { content };
224
226
  }
225
227
 
226
- /** JSON payload as a text block — stable 2-space form so an agent can diff two calls. */
228
+ /**
229
+ * JSON payload as a text block — stable 2-space form so an agent can diff two calls.
230
+ *
231
+ * TOTAL, because the value is an app's: `toolFromAction` hands an action's own return value
232
+ * straight here, and `JSON.stringify` answers `undefined` for a handler that returned nothing —
233
+ * a `text` that is not a string is an invalid MCP frame — and THROWS on a bigint, a cycle or a
234
+ * `toJSON` the value carries. A throw would leave the server's catch reporting a bug in the tool
235
+ * for a fault in the rendering, so the unreadable case is an ordinary `isError` result: the same
236
+ * three-line shape every other expected failure comes back as, and one an agent can act on.
237
+ */
227
238
  export function jsonResult(value: unknown): McpToolResult {
228
- return textResult(JSON.stringify(value, null, 2));
239
+ let text: string | undefined;
240
+ try {
241
+ text = JSON.stringify(value, null, 2);
242
+ } catch {
243
+ return textResult(
244
+ 'the tool ran, but its result is not JSON (a bigint, a cycle, or a toJSON that threw) — the tool has to return a JSON-serialisable value',
245
+ true,
246
+ );
247
+ }
248
+ return textResult(text ?? 'null');
229
249
  }
package/src/resources.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  // tier — the CLI wires them, this package only defines the shape and the URIs.
7
7
 
8
8
  import { McpResourceDuplicateError } from './errors';
9
+ import type { McpCaller, McpVisibility } from './registry';
10
+ import { visibleToCaller } from './registry';
9
11
  import type { JsonSchema } from './wire';
10
12
 
11
13
  /** Stable URIs. These are quoted in AGENTS.md files, so treat them as public API. */
@@ -21,10 +23,24 @@ export interface McpResource {
21
23
  readonly name: string;
22
24
  readonly description: string;
23
25
  readonly mimeType: string;
26
+ /**
27
+ * Required scope, checked exactly as a tool's is — OUTCOME 2. Absent = no scope gate. A
28
+ * resource is a DOCUMENT, and the four this package ships are an app's policy and data map, so
29
+ * the surface owes the same two gates the tool surface does.
30
+ */
31
+ readonly scope?: string;
32
+ /** Who may see and read it — OUTCOME 1. Absent = everyone. Same `McpVisibility` as a tool's. */
33
+ readonly visibleTo?: McpVisibility;
24
34
  /** Read on demand — a resource is never eagerly materialised at boot. */
25
35
  read(): Promise<string> | string;
26
36
  }
27
37
 
38
+ /** The gates that run before a resource is read. The tool surface's `ToolResolution` twin. */
39
+ export type ResourceResolution =
40
+ | { readonly kind: 'ok'; readonly resource: McpResource }
41
+ | { readonly kind: 'not-found'; readonly uri: string }
42
+ | { readonly kind: 'scope-denied'; readonly uri: string; readonly scope: string };
43
+
28
44
  /** `resources/list` row (the MCP shape, minus the body). */
29
45
  export interface ResourceListEntry {
30
46
  readonly uri: string;
@@ -81,9 +97,23 @@ export interface FrameworkResourceProviders {
81
97
  readonly routes?: () => Promise<string> | string;
82
98
  /** Entity/column/invariant description — the DB shape as JSON. */
83
99
  readonly schema?: () => Promise<string> | string;
100
+ /**
101
+ * Scope required to read ANY of the four, and the audience allowed to see them. Both optional and
102
+ * both off by default: `x mcp serve` hands the local developer's own identity to the server with
103
+ * no scopes at all, so a default would refuse the flow these documents exist for. A host serving
104
+ * them over HTTP declares one — the mechanism is here, the policy is the app's.
105
+ */
106
+ readonly scope?: string;
107
+ readonly visibleTo?: McpVisibility;
84
108
  }
85
109
 
86
110
  export function frameworkResources(providers: FrameworkResourceProviders): readonly McpResource[] {
111
+ // Applied to all four: they are one document set — the manifest, the OpenAPI document, the route
112
+ // table and the entity schema — and a host that gates one and not the others has gated nothing.
113
+ const gate = {
114
+ ...(providers.scope === undefined ? {} : { scope: providers.scope }),
115
+ ...(providers.visibleTo === undefined ? {} : { visibleTo: providers.visibleTo }),
116
+ };
87
117
  const out: McpResource[] = [];
88
118
  if (providers.manifest !== undefined) {
89
119
  out.push({
@@ -92,6 +122,7 @@ export function frameworkResources(providers: FrameworkResourceProviders): reado
92
122
  description: 'Generated facts: routes, entities, actions, queries, jobs, policies.',
93
123
  mimeType: 'application/json',
94
124
  read: providers.manifest,
125
+ ...gate,
95
126
  });
96
127
  }
97
128
  if (providers.openapi !== undefined) {
@@ -101,6 +132,7 @@ export function frameworkResources(providers: FrameworkResourceProviders): reado
101
132
  description: 'OpenAPI 3.1 document projected from every action and query.',
102
133
  mimeType: 'application/json',
103
134
  read: providers.openapi,
135
+ ...gate,
104
136
  });
105
137
  }
106
138
  if (providers.routes !== undefined) {
@@ -110,6 +142,7 @@ export function frameworkResources(providers: FrameworkResourceProviders): reado
110
142
  description: 'Route table: url, render mode, offline strategy, hydrate, budget.',
111
143
  mimeType: 'application/json',
112
144
  read: providers.routes,
145
+ ...gate,
113
146
  });
114
147
  }
115
148
  if (providers.schema !== undefined) {
@@ -119,6 +152,7 @@ export function frameworkResources(providers: FrameworkResourceProviders): reado
119
152
  description: 'Entities with columns, types and invariants.',
120
153
  mimeType: 'application/json',
121
154
  read: providers.schema,
155
+ ...gate,
122
156
  });
123
157
  }
124
158
  return out;
@@ -144,9 +178,15 @@ export class ResourceRegistry {
144
178
  return this;
145
179
  }
146
180
 
147
- /** Sorted by URI: a stable list is diffable between two boots. */
148
- list(): readonly ResourceListEntry[] {
149
- return [...this.resources.values()]
181
+ /**
182
+ * Sorted by URI: a stable list is diffable between two boots. Role-filtered when a caller is
183
+ * supplied, exactly as `ToolRegistry.list` is — `resources/list` answered every URI to every
184
+ * accepted token, which is a catalog of an app's whole generated surface.
185
+ */
186
+ list(caller?: McpCaller): readonly ResourceListEntry[] {
187
+ const all = [...this.resources.values()];
188
+ const visible = caller === undefined ? all : all.filter((r) => visibleToCaller(r, caller));
189
+ return visible
150
190
  .map((r) => ({
151
191
  uri: r.uri,
152
192
  name: r.name,
@@ -156,6 +196,23 @@ export class ResourceRegistry {
156
196
  .sort((a, b) => (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0));
157
197
  }
158
198
 
199
+ /**
200
+ * Visibility then scope, in the only order that is safe — the same order and the same two gates
201
+ * `ToolRegistry.resolve` applies, and for the same reason: absent and hidden collapse into ONE
202
+ * branch, or the difference between them is a catalog a prober can enumerate.
203
+ */
204
+ resolve(uri: string, caller: McpCaller): ResourceResolution {
205
+ const resource = this.resources.get(uri);
206
+ if (resource === undefined || !visibleToCaller(resource, caller)) {
207
+ return { kind: 'not-found', uri };
208
+ }
209
+ if (resource.scope !== undefined && !caller.scopes.has(resource.scope)) {
210
+ return { kind: 'scope-denied', uri, scope: resource.scope };
211
+ }
212
+ return { kind: 'ok', resource };
213
+ }
214
+
215
+ /** Raw read with NO gate applied — the resolver owns the gates, as it does for a tool. */
159
216
  async read(uri: string): Promise<ResourceContents | undefined> {
160
217
  const resource = this.resources.get(uri);
161
218
  if (resource === undefined) return undefined;
package/src/server.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // and an already-resolved caller, and returns a response or `null` for a notification.
4
4
  // Both transports (http, stdio) and every test drive this one function.
5
5
 
6
+ import { stringField } from '@ultimat3/core';
6
7
  import { formatIssues } from '@ultimat3/schema';
7
8
  import { auditToolCall, outcomeForCode } from './audit';
8
9
  import { McpScopeDeniedError } from './errors';
@@ -95,9 +96,9 @@ export class McpServer {
95
96
  case 'tools/call':
96
97
  return this.toolsCall(body, caller);
97
98
  case 'resources/list':
98
- return resultResponse(id, { resources: this.resources.list() });
99
+ return resultResponse(id, { resources: this.resources.list(caller) });
99
100
  case 'resources/read':
100
- return this.resourcesRead(body);
101
+ return this.resourcesRead(body, caller);
101
102
  case 'prompts/list':
102
103
  return resultResponse(id, { prompts: this.prompts });
103
104
  default:
@@ -212,19 +213,67 @@ export class McpServer {
212
213
  return resultResponse(id, payload);
213
214
  }
214
215
 
215
- private async resourcesRead(req: JsonRpcRequest): Promise<JsonRpcResponse> {
216
+ /**
217
+ * The same three-outcome shape `toolsCall` above applies, on the document surface. It took no
218
+ * caller at all until 2026-08: every accepted token could list every URI and read every one of
219
+ * them — the manifest, the OpenAPI document, the route table and the entity schema.
220
+ */
221
+ private async resourcesRead(req: JsonRpcRequest, caller: McpCaller): Promise<JsonRpcResponse> {
216
222
  const id = req.id ?? null;
217
223
  const uri = paramsOf(req)?.['uri'];
218
224
  if (typeof uri !== 'string') {
219
225
  return errorResponse(id, INVALID_PARAMS, 'resources/read params.uri must be a string');
220
226
  }
221
- const contents = await this.resources.read(uri);
222
- if (contents === undefined) {
223
- return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`, {
224
- available: this.resources.list().map((r) => r.uri),
225
- });
227
+
228
+ const resolved = this.resources.resolve(uri, caller);
229
+ switch (resolved.kind) {
230
+ // OUTCOME 1. Absent AND hidden collapse to one answer with no `data`: this branch used to
231
+ // return `available: [...every uri]`, so one wrong guess enumerated the whole catalog.
232
+ case 'not-found':
233
+ return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
234
+ // OUTCOME 2. The caller can already see this resource, so naming the missing scope leaks
235
+ // nothing — and the fix travels with it, built by the error that owns the wording.
236
+ case 'scope-denied': {
237
+ const denial = new McpScopeDeniedError({
238
+ name: uri,
239
+ scope: resolved.scope,
240
+ subject: 'resource',
241
+ });
242
+ return errorResponse(id, INVALID_REQUEST, `missing scope: ${resolved.scope}`, {
243
+ code: denial.code,
244
+ scope: resolved.scope,
245
+ fix: denial.fix,
246
+ docs: denial.docs,
247
+ });
248
+ }
249
+ case 'ok':
250
+ break;
251
+ }
252
+
253
+ // The provider is an INJECTED THUNK — `frameworkResources` wires these to file reads, and
254
+ // `Bun.file(...).text()` on a missing `x.manifest.json` throws ENOENT. Outside a try it escaped
255
+ // `handle()` entirely: `serveStdio` rejected with the raw error, zero frames written, the
256
+ // request unanswered and every later request on that buffer never processed. Same shape
257
+ // `toolsCall` uses above, for the same reason.
258
+ try {
259
+ const contents = await this.resources.read(uri);
260
+ if (contents === undefined) {
261
+ return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
262
+ }
263
+ return resultResponse(id, { contents: [contents] });
264
+ } catch (error) {
265
+ const framework = asFrameworkError(error);
266
+ if (framework !== undefined) {
267
+ return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`, {
268
+ code: framework.code,
269
+ cause: framework.cause,
270
+ fix: framework.fix,
271
+ });
272
+ }
273
+ // No internals: a provider's own message names a path, a query or a host the caller has no
274
+ // business seeing, exactly as a failing tool's does.
275
+ return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`);
226
276
  }
227
- return resultResponse(id, { contents: [contents] });
228
277
  }
229
278
  }
230
279
 
@@ -242,17 +291,21 @@ interface FrameworkError {
242
291
  * transport must stay independent of which package threw.
243
292
  */
244
293
  function asFrameworkError(error: unknown): FrameworkError | undefined {
245
- if (typeof error !== 'object' || error === null) return undefined;
246
- const e = error as { code?: unknown; title?: unknown; cause?: unknown; fix?: unknown };
247
- if (typeof e.code !== 'string' || !e.code.startsWith('X_')) return undefined;
294
+ // `stringField` from `@ultimat3/core`, never `typeof e.code === 'string'`: the value is whatever
295
+ // an app's handler, its driver or its SDK threw, so each read is a getter call or a `Proxy`
296
+ // trap. This runs inside the catch block that owes the caller an answer, and a probe that
297
+ // raises here leaves the JSON-RPC request with no response at all — not even the `-32603` the
298
+ // header promises for a genuine bug.
299
+ const code = stringField(error, 'code');
300
+ if (code === undefined || !code.startsWith('X_')) return undefined;
248
301
  return {
249
- code: e.code,
250
- title: typeof e.title === 'string' ? e.title : '',
251
- cause: typeof e.cause === 'string' ? e.cause : 'unknown',
302
+ code,
303
+ title: stringField(error, 'title') ?? '',
304
+ cause: stringField(error, 'cause') ?? 'unknown',
252
305
  // A substituted fix is still a fix an agent will act on, so it has to be runnable. `see docs`
253
- // named no docs and no command; `e.code` is already narrowed to an `X_` string by the guard
306
+ // named no docs and no command; `code` is already narrowed to an `X_` string by the guard
254
307
  // above, so the substitute is the one command that explains exactly this code.
255
- fix: typeof e.fix === 'string' ? e.fix : `x errors explain ${e.code}`,
308
+ fix: stringField(error, 'fix') ?? `x errors explain ${code}`,
256
309
  };
257
310
  }
258
311
 
@@ -10,7 +10,17 @@
10
10
 
11
11
  import type { McpCaller } from './registry';
12
12
  import type { McpServer } from './server';
13
- import { errorResponse, PARSE_ERROR } from './wire';
13
+ import { errorResponse, INVALID_REQUEST, PARSE_ERROR } from './wire';
14
+
15
+ /**
16
+ * Characters held for ONE message that has not ended yet. `transport-http.ts` caps the same wire at
17
+ * 1 MiB with `readWithinLimit`, and two transports must not answer one question two ways: the peer
18
+ * launched this process and is trusted, but a bug in it is not — a stream with no newline in it
19
+ * grew this buffer until the process died, with no frame written for the request already in flight.
20
+ * Characters rather than bytes because characters are what is retained here; one is never less than
21
+ * one byte on the wire, so the cap bounds both.
22
+ */
23
+ export const DEFAULT_STDIO_LINE_LIMIT = 1_048_576;
14
24
 
15
25
  export interface StdioTransportInput {
16
26
  readonly server: McpServer;
@@ -20,6 +30,8 @@ export interface StdioTransportInput {
20
30
  readonly input?: ReadableStream<Uint8Array>;
21
31
  /** Defaults to writing `Bun.stdout`. */
22
32
  write?(chunk: string): Promise<void> | void;
33
+ /** Characters buffered for one unterminated message. Defaults to `DEFAULT_STDIO_LINE_LIMIT`. */
34
+ readonly lineLimitBytes?: number | undefined;
23
35
  }
24
36
 
25
37
  /**
@@ -29,25 +41,60 @@ export interface StdioTransportInput {
29
41
  export async function serveStdio(config: StdioTransportInput): Promise<void> {
30
42
  const stream = config.input ?? Bun.stdin.stream();
31
43
  const write = config.write ?? defaultWrite;
44
+ const limit = config.lineLimitBytes ?? DEFAULT_STDIO_LINE_LIMIT;
32
45
  const decoder = new TextDecoder();
33
46
  let buffer = '';
47
+ // The rest of an over-long message is dropped, not parsed: whatever follows it on the same line
48
+ // is the tail of a message this transport refused, never a message of its own.
49
+ let discarding = false;
34
50
 
35
51
  for await (const chunk of stream) {
36
52
  buffer += decoder.decode(chunk, { stream: true });
53
+ if (discarding) {
54
+ const end = buffer.indexOf('\n');
55
+ if (end === -1) {
56
+ buffer = '';
57
+ continue;
58
+ }
59
+ buffer = buffer.slice(end + 1);
60
+ discarding = false;
61
+ }
37
62
  let newline = buffer.indexOf('\n');
38
63
  while (newline !== -1) {
39
64
  const line = buffer.slice(0, newline);
40
65
  buffer = buffer.slice(newline + 1);
41
- await handleLine(config.server, config.caller, line, write);
66
+ // Checked before the parse, never inside it: a line already over the cap costs a `JSON.parse`
67
+ // over the whole of it, which is the work the cap exists to refuse.
68
+ if (line.length > limit) await write(`${JSON.stringify(overLimit(limit))}\n`);
69
+ else await handleLine(config.server, config.caller, line, write);
42
70
  newline = buffer.indexOf('\n');
43
71
  }
72
+ if (buffer.length > limit) {
73
+ await write(`${JSON.stringify(overLimit(limit))}\n`);
74
+ buffer = '';
75
+ discarding = true;
76
+ }
44
77
  }
45
- // A trailing message with no newline is still a message.
46
- if (buffer.trim().length > 0) {
47
- await handleLine(config.server, config.caller, buffer, write);
78
+ // A trailing message with no newline is still a message — unless it is the tail being dropped.
79
+ if (!discarding && buffer.trim().length > 0) {
80
+ if (buffer.length > limit) await write(`${JSON.stringify(overLimit(limit))}\n`);
81
+ else await handleLine(config.server, config.caller, buffer, write);
48
82
  }
49
83
  }
50
84
 
85
+ /** Answered once per over-long message, and the `fix` is what the peer has to change. */
86
+ function overLimit(limit: number): ReturnType<typeof errorResponse> {
87
+ return errorResponse(
88
+ null,
89
+ INVALID_REQUEST,
90
+ `a single message exceeded ${limit} characters and was dropped`,
91
+ {
92
+ limit,
93
+ fix: `send one JSON-RPC message per line, each under ${limit} characters — split a large tool result into paged calls`,
94
+ },
95
+ );
96
+ }
97
+
51
98
  async function handleLine(
52
99
  server: McpServer,
53
100
  caller: McpCaller,
@@ -78,29 +78,43 @@ function object(
78
78
  const out: Record<string, unknown> = {};
79
79
 
80
80
  for (const key of Object.keys(source)) {
81
- if (properties[key] === undefined) {
81
+ // `Object.hasOwn`, never `properties[key] === undefined`: the second walks the prototype
82
+ // chain, so `constructor`, `toString` and `__proto__` read as DECLARED on every schema and an
83
+ // argument named after one was accepted past an `additionalProperties: false` that forbids it,
84
+ // then silently dropped. Same discriminator as the loop below, which always had it right.
85
+ if (!Object.hasOwn(properties, key)) {
82
86
  if (schema.additionalProperties === false) {
83
87
  issues.push({ path: join(path, key), message: 'unknown property' });
84
88
  continue;
85
89
  }
86
- out[key] = source[key];
90
+ put(out, key, source[key]);
87
91
  }
88
92
  }
89
93
  for (const [key, child] of Object.entries(properties)) {
90
94
  const at = join(path, key);
91
95
  const present = Object.hasOwn(source, key) && source[key] !== undefined;
92
96
  if (!present) {
93
- if (child.default !== undefined) out[key] = child.default;
97
+ if (child.default !== undefined) put(out, key, child.default);
94
98
  else if (schema.required?.includes(key) === true) {
95
99
  issues.push({ path: at, message: 'is required' });
96
100
  }
97
101
  continue;
98
102
  }
99
- out[key] = walk(child, source[key], at, issues);
103
+ put(out, key, walk(child, source[key], at, issues));
100
104
  }
101
105
  return out;
102
106
  }
103
107
 
108
+ /**
109
+ * One validated key onto the result. `out[key] = value` is not an assignment for exactly one
110
+ * name: `__proto__` runs `Object.prototype`'s setter and REPLACES the object's prototype instead
111
+ * of adding a key, so a caller-chosen argument name decides what the handler's `args.isAdmin`
112
+ * reads. `defineProperty` writes a plain own data property whatever the name is.
113
+ */
114
+ function put(out: Record<string, unknown>, key: string, value: unknown): void {
115
+ Object.defineProperty(out, key, { value, writable: true, enumerable: true, configurable: true });
116
+ }
117
+
104
118
  function array(schema: JsonSchema, input: unknown, path: string, issues: ArgIssue[]): unknown {
105
119
  if (!Array.isArray(input)) {
106
120
  issues.push({ path, message: 'must be an array' });
package/src/wire.ts CHANGED
@@ -76,7 +76,19 @@ export interface JsonSchema {
76
76
  readonly enum?: readonly (string | number | boolean | null)[];
77
77
  readonly const?: string | number | boolean | null;
78
78
  readonly default?: unknown;
79
- readonly format?: string;
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;
80
92
  readonly minimum?: number;
81
93
  readonly maximum?: number;
82
94
  readonly minLength?: number;