@ultimat3/mcp 1.1.0 → 2.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 ADDED
@@ -0,0 +1,161 @@
1
+ # @ultimat3/mcp — boundary
2
+
3
+ Tier 4. May import tier 0–3: `core schema i18n money time cache seo entity policy http action
4
+ query jobs realtime`. **Never** `render manifest ai pwa ui admin testing cli`.
5
+
6
+ Same-tier data (routes, manifest, policy catalog) arrives as an **injected thunk**, never an
7
+ import. The CLI wires it.
8
+
9
+ ## Owns
10
+
11
+ | File | Job |
12
+ |---|---|
13
+ | `wire.ts` | JSON-RPC types, error codes, protocol version, `JsonSchema` subset |
14
+ | `registry.ts` | catalog + the first two security outcomes (visibility, scope) |
15
+ | `audit.ts` | one structured line per `tools/call`, outcome → level |
16
+ | `validate-args.ts` | JSON-Schema-subset arg validation, applies defaults |
17
+ | `server.ts` | JSON-RPC dispatch, `classify` for rate-limit buckets |
18
+ | `from-action.ts` | action/query → tool; the "one authz system" projection; `toolsFrom` (sweep, skips) vs `toolsListed` (written out, refuses) |
19
+ | `resources.ts` | resources + prompts, stable `ultimate://` URIs |
20
+ | `dev-server.ts` | the 13 dev tools; depends only on an injected `DevHost` |
21
+ | `dev-host.ts` | wires `describe*` from entity/action/query/jobs into a `DevHost` |
22
+ | `transport-http.ts` | `POST /mcp` route descriptor, bearer → agent actor |
23
+ | `transport-stdio.ts` | NDJSON on stdin/stdout for `x mcp serve` |
24
+ | `app-tools.ts` | `defineAppMcp` — a generated app's own MCP surface, one call |
25
+ | `app-tool.ts` | the authored `tools: { name: {...} }` record → `ProjectablePrimitive` |
26
+ | `projectable.ts` | a real `action`/`query` → `ProjectablePrimitive`; the ONE adapter both the sweep and the written-out list use |
27
+ | `exposed.ts` | `include: 'exposed'` — the action/query registries → primitives |
28
+ | `scopes.ts` | the `scopes:` map — outcome 2's declaration surface; boot-time refusal of an unknown or doubly-claimed tool |
29
+ | `input-schema.ts` | Standard Schema → the `JsonSchema` subset `validate-args.ts` enforces |
30
+ | `readonly-sql.ts` | layer 3 of `db.query` — the single-read parse — and `db.migrate`'s branch check |
31
+ | `query-limits.ts` | layer 4 of `db.query` — the row, byte and timeout ceilings, and what truncation reports |
32
+
33
+ ## Invariants
34
+
35
+ - `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a `defineAppMcp` file
36
+ imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on
37
+ every access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
38
+ - Three outcomes, never blurred: role-hidden → `-32601` ToolNotFound with no `data`;
39
+ scope → `-32600` `X_MCP_SCOPE_DENIED` naming the scope; policy → an `isError` result
40
+ carrying `X_FORBIDDEN`. Swapping any two is an enumeration oracle.
41
+ - `visibleTo` is **fail-closed** three ways: a role list admits only the roles it names (a
42
+ caller with no role matches none), a predicate must return the literal `true`, and a
43
+ predicate that THROWS hides the tool. A predicate takes the caller — never the arguments —
44
+ so existence cannot be probed by varying input. `tools/list` is answered per caller: one
45
+ `McpCaller` per HTTP request, one per stdio connection.
46
+ - `visibleTo` declared in a primitive's `mcp` block is carried through `exposed.ts`'s
47
+ `exposureOf` to the projected tool — outcome 1's only declaration surface for a projected
48
+ primitive. Dropping it there silently disables outcome 1 for every projected tool: nothing
49
+ fails, every caller simply sees every tool.
50
+ - Resolve order is visibility → scope → args → policy. Validating first leaks a schema;
51
+ running the policy first decides a refusal from attacker-supplied input.
52
+ - A framework error rendered into a tool result is **byte-identical to
53
+ `UltimateError.format()`** — one denial must not read one way over MCP and another in the
54
+ terminal. `server.ts` renders it; the test pins it against `format()`, never a literal.
55
+ - Every outcome is audited via `audit.ts`, hidden included, at `warn`. Never log arguments
56
+ or row data — a denial reason naming a row is a leak wearing an audit line's clothes.
57
+ - `security.test.ts` and `app-security.test.ts` are the executable contract for all of the
58
+ above — the first over hand-built tools (each gate in isolation), the second over what an app
59
+ actually declares (`defineAppMcp` projecting real actions and queries). Extend them, never
60
+ weaken them. A gate can only refuse what a declaration can reach, so a new gate needs a test
61
+ in BOTH: the registry half passes while the declaration surface silently drops the field.
62
+ - **`isExposed` and `exposureOf` both delegate to `isMcpExposed` in `@ultimat3/core`.** That is the
63
+ framework's one answer to "did this opt in?", shared with `action`, `query`, `ai` and `manifest`
64
+ — five packages that cannot import each other, which is how three spellings of the check shipped
65
+ and why the pin lives in `@ultimat3/cli`. Never spell `=== true` inline here again.
66
+ - Exposure is declared at the primitive, never in `defineAppMcp`. A primitive NAMED in
67
+ `actions:`/`queries:` without `mcp: { expose: true }` is `X_MCP_TOOL_UNDECLARED` at boot —
68
+ a written-out list is a request, so filtering it would ship a catalog missing a tool its
69
+ author believes is there. `include: 'exposed'` sweeps the registries and therefore skips,
70
+ because that list is every primitive the app registered, not one anyone wrote out.
71
+ `actions:` and `queries:` go through **one** `toolsListed` call over the concatenation: it
72
+ collects every offender before throwing, so one boot names all of them and one edit closes
73
+ all of them. Two calls would throw on the first array and never examine the second.
74
+ - `actions:`/`queries:` take the **real primitives** (`actions: [publishPost]`), adapted by
75
+ `projectable.ts` into the same `ProjectablePrimitive` the registry sweep builds. They took
76
+ `ProjectablePrimitive` alone until 2026-08, which no `action()` or `query()` satisfies — they
77
+ carry `as`/`tool`, never `run` — so listing one was a TS2741 and the only value that could
78
+ reach `X_MCP_TOOL_UNDECLARED` was a hand-built fake. A gate that no declaration can reach
79
+ refuses nothing. `ProjectablePrimitive` stays in the union for surfaces that build a catalog
80
+ programmatically (`@ultimat3/admin`); `isAction`/`isQuery` read each package's private
81
+ declaration store, so a look-alike falls through instead of borrowing `invoke`.
82
+ - The adapter is **one function with two callers**, never a copy per route: the written-out list
83
+ and `include: 'exposed'` land on the same `run` — `invoke` for an action, `sourceFor` for a
84
+ query. Writing a primitive out NAMES a tool; it never re-shapes or re-runs one. An action
85
+ with no export name is `X_ACTION_UNREGISTERED` rather than a tool called `''`, which no
86
+ `tools/call` and no `scopes:` entry could ever address.
87
+ - **This package NAMES a tool, it never derives one.** `primitive.mcp?.name ?? primitive.name` in
88
+ `from-action.ts` is the whole rule, fed the verbatim export name by `projectable.ts` — a
89
+ transform here would be a second spelling of a name that is already an addressable identity.
90
+ Every surface that PUBLISHES the name owes the same string: `action.tool()`, `query.tool()`,
91
+ `x-ultimate.mcpTool`, `ActionDescriptor.mcp.tool`. The three action publishers snake_cased it
92
+ through `toToolName` until 2026-08, so a spec-reading agent called `publish_post` and got
93
+ `-32601` from a catalog holding `publishPost`, and nothing noticed because no test compared the
94
+ served name to a published one. `cross-surface.test.ts` is that comparison — it reads the
95
+ catalog off `tools/list` and drives a `tools/call` with the name OpenAPI published, so a
96
+ publisher that re-derives is a failing test and not a wiki note.
97
+ - Every boot-time refusal in `defineAppMcp` is an `UltimateError` with a code, never a bare
98
+ throw: `X_MCP_TOOL_UNDECLARED`, `X_MCP_TOOL_UNSAFE`, `X_MCP_TOOL_DUPLICATE`,
99
+ `X_MCP_SCOPE_UNKNOWN`, `X_MCP_SCOPE_CONFLICT`. The caller reading them is usually an agent
100
+ that needs `{ code, cause, fix }`.
101
+ - `scopes:` is refused at boot two ways, both because the alternative ships a tool silently
102
+ ungated: a name no projected tool answers to is `X_MCP_SCOPE_UNKNOWN` (a typo, a rename, a
103
+ primitive never listed); one tool claimed by two scopes is `X_MCP_SCOPE_CONFLICT` — a tool
104
+ carries exactly one, and object key order is not a security model.
105
+ - The **projection** invents no `scope` — `toolFromAction` cannot know what a token means.
106
+ `defineAppMcp`'s `scopes:` may attach one afterward, as a capability of the CONNECTION; that
107
+ is not a second authz path, because the scope gate decides before the policy runs and never
108
+ reads the input. A hand-written app tool is the same: its `policy` reaches `guard()` from
109
+ `@ultimat3/action`, which is the one authz path that reads the input — never a second check
110
+ written for MCP.
111
+ - **A URI is taken once.** `ResourceRegistry.register` throws `X_MCP_RESOURCE_DUPLICATE` on a
112
+ second claim, exactly as `ToolRegistry.register` throws on a second tool name — one package
113
+ cannot answer "this name is taken" two ways. `Map.set` made the answer whichever provider was
114
+ wired last, and a `ultimate://` URI is quoted in AGENTS.md files.
115
+ - **`\'` inside a string literal is refused** (`readonly-sql.ts`). `E'\''` is one quote to
116
+ Postgres and `standard_conforming_strings` decides the plain spelling, so the two readings
117
+ disagree about where the string ends — which is exactly where a `;` hides.
118
+ `select E'\'' ; drop table posts --'` was accepted as one read-only statement and handed back
119
+ verbatim to run. Only inside a single-quoted run: a comment, a `$tag$` body and a quoted
120
+ identifier are unambiguous and still read fine.
121
+ - **An unterminated run is refused, never swallowed** (`readonly-sql.ts`). The stripper blanks what
122
+ it believes is inside a literal, so an opening delimiter that never closes hid the whole tail:
123
+ `select '; delete from members` counted one statement with no mutating keyword and was handed
124
+ back to run. All four forms now throw `X_MCP_QUERY_REJECTED` — `'`, `"`, `$tag$`, `/* */`. Not
125
+ exploitable through Postgres (a syntax error either way); the point is that this layer must not
126
+ be the thing that waves it through. `@ultimat3/admin`'s `/_x` panel failed CLOSED here where this
127
+ failed open, which is how it was found — a second, differently-behaved copy of one rule.
128
+ - `db.query` / `db.migrate` refuse structurally, in `readonly-sql.ts`, before the host runs
129
+ (`X_MCP_QUERY_REJECTED` / `X_MCP_NOT_BRANCH_DB` — one code each, because they want different
130
+ next commands).
131
+ - Banned SQL functions are matched as a **prefix of a CALLED function name**, so the family is the
132
+ unit and a spelling nobody wrote down is refused rather than admitted — an exact-name list let
133
+ `pg_sleep_for` past a ban on `pg_sleep`, and `set_config` past `SET`, which is already a write
134
+ keyword. Add a family, never a name. The unit is the call (`name` before `(`), never a bare word:
135
+ a word scan refused a column named `pg_sleep_for_seconds`. The call scan reads a strip that KEEPS
136
+ 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.
142
+ - `db.query` is defended four ways: a SELECT-only role and `BEGIN READ ONLY` in `@ultimat3/db`
143
+ (the CLI wires them — this package must never import `db`), the parse here, and the caps here.
144
+ `limit` is a request, never a permission: `resolveQueryLimits` clamps it into a hard 1000.
145
+ - The caps run in the **tool**, not the host. A host that forgets them answers a million rows
146
+ into a model's context. `guards` names the layers that engaged; a layer that could not engage
147
+ is absent from the list, never assumed present.
148
+ - **Every authentication answer lands before `request.json()`.** A missing token, a token
149
+ `resolveToken` rejects and a non-agent actor all return before the body is read: parsing first
150
+ answered `400 parse error` for a malformed payload and `401` for a well-formed one under the
151
+ SAME rejected token, which is precisely the oracle the pre-parse 401 exists to remove. The parse
152
+ error still exists — it is what an authenticated agent gets.
153
+ - `transport-stdio.ts` never writes stdout except the wire. Diagnostics → stderr.
154
+ - New mutating tool ⇒ set `destructive: true`, or it is metered as cheap read chatter.
155
+
156
+ ## Commands
157
+
158
+ ```
159
+ bun test packages/mcp
160
+ bun run --filter @ultimat3/mcp typecheck
161
+ ```
package/README.md CHANGED
@@ -40,12 +40,14 @@ the list**, never assumed. Truncation is never silent.
40
40
  ## One authz system, two surfaces
