@ultimat3/mcp 21.0.0 → 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -3,9 +3,7 @@
3
3
  Tier 4. May import tier 0–3: `core schema i18n money time cache seo entity policy http action
4
4
  query jobs realtime`. **Never** `render manifest ai pwa ui admin testing cli`.
5
5
 
6
- `@ultimat3/http` is a DIRECT dependency since 2026-08-24 (`transport-http.ts`, the rate limiter) and
7
- was already a transitive one through `action` and `query` — declaring it added nothing to the
8
- install graph and made the edge readable.
6
+ `@ultimat3/http` is a DIRECT dependency (`transport-http.ts`, the rate limiter).
9
7
 
10
8
  Same-tier data (routes, manifest, policy catalog) arrives as an **injected thunk**, never an
11
9
  import. The CLI wires it.
@@ -44,6 +42,11 @@ import. The CLI wires it.
44
42
  wording (`McpBodyTooLargeError`, shared with the stdio line cap; `McpProtocolError`). The
45
43
  `-32601` for a tool carries no `data` — see the next rule — so its instruction rides in the
46
44
  MESSAGE, and it is `TOOL_UNKNOWN_FIX`, the same sentence on the absent and the hidden branch.
45
+ - **`ping` answers `{}` and `prompts/get` resolves, `As of 2026-09-23`.** Both answered `-32601`
46
+ while `initialize` advertised `prompts` and `prompts/list` listed them. A prompt's body is an
47
+ injected `read(args)` (`promptFromPath` reads its file on each call); a prompt with none, an
48
+ unknown name or a missing name is `-32602`. `prompts/list` sends `promptListEntry` — never the
49
+ object, whose reader is a function. `METHODS` and `classify` move with every new method.
47
50
  - Three outcomes, never blurred: role-hidden → `-32601` ToolNotFound with no `data`;
48
51
  scope → `-32600` `X_MCP_SCOPE_DENIED` naming the scope; policy → an `isError` result
49
52
  carrying `X_FORBIDDEN`. Swapping any two is an enumeration oracle.
@@ -75,29 +78,20 @@ import. The CLI wires it.
75
78
  lands on the result through `Object.defineProperty`, because `out[key] = v` for `__proto__` runs
76
79
  the setter on `Object.prototype` and re-prototypes the record instead of adding a key.
77
80
  - **The RESOURCE surface owes the same three outcomes as the tool surface.** `resources/list` and
78
- `resources/read` take the `McpCaller`; `McpResource` carries `visibleTo` and `scope`, and
79
- `ResourceRegistry.resolve` applies them in the same order `ToolRegistry.resolve` does, through the
80
- same `visibleToCaller`. Both took no caller at all until 2026-08: any token `resolveToken`
81
- accepted could enumerate every URI and read every document — the manifest, the OpenAPI document,
82
- the route table and the entity schema, which together are an app's whole policy and data map. The
83
- not-found branch answered `data.available` with the full catalog, so one wrong guess enumerated
84
- it; it now carries no `data`, exactly as `tool not found` does. `resource-security.test.ts` is the
85
- contract, in both halves — hand-built resources AND `defineAppMcp({ resources })`.
81
+ `resources/read` take the `McpCaller`; `McpResource` carries `visibleTo` and `scope`, applied by
82
+ `ResourceRegistry.resolve` in `ToolRegistry.resolve`'s order through the same `visibleToCaller`.
83
+ The not-found branch carries no `data` (a catalog there enumerates it). `resource-security.test.ts`
84
+ is the contract, for hand-built resources AND `defineAppMcp({ resources })`.
86
85
  - **Every provider call is inside a `try`.** A resource's `read` is an INJECTED THUNK —
87
86
  `frameworkResources` wires it to a file read, and `Bun.file(...).text()` on a missing
88
87
  `x.manifest.json` throws ENOENT. Outside the try it escaped `handle()`: `serveStdio` REJECTED with
89
88
  the raw error, zero frames written, the request unanswered and every later request on that buffer
90
89
  never processed. Same shape `toolsCall` uses — a framework error keeps its code/cause/fix, anything
91
90
  else is `-32603` with no internals.
