@ultimat3/mcp 2.0.0 → 3.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,28 @@ 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
+ - **Anything a tool RETURNS is rendered totally.** `jsonResult` is handed an action's own return
62
+ value, and `JSON.stringify` answers `undefined` for a handler that returned nothing (a
63
+ `ContentBlock.text` that is not a string is an invalid frame) and THROWS on a bigint, a cycle or
64
+ a `toJSON` the value carries. Unreadable is an ordinary `isError` result, never an escape past
65
+ `server.ts`'s catch, which would report a bug in the tool for a fault in the rendering.
66
+ `query-limits.ts`' `rowBytes` holds the same line one layer earlier: a row the driver decoded
67
+ into a bigint costs `Infinity` and is cut by the byte ceiling, rather than raising inside the cap
68
+ that exists to protect the answer.
69
+ - **A thrown value is read with `stringField` from `@ultimat3/core`, never `typeof e.code ===
70
+ 'string'`.** `asFrameworkError` reads four fields off whatever an app's handler threw; each read
71
+ is a getter call, or a `Proxy` trap, inside the catch block that owes the caller a response — and
72
+ a probe that raises there leaves the JSON-RPC request with no answer at all, not even the
73
+ `-32603` the transport promises for a genuine bug.
52
74
  - A framework error rendered into a tool result is **byte-identical to
53
75
  `UltimateError.format()`** — one denial must not read one way over MCP and another in the
54
76
  terminal. `server.ts` renders it; the test pins it against `format()`, never a literal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "2.0.0",
3
+ "version": "3.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": "3.0.0",
35
+ "@ultimat3/core": "3.0.0",
36
+ "@ultimat3/entity": "3.0.0",
37
+ "@ultimat3/jobs": "3.0.0",
38
+ "@ultimat3/policy": "3.0.0",
39
+ "@ultimat3/query": "3.0.0",
40
+ "@ultimat3/schema": "3.0.0"
41
41
  }
42
42
  }
@@ -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
  /**
package/src/registry.ts CHANGED
@@ -223,7 +223,25 @@ export function textResult(text: string, isError = false): McpToolResult {
223
223
  return isError ? { content, isError: true } : { content };
224
224
  }
225
225
 
226
- /** JSON payload as a text block — stable 2-space form so an agent can diff two calls. */
226
+ /**
227
+ * JSON payload as a text block — stable 2-space form so an agent can diff two calls.
228
+ *
229
+ * TOTAL, because the value is an app's: `toolFromAction` hands an action's own return value
230
+ * straight here, and `JSON.stringify` answers `undefined` for a handler that returned nothing —
231
+ * a `text` that is not a string is an invalid MCP frame — and THROWS on a bigint, a cycle or a
232
+ * `toJSON` the value carries. A throw would leave the server's catch reporting a bug in the tool
233
+ * for a fault in the rendering, so the unreadable case is an ordinary `isError` result: the same
234
+ * three-line shape every other expected failure comes back as, and one an agent can act on.
235
+ */
227
236
  export function jsonResult(value: unknown): McpToolResult {
228
- return textResult(JSON.stringify(value, null, 2));
237
+ let text: string | undefined;
238
+ try {
239
+ text = JSON.stringify(value, null, 2);
240
+ } catch {
241
+ return textResult(
242
+ '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',
243
+ true,
244
+ );
245
+ }
246
+ return textResult(text ?? 'null');
229
247
  }
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';
@@ -242,17 +243,21 @@ interface FrameworkError {
242
243
  * transport must stay independent of which package threw.
243
244
  */
244
245
  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;
246
+ // `stringField` from `@ultimat3/core`, never `typeof e.code === 'string'`: the value is whatever
247
+ // an app's handler, its driver or its SDK threw, so each read is a getter call or a `Proxy`
248
+ // trap. This runs inside the catch block that owes the caller an answer, and a probe that
249
+ // raises here leaves the JSON-RPC request with no response at all — not even the `-32603` the
250
+ // header promises for a genuine bug.
251
+ const code = stringField(error, 'code');
252
+ if (code === undefined || !code.startsWith('X_')) return undefined;
248
253
  return {
249
- code: e.code,
250
- title: typeof e.title === 'string' ? e.title : '',
251
- cause: typeof e.cause === 'string' ? e.cause : 'unknown',
254
+ code,
255
+ title: stringField(error, 'title') ?? '',
256
+ cause: stringField(error, 'cause') ?? 'unknown',
252
257
  // 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
258
+ // named no docs and no command; `code` is already narrowed to an `X_` string by the guard
254
259
  // 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}`,
260
+ fix: stringField(error, 'fix') ?? `x errors explain ${code}`,
256
261
  };
257
262
  }
258
263
 
@@ -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' });