41
41
 
42
42
  Every `action` with `mcp: { expose: true }` becomes a tool for free, and the tool's `handle`
43
- calls the **same `action.run`** the HTTP route calls. Policy evaluation lives inside `run`.
43
+ reaches the **same `invoke`** the HTTP route reaches the projection's `run` is that call with
44
+ `surface: 'mcp'`, nothing more. Policy evaluation lives inside `invoke`. (An action has no `.run`
45
+ member; `run` is the projection seam, and a query's half of it is `sourceFor`.)
44
46
 
45
47
  ```
46
- HTTP POST /api/publishPost ─┐
47
- ├─→ action.run({ input, actor }) ─→ policy ─→ handler
48
- MCP tools/call publishPost ─┘
48
+ HTTP POST /api/posts/publish ─┐
49
+ ├─→ invoke(action, input, { surface, actor }) ─→ policy ─→ handler
50
+ MCP tools/call publishPost ─┘
49
51
  ```
50
52
 
51
53
  `mcp: { visibleTo: [...] }` on the action or query travels with the projection too — the only
@@ -142,6 +144,17 @@ One adapter serves both routes, so a written-out primitive runs through the same
142
144
  An action that was never handed to `defineApi` has no export name, and is
143
145
  `X_ACTION_UNREGISTERED` rather than a tool called `''` that nothing could call.
144
146
 
147
+ **The tool name is the export name, verbatim** — `publishPost`, never `publish_post`. This server
148
+ answers `tools/call` for that name and no other, so every surface that PUBLISHES a name has to
149
+ publish the same one: `action.tool()`, `query.tool()`, `x-ultimate.mcpTool` in `openapi.json`, and
150
+ `ActionDescriptor.mcp.tool`. The projection reads `primitive.mcp?.name ?? primitive.name`, so the
151
+ export name is the **default** and `mcp.name` is an explicit override — unreachable from `action()`
152
+ or `query()`, whose declarations carry no `name` field, and available only to a hand-authored
153
+ `ProjectablePrimitive` passed to `defineAppMcp`'s `tools:`. The three action publishers snake_cased
154
+ the name `As of 2026-08`, so an agent reading the spec called a tool the catalog never contained and
155
+ got ToolNotFound.
156
+ `src/cross-surface.test.ts` is what makes a fourth spelling a failing test rather than a note.
157
+
145
158
  A hand-written tool's `policy` is a permission, evaluated through the same `guard()` an
146
159
  HTTP request goes through, so a tool cannot acquire a second authz path. A tool without one
147
160
  is `X_MCP_TOOL_UNSAFE` at boot, and an unmarked tool is metered as a write.
@@ -200,3 +213,4 @@ inside their own handler.
200
213
  | `X_MCP_PROTOCOL` | malformed envelope, unknown method, bad auth header |
201
214
  | `X_MCP_QUERY_REJECTED` | `db.query` given anything but one read-only statement |
202
215
  | `X_MCP_NOT_BRANCH_DB` | `db.migrate` aimed at a production or otherwise non-branch database |
216
+ | `X_MCP_RESOURCE_DUPLICATE` | two resources claim one `ultimate://` URI — refused at registration, as a duplicate tool name is |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "1.1.0",
3
+ "version": "2.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",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,12 +31,12 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/action": "1.1.0",
34
- "@ultimat3/core": "1.1.0",
35
- "@ultimat3/entity": "1.1.0",
36
- "@ultimat3/jobs": "1.1.0",
37
- "@ultimat3/policy": "1.1.0",
38
- "@ultimat3/query": "1.1.0",
39
- "@ultimat3/schema": "1.1.0"
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"
40
41
  }