92
- - **A declared `pattern` is compiled once per schema NODE, never once per `tools/call`.** A tool's
93
- schema is registered at boot and validated on every call, so `new RegExp(pattern)` in `string()`
94
- was per-request work over a constant — `@ultimat3/schema`'s `patternTester` already draws the
95
- line in the same place for the same contract. The memo is a `WeakMap` keyed on the node, not a
96
- `Map` keyed on the pattern string: a process registering tools dynamically would otherwise
97
- accumulate one entry per distinct pattern forever with nothing to evict it. An uncompilable
98
- pattern caches its `null` verdict too — it is the branch with the highest per-call cost.
99
- `compiledPatternCount()` is the test-only probe (not in `index.ts`); a count that climbs once
100
- per CALL is the memo gone, which `validate-args.test.ts` asserts over 100 calls.
91
+ - **A declared `pattern` is compiled once per schema NODE, never per `tools/call`** — a `WeakMap`
92
+ keyed on the node (never on the pattern string, which grows forever), caching an uncompilable
93
+ pattern's `null` too. `compiledPatternCount()` is the test-only probe; `validate-args.test.ts`
94
+ asserts it does not climb over 100 calls.
101
95
  - **`format` is NOT in the wire subset**, and `wire.ts` types it `never` so re-adding it does not
102
96
  compile. It names a rule whose meaning lives in `@ultimat3/schema` (`uuid`, `email`,
103
97
  `iana-time-zone`), and this package cannot check it without a second definition of each that can
@@ -127,16 +121,11 @@ import. The CLI wires it.
127
121
  - A framework error rendered into a tool result is **byte-identical to
128
122
  `UltimateError.format()`** — one denial must not read one way over MCP and another in the
129
123
  terminal. `server.ts` renders it; the test pins it against `format()`, never a literal.
130
- - Every outcome is audited via `audit.ts`, hidden included, at `warn`. Never log arguments
131
- or row data — a denial reason naming a row is a leak wearing an audit line's clothes.
132
- **On both surfaces**: `mcp.tool-call.<outcome>` from `toolsCall`, `mcp.resource-read.<outcome>`
133
- from `resourcesRead`, one `LEVEL` table and one field builder behind them. `resources/read`
134
- emitted nothing at all until 2026-08-23, so a URI walk over the four documents that describe an
135
- app's whole policy and data map left no trace while the identical walk over tool NAMES was one
136
- `warn` per attempt. Two EVENTS and not one, because an alert that buckets a document read as a
137
- tool call cannot tell the two walks apart. `resources/list` and `tools/list` are both silent by
138
- design — each is answered pre-filtered, so it reveals only what the caller could already see.
139
- `resource-security.test.ts` reads BOTH streams: core's logger puts `error` on stderr.
124
+ - Every outcome is audited via `audit.ts`, hidden included, at `warn`. Never log arguments or row
125
+ data. **On both surfaces**: `mcp.tool-call.<outcome>` and `mcp.resource-read.<outcome>` — two
126
+ events, one `LEVEL` table, so an alert can tell a URI walk from a tool-name walk. `resources/list`
127
+ and `tools/list` are silent by design (answered pre-filtered). `resource-security.test.ts` reads
128
+ both streams: core's logger puts `error` on stderr.
140
129
  - **A tool that renders its OWN `isError` result may NAME the code it refused with**
141
130
  (`McpToolResult.code`), and `outcomeForResult` sends it through the same `outcomeForCode` a
142
131
  THROWN error goes through. Audit-only: `server.ts` never puts it on the wire, because the code is
@@ -147,13 +136,8 @@ import. The CLI wires it.
147
136
  `X_INPUT_INVALID` deliberately stays `failed`, because a projected action publishes its WHOLE
148
137
  schema and input this server already validated failing inside it means the two have drifted.
149
138
  A result naming no code keeps the conservative `policy-denied`.
150
- - **A `--` comment ends at the first CR *or* LF, because that is Postgres' own boundary set**
151
- (`readonly-sql.ts`). `non_newline` is `[^\n\r]`, so a bare CR ends the comment for the SERVER
152
- and did not for this scanner: `select 1;--\rupdate members set role='admin'` was one statement
153
- with no mutating keyword to all five layer-3 checks at once — the statement split, the read-leader
154
- check, the write-keyword scan, the forbidden-call scan and the `FOR UPDATE` regex all read the
155
- stripped form — and `verbatim()` handed the caller's bytes back to run. `endOfLineComment` is the
156
- lexer's set, never one character of it, the same shape `skipSingleQuoted` already had.
139
+ - **A `--` comment ends at the first CR *or* LF — Postgres' own boundary set** (`readonly-sql.ts`,
140
+ `endOfLineComment`). A scanner stopping at LF alone read `select 1;--\rupdate …` as one read.
157
141
  - `security.test.ts` and `app-security.test.ts` are the executable contract for all of the
158
142
  above — the first over hand-built tools (each gate in isolation), the second over what an app
159
143
  actually declares (`defineAppMcp` projecting real actions and queries). Extend them, never
@@ -172,28 +156,19 @@ import. The CLI wires it.
172
156
  collects every offender before throwing, so one boot names all of them and one edit closes
173
157
  all of them. Two calls would throw on the first array and never examine the second.
