@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 +68 -6
- package/package.json +8 -8
- package/src/app-tool.ts +31 -5
- package/src/app-tools.ts +24 -8
- package/src/caller-context.ts +24 -0
- package/src/errors.ts +32 -6
- package/src/index.ts +1 -0
- package/src/input-schema.ts +7 -1
- package/src/projectable.ts +3 -2
- package/src/query-limits.ts +14 -2
- package/src/readonly-sql.ts +10 -1
- package/src/registry.ts +33 -13
- package/src/resources.ts +60 -3
- package/src/server.ts +70 -17
- package/src/transport-stdio.ts +52 -5
- package/src/validate-args.ts +18 -4
- package/src/wire.ts +13 -1
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/jobs": "
|
|
38
|
-
"@ultimat3/policy": "
|
|
39
|
-
"@ultimat3/query": "
|
|
40
|
-
"@ultimat3/schema": "
|
|
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
|
|
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
|
-
|
|
99
|
+
asCallerContext(actor, async () => {
|
|
98
100
|
const ctx = useContext();
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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:
|
|
80
|
-
fix:
|
|
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
|
-
|
|
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:
|
|
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
package/src/input-schema.ts
CHANGED
|
@@ -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 }),
|
package/src/projectable.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
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/readonly-sql.ts
CHANGED
|
@@ -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(
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
250
|
-
title:
|
|
251
|
-
cause:
|
|
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; `
|
|
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:
|
|
308
|
+
fix: stringField(error, 'fix') ?? `x errors explain ${code}`,
|
|
256
309
|
};
|
|
257
310
|
}
|
|
258
311
|
|
package/src/transport-stdio.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
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' });
|
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
|
-
|
|
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;
|