41
42
  }
package/src/dev-host.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // primitive registries.
5
5
 
6
6
  import { describeActions } from '@ultimat3/action';
7
- import { FRAMEWORK_VERSION } from '@ultimat3/core';
7
+ import { frameworkVersion } from '@ultimat3/core';
8
8
  import { describeEntities } from '@ultimat3/entity';
9
9
  import { describeJobs, inspectJob, jobDriver } from '@ultimat3/jobs';
10
10
  import { describeQueries } from '@ultimat3/query';
@@ -49,7 +49,7 @@ export function createDevServer(input: CreateDevServerInput): McpServer {
49
49
  return createMcpServer({
50
50
  tools: devTools(input.host),
51
51
  resources: frameworkResources(input.resources ?? {}),
52
- serverInfo: { name: 'ultimate-dev', version: FRAMEWORK_VERSION },
52
+ serverInfo: { name: 'ultimate-dev', version: frameworkVersion() },
53
53
  });
54
54
  }
55
55
 
package/src/errors.ts CHANGED
@@ -13,6 +13,7 @@ export const MCP_ERROR_CODES = [
13
13
  'X_MCP_TOOL_UNSAFE',
14
14
  'X_MCP_TOOL_UNDECLARED',
15
15
  'X_MCP_TOOL_DUPLICATE',
16
+ 'X_MCP_RESOURCE_DUPLICATE',
16
17
  'X_MCP_SCOPE_UNKNOWN',
17
18
  'X_MCP_SCOPE_CONFLICT',
18
19
  ] as const;
@@ -29,6 +30,7 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
29
30
  X_MCP_TOOL_UNSAFE: 'an MCP tool declares no policy',
30
31
  X_MCP_TOOL_UNDECLARED: 'defineAppMcp lists a primitive that declares no MCP exposure',
31
32
  X_MCP_TOOL_DUPLICATE: 'two primitives project to one MCP tool name',
33
+ X_MCP_RESOURCE_DUPLICATE: 'two resources claim one MCP resource URI',
32
34
  X_MCP_SCOPE_UNKNOWN: 'defineAppMcp scopes a tool this server does not project',
33
35
  X_MCP_SCOPE_CONFLICT: 'two scopes claim one MCP tool',
34
36
  };