174
158
  - `actions:`/`queries:` take the **real primitives** (`actions: [publishPost]`), adapted by
175
- `projectable.ts` into the same `ProjectablePrimitive` the registry sweep builds. They took
176
- `ProjectablePrimitive` alone until 2026-08, which no `action()` or `query()` satisfies — they
177
- carry `as`/`tool`, never `run` — so listing one was a TS2741 and the only value that could
178
- reach `X_MCP_TOOL_UNDECLARED` was a hand-built fake. A gate that no declaration can reach
179
- refuses nothing. `ProjectablePrimitive` stays in the union for surfaces that build a catalog
180
- programmatically (`@ultimat3/admin`); `isAction`/`isQuery` read each package's private
181
- declaration store, so a look-alike falls through instead of borrowing `invoke`.
159
+ `projectable.ts` into the same `ProjectablePrimitive` the sweep builds. `ProjectablePrimitive`
160
+ stays in the union for programmatic catalogs (`@ultimat3/admin`); `isAction`/`isQuery` read each
161
+ package's private store, so a look-alike falls through.
182
162
  - The adapter is **one function with two callers**, never a copy per route: the written-out list
183
163
  and `include: 'exposed'` land on the same `run` — `invoke` for an action, `sourceFor` for a
184
164
  query. Writing a primitive out NAMES a tool; it never re-shapes or re-runs one. An action
185
165
  with no export name is `X_ACTION_UNREGISTERED` rather than a tool called `''`, which no
186
166
  `tools/call` and no `scopes:` entry could ever address.
187
167
  - **This package NAMES a tool, it never derives one.** `primitive.mcp?.name ?? primitive.name` in
188
- `from-action.ts` is the whole rule, fed the verbatim export name by `projectable.ts` — a
189
- transform here would be a second spelling of a name that is already an addressable identity.
190
- Every surface that PUBLISHES the name owes the same string: `action.tool()`, `query.tool()`,
191
- `x-ultimate.mcpTool`, `ActionDescriptor.mcp.tool`. The three action publishers snake_cased it
192
- through `toToolName` until 2026-08, so a spec-reading agent called `publish_post` and got
193
- `-32601` from a catalog holding `publishPost`, and nothing noticed because no test compared the
194
- served name to a published one. `cross-surface.test.ts` is that comparison — it reads the
195
- catalog off `tools/list` and drives a `tools/call` with the name OpenAPI published, so a
196
- publisher that re-derives is a failing test and not a wiki note.
168
+ `from-action.ts` is the whole rule, fed the verbatim export name by `projectable.ts`. Every
169
+ publisher (`action.tool()`, `query.tool()`, `x-ultimate.mcpTool`, `ActionDescriptor.mcp.tool`)
170
+ owes the same string; `cross-surface.test.ts` drives a `tools/call` with the name OpenAPI
171
+ published.
197
172
  - Every boot-time refusal in `defineAppMcp` is an `UltimateError` with a code, never a bare
198
173
  throw: `X_MCP_TOOL_UNDECLARED`, `X_MCP_TOOL_UNSAFE`, `X_MCP_TOOL_DUPLICATE`,
199
174
  `X_MCP_SCOPE_UNKNOWN`, `X_MCP_SCOPE_CONFLICT`. The caller reading them is usually an agent
@@ -232,59 +207,31 @@ import. The CLI wires it.
232
207
  - `db.query` / `db.migrate` refuse structurally, in `readonly-sql.ts`, before the host runs
233
208
  (`X_MCP_QUERY_REJECTED` / `X_MCP_NOT_BRANCH_DB` — one code each, because they want different
234
209
  next commands).
