@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 +22 -0
- package/package.json +8 -8
- package/src/query-limits.ts +14 -2
- package/src/registry.ts +20 -2
- package/src/server.ts +13 -8
- package/src/validate-args.ts +18 -4
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/jobs": "
|
|
38
|
-
"@ultimat3/policy": "
|
|
39
|
-
"@ultimat3/query": "
|
|
40
|
-
"@ultimat3/schema": "
|
|
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
|
}
|
package/src/query-limits.ts
CHANGED
|
@@ -56,9 +56,21 @@ export function resolveQueryLimits(requested: unknown): QueryLimits {
|
|
|
56
56
|
|
|
57
57
|
const encoder = new TextEncoder();
|
|
58
58
|
|
|
59
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
250
|
-
title:
|
|
251
|
-
cause:
|
|
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; `
|
|
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:
|
|
260
|
+
fix: stringField(error, 'fix') ?? `x errors explain ${code}`,
|
|
256
261
|
};
|
|
257
262
|
}
|
|
258
263
|
|
package/src/validate-args.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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' });
|