@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 +52 -107
- package/README.md +24 -1
- package/package.json +9 -9
- package/src/index.ts +1 -14
- package/src/prompts-get.ts +51 -0
- package/src/readonly-sql.ts +10 -2
- package/src/resources.ts +13 -1
- package/src/server.ts +9 -1
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
|
|
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`,
|
|
79
|
-
`ResourceRegistry.resolve`
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
151
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
`
|
|
239
|
-
|
|
240
|
-
`
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
298
|
-
`Bun.stdout.write
|
|
299
|
-
|
|
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/http": "
|
|
38
|
-
"@ultimat3/jobs": "
|
|
39
|
-
"@ultimat3/policy": "
|
|
40
|
-
"@ultimat3/query": "
|
|
41
|
-
"@ultimat3/schema": "
|
|
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
|
|
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
|
+
}
|
package/src/readonly-sql.ts
CHANGED
|
@@ -344,8 +344,13 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
|
|
|
344
344
|
i = end;
|
|
345
345
|
continue;
|
|
346
346
|
}
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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,
|