235
- - `pg_notify` and the server-control / replication families are banned for the reason every other
236
- family is: the same ban already exists in another spelling. `notify`/`listen`/`unlisten` are
237
- WRITE KEYWORDS, so `pg_notify()` is `NOTIFY` as a call the keyword scan cannot see;
238
- `pg_cancel_backend`/`pg_terminate_backend` establish that server control belongs, so
239
- `pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote` and `pg_wal_replay_*` join them; and
240
- `pg_logical_slot_get_changes` is `nextval`'s argument exactly — it advances a slot's confirmed
241
- position, a write with no keyword that no `ROLLBACK` undoes — which brings `pg_create_*`,
242
- `pg_drop_*`, `pg_replication_*` and `pg_logical_*` with it. `pg_file_*` is the writing half of
243
- `pg_read_*`. `txid_current`/`pg_current_xact_id` ASSIGN a transaction id a rollback does not
244
- return. The catalog VIEWS beside them (`pg_replication_slots`, `pg_stat_replication`) are read
245
- `from` and never called, so the call scan never sees them.
246
- - Banned SQL functions are matched as a **prefix of a CALLED function name**, so the family is the
247
- unit and a spelling nobody wrote down is refused rather than admitted — an exact-name list let
248
- `pg_sleep_for` past a ban on `pg_sleep`, and `set_config` past `SET`, which is already a write
249
- keyword. Add a family, never a name. The unit is the call (`name` before `(`), never a bare word:
250
- a word scan refused a column named `pg_sleep_for_seconds`. The call scan reads a strip that KEEPS
251
- quoted-identifier content, because `"pg_advisory_lock"(1)` is the same call as the bare spelling —
252
- the keyword scan still reads the blanked form, so `select "update" from t` stays a column. Two of
253
- the families exist because the same ban is already made elsewhere in another spelling:
254
- `pg_advisory_*` is `FOR UPDATE`'s ban and the worse breach (a session lock survives layer 2's
255
- `ROLLBACK`, so it outlives the read on a pooled connection — proved live in
256
- `packages/testing/src/db-integration.test.ts`), and `pg_sleep*` is the one ban that still holds on
257
- embedded PGlite, whose single WASM thread cannot honour a statement timeout. `nextval`/`setval`
258
- are a family no keyword can reach: they advance a SEQUENCE, which is a write, and one `ROLLBACK`
259
- does not undo — a consumed id is gone, so a read can burn the next id a real insert would take.
210
+ - **Banned function families** (each is a ban already made elsewhere in another spelling):
211
+ `pg_notify` (= `NOTIFY`, a write keyword); server control (`pg_cancel_backend`,
212
+ `pg_terminate_backend`, `pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote`,
213
+ `pg_wal_replay_*`); replication (`pg_logical_*`, `pg_create_*`, `pg_drop_*`, `pg_replication_*` —
214
+ advancing a slot is a write no `ROLLBACK` undoes); `pg_file_*`; `txid_current` /
215
+ `pg_current_xact_id`. Catalog VIEWS (`pg_replication_slots`) are read `from`, never called.
216
+ - Banned SQL functions are matched as a **prefix of a CALLED function name** — add a family, never
217
+ a name (`pg_sleep_for` passed an exact `pg_sleep` ban). The unit is the call (`name(`), so a
218
+ column `pg_sleep_for_seconds` is fine; the call scan keeps quoted-identifier content
219
+ (`"pg_advisory_lock"(1)` is a call), the keyword scan blanks it. `pg_advisory_*` (a session lock
220
+ survives layer 2's `ROLLBACK`; `packages/testing/src/db-integration.test.ts`), `pg_sleep*` (the
221
+ one ban that holds on PGlite), `nextval`/`setval` (a consumed id is not rolled back).
260
222
  - `db.query` is defended four ways: a SELECT-only role and `BEGIN READ ONLY` in `@ultimat3/db`
261
223
  (the CLI wires them — this package must never import `db`), the parse here, and the caps here.
262
224
  `limit` is a request, never a permission: `resolveQueryLimits` clamps it into a hard 1000.
263
225
  - The caps run in the **tool**, not the host. A host that forgets them answers a million rows
264
226
  into a model's context. `guards` names the layers that engaged; a layer that could not engage
265
227
  is absent from the list, never assumed present.