@@ -75,7 +77,7 @@ export class McpScopeDeniedError extends UltimateError {
75
77
  super({
76
78
  code: 'X_MCP_SCOPE_DENIED',
77
79
  cause: `tool "${input.name}" requires scope "${input.scope}", which this connection's token does not carry`,
78
- fix: `x token grant ${input.scope} # then reconnect: scopes are fixed for the life of a connection`,
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`,
79
81
  docs: docsFor('X_MCP_SCOPE_DENIED'),
80
82
  });
81
83
  this.scope = input.scope;
@@ -154,6 +156,23 @@ export class McpToolDuplicateError extends UltimateError {
154
156
  }
155
157
  }
156
158
 
159
+ /**
160
+ * Two resources claim one `ultimate://` URI. The twin of `McpToolDuplicateError`, and refused for
161
+ * the same reason: a URI is quoted in AGENTS.md files, so `resources/read` reaching whichever copy
162
+ * was wired last is a read that succeeds against the wrong document and reports nothing. Silent
163
+ * replacement also made the answer depend on registration order, which is not a contract.
164
+ */
165
+ export class McpResourceDuplicateError extends UltimateError {
166
+ constructor(input: { uri: string }) {
167
+ super({
168
+ code: 'X_MCP_RESOURCE_DUPLICATE',
169
+ cause: `two resources are registered at "${input.uri}"; a URI addresses one document`,
170
+ fix: `give one of them its own URI — register({ uri: '${input.uri}-<what-it-is>', … }) — or drop the duplicate registration`,
171
+ docs: docsFor('X_MCP_RESOURCE_DUPLICATE'),
172
+ });
173
+ }
174
+ }
175
+
157
176
  /**
158
177
  * `defineAppMcp`'s `scopes:` names a tool the server does not project. Boot-time and loud:
159
178
  * the alternative is a scope entry that quietly covers nothing, leaving the tool the author
@@ -216,12 +235,19 @@ export class McpProtocolError extends UltimateError {
216
235
  * LAYER 3 of `db.query`'s four defences: the statement is not one read-only statement, so it
217
236
  * never reaches the server. Separate from the migration refusal below because the two want
218
237
  * different next commands, and a code that covers both tells the agent neither.
238
+ *
239
+ * The cause is the problem ALONE — no `db.query refused:` frame. The guard has two callers on two
240
+ * surfaces (the MCP tool, and `@ultimat3/admin`'s `/_x` DB panel), and only the caller knows which
241
+ * reader it is talking to: the panel added its own frame and rendered `refused: db.query refused:
242
+ * …`, naming an MCP tool to a developer in a browser who never called one. Nothing is lost —
243
+ * `X_MCP_QUERY_REJECTED`'s TITLE already says `db.query`, and `format()` renders it above the
244
+ * cause, so the tool is named once, by the code, wherever the error surfaces.
219
245
  */
220
246
  export class McpQueryRejectedError extends UltimateError {
221
247
  constructor(input: { cause: string; fix: string }) {
222
248
  super({
223
249
  code: 'X_MCP_QUERY_REJECTED',
224
- cause: `db.query refused: ${input.cause}`,
250
+ cause: input.cause,
225
251
  fix: input.fix,
226
252
  docs: docsFor('X_MCP_QUERY_REJECTED'),
227
253
  });
@@ -232,12 +258,16 @@ export class McpQueryRejectedError extends UltimateError {
232
258
  * `db.migrate` was pointed at a database that is not a branch. Enforced, not documented — the
233
259
  * dev server holds real credentials, and a migration is the one dev tool that cannot be undone
234
260
  * by reading the error afterwards.
261
+ *
262
+ * Unframed for the same reason as the refusal above, and it is the same defect even with one
263
+ * caller today: the title says `db.migrate`, so a `db.migrate refused:` cause said it twice. Two
264
+ * adjacent errors in one file cannot answer "who names the surface" two different ways.
235
265
  */
236
266
  export class McpNotBranchDbError extends UltimateError {
237
267
  constructor(input: { cause: string; fix: string }) {
238
268
  super({
239
269
  code: 'X_MCP_NOT_BRANCH_DB',
240
- cause: `db.migrate refused: ${input.cause}`,
270
+ cause: input.cause,
241
271
  fix: input.fix,
242
272
  docs: docsFor('X_MCP_NOT_BRANCH_DB'),
243
273
  });
@@ -1,18 +1,31 @@
1
1
  // The free-tool projection: an `action` (or `query`) with `mcp.expose` becomes an MCP tool
2
2
  // at zero authorization cost.
3
3
  //
4
- // The whole claim rests on one line in `handle` below: the tool calls `action.run(...)`
5
- // the SAME entry point the HTTP route calls. Policy evaluation lives inside `run`, so
4
+ // The whole claim rests on one line in `handle` below: the tool calls `primitive.run(...)`, and
5
+ // `projectable.ts` builds that `run` out of `invoke(target, …, { surface: 'mcp' })` — the SAME
6
+ // function `toRoute`'s handler calls for an HTTP request, differing only in the surface it
7
+ // declares. (An action has NO `.run` member — it is `as`/`tool`/`openapi`/`job`/`contract` and
8
+ // the callable itself; this comment named one until 2026-08. `run` is `ProjectablePrimitive`'s,
9
+ // the seam, and a query's half of it is `sourceFor`.) Policy evaluation lives inside `invoke`, so
6
10
  // there is nothing here to keep in sync and no second authz system to drift. This
7
11
  // projection therefore INVENTS no `scope`: a scope is a capability of the connection's
8
12
  // token, which a projection cannot know anything about. An app that wants one names the
9
13
  // tool in `defineAppMcp`'s `scopes:` map (see `scopes.ts`) — declared once, next to the
10
14
  // other tools that same token capability covers, never guessed from the action.
11
15
  //
12
- // `toMcpTool` in @ultimat3/action owns the schema half of the projection (input schema →
13
- // JSON Schema); this file owns the execution half.
16
+ // `toWireSchema` (`input-schema.ts`), reached through `projectable.ts`, owns the schema half of
17
+ // what THIS server publishes; this file owns the execution half. It is deliberately not
18
+ // `toMcpTool` in @ultimat3/action — which this comment claimed until 2026-08, while
19
+ // `projectable.ts` had always called `toWireSchema`. The two are not interchangeable:
20
+ // `toMcpTool` emits the full draft-07 vocabulary and `toWireSchema` narrows to the subset
21
+ // `validate-args.ts` can ENFORCE, so a keyword in one and not the other is a contract an agent
22
+ // is judged against and was never shown. `pattern` was exactly that until 2026-08.
23
+ // `action` is tier 3 and this package is tier 4, so the two cannot share one function today; the
24
+ // shared home would be `@ultimat3/schema`. `packages/mcp/src/cross-surface.test.ts` is what keeps
25
+ // them from diverging again.
14
26
 
15
27
  import type { Actor } from '@ultimat3/core';
28
+ import { isMcpExposed } from '@ultimat3/core';
16
29
  import { McpToolUndeclaredError } from './errors';
17
30
  import type { AnyMcpTool, McpCaller, McpRole, McpToolResult, ToolArgs } from './registry';
18
31
  import { jsonResult } from './registry';
@@ -48,7 +61,7 @@ export interface ProjectablePrimitive {
48
61
  readonly name: string;
49
62
  readonly description?: string;
50
63
  readonly mcp?: McpExposure;
51
- /** JSON Schema of the input, as produced by `toMcpTool`. */
64
+ /** JSON Schema of the input, narrowed to the wire subset by `toWireSchema`. */
52
65
  readonly inputJsonSchema?: JsonSchema;
53
66
  /** True for a mutation. Drives the rate-limit bucket; queries set it false. */
54
67
  readonly mutates?: boolean;
@@ -56,9 +69,13 @@ export interface ProjectablePrimitive {
56
69
  run(args: { input: unknown; actor: Actor }): Promise<unknown>;
57
70
  }
58
71
 
59
- /** True when the primitive opted into MCP. Opt-in, never opt-out: silence exposes nothing. */
72
+ /**
73
+ * True when the primitive opted into MCP. Opt-in, never opt-out: silence exposes nothing.
74
+ * `isMcpExposed` from `@ultimat3/core` is the one predicate — the manifest fact, the OpenAPI
75
+ * hint and both tool projections all ask it, so none of them can answer differently.
76
+ */
60
77
  export function isExposed(primitive: ProjectablePrimitive): boolean {
61
- return primitive.mcp?.expose === true;
78
+ return isMcpExposed(primitive.mcp);
62
79
  }
63
80
 
64
81
  /**
@@ -80,8 +97,9 @@ export function toolFromAction(primitive: ProjectablePrimitive): AnyMcpTool {
80
97
  ...(visibleTo !== undefined ? { visibleTo } : {}),
81
98
  // No `scope` from here: see the header. `defineAppMcp`'s `scopes:` map may add one.
82
99
  async handle(args: ToolArgs, caller: McpCaller): Promise<McpToolResult> {
83
- // ONE authz system, TWO surfaces. HTTP does exactly this call with an actor of
84
- // kind 'user'; MCP does it with kind 'agent'. Same policy, same decision.
100
+ // ONE authz system, TWO surfaces. This `run` IS `invoke`; the HTTP route reaches the
101
+ // same `invoke` with an actor of kind 'user', MCP arrives with kind 'agent'. Same
102
+ // policy, same decision.
85
103
  const output = await primitive.run({ input: args, actor: caller.actor });
86
104
  return jsonResult(output);
87
105
  },
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  McpNotBranchDbError,
38
38
  McpProtocolError,
39
39
  McpQueryRejectedError,
40
+ McpResourceDuplicateError,
40
41
  McpScopeConflictError,
41
42
  McpScopeDeniedError,
42
43
  McpScopeUnknownError,
@@ -105,7 +106,13 @@ export type {
105
106
  McpRouteDescriptor,
106
107
  ResolvedToken,
107
108
  } from './transport-http';
108
- export { bearerToken, isAgentActor, MCP_RATE_LIMITS, mcpHttpRoute } from './transport-http';
109
+ export {
110
+ bearerToken,
111
+ DEFAULT_MCP_BODY_LIMIT_BYTES,
112
+ isAgentActor,
113
+ MCP_RATE_LIMITS,
114
+ mcpHttpRoute,
115
+ } from './transport-http';
109
116
  export type { StdioTransportInput } from './transport-stdio';
110
117
  export { serveStdio } from './transport-stdio';
111
118
  export type { ArgIssue, ArgValidation } from './validate-args';
@@ -119,7 +126,7 @@ export type {
119
126
  ServerInfo,
120
127
  } from './wire';
121
128
  export {
122
- DEFAULT_SERVER_INFO,
129
+ defaultServerInfo,
123
130
  errorResponse,
124
131
  INTERNAL_ERROR,
125
132
  INVALID_PARAMS,
@@ -34,6 +34,7 @@ function narrow(source: RichJsonSchema): JsonSchema {
34
34
  ...(source.maximum === undefined ? {} : { maximum: source.maximum }),
35
35
  ...(source.minLength === undefined ? {} : { minLength: source.minLength }),
36
36
  ...(source.maxLength === undefined ? {} : { maxLength: source.maxLength }),
37
+ ...(source.pattern === undefined ? {} : { pattern: source.pattern }),
37
38
  ...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(narrow) }),
38
39
  };
39
40
  }
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { AnyAction } from '@ultimat3/action';
7
7
  import { actionName, invoke, isAction } from '@ultimat3/action';
8
- import { withChildContext } from '@ultimat3/core';
8
+ import { isMcpExposed, withChildContext } from '@ultimat3/core';
9
9
  import type { AnyQuery } from '@ultimat3/query';
10
10
  import { isQuery, queryName, sourceFor } from '@ultimat3/query';
11
11
  import type { McpExposure, ProjectablePrimitive } from './from-action';
@@ -73,8 +73,9 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
73
73
 
74
74
  /**
75
75
  * An action and a query declare MCP exposure with the same fields, so one typed path reads
76
- * both. Narrow on purpose: only a literal `expose: true` counts, so nothing is exposed by
77
- * accident — an undeclared `mcp` block yields no exposure at all.
76
+ * both. Narrow on purpose, through `@ultimat3/core`'s `isMcpExposed`: only a literal
77
+ * `expose: true` counts, so nothing is exposed by accident — an undeclared `mcp` block yields
78
+ * no exposure at all.
78
79
  *
79
80
  * `visibleTo` travels with it, and must: it is OUTCOME 1's only declaration surface for a
80
81
  * projected primitive. Dropping it here — which this function did until 2026-08 — left
@@ -85,7 +86,7 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
85
86
  function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined {
86
87
  if (declared === undefined) return undefined;
87
88
  return {
88
- expose: declared.expose === true,
89
+ expose: isMcpExposed(declared),
89
90
  ...(declared.description === undefined ? {} : { description: declared.description }),
90
91
  ...(declared.visibleTo === undefined ? {} : { visibleTo: declared.visibleTo }),
91
92
  };
@@ -163,8 +163,10 @@ export function assertReadOnlyQuery(sql: string): string {
163
163
  `the statement contains the mutating keyword "${word}"`,
164
164
  // `db.migrate` applies pending migrations; it is not an INSERT/UPDATE/DELETE path, and
165
165
  // there is no MCP tool that is. Data changes go through an action, which carries a policy.
166
+ // `create`, not the bare name: `x db branch` takes a VERB from a closed set, and the
167
+ // bare form this used to hand out now resolves to nothing.
166
168
  'db.query has no write path: change data by calling an action exposed with ' +
167
- 'mcp: { expose: true }, and change schema with db.migrate after x db branch <name>',
169
+ 'mcp: { expose: true }, and change schema with db.migrate after x db branch create <name>',
168
170
  );
169
171
  }
170
172
  }
@@ -229,7 +231,14 @@ export function assertBranchDatabase(target: DatabaseTarget): string {
229
231
  if (target.branch === null) {
230
232
  throw notBranch(
231
233
  `"${target.label}" is not a branch database`,
232
- 'x db branch <name>, then retry db.migrate',
234
+ // The verb is load-bearing: `x db branch <name>` is now X_CLI_UNKNOWN_COMMAND, and before
235
+ // the verbs existed it CREATED whatever word followed — including `ls`.
236
+ //
237
+ // The guidance rides behind `#`, never a comma: a fix line is pasted into a shell whole, and
238
+ // `#` is the one joiner that leaves the command in front of it runnable. `<name>` stays a
239
+ // placeholder — the branch name is the caller's to choose, which is why `@ultimat3/db` ships
240
+ // this same line, same slot, in `branchNameInvalid`'s X_SQL_UNSAFE fix.
241
+ 'x db branch create <name> # then retry db.migrate',
233
242
  );
234
243
  }
235
244
  return target.branch;
@@ -239,6 +248,20 @@ function rejected(cause: string, fix: string): McpQueryRejectedError {
239
248
  return new McpQueryRejectedError({ cause, fix });
240
249
  }
241
250
 
251
+ /**
252
+ * A delimiter that never closes: the scanner would blank everything after it, so the tail — `;`
253
+ * and any write keyword in it — vanishes from every check below. `select '; delete from members`
254
+ * counted one statement, contained no mutating word, and was handed back verbatim to run.
255
+ *
256
+ * Refused, not swallowed, for the same reason `\'` is: this layer's job is to refuse what it
257
+ * cannot read. Postgres would answer a syntax error either way, and that is exactly the
258
+ * dependency on the layer below that four defences exist not to have. Over-refusing costs a
259
+ * malformed query a clearer message; under-refusing costs the guarantee.
260
+ */
261
+ function unterminated(what: string, fix: string): McpQueryRejectedError {
262
+ return rejected(`the statement ends inside ${what}, so the rest of it cannot be read`, fix);
263
+ }
264
+
242
265
  function notBranch(cause: string, fix: string): McpNotBranchDbError {
243
266
  return new McpNotBranchDbError({ cause, fix });
244
267
  }
@@ -266,13 +289,16 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
266
289
  }
267
290
  if (two === '/*') {
268
291
  const end = sql.indexOf('*/', i + 2);
269
- i = end === -1 ? sql.length : end + 2;
292
+ if (end === -1) {
293
+ throw unterminated('a /* block comment', 'close it with */, or use -- to the end of line');
294
+ }
295
+ i = end + 2;
270
296
  out += ' ';
271
297
  continue;
272
298
  }
273
299
  const char = sql[i];
274
300
  if (char === "'" || char === '"') {
275
- const end = skipQuoted(sql, i, char);
301
+ const end = char === "'" ? skipSingleQuoted(sql, i) : skipQuoted(sql, i, char);
276
302
  // Padded, never spliced in place: `select"pg_advisory_lock"(1)` must not fuse into one
277
303
  // token, or the call the quotes were hiding stays hidden behind the leading keyword.
278
304
  out += char === '"' && identifiers === 'keep' ? ` ${inner(sql.slice(i, end))} ` : ' ';
@@ -284,7 +310,13 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
284
310
  if (tag !== null) {
285
311
  const marker = tag[0];
286
312
  const end = sql.indexOf(marker, i + marker.length);
287
- i = end === -1 ? sql.length : end + marker.length;
313
+ if (end === -1) {
314
+ throw unterminated(
315
+ `a ${marker} dollar-quoted body`,
316
+ `close it with the same tag: ${marker} … ${marker}`,
317
+ );
318
+ }
319
+ i = end + marker.length;
288
320
  out += ' ';
289
321
  continue;
290
322
  }
@@ -301,7 +333,49 @@ function inner(run: string): string {
301
333
  return run.slice(1, closed ? -1 : undefined).replaceAll('""', '"');
302
334
  }
303
335
 
304
- /** Advance past a quoted run, honouring SQL's doubled-quote escape (`'it''s'`). */
336
+ /**
337
+ * Advance past a single-quoted run, and refuse the one sequence whose meaning this scanner cannot
338
+ * decide: a backslash immediately before a quote.
339
+ *
340
+ * `E'\''` is ONE quote to Postgres — the backslash escapes it, the third quote closes the string —
341
+ * so what follows is real statement text. A scanner that knows only the doubled-quote escape reads
342
+ * the same bytes as a string that is still open, blanks the rest of the line, and counts one
343
+ * statement: `select E'\'' ; drop table posts --'` was accepted and handed back verbatim to run.
344
+ * Guessing the other way is no better, since `standard_conforming_strings` (a session setting this
345
+ * tool cannot see) decides whether a PLAIN `'a\'` closes there. So the sequence is refused rather
346
+ * than parsed under one of two readings — `''` embeds a quote under both.
347
+ *
348
+ * `\\` is consumed as a pair on purpose: both readings agree that `E'\\'` ends at that quote, so
349
+ * refusing it would cost an ordinary read nothing is wrong with.
350
+ */
351
+ function skipSingleQuoted(sql: string, start: number): number {
352
+ let i = start + 1;
353
+ while (i < sql.length) {
354
+ const char = sql[i];
355
+ if (char === '\\') {
356
+ if (sql[i + 1] === "'") {
357
+ throw rejected(
358
+ String.raw`the statement contains \' inside a string literal, which ends the string ` +
359
+ 'under one Postgres setting and not the other',
360
+ String.raw`double the quote instead of escaping it: 'it''s' rather than E'it\'s'`,
361
+ );
362
+ }
363
+ i += 2;
364
+ continue;
365
+ }
366
+ if (char === "'") {
367
+ if (sql[i + 1] === "'") {
368
+ i += 2;
369
+ continue;
370
+ }
371
+ return i + 1;
372
+ }
373
+ i += 1;
374
+ }
375
+ throw unterminated('a string literal', "close the quote, or double it to embed one: 'it''s'");
376
+ }
377
+
378
+ /** Advance past a quoted identifier, honouring SQL's doubled-quote escape (`"a""b"`). */
305
379
  function skipQuoted(sql: string, start: number, quote: string): number {
306
380
  let i = start + 1;
307
381
  while (i < sql.length) {
@@ -314,5 +388,5 @@ function skipQuoted(sql: string, start: number, quote: string): number {
314
388
  }
315
389
  i += 1;
316
390
  }
317
- return sql.length;
391
+ throw unterminated('a quoted identifier', 'close the quote: select "column name" from posts');
318
392
  }
package/src/registry.ts CHANGED
@@ -138,13 +138,13 @@ export type ToolResolution =
138
138
  | { readonly kind: 'invalid-args'; readonly name: string; readonly issues: readonly ArgIssue[] };
139
139
 
140
140
  export class ToolRegistry {
141
- private readonly tools = new Map<string, AnyMcpTool>();
141
+ readonly #tools = new Map<string, AnyMcpTool>();
142
142
 
143
143
  register(tool: AnyMcpTool): this {
144
- if (this.tools.has(tool.name)) {
144
+ if (this.#tools.has(tool.name)) {
145
145
  throw new McpDuplicateToolError(tool.name);
146
146
  }
147
- this.tools.set(tool.name, tool);
147
+ this.#tools.set(tool.name, tool);
148
148
  return this;
149
149
  }
150
150
 
@@ -155,7 +155,7 @@ export class ToolRegistry {
155
155
 
156
156
  /** Raw lookup with NO gate applied — the resolver owns the gates. */
157
157
  get(name: string): AnyMcpTool | undefined {
158
- return this.tools.get(name);
158
+ return this.#tools.get(name);
159
159
  }
160
160
 
161
161
  /**
@@ -163,7 +163,7 @@ export class ToolRegistry {
163
163
  * this catalog between runs and map insertion order is not a contract.
164
164
  */
165
165
  list(caller?: McpCaller): readonly ToolListEntry[] {
166
- const all = [...this.tools.values()];
166
+ const all = [...this.#tools.values()];
167
167
  const visible = caller === undefined ? all : all.filter((t) => visibleToCaller(t, caller));
168
168
  return visible
169
169
  .map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
@@ -185,7 +185,7 @@ export class ToolRegistry {
185
185
  * input. Absent and hidden collapse into ONE branch so the two cannot drift apart.
186
186
  */
187
187
  resolve(name: string, rawArgs: unknown, caller: McpCaller): ToolResolution {
188
- const tool = this.tools.get(name);
188
+ const tool = this.#tools.get(name);
189
189
  if (tool === undefined || !visibleToCaller(tool, caller)) {
190
190
  return { kind: 'not-found', name };
191
191
  }
@@ -202,7 +202,7 @@ export class ToolRegistry {
202
202
  * strict bucket, because a probing client must never get the cheap one.
203
203
  */
204
204
  verbClass(name: string): McpVerbClass {
205
- const tool = this.tools.get(name);
205
+ const tool = this.#tools.get(name);
206
206
  if (tool === undefined) return 'write';
207
207
  return tool.destructive === true ? 'write' : 'read';
208
208
  }
package/src/resources.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // injected as thunks because `@ultimat3/manifest` and `@ultimat3/render` sit in this same
6
6
  // tier — the CLI wires them, this package only defines the shape and the URIs.
7
7
 
8
+ import { McpResourceDuplicateError } from './errors';
8
9
  import type { JsonSchema } from './wire';
9
10
 
10
11
  /** Stable URIs. These are quoted in AGENTS.md files, so treat them as public API. */
@@ -126,7 +127,14 @@ export function frameworkResources(providers: FrameworkResourceProviders): reado
126
127
  export class ResourceRegistry {
127
128
  private readonly resources = new Map<string, McpResource>();
128
129
 
130
+ /**
131
+ * Refuses a second claim on a URI, exactly as `ToolRegistry.register` refuses a second claim on
132
+ * a tool name — one package cannot answer "this name is taken" two ways. `Map.set` made the
133
+ * answer whichever provider was wired last, which for a URI quoted in an AGENTS.md file is a
134
+ * read that succeeds against the wrong document.
135
+ */
129
136
  register(resource: McpResource): this {
137
+ if (this.resources.has(resource.uri)) throw new McpResourceDuplicateError(resource);
130
138
  this.resources.set(resource.uri, resource);
131
139
  return this;
132
140
  }
package/src/server.ts CHANGED
@@ -12,7 +12,7 @@ import type { McpPrompt, McpResource } from './resources';
12
12
  import { ResourceRegistry } from './resources';
13
13
  import type { JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
14
14
  import {
15
- DEFAULT_SERVER_INFO,
15
+ defaultServerInfo,
16
16
  errorResponse,
17
17
  INTERNAL_ERROR,
18
18
  INVALID_PARAMS,
@@ -49,7 +49,7 @@ export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
49
49
  tools,
50
50
  resources,
51
51
  input.prompts ?? [],
52
- input.serverInfo ?? DEFAULT_SERVER_INFO,
52
+ input.serverInfo ?? defaultServerInfo(),
53
53
  );
54
54
  }
55
55
 
@@ -249,7 +249,10 @@ function asFrameworkError(error: unknown): FrameworkError | undefined {
249
249
  code: e.code,
250
250
  title: typeof e.title === 'string' ? e.title : '',
251
251
  cause: typeof e.cause === 'string' ? e.cause : 'unknown',
252
- fix: typeof e.fix === 'string' ? e.fix : 'see docs',
252
+ // 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
254
+ // 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}`,
253
256
  };
254
257
  }
255
258
 
@@ -13,11 +13,20 @@
13
13
  // refuse what a human would be allowed.
14
14
 
15
15
  import type { Actor } from '@ultimat3/core';
16
+ import { readWithinLimit } from '@ultimat3/core';
16
17
  import type { McpCaller, McpRole, McpVerbClass } from './registry';
17
18
  import type { McpServer } from './server';
18
19
  import type { JsonRpcResponse } from './wire';
19
20
  import { errorResponse, INVALID_REQUEST, PARSE_ERROR } from './wire';
20
21
 
22
+ /**
23
+ * The same 1 MiB `@ultimat3/http`'s `bodyLimitBytes` defaults to. This descriptor is driven from a
24
+ * bare `Request` and never passes through that pipeline, so without a cap here Bun's 128 MiB
25
+ * default was the only ceiling — and `x mcp serve` and `createServer` both pass no
26
+ * `maxRequestBodySize`.
27
+ */
28
+ export const DEFAULT_MCP_BODY_LIMIT_BYTES = 1_048_576;
29
+
21
30
  /** Requests per minute per token, by class. Reads are cheap; a write may run migrations. */
22
31
  export const MCP_RATE_LIMITS: Readonly<Record<McpVerbClass, number>> = {
23
32
  read: 120,
@@ -40,6 +49,8 @@ export interface McpHttpTransportInput {
40
49
  resolveToken(token: string): Promise<ResolvedToken | null> | ResolvedToken | null;
41
50
  /** Route path. Overridable so an app can mount a second, app-scoped surface. */
42
51
  readonly path?: string;
52
+ /** Bytes this transport will hold for one request. Defaults to `DEFAULT_MCP_BODY_LIMIT_BYTES`. */
53
+ readonly bodyLimitBytes?: number | undefined;
43
54
  }
44
55
 
45
56
  export interface McpRouteDescriptor {
@@ -55,6 +66,7 @@ const JSON_HEADERS = { 'content-type': 'application/json' } as const;
55
66
 
56
67
  export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
57
68
  const { server } = input;
69
+ const bodyLimitBytes = input.bodyLimitBytes ?? DEFAULT_MCP_BODY_LIMIT_BYTES;
58
70
 
59
71
  return {
60
72
  method: 'POST',
@@ -63,24 +75,40 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
63
75
  rateLimitClass: (body) => server.classify(body),
64
76
 
65
77
  async handle(request: Request): Promise<Response> {
78
+ // Every authentication answer lands BEFORE the body is read. Parsing first meant a caller
79
+ // holding a rejected token still learned whether its JSON was well formed — `400 parse
80
+ // error` for one payload and `401` for the next is exactly the oracle the 401 exists to
81
+ // remove, and it costs nothing to close: the body is not an input to any of these.
66
82
  const token = bearerToken(request);
67
- if (token === null) {
68
- // 401 before parsing: an unauthenticated caller learns nothing about the catalog,
69
- // not even whether its JSON was well formed.
70
- return unauthorized();
83
+ if (token === null) return unauthorized();
84
+
85
+ const resolved = await input.resolveToken(token);
86
+ if (resolved === null) return unauthorized();
87
+ if (!isAgentActor(resolved.actor)) return notAnAgent();
88
+
89
+ // Read through the counting reader, never `request.json()`: the cap has to be enforced
90
+ // WHILE the bytes arrive, or a `transfer-encoding: chunked` payload is materialised in full
91
+ // before anything measures it. Core owns the reader so this and `UltimateRequest.#read`
92
+ // cannot drift.
93
+ const read = await readWithinLimit(request.body, bodyLimitBytes);
94
+ if ('over' in read) {
95
+ return json(
96
+ errorResponse(
97
+ null,
98
+ INVALID_REQUEST,
99
+ `request body is at least ${read.over} bytes, limit is ${bodyLimitBytes}`,
100
+ ),
101
+ 413,
102
+ );
71
103
  }
72
104
 
73
105
  let body: unknown;
74
106
  try {
75
- body = await request.json();
107
+ body = JSON.parse(new TextDecoder().decode(read.bytes));
76
108
  } catch {
77
109
  return json(errorResponse(null, PARSE_ERROR, 'request body is not valid JSON'), 400);
78
110
  }
79
111
 
80
- const resolved = await input.resolveToken(token);
81
- if (resolved === null) return unauthorized();
82
- if (!isAgentActor(resolved.actor)) return notAnAgent();
83
-
84
112
  const caller: McpCaller = {
85
113
  actor: resolved.actor,
86
114
  scopes: resolved.scopes,
@@ -131,7 +159,7 @@ function unauthorized(): Response {
131
159
  JSON.stringify({
132
160
  code: 'X_MCP_PROTOCOL',
133
161
  cause: 'missing or unrecognised bearer token',
134
- fix: 'x token create --scopes dev:read, then send Authorization: Bearer <token>',
162
+ fix: "send Authorization: Bearer <token> with a token the app's resolveToken(token) resolves to { actor, scopes } it is the only issuer this route has",
135
163
  }),
136
164
  {
137
165
  status: 401,
@@ -122,9 +122,27 @@ function string(schema: JsonSchema, input: unknown, path: string, issues: ArgIss
122
122
  if (schema.maxLength !== undefined && input.length > schema.maxLength) {
123
123
  issues.push({ path, message: `must be at most ${schema.maxLength} characters` });
124
124
  }
125
+ if (schema.pattern !== undefined) matchesPattern(schema.pattern, input, path, issues);
125
126
  return input;
126
127
  }
127
128
 
129
+ /**
130
+ * A pattern this server cannot COMPILE is refused, not skipped. `tools/list` published it, so an
131
+ * agent has already been told the rule — passing a call the server cannot check is the silent-pass
132
+ * this whole module exists to prevent. Every framework-projected pattern is a `RegExp.source` and
133
+ * compiles; only a hand-written tool can reach the second branch, and that is its author's bug.
134
+ */
135
+ function matchesPattern(pattern: string, input: string, path: string, issues: ArgIssue[]): void {
136
+ let compiled: RegExp;
137
+ try {
138
+ compiled = new RegExp(pattern);
139
+ } catch {
140
+ issues.push({ path, message: `declares a pattern this server cannot compile: ${pattern}` });
141
+ return;
142
+ }
143
+ if (!compiled.test(input)) issues.push({ path, message: `must match ${pattern}` });
144
+ }
145
+
128
146
  function number(schema: JsonSchema, input: unknown, path: string, issues: ArgIssue[]): unknown {
129
147
  if (typeof input !== 'number' || Number.isNaN(input)) {
130
148
  issues.push({ path, message: 'must be a number' });
package/src/wire.ts CHANGED
@@ -5,13 +5,19 @@
5
5
  //
6
6
  // Reference: https://modelcontextprotocol.io/specification (2025-06-18).
7
7
 
8
- import { FRAMEWORK_VERSION } from '@ultimat3/core';
8
+ import { frameworkVersion } from '@ultimat3/core';
9
9
 
10
10
  /** MCP protocol version advertised on `initialize`. */
11
11
  export const MCP_PROTOCOL_VERSION = '2025-06-18';
12
12
 
13
- /** Identity advertised on `initialize` unless the host overrides it. */
14
- export const DEFAULT_SERVER_INFO = { name: 'ultimate', version: FRAMEWORK_VERSION } as const;
13
+ /**
14
+ * Identity advertised on `initialize` unless the host overrides it. A call and not a constant:
15
+ * `frameworkVersion()` resolves on first use, so importing this module cannot be what stops a
16
+ * compiled single-file binary from booting.
17
+ */
18
+ export function defaultServerInfo(): ServerInfo {
19
+ return { name: 'ultimate', version: frameworkVersion() };
20
+ }
15
21
 
16
22
  export interface ServerInfo {
17
23
  readonly name: string;
@@ -75,6 +81,14 @@ export interface JsonSchema {
75
81
  readonly maximum?: number;
76
82
  readonly minLength?: number;
77
83
  readonly maxLength?: number;
84
+ /**
85
+ * A `RegExp.source` string, JSON Schema semantics (a partial match unless it anchors itself).
86
+ * In the subset because `validate-args.ts` enforces it: without it `tools/list` published
87
+ * `{minLength,maxLength}` for a field whose OpenAPI component carried the pattern, so an HTTP
88
+ * client and an MCP agent held different contracts for one declaration and the agent's only
89
+ * way to learn the format was `X_INPUT_INVALID` from the action's own parse.
90
+ */
91
+ readonly pattern?: string;
78
92
  readonly anyOf?: readonly JsonSchema[];
79
93
  }
80
94