266
- - **`MCP_RATE_LIMITS` is ENFORCED, in `handle`, and it has to be there** (2026-08-24). `limits` and
267
- `rateLimitClass` were published on `McpRouteDescriptor` and read by no mount point — `x mcp serve`
268
- runs `route.handle` in a bare `Bun.serve` and `defineAppMcp` hands its route to the app — so the
269
- type promised 20 writes a minute while the real ceiling was Bun's accept rate: an agent looping on
270
- `db.query` was never UNSAFE (the `readonly-sql` parse and `query-limits` caps hold per call) and
271
- never BOUNDED. It cannot be enforced from OUTSIDE, which is why deleting the option was the wrong
272
- half of the choice: `rateLimitClass(body)` takes an ALREADY-PARSED body and `handle` is the only
273
- thing that parses one, so a limiter above it would have to consume the request stream first and a
274
- `Request` body reads once. The maths, the `Bucket`, `toBucket`, `rateLimitKey` and the store are
275
- `@ultimat3/http`'s — tier 2, a legal downward import — and **never** a second token bucket written
276
- here. Metered after the parse and before dispatch; an unauthenticated caller is answered 401 first,
277
- so a token nobody issued cannot spend an actor's allowance. The key is
278
- `mcp:<class>|actor:<id>`, never the TOKEN: a bucket key reaches a log and an error reporter, and a
279
- credential in one is a leak wearing a throttle's clothes.
280
- - **`X_MCP_RATE_LIMITED`, not `@ultimat3/http`'s `X_RATE_LIMITED`, and the reason is the KNOB.** The
281
- enforcement is shared to the last function; only the sentence differs. `X_RATE_LIMITED`'s `fix:`
282
- says to raise `rateLimit.buckets` in `app.config.ts`, which governs the HTTP pipeline and has no
283
- effect on this route — an instruction that runs and changes nothing is worse than none, which is
284
- the same call `@ultimat3/realtime`'s `SubscriptionLimitError` makes when it names the knob rather
285
- than the default. The 429 renders `{ code, cause, fix }` plus `Retry-After`, the shape this file's
286
- 401 and 403 already use, never a JSON-RPC envelope: the transport refused before dispatch, so
287
- there is no call to answer.
228
+ - **`MCP_RATE_LIMITS` is ENFORCED, in `handle`, and has to be there**: `rateLimitClass(body)` needs
229
+ the parsed body and `handle` is the only parser (a `Request` body reads once). The bucket, store
230
+ and key maths are `@ultimat3/http`'s — never a second token bucket here. Metered after the parse
231
+ and the 401, before dispatch. Key `mcp:<class>|actor:<id>`, never the token.
232
+ - **`X_MCP_RATE_LIMITED`, not http's `X_RATE_LIMITED`**, because the knob differs: http's `fix:`
233
+ names `rateLimit.buckets`, which does not govern this route. The 429 is `{ code, cause, fix }`
234
+ plus `Retry-After`, never a JSON-RPC envelope.
288
235
  - **A per-process store is the default and a lie for a fleet.** `mcpHttpRoute({ rateLimitStore })`
289
236
  and `defineAppMcp({ rateLimitStore })` are the seam; N replicas on the memory store enforce N x
290
237
  every number, silently and only in production. Forwarded through `defineAppMcp` deliberately —
@@ -294,13 +241,9 @@ import. The CLI wires it.
294
241
  answered `400 parse error` for a malformed payload and `401` for a well-formed one under the
295
242
  SAME rejected token, which is precisely the oracle the pre-parse 401 exists to remove. The parse
296
243
  error still exists — it is what an authenticated agent gets.
297
- - **`transport-stdio.ts`'s default `write` is AWAITED, and it has to be** (`As of 2026-09`). It was
298
- `Bun.stdout.write(chunk)` returning `void`, so every `await write(...)` in `serveStdio` awaited
299
- nothing: fd 1 is a pipe for every real peer (a local agent LAUNCHED this process), the runtime
300
- queues past the buffer, and the CLI's exit threw the queue away — measured, a 4,000,236-byte
301
- frame arrived as 1,388,672 bytes. The same failure `scripts/stdout-truncation.test.ts` pins for
302
- `--json`, and it needs a CHILD PROCESS to see: in-process it does not exist. Awaiting it is also
303
- the loop's only back-pressure.
244
+ - **`transport-stdio.ts`'s default `write` is AWAITED** — fd 1 is a pipe, and an unawaited
245
+ `Bun.stdout.write` lost the tail of a 4 MB frame at exit. It is also the loop's only
246
+ back-pressure; only a child-process test can see it.
304
247
  - **A schema property lands through `Object.defineProperty` too** — `input-schema.ts`'s
305
248
  `narrowProperties`, the twin of `validate-args.ts`'s `put` on the schema side. `out[key] = …` for
306
249
  a property named `__proto__` runs `Object.prototype`'s setter and re-prototypes the published
@@ -320,3 +263,5 @@ import. The CLI wires it.
320
263
  bun test packages/mcp
321
264
  bun run --filter @ultimat3/mcp typecheck
322
265
  ```
266
+
267
+ Why each rule above is shaped the way it is: [`docs/history/mcp.md`](../../docs/history/mcp.md).
package/README.md CHANGED
@@ -98,7 +98,7 @@ export const mcp = defineAppMcp({
98
98
  name: 'acme-admin',
99
99
  include: 'exposed', // every action/query with mcp: { expose: true }
100
100
  resources: [orgExport],
101
- prompts: ['apps/web/app/posts/prompts/summarize.v3.md'],
101
+ prompts: ['apps/web/app/posts/prompts/summarize.v3.md'], // prompts/get reads the file
102
102
  tools: {
103
103
  seatReport: { // the key IS the tool name
104
104
  description: 'Seats used, remaining and the plan limit. Read-only.',
@@ -245,3 +245,26 @@ inside their own handler.
245
245
  | `X_MCP_NOT_BRANCH_DB` | `db.migrate` aimed at a production or otherwise non-branch database |
246
246
  | `X_MCP_RESOURCE_DUPLICATE` | two resources claim one `ultimate://` URI — refused at registration, as a duplicate tool name is |
247
247
  | `X_MCP_RATE_LIMITED` | the caller spent its per-minute allowance for this request's class. Its own code rather than `@ultimat3/http`'s `X_RATE_LIMITED` because the KNOB differs — `rateLimits` on the route, never `rateLimit.buckets` in `app.config.ts` |
248
+
249
+ ### Error classes
250
+
251
+ Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
252
+ a job boundary the class is gone and the `code` is what survives — match on that.
253
+
254
+ | Class | Code | Declared in |
255
+ |---|---|---|
256
+ | `McpAppUnmountedError` | `X_MCP_APP_UNMOUNTED` | `src/errors.ts` |
257
+ | `McpArgsInvalidError` | `X_MCP_ARGS_INVALID` | `src/errors.ts` |
258
+ | `McpBodyTooLargeError` | `X_MCP_BODY_TOO_LARGE` | `src/errors.ts` |
259
+ | `McpNotBranchDbError` | `X_MCP_NOT_BRANCH_DB` | `src/errors.ts` |
260
+ | `McpProtocolError` | `X_MCP_PROTOCOL` | `src/errors.ts` |
261
+ | `McpQueryRejectedError` | `X_MCP_QUERY_REJECTED` | `src/errors.ts` |
262
+ | `McpRateLimitedError` | `X_MCP_RATE_LIMITED` | `src/errors.ts` |
263
+ | `McpResourceDuplicateError` | `X_MCP_RESOURCE_DUPLICATE` | `src/errors.ts` |
264
+ | `McpScopeConflictError` | `X_MCP_SCOPE_CONFLICT` | `src/errors.ts` |
265
+ | `McpScopeDeniedError` | `X_MCP_SCOPE_DENIED` | `src/errors.ts` |
266
+ | `McpScopeUnknownError` | `X_MCP_SCOPE_UNKNOWN` | `src/errors.ts` |
267
+ | `McpToolDuplicateError` | `X_MCP_TOOL_DUPLICATE` | `src/errors.ts` |
268
+ | `McpToolUndeclaredError` | `X_MCP_TOOL_UNDECLARED` | `src/errors.ts` |
269
+ | `McpToolUnknownError` | `X_MCP_TOOL_UNKNOWN` | `src/errors.ts` |
270
+ | `McpToolUnsafeError` | `X_MCP_TOOL_UNSAFE` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "21.0.0",
3
+ "version": "22.0.0",
4
4
  "description": "MCP server, dev tools, and the action-to-tool projection \u2014 one authz system, two surfaces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,13 +31,13 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/action": "21.0.0",
35
- "@ultimat3/core": "21.0.0",
36
- "@ultimat3/entity": "21.0.0",
37
- "@ultimat3/http": "21.0.0",
38
- "@ultimat3/jobs": "21.0.0",
39
- "@ultimat3/policy": "21.0.0",
40
- "@ultimat3/query": "21.0.0",
41
- "@ultimat3/schema": "21.0.0"
34
+ "@ultimat3/action": "22.0.0",
35
+ "@ultimat3/core": "22.0.0",
36
+ "@ultimat3/entity": "22.0.0",
37
+ "@ultimat3/http": "22.0.0",
38
+ "@ultimat3/jobs": "22.0.0",
39
+ "@ultimat3/policy": "22.0.0",
40
+ "@ultimat3/query": "22.0.0",
41
+ "@ultimat3/schema": "22.0.0"
42
42
  }
43
43
  }
package/src/index.ts CHANGED
@@ -10,7 +10,6 @@ export type {
10
10
  AppToolDefinition,
11
11
  AppTools,
12
12
  } from './app-tool';
13
- export { appToolPrimitive, appToolPrimitives } from './app-tool';
14
13
  export type { AppMcp, AppToolSchemas, DefineAppMcpInput } from './app-tools';
15
14
  export { defineAppMcp } from './app-tools';
16
15
  export type { McpAuditEntry, McpOutcome, McpResourceAuditEntry } from './audit';
@@ -54,13 +53,8 @@ export type {
54
53
  export {
55
54
  DEV_SCOPES,
56
55
  devTools,
57
- STYLE_NAME,
58
- UI_DIFF_DEFAULT_THRESHOLD,
59
56
  UI_INSPECT_LIMITS,
60
57
  UI_INTERACT_LIMITS,
61
- UI_INTERACT_STEP_SCHEMA,
62
- UI_VIEWPORTS,
63
- viewportOf,
64
58
  } from './dev-server';
65
59
  export type { McpErrorCode } from './errors';
66
60
  export {
@@ -97,12 +91,10 @@ export { asProjectable } from './projectable';
97
91
  export type { QueryLimits, QueryResult, QueryRows } from './query-limits';
98
92
  export {
99
93
  capQueryRows,
100
- DEFAULT_QUERY_ROWS,
101
- QUERY_LIMITS,
102
94
  resolveQueryLimits,
103
95
  } from './query-limits';
104
96
  export type { DatabaseTarget } from './readonly-sql';
105
- export { assertBranchDatabase, assertReadOnlyQuery, PARSE_GUARD } from './readonly-sql';
97
+ export { assertBranchDatabase, assertReadOnlyQuery } from './readonly-sql';
106
98
  export type {
107
99
  AnyMcpTool,
108
100
  ContentBlock,
@@ -132,7 +124,6 @@ export {
132
124
  RESOURCE_URIS,
133
125
  ResourceRegistry,
134
126
  toPrompts,
135
- URI_ARG_SCHEMA,
136
127
  } from './resources';
137
128
  export type { McpScopes } from './scopes';
138
129
  export { withScopes } from './scopes';
@@ -144,10 +135,7 @@ export type {
144
135
  ResolvedToken,
145
136
  } from './transport-http';
146
137
  export {
147
- bearerToken,
148
138
  DEFAULT_MCP_BODY_LIMIT_BYTES,
149
- isAgentActor,
150
- MCP_RATE_LIMIT_WINDOW_MS,
151
139
  MCP_RATE_LIMITS,
152
140
  mcpHttpRoute,
153
141
  } from './transport-http';
@@ -172,7 +160,6 @@ export {
172
160
  isJsonRpcRequest,
173
161
  MCP_PROTOCOL_VERSION,
174
162
  METHOD_NOT_FOUND,
175
- NO_ARGS,
176
163
  PARSE_ERROR,
177
164
  resultResponse,
178
165
  } from './wire';
@@ -0,0 +1,51 @@
1
+ // Single responsibility: MCP's `prompts/list` entries and the `prompts/get` answer. `initialize`
2
+ // advertises the `prompts` capability, so a listed prompt that `prompts/get` cannot resolve is a
3
+ // capability the server claims and does not have.
4
+
5
+ import type { McpPrompt } from './resources';
6
+ import type { JsonRpcId, JsonRpcResponse } from './wire';
7
+ import { errorResponse, INTERNAL_ERROR, INVALID_PARAMS, resultResponse } from './wire';
8
+
9
+ /** The wire fields alone — a prompt's reader is a function and never leaves the process. */
10
+ export const promptListEntry = (prompt: McpPrompt) => ({
11
+ name: prompt.name,
12
+ description: prompt.description,
13
+ ...(prompt.arguments === undefined ? {} : { arguments: prompt.arguments }),
14
+ });
15
+
16
+ /** Only string arguments: MCP types every prompt argument as a string. */
17
+ const stringArguments = (raw: unknown): Readonly<Record<string, string>> => {
18
+ if (typeof raw !== 'object' || raw === null) return {};
19
+ return Object.fromEntries(
20
+ Object.entries(raw).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
21
+ );
22
+ };
23
+
24
+ export async function promptsGet(
25
+ prompts: readonly McpPrompt[],
26
+ id: JsonRpcId,
27
+ params: Readonly<Record<string, unknown>> | null,
28
+ ): Promise<JsonRpcResponse> {
29
+ const name = params?.['name'];
30
+ if (typeof name !== 'string') {
31
+ return errorResponse(id, INVALID_PARAMS, 'prompts/get params.name must be a string');
32
+ }
33
+ const prompt = prompts.find((declared) => declared.name === name);
34
+ if (prompt === undefined) {
35
+ return errorResponse(id, INVALID_PARAMS, `prompt not found: ${name} — read prompts/list`);
36
+ }
37
+ if (prompt.read === undefined) {
38
+ return errorResponse(id, INVALID_PARAMS, `prompt "${name}" declares no body to serve`);
39
+ }
40
+ try {
41
+ const text = await prompt.read(stringArguments(params?.['arguments']));
42
+ return resultResponse(id, {
43
+ description: prompt.description,
44
+ messages: [{ role: 'user', content: { type: 'text', text } }],
45
+ });
46
+ } catch {
47
+ // No internals: a reader's own message names a path the caller has no business seeing — the
48
+ // rule `resources/read` follows.
49
+ return errorResponse(id, INTERNAL_ERROR, `prompt "${name}" could not be read`);
50
+ }
51
+ }
@@ -344,8 +344,13 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
344
344
  i = end;
345
345
  continue;
346
346
  }
347
- if (char === '$') {
348
- const tag = /^\$[a-z_]*\$/i.exec(sql.slice(i));
347
+ // Glued to an identifier, `$` is part of the NAME (`a$b$` is one identifier to Postgres), so
348
+ // it opens nothing — `@ultimat3/db`'s `dollarTagAt` makes the same check.
349
+ if (char === '$' && !IDENTIFIER_CHAR.test(sql[i - 1] ?? '')) {
350
+ // Postgres's own tag grammar: an identifier that does not start with a digit — letters,
351
+ // `_`, digits after the first, and any non-ASCII character. `[a-z_]*` read `$a1$` as text,
352
+ // and the `'` it then saw hid a `pg_sleep` Postgres runs outside every quote.
353
+ const tag = DOLLAR_TAG.exec(sql.slice(i));
349
354
  if (tag !== null) {
350
355
  const marker = tag[0];
351
356
  const end = sql.indexOf(marker, i + marker.length);
@@ -366,6 +371,9 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
366
371
  return out;
367
372
  }
368
373
 
374
+ const IDENTIFIER_CHAR = /[A-Za-z0-9_$\u0080-\uffff]/;
375
+ const DOLLAR_TAG = /^\$(?:[A-Za-z_\u0080-\uffff][A-Za-z0-9_\u0080-\uffff]*)?\$/;
376
+
369
377
  /**
370
378
  * Where a `--` comment ends: the first CR **or** LF, or the end of the input.
371
379
  *
package/src/resources.ts CHANGED
@@ -61,6 +61,12 @@ export interface McpPrompt {
61
61
  readonly name: string;
62
62
  readonly description: string;
63
63
  readonly arguments?: readonly McpPromptArgument[];
64
+ /**
65
+ * The prompt's TEXT, for `prompts/get`, given the arguments the client passed. An injected thunk
66
+ * — the `frameworkResources` pattern — so the server never learns where a prompt lives.
67
+ * Absent, the prompt is listed and `prompts/get` refuses it (`-32602`) rather than inventing a body.
68
+ */
69
+ readonly read?: (args: Readonly<Record<string, string>>) => Promise<string> | string;
64
70
  }
65
71
 
66
72
  export interface McpPromptArgument {
@@ -79,7 +85,13 @@ export interface McpPromptArgument {
79
85
  export function promptFromPath(path: string): McpPrompt {
80
86
  const file = path.split('/').pop() ?? path;
81
87
  const name = file.replace(/\.(md|markdown|txt|prompt)$/i, '');
82
- return { name, description: `Versioned prompt artifact: ${path}` };
88
+ // The file IS the body, read when an agent asks for it — never cached, so an edited artifact is
89
+ // what the next `prompts/get` serves.
90
+ return {
91
+ name,
92
+ description: `Versioned prompt artifact: ${path}`,
93
+ read: () => Bun.file(path).text(),
94
+ };
83
95
  }
84
96
 
85
97
  /** Accepts either authoring form; an object is already the wire shape and passes through. */
package/src/server.ts CHANGED
@@ -7,6 +7,7 @@ import { FRAMEWORK_CODE, singleLine, stringField } from '@ultimat3/core';
7
7
  import { formatIssues } from '@ultimat3/schema';
8
8
  import { auditResourceRead, auditToolCall, outcomeForCode, outcomeForResult } from './audit';
9
9
  import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
10
+ import { promptListEntry, promptsGet } from './prompts-get';
10
11
  import type { AnyMcpTool, McpCaller, McpToolResult, McpVerbClass, ToolListEntry } from './registry';
11
12
  import { ToolRegistry } from './registry';
12
13
  import type { McpPrompt, McpResource } from './resources';
@@ -53,11 +54,13 @@ export interface CreateMcpServerInput {
53
54
  /** The set of JSON-RPC methods this server answers. Kept in sync with `classify`. */
54
55
  const METHODS = [
55
56
  'initialize',
57
+ 'ping',
56
58
  'tools/list',
57
59
  'tools/call',
58
60
  'resources/list',
59
61
  'resources/read',
60
62
  'prompts/list',
63
+ 'prompts/get',
61
64
  ] as const;
62
65
 
63
66
  export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
@@ -141,8 +144,13 @@ export class McpServer {
141
144
  return resultResponse(id, { resources: this.resources.list(caller) });
142
145
  case 'resources/read':
143
146
  return this.resourcesRead(body, caller);
147
+ // MCP's keep-alive: an empty result, which clients treat as "still here".
148
+ case 'ping':
149
+ return resultResponse(id, {});
144
150
  case 'prompts/list':
145
- return resultResponse(id, { prompts: this.prompts });
151
+ return resultResponse(id, { prompts: this.prompts.map(promptListEntry) });
152
+ case 'prompts/get':
153
+ return promptsGet(this.prompts, id, paramsOf(body));
146
154
  default:
147
155
  return errorResponse(id, METHOD_NOT_FOUND, `method not found: ${body.method}`, {
148
156
  supported: METHODS,