@ultimat3/mcp 23.0.0 → 25.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 +54 -51
- package/README.md +153 -21
- package/package.json +12 -11
- package/src/app-tool.ts +7 -8
- package/src/app-tools.ts +38 -13
- package/src/audit-hook.ts +108 -0
- package/src/audit.ts +17 -0
- package/src/caller-context.ts +2 -4
- package/src/confirmation-decide.ts +116 -0
- package/src/confirmation-errors.ts +137 -0
- package/src/confirmation-gate.ts +101 -0
- package/src/confirmation-postgres.ts +143 -0
- package/src/confirmation-schema.ts +34 -0
- package/src/confirmation-store.ts +136 -0
- package/src/confirmations.ts +147 -0
- package/src/dev-host.ts +4 -4
- package/src/errors-transport.ts +94 -0
- package/src/errors.ts +44 -102
- package/src/framework-error.ts +31 -3
- package/src/from-action.ts +30 -58
- package/src/index.ts +58 -19
- package/src/list-params.ts +9 -4
- package/src/meta-call.ts +5 -4
- package/src/meta-surface.ts +86 -19
- package/src/mount-errors.ts +1 -1
- package/src/oauth-metadata.ts +1 -1
- package/src/projectable.ts +44 -40
- package/src/readonly-sql-calls.ts +106 -0
- package/src/readonly-sql.ts +26 -97
- package/src/registry.ts +6 -5
- package/src/scopes.ts +15 -4
- package/src/server-voice.ts +1 -1
- package/src/server.ts +38 -36
- package/src/tool-thrown.ts +5 -3
- package/src/transport-http.ts +119 -17
- package/src/transport-stdio.ts +1 -1
- package/src/type-pins.ts +52 -0
- package/src/validate-args.ts +12 -4
- package/src/wire.ts +21 -40
- package/src/input-schema.ts +0 -140
package/CLAUDE.md
CHANGED
|
@@ -22,15 +22,16 @@ import. The CLI wires it.
|
|
|
22
22
|
| `dev-server.ts` | the 13 dev tools; depends only on an injected `DevHost` |
|
|
23
23
|
| `dev-host.ts` | wires `describe*` from entity/action/query/jobs into a `DevHost` |
|
|
24
24
|
| `transport-http.ts` | `POST /mcp` route descriptor, bearer → agent actor, and the per-caller rate limit it enforces itself |
|
|
25
|
+
| `audit-hook.ts` | `onAudit`; `resolveToken`'s 2nd arg is http's `RequestFacts` (`seen.address` only, never `x-forwarded-for`), re-exported as `McpRequestFacts` |
|
|
26
|
+
| `confirmations.ts` · `confirmation-*.ts` | `mcpConfirmations`: a factory over `action` (view/decide) · the gate on `handle` · stores · DDL · codes |
|
|
25
27
|
| `oauth-metadata.ts` | RFC 9728 protected-resource metadata + the 401 `resource_metadata` challenge (`defineAppMcp({ oauth })`); the authorization server is the app's |
|
|
26
28
|
| `transport-stdio.ts` | NDJSON on stdin/stdout for `x mcp serve` |
|
|
27
29
|
| `app-tools.ts` | `defineAppMcp` — a generated app's own MCP surface, one call |
|
|
28
30
|
| `app-tool.ts` | the authored `tools: { name: {...} }` record → `ProjectablePrimitive` |
|
|
29
31
|
| `projectable.ts` | a real `action`/`query` → `ProjectablePrimitive`; the ONE adapter both the sweep and the written-out list use |
|
|
30
32
|
| `exposed.ts` | `include: 'exposed'` — the action/query registries → primitives |
|
|
31
|
-
| `scopes.ts` | the `scopes:` map — outcome 2's declaration surface
|
|
32
|
-
| `
|
|
33
|
-
| `readonly-sql.ts` | layer 3 of `db.query` — the single-read parse — and `db.migrate`'s branch check |
|
|
33
|
+
| `scopes.ts` | the `scopes:` map — outcome 2's declaration surface and its three boot refusals |
|
|
34
|
+
| `readonly-sql.ts` · `readonly-sql-calls.ts` | layer 3 of `db.query` — the single-read parse — and `db.migrate`'s branch check · the banned call families |
|
|
34
35
|
| `query-limits.ts` | layer 4 of `db.query` — the row, byte and timeout ceilings, and what truncation reports |
|
|
35
36
|
| `meta-surface.ts` | `surface: 'meta'` catalog: `list_resources` / `describe_resource`, `groups:` boot checks; dispatch stays in `server.ts` |
|
|
36
37
|
| `list-params.ts` | `listParams` whitelist → the schema `manage_resource` enforces (flat keys: `status_eq`) |
|
|
@@ -84,8 +85,7 @@ import. The CLI wires it.
|
|
|
84
85
|
`Object.prototype` supplies a value for `constructor`, `toString`, `hasOwnProperty` and
|
|
85
86
|
`__proto__` on every plain object — so the index read answered "declared" for four names no
|
|
86
87
|
schema declares, and `validate-args.ts` accepted them past an `additionalProperties: false` that
|
|
87
|
-
forbids them and then dropped them.
|
|
88
|
-
`@ultimat3/i18n`'s catalog lookup and `@ultimat3/schema`'s `coerce`. Its twin: a validated key
|
|
88
|
+
forbids them and then dropped them. Its twin: a validated key
|
|
89
89
|
lands on the result through `Object.defineProperty`, because `out[key] = v` for `__proto__` runs
|
|
90
90
|
the setter on `Object.prototype` and re-prototypes the record instead of adding a key.
|
|
91
91
|
- **The RESOURCE surface owes the same three outcomes as the tool surface.** `resources/list` and
|
|
@@ -102,17 +102,19 @@ import. The CLI wires it.
|
|
|
102
102
|
- **A declared `pattern` is compiled once per schema NODE, never per `tools/call`** — a `WeakMap`
|
|
103
103
|
keyed on the node (never on the pattern string, which grows forever), caching an uncompilable
|
|
104
104
|
pattern's `null` too. `compiledPatternCount()` is the test-only probe; `validate-args.test.ts`
|
|
105
|
-
asserts it does not climb over 100 calls.
|
|
105
|
+
asserts it does not climb over 100 calls. Compiled WITH `x-ultimate-pattern-flags` (schema's),
|
|
106
|
+
`lastIndex` reset.
|
|
106
107
|
- **`format` is NOT in the wire subset**, and `wire.ts` types it `never` so re-adding it does not
|
|
107
108
|
compile. It names a rule whose meaning lives in `@ultimat3/schema` (`uuid`, `email`,
|
|
108
109
|
`iana-time-zone`), and this package cannot check it without a second definition of each that can
|
|
109
110
|
only drift from the action's own parse. `tools/list` published it and `validate-args.ts` ignored
|
|
110
|
-
it, so a tool declaring `t.uuid` accepted `"not-a-uuid"` with `ok: true`
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
it, so a tool declaring `t.uuid` accepted `"not-a-uuid"` with `ok: true`. `pattern` is the
|
|
112
|
+
opposite case and is kept: the rule travels with the schema. The narrowing is `@ultimat3/schema`'s
|
|
113
|
+
`toWireSchema` (tier 0, so OpenAPI-adjacent readers reach the same document) and the
|
|
114
|
+
type is its `WireJsonSchema`, which `wire.ts` names `JsonSchema`; `validate-args-subset.test.ts`
|
|
115
|
+
asserts every published keyword is one this server enforces, at any depth.
|
|
114
116
|
- **A hand-written app tool parses its own input**, in the slot `invoke` puts it: parse, then
|
|
115
|
-
`
|
|
117
|
+
`guardAction()`, then `handle`. A projected action re-parses inside `invoke`; `app-tool.ts` had no second
|
|
116
118
|
parse, so `handle` was handed whatever the wire subset let through — typed `InferOutput<TInput>`,
|
|
117
119
|
past the policy. One code either way, `X_INPUT_INVALID`, built from `@ultimat3/action`'s own
|
|
118
120
|
`InputInvalidError`.
|
|
@@ -131,22 +133,24 @@ import. The CLI wires it.
|
|
|
131
133
|
`-32603` the transport promises for a genuine bug.
|
|
132
134
|
- A framework error rendered into a tool result is **byte-identical to
|
|
133
135
|
`UltimateError.format()`** — one denial must not read one way over MCP and another in the
|
|
134
|
-
terminal. `server.ts` renders it; the test pins it against `format()`, never a literal.
|
|
136
|
+
terminal. `server.ts` renders it; the test pins it against `format()`, never a literal. Except a
|
|
137
|
+
hidden 5xx for `'caller'` (`forAudience`, `As of 2026-10`): `statusFor(code) >= 500` and not core's
|
|
138
|
+
`hasPublicCause` → `HIDDEN_CAUSE` + `x errors explain`, tool results and resource reads alike.
|
|
135
139
|
- Every outcome is audited via `audit.ts`, hidden included, at `warn`. Never log arguments or row
|
|
136
140
|
data. **On both surfaces**: `mcp.tool-call.<outcome>` and `mcp.resource-read.<outcome>` — two
|
|
137
141
|
events, one `LEVEL` table, so an alert can tell a URI walk from a tool-name walk. `resources/list`
|
|
138
142
|
and `tools/list` are silent by design (answered pre-filtered). `resource-security.test.ts` reads
|
|
139
143
|
both streams: core's logger puts `error` on stderr.
|
|
140
144
|
- **A tool that renders its OWN `isError` result may NAME the code it refused with**
|
|
141
|
-
(`McpToolResult.code
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
`
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
145
|
+
(`McpToolResult.code`, audit-only, never on the wire); `outcomeForResult` sends it through the
|
|
146
|
+
`outcomeForCode` a thrown error takes. Unnamed keeps the conservative `policy-denied`. Why:
|
|
147
|
+
`X_ADMIN_INVALID` (`ARGUMENT_CODES`) sat in the enumeration alert's bucket. `X_INPUT_INVALID`
|
|
148
|
+
stays `failed` — a projected action's whole schema was already validated, so it means drift.
|
|
149
|
+
`X_MCP_CONFIRMATION_PENDING`/`_EXPIRED` are `unconfirmed` (`info`); `_REJECTED` is a denial.
|
|
150
|
+
- **`onAudit` is a second DESTINATION, never a second path** (`audit-hook.ts`, `As of 2026-10`):
|
|
151
|
+
the line is written, then the decision handed on, unawaited; a throwing hook changes no answer
|
|
152
|
+
(`mcp.audit-hook.failed`). Core's `AuditSink` = what a primitive DID; `onAudit` = what the gate
|
|
153
|
+
decided, refused tokens included (`mcp.auth.<reason>`). The route reads `server.audit`.
|
|
150
154
|
- **A `--` comment ends at the first CR *or* LF — Postgres' own boundary set** (`readonly-sql.ts`,
|
|
151
155
|
`endOfLineComment`). A scanner stopping at LF alone read `select 1;--\rupdate …` as one read.
|
|
152
156
|
- `security.test.ts` and `app-security.test.ts` are the executable contract for all of the
|
|
@@ -154,10 +158,9 @@ import. The CLI wires it.
|
|
|
154
158
|
actually declares (`defineAppMcp` projecting real actions and queries). Extend them, never
|
|
155
159
|
weaken them. A gate can only refuse what a declaration can reach, so a new gate needs a test
|
|
156
160
|
in BOTH: the registry half passes while the declaration surface silently drops the field.
|
|
157
|
-
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
and why the pin lives in `@ultimat3/cli`. Never spell `=== true` inline here again.
|
|
161
|
+
- **Exposure is core's `isMcpExposed`** (private `exposed` and `exposureOf`; no exported
|
|
162
|
+
wrapper since 25.0.0) — the one answer shared with `action`, `query`, `ai`, `manifest`; the pin
|
|
163
|
+
lives in `@ultimat3/cli`. Never spell `=== true` inline here again.
|
|
161
164
|
- Exposure is declared at the primitive, never in `defineAppMcp`. A primitive NAMED in
|
|
162
165
|
`actions:`/`queries:` without `mcp: { expose: true }` is `X_MCP_TOOL_UNDECLARED` at boot —
|
|
163
166
|
a written-out list is a request, so filtering it would ship a catalog missing a tool its
|
|
@@ -175,23 +178,24 @@ import. The CLI wires it.
|
|
|
175
178
|
query. Writing a primitive out NAMES a tool; it never re-shapes or re-runs one. An action
|
|
176
179
|
with no export name is `X_ACTION_UNREGISTERED` rather than a tool called `''`, which no
|
|
177
180
|
`tools/call` and no `scopes:` entry could ever address.
|
|
178
|
-
- **This package NAMES a tool,
|
|
179
|
-
`
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
- **This package NAMES a tool, never derives one**: `primitive.name`, verbatim from
|
|
182
|
+
`projectable.ts`; core's `McpExposureDeclaration` is the one block, no `name` (`type-pins.ts`). `x-ultimate.mcpTool` and
|
|
183
|
+
`ActionDescriptor.mcp.tool` owe the same string (`cross-surface.test.ts`).
|
|
184
|
+
- **`toolFrom` is THE tool projection** (O-tool, 25.0.0), over a real action/query or a
|
|
185
|
+
`ProjectablePrimitive`; `toolListEntry(toolFrom(x))` deep-equals the `tools/list` entry.
|
|
186
|
+
action/query `.tool()` and the `toolFromQuery` alias are deleted.
|
|
183
187
|
- Every boot-time refusal in `defineAppMcp` is an `UltimateError` with a code, never a bare
|
|
184
188
|
throw: `X_MCP_TOOL_UNDECLARED`, `X_MCP_TOOL_UNSAFE`, `X_MCP_TOOL_DUPLICATE`,
|
|
185
|
-
`X_MCP_SCOPE_UNKNOWN`, `X_MCP_SCOPE_CONFLICT`. The caller reading them is usually an agent
|
|
189
|
+
`X_MCP_SCOPE_UNKNOWN`, `X_MCP_SCOPE_CONFLICT`, `X_MCP_SCOPE_UNCOVERED`. The caller reading them is usually an agent
|
|
186
190
|
that needs `{ code, cause, fix }`.
|
|
187
|
-
- `scopes:` is refused at boot
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
- The **projection** invents no `scope` — `
|
|
191
|
+
- `scopes:` is refused at boot three ways, each shipping a silently ungated tool otherwise: a name
|
|
192
|
+
nothing projects (`X_MCP_SCOPE_UNKNOWN`); one tool under two scopes (`X_MCP_SCOPE_CONFLICT` —
|
|
193
|
+
key order is not a security model); a tool under none (`X_MCP_SCOPE_UNCOVERED`, `As of 2026-10`:
|
|
194
|
+
it answered every token while `bearerMount` served it to nobody — `scope-surfaces.test.ts`).
|
|
195
|
+
- The **projection** invents no `scope` — `toolFrom` cannot know what a token means.
|
|
192
196
|
`defineAppMcp`'s `scopes:` may attach one afterward, as a capability of the CONNECTION; that
|
|
193
197
|
is not a second authz path, because the scope gate decides before the policy runs and never
|
|
194
|
-
reads the input. A hand-written app tool is the same: its `policy` reaches `
|
|
198
|
+
reads the input. A hand-written app tool is the same: its `policy` reaches `guardAction()` from
|
|
195
199
|
`@ultimat3/action`, which is the one authz path that reads the input — never a second check
|
|
196
200
|
written for MCP.
|
|
197
201
|
- **A URI is taken once.** `ResourceRegistry.register` throws `X_MCP_RESOURCE_DUPLICATE` on a
|
|
@@ -218,18 +222,11 @@ import. The CLI wires it.
|
|
|
218
222
|
- `db.query` / `db.migrate` refuse structurally, in `readonly-sql.ts`, before the host runs
|
|
219
223
|
(`X_MCP_QUERY_REJECTED` / `X_MCP_NOT_BRANCH_DB` — one code each, because they want different
|
|
220
224
|
next commands).
|
|
221
|
-
- **Banned function families**
|
|
222
|
-
|
|
223
|
-
`
|
|
224
|
-
`pg_wal_replay_*`); replication (`pg_logical_*`, `pg_create_*`, `pg_drop_*`, `pg_replication_*` —
|
|
225
|
-
advancing a slot is a write no `ROLLBACK` undoes); `pg_file_*`; `txid_current` /
|
|
226
|
-
`pg_current_xact_id`. Catalog VIEWS (`pg_replication_slots`) are read `from`, never called.
|
|
225
|
+
- **Banned function families** — each a ban already made elsewhere in another spelling; the list
|
|
226
|
+
and why lives in README's `db.query` section, held by `readonly-sql-forbidden-calls.test.ts`.
|
|
227
|
+
`U&"…"` is refused, never decoded; a keyword is a whole identifier (`set2` is a column).
|
|
227
228
|
- Banned SQL functions are matched as a **prefix of a CALLED function name** — add a family, never
|
|
228
|
-
a name (`pg_sleep_for` passed an exact `pg_sleep` ban)
|
|
229
|
-
column `pg_sleep_for_seconds` is fine; the call scan keeps quoted-identifier content
|
|
230
|
-
(`"pg_advisory_lock"(1)` is a call), the keyword scan blanks it. `pg_advisory_*` (a session lock
|
|
231
|
-
survives layer 2's `ROLLBACK`; `packages/testing/src/db-integration.test.ts`), `pg_sleep*` (the
|
|
232
|
-
one ban that holds on PGlite), `nextval`/`setval` (a consumed id is not rolled back).
|
|
229
|
+
a name (`pg_sleep_for` passed an exact `pg_sleep` ban); the call unit and the three named bans: README.
|
|
233
230
|
- `db.query` is defended four ways: a SELECT-only role and `BEGIN READ ONLY` in `@ultimat3/db`
|
|
234
231
|
(the CLI wires them — this package must never import `db`), the parse here, and the caps here.
|
|
235
232
|
`limit` is a request, never a permission: `resolveQueryLimits` clamps it into a hard 1000.
|
|
@@ -251,12 +248,18 @@ import. The CLI wires it.
|
|
|
251
248
|
`resolveToken` rejects and a non-agent actor all return before the body is read: parsing first
|
|
252
249
|
answered `400 parse error` for a malformed payload and `401` for a well-formed one under the
|
|
253
250
|
SAME rejected token, which is precisely the oracle the pre-parse 401 exists to remove. The parse
|
|
254
|
-
error still exists — it is what an authenticated agent gets.
|
|
251
|
+
error still exists — it is what an authenticated agent gets. Failures spend a per-ADDRESS bucket
|
|
252
|
+
(`seen.address`), and an exhausted address is `429` BEFORE `resolveToken` — a valid guess too.
|
|
253
|
+
- **Confirmation order: visibility → scope → args → `admit` → confirmation → policy.** An approval
|
|
254
|
+
binds actor + tool + `keyedFingerprint(args)` (rotation ⇒ new row); `consume` (CAS) makes one approval run one
|
|
255
|
+
call; rejected/expired is told once, then the call asks again. Args sealed; `approve` carries
|
|
256
|
+
viewed args (digest must match); default `check`: same org. An agent never decides (in the
|
|
257
|
+
policy). Statuses < 500, or `forAudience` hides the id.
|
|
255
258
|
- **`transport-stdio.ts`'s default `write` is AWAITED** — fd 1 is a pipe, and an unawaited
|
|
256
259
|
`Bun.stdout.write` lost the tail of a 4 MB frame at exit. It is also the loop's only
|
|
257
260
|
back-pressure; only a child-process test can see it.
|
|
258
|
-
- **A schema property lands through `Object.defineProperty` too** —
|
|
259
|
-
`
|
|
261
|
+
- **A schema property lands through `Object.defineProperty` too** — `@ultimat3/schema`'s
|
|
262
|
+
`wire-schema.ts`, the twin of `validate-args.ts`'s `put` on the schema side. `out[key] = …` for
|
|
260
263
|
a property named `__proto__` runs `Object.prototype`'s setter and re-prototypes the published
|
|
261
264
|
`properties` record instead of adding a key, so a field the tool author declared vanishes from
|
|
262
265
|
`tools/list` and from what the arg validator reads back.
|
package/README.md
CHANGED
|
@@ -33,6 +33,10 @@ reads only the catalog still knows what it is holding.
|
|
|
33
33
|
| 3. Parse | one statement, a read leader, no mutating keyword at statement level, no lock — clause **or** `pg_advisory_*` call — and no call into a banned function family, matched by prefix of the called name — quoted and schema-qualified spellings included — so a new spelling is refused by default and a column sharing a prefix is not; on a form with literals and comments blanked | `readonly-sql.ts` |
|
|
34
34
|
| 4. Limits | `SET LOCAL statement_timeout`, a hard 1000-row ceiling (`limit` clamps into it, never past it) and a 256 KiB byte cap | `query-limits.ts` |
|
|
35
35
|
|
|
36
|
+
**Banned function families** — each is a ban already made elsewhere in another spelling: `pg_notify` (= `NOTIFY`, a write keyword); server control (`pg_cancel_backend`, `pg_terminate_backend`, `pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote`, `pg_wal_replay_*`); replication (`pg_logical_*`, `pg_create_*`, `pg_drop_*`, `pg_replication_*` — advancing a slot is a write no `ROLLBACK` undoes); `pg_file_*`; `txid_current` / `pg_current_xact_id`; `pg_import_*`; SQL run from a string (`*_to_xml*`, `ts_stat`, `ts_rewrite`). `U&"…"` is refused, never decoded; a keyword is a whole identifier (`set2` is a column). Catalog VIEWS (`pg_replication_slots`) are read `from`, never called.
|
|
37
|
+
|
|
38
|
+
Banned SQL functions are matched as a **prefix of a CALLED function name** — add a family, never a name (`pg_sleep_for` passed an exact `pg_sleep` ban). The unit is the call (`name(`), so a column `pg_sleep_for_seconds` is fine; the call scan keeps quoted-identifier content (`"pg_advisory_lock"(1)` is a call), the keyword scan blanks it. `pg_advisory_*` (a session lock survives layer 2's `ROLLBACK`; `packages/testing/src/db-integration.test.ts`), `pg_sleep*` (the one ban that holds on PGlite), `nextval`/`setval` (a consumed id is not rolled back).
|
|
39
|
+
|
|
36
40
|
The answer carries `guards` — the layers that actually engaged — plus `truncatedBy` and `bytes`.
|
|
37
41
|
A layer that could not engage (a managed Postgres that refuses `CREATE ROLE`) is **absent from
|
|
38
42
|
the list**, never assumed. Truncation is never silent.
|
|
@@ -79,7 +83,7 @@ strand a well-behaved client.
|
|
|
79
83
|
| A predicate audience sees the caller and nothing else | it is handed `McpCaller` — never the call arguments, so two calls with different inputs cannot answer differently. Must return the literal `true`; if it throws, the tool is hidden |
|
|
80
84
|
| `tools/list` is answered per caller | filtered on every call against the caller the transport resolved — one per HTTP request, one per stdio connection — never a static catalog |
|
|
81
85
|
| Gate order | visibility → scope → arguments → policy; the scope gate never waits on a policy run against attacker-supplied input |
|
|
82
|
-
| Every outcome is audited | one line per `tools/call`; hidden/scope/policy at `warn`, ok
|
|
86
|
+
| Every outcome is audited | one line per `tools/call`; hidden/scope/policy at `warn`, ok, invalid-args and unconfirmed at `info` — see `audit.ts`; the same decision reaches `onAudit` after the line. A tool that renders its OWN `isError` result may name the code it refused with (`McpToolResult.code`, audit-only, never on the wire) and is then classified by the same `outcomeForCode` a thrown error is — otherwise every self-rendered refusal lands in the `policy-denied` bucket a prober's name walk is alerted from |
|
|
83
87
|
| Audit lines carry no payload | tool, outcome, actor, code. Never arguments, never rows |
|
|
84
88
|
| No trusted-tool mode | there is no flag that skips policy evaluation |
|
|
85
89
|
|
|
@@ -110,7 +114,10 @@ export const mcp = defineAppMcp({
|
|
|
110
114
|
},
|
|
111
115
|
},
|
|
112
116
|
},
|
|
113
|
-
scopes: {
|
|
117
|
+
scopes: { // scope name → tool NAMES; given, it must cover EVERY tool
|
|
118
|
+
'admin:seats': ['seatReport'],
|
|
119
|
+
'posts:write': ['publishPost'], // …including each one include: 'exposed' projected
|
|
120
|
+
},
|
|
114
121
|
instructions: 'Admin console. seatReport({}) answers seat questions; writes are audited.',
|
|
115
122
|
resolveToken: (token) => sessions.resolveAgentToken(token),
|
|
116
123
|
});
|
|
@@ -121,7 +128,7 @@ export const mcp = defineAppMcp({
|
|
|
121
128
|
has no `routes:` key and never had one; this page said `routes: [mcp.route]` while nothing read it,
|
|
122
129
|
and `POST /mcp` answered `X_ROUTE_NOT_FOUND` in every app ever scaffolded. Now: `apps/<app>/mcp.ts`
|
|
123
130
|
exports `mcp` (the value `defineAppMcp` returns), and both boots — `x dev` and `runRole` — mount
|
|
124
|
-
`POST
|
|
131
|
+
`POST <defineAppMcp path>` (default `/mcp`) → `mcp.route.handle(request)` when `config.ai.mcp.expose` is `true`,
|
|
125
132
|
which is the **default**, and say `app mcp mounted` in the boot log (`x dev` prints `mcp POST /mcp`
|
|
126
133
|
in its summary). The http pipeline does not pre-judge the route (`auth: 'public'`,
|
|
127
134
|
`enforcedBy: 'handler'`): `mcp.route.handle` reads `Authorization: Bearer` through `resolveToken`
|
|
@@ -131,9 +138,8 @@ to mount — no file exports `mcp`, or the export was built without `resolveToke
|
|
|
131
138
|
and says nothing.
|
|
132
139
|
|
|
133
140
|
**Several endpoints, one per population (Unreleased).** `mcp` may be a non-empty array of
|
|
134
|
-
`defineAppMcp` values: endpoint
|
|
135
|
-
document
|
|
136
|
-
metadata document. Each is its own server — catalog, instructions, groups, scopes, prompts. Two on
|
|
141
|
+
`defineAppMcp` values: every endpoint mounts at its own `defineAppMcp({ path })`; endpoint #0 owns
|
|
142
|
+
the root well-known document, and every other serves only its path-inserted metadata document. Each is its own server — catalog, instructions, groups, scopes, prompts. Two on
|
|
137
143
|
one route throws `X_MCP_PATH_DUPLICATE` at boot. `McpPrompt.visibleTo` hides a prompt from
|
|
138
144
|
`prompts/list` and `prompts/get` with the tool/resource `McpVisibility` semantics.
|
|
139
145
|
|
|
@@ -165,16 +171,22 @@ An action that was never handed to `defineApi` has no export name, and is
|
|
|
165
171
|
|
|
166
172
|
**The tool name is the export name, verbatim** — `publishPost`, never `publish_post`. This server
|
|
167
173
|
answers `tools/call` for that name and no other, so every surface that PUBLISHES a name has to
|
|
168
|
-
publish the same one: `
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
publish the same one: `x-ultimate.mcpTool` in `openapi.json` and `ActionDescriptor.mcp.tool`. The
|
|
175
|
+
projection reads `primitive.name` and nothing else — a hand-built `ProjectablePrimitive` names its
|
|
176
|
+
tool with its own `name`; the `mcp.name` override, which no `action()` or `query()` could set, is
|
|
177
|
+
gone (`As of 25.0.0`). The action publishers snake_cased the name `As of 2026-08`, so an agent
|
|
178
|
+
reading the spec called a tool the catalog never contained and got ToolNotFound.
|
|
179
|
+
|
|
180
|
+
**One projection: `toolFrom`** (`As of 25.0.0`). It takes a real action or query (or a
|
|
181
|
+
`ProjectablePrimitive`) and answers the tool this server serves —
|
|
182
|
+
`toolListEntry(toolFrom(publishPost))` is, field for field, its `tools/list` entry. Reach for
|
|
183
|
+
it where `publishPost.tool()` / `liveFeed.tool()` were: those were a second projection, built a tier
|
|
184
|
+
below this one, and they disagreed with the served tool (description fallback, the `idempotencyKey`
|
|
185
|
+
argument, annotations). `toolFromQuery` was this function under another name and is gone; ask
|
|
186
|
+
`isMcpExposed(primitive.mcp)` from `@ultimat3/core` where `isExposed` was.
|
|
175
187
|
`src/cross-surface.test.ts` is what makes a fourth spelling a failing test rather than a note.
|
|
176
188
|
|
|
177
|
-
A hand-written tool's `policy` is a permission, evaluated through the same `
|
|
189
|
+
A hand-written tool's `policy` is a permission, evaluated through the same `guardAction()` an
|
|
178
190
|
HTTP request goes through, so a tool cannot acquire a second authz path. A tool without one
|
|
179
191
|
is `X_MCP_TOOL_UNSAFE` at boot, and an unmarked tool is metered as a write.
|
|
180
192
|
|
|
@@ -186,6 +198,13 @@ beside the action, because a scope is a capability of the CONNECTION's token —
|
|
|
186
198
|
stays the only rule that reads the input. A name this server does not project is
|
|
187
199
|
`X_MCP_SCOPE_UNKNOWN` at boot; one tool claimed by two scopes is `X_MCP_SCOPE_CONFLICT`.
|
|
188
200
|
|
|
201
|
+
**Given at all, `scopes:` must cover every tool** — `X_MCP_SCOPE_UNCOVERED` at boot names each one
|
|
202
|
+
no scope lists, `As of 2026-10`. The registry gates a tool only when it carries a scope, so an
|
|
203
|
+
unlisted tool answered every token, while `bearerMount` over the same map serves an unlisted
|
|
204
|
+
primitive to nobody; one map now exposes one set on both doors. A ready `McpTool` that arrives with
|
|
205
|
+
its own `scope` is covered by it. With `include: 'exposed'`, every primitive that opts in later
|
|
206
|
+
needs a line here too, or the next boot says which.
|
|
207
|
+
|
|
189
208
|
### A constant surface: `surface: 'meta'`
|
|
190
209
|
|
|
191
210
|
A large or sparsely used catalog blows an agent's context one tool per primitive. `surface: 'meta'`
|
|
@@ -209,7 +228,7 @@ defineAppMcp({
|
|
|
209
228
|
|
|
210
229
|
| Tool | Answers |
|
|
211
230
|
|---|---|
|
|
212
|
-
| `list_resources` | PLAIN TEXT (`As of 22.10`; JSON until then): one line per resource, and under it one line per action — ` previewPayoutBatch (action; confirms
|
|
231
|
+
| `list_resources` | PLAIN TEXT (`As of 22.10`; JSON until then): one line per resource, and under it one line per action — ` previewPayoutBatch (action; confirms) {batchId: string, note?: string} — <description>`. Compact by rule, never by a character cut (`As of 2026-10`, #590): a scope every action of a resource shares — or every action of one kind, `query scope payouts:read; action scope payouts:write` — is said once on the resource line, and stays on each action otherwise; only `(action)` is tagged, untagged is a read-only query (from `destructive`, never the name); `oneLineParams` names every required field, then optional ones up to 4 fields / 100 characters, then `…`, and cuts an enum after 8 literals with `\|…`. The same catalog as data: `server.catalog(caller)` (`MetaResource[]`, per-action `scope` intact, `undefined` for a flat caller); the renderer is `renderCatalog` |
|
|
213
232
|
| `describe_resource` | `{ resources: string[] }` (batched) → each action's full `inputSchema` |
|
|
214
233
|
| `manage_resource` | `{ resource, action, params }` → the flat tool's answer, byte for byte |
|
|
215
234
|
|
|
@@ -226,7 +245,9 @@ meta tool name taken by an app tool (`X_MCP_TOOL_DUPLICATE`).
|
|
|
226
245
|
maxLimit: 100 } }`. Keys are FLAT — `status_eq`, `createdAt_gt`, `sort: '-createdAt'`, `fields`,
|
|
227
246
|
`cursor` (opaque keyset), `limit` — because a query's input travels as a query string; the query's
|
|
228
247
|
own `input` declares them and implements them. `describe_resource` publishes the whitelist and
|
|
229
|
-
`manage_resource`
|
|
248
|
+
`manage_resource` admits the whitelist plus the keys the input `required`s and refuses anything
|
|
249
|
+
else, an optional input key the whitelist left out included (an `isError` result carrying
|
|
250
|
+
`X_INPUT_INVALID`), before the query runs; a
|
|
230
251
|
whitelisted key the input does not declare is `X_MCP_LIST_PARAMS_INVALID` at boot.
|
|
231
252
|
|
|
232
253
|
### What a client is told beyond the schema
|
|
@@ -236,11 +257,11 @@ whitelisted key the input does not declare is `X_MCP_LIST_PARAMS_INVALID` at boo
|
|
|
236
257
|
|
|
237
258
|
| Field | Source | Default |
|
|
238
259
|
|---|---|---|
|
|
239
|
-
| `initialize.instructions` | `defineAppMcp({ instructions })` / `
|
|
260
|
+
| `initialize.instructions` | `defineAppMcp({ instructions })` / `mcpServer({ instructions })` — a string, or `(caller) => string \| undefined` | none; a function that throws or answers blank sends none |
|
|
240
261
|
| `title` | `mcp: { title }` · hand-written `title` | none |
|
|
241
262
|
| `annotations` | `deriveAnnotations(primitive)`, then `mcp: { annotations }` key by key | query `{ readOnlyHint: true }`; action `{ readOnlyHint: false, destructiveHint: true, idempotentHint: <idempotent> }`; `openWorldHint` only when declared |
|
|
242
|
-
| `outputSchema` | an action's `output` with an object root; a query's `rows` as `{ rows: [<row>] }` — `
|
|
243
|
-
| `structuredContent` | `structuredResult(value, wrap?)`: the serialized answer read back, beside the text block | absent |
|
|
263
|
+
| `outputSchema` | an action's `output` with an object root; a query's `rows` as `{ rows: [<row>] }`, or as the row itself for a `single: true` read (which answers one row or `X_NOT_FOUND`, as its route does) — `toWireOutputSchema` (from `@ultimat3/schema`) / `toRowsOutputSchema`, structure only (no bounds, patterns or `additionalProperties`: a client refuses a result that misses its schema) | none |
|
|
264
|
+
| `structuredContent` | `structuredResult(value, wrap?)`: the serialized answer read back, beside the text block — a list query's rows under `rows`, a `single: true` query's row unwrapped | absent |
|
|
244
265
|
|
|
245
266
|
A tool's text is **compact JSON** (`jsonResult`; the 2-space form until 22.10). The meta tools
|
|
246
267
|
carry their own hints: `list_resources` and `describe_resource` read-only, `manage_resource` a
|
|
@@ -254,7 +275,7 @@ surface over its ceiling. Put the derivation beside the number.
|
|
|
254
275
|
**Whose fix.** `errorAudience: 'caller'` (`defineAppMcp`'s default) renders an error's
|
|
255
276
|
`callerFix` — `X_FORBIDDEN`: ask the account owner for the permission; `X_MCP_SCOPE_DENIED`: ask for
|
|
256
277
|
a token with the scope; `X_INPUT_INVALID`: correct the named fields — where `fix` names something
|
|
257
|
-
only the app's developer can run. `'developer'` (`
|
|
278
|
+
only the app's developer can run. `'developer'` (`mcpServer`'s default, the dev server's)
|
|
258
279
|
keeps `fix`. An error whose `docs` is not the framework's one Error-Codes page — an app's
|
|
259
280
|
`docs://recipes/...` — renders a fourth `docs:` line. Byte-identical to
|
|
260
281
|
`UltimateError.format({ audience, docs })`.
|
|
@@ -263,7 +284,7 @@ keeps `fix`. An error whose `docs` is not the framework's one Error-Codes page
|
|
|
263
284
|
|
|
264
285
|
| Transport | Entry | Auth |
|
|
265
286
|
|---|---|---|
|
|
266
|
-
| HTTP | `mcpHttpRoute({ server, resolveToken })` → `POST /mcp` | `Authorization: Bearer <token>` → `Actor { kind: 'agent' }` |
|
|
287
|
+
| HTTP | `mcpHttpRoute({ server, resolveToken })` → `POST /mcp` — `resolveToken(token, facts)` | `Authorization: Bearer <token>` → `Actor { kind: 'agent' }` |
|
|
267
288
|
| stdio | `serveStdio({ server, caller })` | none — the peer already owns the shell |
|
|
268
289
|
|
|
269
290
|
The HTTP transport exports a route *descriptor*, not a mounted handler: a host owns the lifecycle,
|
|
@@ -290,9 +311,58 @@ did not.
|
|
|
290
311
|
|---|---|---|
|
|
291
312
|
| the body cap | `mcpHttpRoute({ bodyLimitBytes })` · `defineAppMcp({ bodyLimitBytes })` | `DEFAULT_MCP_BODY_LIMIT_BYTES`, 1 MiB |
|
|
292
313
|
| the numbers | `mcpHttpRoute({ rateLimits })` · `defineAppMcp({ rateLimits })` | `MCP_RATE_LIMITS` |
|
|
314
|
+
| failed authentications per address | `rateLimits: { unauthenticated }`, keyed on `handle(request, { address })` | `MCP_UNAUTHENTICATED_LIMIT`, 20 / minute; past it the address is `429` before `resolveToken` runs. No `address`, not metered here |
|
|
293
315
|
| where they are counted | `mcpHttpRoute({ rateLimitStore })` · `defineAppMcp({ rateLimitStore })` | a per-**process** memory store — N replicas behind one URL each enforce the full allowance, so a fleet passes `postgresRateLimitStore({ executor })` |
|
|
294
316
|
| OAuth discovery | `mcpHttpRoute({ oauth })` · `defineAppMcp({ oauth })` | absent: the 401 is `Bearer realm="ultimate-mcp"` |
|
|
295
317
|
|
|
318
|
+
### What `resolveToken` is told: `McpRequestFacts`
|
|
319
|
+
|
|
320
|
+
`resolveToken(token, facts)` (`As of 2026-10`) — a second argument a one-argument resolver ignores.
|
|
321
|
+
Frozen, typed, never the raw `Headers`, so a token can be bound to a network or refused from a
|
|
322
|
+
browser origin without the resolver parsing a header it cannot trust.
|
|
323
|
+
|
|
324
|
+
| Fact | Source | Absent |
|
|
325
|
+
|---|---|---|
|
|
326
|
+
| `address` | `handle(request, { address })` — what the HOST resolved (`ctx.ip`: a declared proxy's `x-forwarded-for` per `trustedProxyHops`, else the socket). Never read from a header here | `null` |
|
|
327
|
+
| `userAgent` | `User-Agent`, verbatim — a hint, never an identity | `null` |
|
|
328
|
+
| `origin` | the `Origin` request header; `'null'` (the string) is an opaque origin, not an absent one | `null` |
|
|
329
|
+
| `path` | the URL path, no query string | — |
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
import type { McpRequestFacts, ResolvedToken } from '@ultimat3/mcp';
|
|
333
|
+
import { defineAppMcp } from '@ultimat3/mcp';
|
|
334
|
+
|
|
335
|
+
declare const tokens: {
|
|
336
|
+
resolve(token: string, where: { network: string | null }): Promise<ResolvedToken | null>;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export const mcp = defineAppMcp({
|
|
340
|
+
include: 'exposed',
|
|
341
|
+
resolveToken: async (token: string, facts: McpRequestFacts) => {
|
|
342
|
+
if (facts.origin !== null) return null; // agents are not browsers — the same 401 as a bad token
|
|
343
|
+
return tokens.resolve(token, { network: facts.address });
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### `onAudit`: the gate's decisions, as data
|
|
349
|
+
|
|
350
|
+
`mcpServer({ onAudit })` · `defineAppMcp({ onAudit })` — the route reads its server's. One
|
|
351
|
+
audit PATH with a second destination: the `mcp.*` log line is written, then the same decision is
|
|
352
|
+
handed on. What an action or query DID still reaches core's `setAuditSink` (`surface: 'mcp'`);
|
|
353
|
+
`onAudit` carries what no `AuditRecord` can — the refusals before anything ran.
|
|
354
|
+
|
|
355
|
+
| `event.kind` | When | Carries |
|
|
356
|
+
|---|---|---|
|
|
357
|
+
| `tool-call` | every `tools/call` outcome (`ok`, `hidden`, `scope-denied`, `policy-denied`, `invalid-args`, `unconfirmed`, `failed`) | `tool`, `outcome`, `caller`, `scope?`, `code?` |
|
|
358
|
+
| `resource-read` | every `resources/read` outcome | `uri`, `outcome`, `caller`, `scope?`, `code?` |
|
|
359
|
+
| `auth-refused` | a missing or rejected token, a non-agent actor, an address past its failure allowance — also logged `mcp.auth.<reason>` at `warn` | `reason`, `status` (401/403/429), `facts` |
|
|
360
|
+
|
|
361
|
+
Every event has `at` from the server's `clock`. Never the token, never the arguments. The hook is
|
|
362
|
+
not awaited and cannot change an answer: one that throws or rejects is logged once as
|
|
363
|
+
`mcp.audit-hook.failed` at `error` (its `code` when it has one, never its message). `tools/list` and
|
|
364
|
+
`resources/list` stay silent, as in the log.
|
|
365
|
+
|
|
296
366
|
### OAuth discovery (RFC 9728)
|
|
297
367
|
|
|
298
368
|
`defineAppMcp({ oauth: { authorizationServers: ['https://www.example.com'] } })` (`As of 22.6.0`;
|
|
@@ -305,6 +375,50 @@ PUBLIC origin (`handle(request, { origin })`, which the boot fills from `ctx.htt
|
|
|
305
375
|
path; `scopes_supported` to the `scopes` map's keys. Issuers must be https (http on loopback):
|
|
306
376
|
`X_MCP_OAUTH_INVALID`. The authorization server is the app's own routes.
|
|
307
377
|
|
|
378
|
+
## Human confirmation: `mcpConfirmations`
|
|
379
|
+
|
|
380
|
+
A factory over `action`, never a ninth primitive: what it returns IS the decision action
|
|
381
|
+
(`{ id, decision: 'approve' | 'reject' }`), registered like any other — route, OpenAPI, typed client,
|
|
382
|
+
`audit: true` if asked — and `defineAppMcp({ confirmations })` gates the named tools with it.
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
386
|
+
import { defineAppMcp, mcpConfirmations, postgresConfirmationStore } from '@ultimat3/mcp';
|
|
387
|
+
|
|
388
|
+
declare const executor: PgExecutor;
|
|
389
|
+
|
|
390
|
+
// apps/web/app/orders/actions/confirm-refunds.ts — register it like any action
|
|
391
|
+
export const confirmRefunds = mcpConfirmations({
|
|
392
|
+
tools: ['refundOrder'],
|
|
393
|
+
store: postgresConfirmationStore({ executor }), // memoryConfirmationStore() in a test
|
|
394
|
+
permission: 'order:refund',
|
|
395
|
+
ttlMs: 600_000, // DEFAULT_MCP_CONFIRMATION_TTL_MS
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// apps/web/app/mcp.ts
|
|
399
|
+
export const mcp = defineAppMcp({ include: 'exposed', confirmations: confirmRefunds });
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
| Step | What happens |
|
|
403
|
+
|---|---|
|
|
404
|
+
| agent calls `refundOrder` | after visibility → scope → args → the tool's caller-only `admit`, a pending row opens — actor, org, tool, `keyedFingerprint` (HMAC-SHA-256 under the signing secret) of the validated arguments, expiry — and the call answers `X_MCP_CONFIRMATION_PENDING` naming the id. Nothing ran. Repeating it answers the same id |
|
|
405
|
+
| a person views it | `confirmRefunds({ id, decision: 'view' })` answers the request exactly as the agent sent it — `{ tool, status, expiresAt, arguments }`, the arguments opened from their seal. Changes nothing |
|
|
406
|
+
| a person decides | `confirmRefunds({ id, decision: 'approve', arguments })` with the `arguments` the view returned, or `decision: 'reject'`, over any surface the action projects to. An approval whose arguments' keyed digest is not the row's — none, different, or a row keyed before a secret rotation — is `X_MCP_CONFIRMATION_ARGUMENTS_MISMATCH`, so a swap between the view and the approval decides nothing. Decided once (`X_MCP_CONFIRMATION_DECIDED`), never after expiry (`X_MCP_CONFIRMATION_EXPIRED`) |
|
|
407
|
+
| who may view and decide | policy `can(permission, check)`. An agent never may, whatever it holds (`X_FORBIDDEN`). The default `check` is the asking org: `row !== null && row.orgId === (actor.orgId ?? null)` — another tenant's row and an unknown id are one `X_FORBIDDEN`. Crossing tenants is an explicit `check`; with one that admits a missing row, an unknown id is `X_MCP_CONFIRMATION_UNKNOWN` |
|
|
408
|
+
| agent repeats the SAME call | approved: it runs once, through the tool's own `handle` — the action's full policy still decides it. Rejected: `X_MCP_CONFIRMATION_REJECTED`, audited as a denial. Past expiry: `X_MCP_CONFIRMATION_EXPIRED`. Each outcome is delivered once; the next identical call asks again |
|
|
409
|
+
|
|
410
|
+
Bound to the asking actor, the tool and the input digest: other arguments, or another agent, ride
|
|
411
|
+
no one's approval. The digest is keyed because the row stores it — an unkeyed hash of `{ accountNumber }` is
|
|
412
|
+
brute-forced from a database read. The open row is found by equality on it, key id included, so
|
|
413
|
+
after a signing-secret rotation the same call opens a FRESH confirmation: never a mismatch error,
|
|
414
|
+
never the old approval. The arguments are stored SEALED (`seal()`, AES-256-GCM under the app's one
|
|
415
|
+
master key, purpose `MCP_CONFIRMATION_ARGUMENTS_PURPOSE`), never plaintext at rest — the person
|
|
416
|
+
deciding must see what they approve, or a prompt-injected agent describes "$5 to order 17" and sends
|
|
417
|
+
another. `sealKeys` points at the key in a test. `SQL_MCP_CONFIRMATIONS_TABLE` is the store's DDL, imported from `@ultimat3/mcp/schema` (its one path — 25.0.0 took it off the barrel); `store.purge(before)` from a
|
|
418
|
+
`task` bounds the table. A gated name the server does not project — or `tools: []` — is
|
|
419
|
+
`X_MCP_CONFIRMATION_TOOL_UNKNOWN` at boot. The gated tool carries `confirms: true`, which the meta
|
|
420
|
+
catalog shows as `(confirms)`.
|
|
421
|
+
|
|
308
422
|
## Resources
|
|
309
423
|
|
|
310
424
|
| URI | Contents |
|
|
@@ -357,6 +471,7 @@ is `X_MCP_IDEMPOTENCY_KEY_SHADOWED` at boot.
|
|
|
357
471
|
| `X_MCP_SCOPE_DENIED` | visible, but the connection's token lacks the scope |
|
|
358
472
|
| `X_MCP_SCOPE_UNKNOWN` | `defineAppMcp`'s `scopes:` names a tool this server does not project |
|
|
359
473
|
| `X_MCP_SCOPE_CONFLICT` | two scopes in `defineAppMcp`'s `scopes:` claim one tool |
|
|
474
|
+
| `X_MCP_SCOPE_UNCOVERED` | `defineAppMcp` was given `scopes:` and a projected tool is under none of them |
|
|
360
475
|
| `X_INPUT_INVALID` | arguments failed the published `inputSchema` — an `isError` result since 22.10 |
|
|
361
476
|
| `X_MCP_ARGS_INVALID` | no longer raised on the wire (22.10); `McpArgsInvalidError` stays exported |
|
|
362
477
|
| `X_MCP_SURFACE_OVER_BUDGET` | `assertMcpSurfaceBudget` measured a surface over its declared ceiling |
|
|
@@ -368,6 +483,14 @@ is `X_MCP_IDEMPOTENCY_KEY_SHADOWED` at boot.
|
|
|
368
483
|
| `X_MCP_GROUP_UNKNOWN` · `X_MCP_GROUP_CONFLICT` | `groups:` names a tool not projected · lists one tool twice |
|
|
369
484
|
| `X_MCP_SURFACE_INVALID` | `surface` and `groups` disagree |
|
|
370
485
|
| `X_MCP_LIST_PARAMS_INVALID` | `listParams` whitelists a key the tool's input does not declare |
|
|
486
|
+
| `X_MCP_CONFIRMATION_PENDING` | a gated call waits for a person: the cause names the confirmation id |
|
|
487
|
+
| `X_MCP_CONFIRMATION_EXPIRED` | the confirmation lapsed before the call came back, or before a person decided it |
|
|
488
|
+
| `X_MCP_CONFIRMATION_REJECTED` | a person rejected this exact call |
|
|
489
|
+
| `X_MCP_CONFIRMATION_DECIDED` | approve/reject on a confirmation already decided |
|
|
490
|
+
| `X_MCP_CONFIRMATION_UNKNOWN` | no confirmation has that id |
|
|
491
|
+
| `X_MCP_CONFIRMATION_TOOL_UNKNOWN` | `mcpConfirmations({ tools })` names a tool this server does not project, or none |
|
|
492
|
+
| `X_MCP_CONFIRMATION_CONTESTED` | identical concurrent calls kept racing one confirmation; retry |
|
|
493
|
+
| `X_MCP_CONFIRMATION_ARGUMENTS_MISMATCH` | an approval did not carry the arguments the agent sent (missing, different, or keyed before a rotation) |
|
|
371
494
|
| `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` |
|
|
372
495
|
|
|
373
496
|
### Error classes
|
|
@@ -381,6 +504,14 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
381
504
|
| `McpArgsInvalidError` | `X_MCP_ARGS_INVALID` | `src/errors.ts` |
|
|
382
505
|
| `McpBodyTooLargeError` | `X_MCP_BODY_TOO_LARGE` | `src/errors.ts` |
|
|
383
506
|
| `McpIdempotencyKeyShadowedError` | `X_MCP_IDEMPOTENCY_KEY_SHADOWED` | `src/errors.ts` |
|
|
507
|
+
| `McpConfirmationArgumentsMismatchError` | `X_MCP_CONFIRMATION_ARGUMENTS_MISMATCH` | `src/confirmation-errors.ts` |
|
|
508
|
+
| `McpConfirmationContestedError` | `X_MCP_CONFIRMATION_CONTESTED` | `src/confirmation-errors.ts` |
|
|
509
|
+
| `McpConfirmationDecidedError` | `X_MCP_CONFIRMATION_DECIDED` | `src/confirmation-errors.ts` |
|
|
510
|
+
| `McpConfirmationExpiredError` | `X_MCP_CONFIRMATION_EXPIRED` | `src/confirmation-errors.ts` |
|
|
511
|
+
| `McpConfirmationPendingError` | `X_MCP_CONFIRMATION_PENDING` | `src/confirmation-errors.ts` |
|
|
512
|
+
| `McpConfirmationRejectedError` | `X_MCP_CONFIRMATION_REJECTED` | `src/confirmation-errors.ts` |
|
|
513
|
+
| `McpConfirmationToolUnknownError` | `X_MCP_CONFIRMATION_TOOL_UNKNOWN` | `src/confirmation-errors.ts` |
|
|
514
|
+
| `McpConfirmationUnknownError` | `X_MCP_CONFIRMATION_UNKNOWN` | `src/confirmation-errors.ts` |
|
|
384
515
|
| `McpGroupConflictError` | `X_MCP_GROUP_CONFLICT` | `src/meta-errors.ts` |
|
|
385
516
|
| `McpGroupUnknownError` | `X_MCP_GROUP_UNKNOWN` | `src/meta-errors.ts` |
|
|
386
517
|
| `McpListParamsInvalidError` | `X_MCP_LIST_PARAMS_INVALID` | `src/meta-errors.ts` |
|
|
@@ -392,6 +523,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
392
523
|
| `McpResourceDuplicateError` | `X_MCP_RESOURCE_DUPLICATE` | `src/errors.ts` |
|
|
393
524
|
| `McpScopeConflictError` | `X_MCP_SCOPE_CONFLICT` | `src/errors.ts` |
|
|
394
525
|
| `McpScopeDeniedError` | `X_MCP_SCOPE_DENIED` | `src/errors.ts` |
|
|
526
|
+
| `McpScopeUncoveredError` | `X_MCP_SCOPE_UNCOVERED` | `src/errors.ts` |
|
|
395
527
|
| `McpScopeUnknownError` | `X_MCP_SCOPE_UNKNOWN` | `src/errors.ts` |
|
|
396
528
|
| `McpSurfaceOverBudgetError` | `X_MCP_SURFACE_OVER_BUDGET` | `src/errors.ts` |
|
|
397
529
|
| `McpToolDuplicateError` | `X_MCP_TOOL_DUPLICATE` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.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",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"provenance": true
|
|
15
15
|
},
|
|
16
16
|
"exports": {
|
|
17
|
-
".": "./src/index.ts"
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./schema": "./src/confirmation-schema.ts"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"src",
|
|
@@ -25,20 +26,20 @@
|
|
|
25
26
|
"LICENSE"
|
|
26
27
|
],
|
|
27
28
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
29
|
+
"bun": ">=1.4.2"
|
|
29
30
|
},
|
|
30
31
|
"scripts": {
|
|
31
32
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
33
|
"test": "bun test"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
|
-
"@ultimat3/action": "
|
|
36
|
-
"@ultimat3/core": "
|
|
37
|
-
"@ultimat3/entity": "
|
|
38
|
-
"@ultimat3/http": "
|
|
39
|
-
"@ultimat3/jobs": "
|
|
40
|
-
"@ultimat3/policy": "
|
|
41
|
-
"@ultimat3/query": "
|
|
42
|
-
"@ultimat3/schema": "
|
|
36
|
+
"@ultimat3/action": "25.0.0",
|
|
37
|
+
"@ultimat3/core": "25.0.0",
|
|
38
|
+
"@ultimat3/entity": "25.0.0",
|
|
39
|
+
"@ultimat3/http": "25.0.0",
|
|
40
|
+
"@ultimat3/jobs": "25.0.0",
|
|
41
|
+
"@ultimat3/policy": "25.0.0",
|
|
42
|
+
"@ultimat3/query": "25.0.0",
|
|
43
|
+
"@ultimat3/schema": "25.0.0"
|
|
43
44
|
}
|
|
44
45
|
}
|
package/src/app-tool.ts
CHANGED
|
@@ -5,22 +5,21 @@
|
|
|
5
5
|
// registry runs, so this file is the mapping to `ProjectablePrimitive`, which `toolsFrom` then
|
|
6
6
|
// projects exactly as it projects an action. One projection, not two.
|
|
7
7
|
//
|
|
8
|
-
// The load-bearing line is `
|
|
8
|
+
// The load-bearing line is `guardAction(...)` in `run` below: it is the SAME function `invoke` calls
|
|
9
9
|
// for an HTTP request, reached through the same `@ultimat3/action` policy gate. A hand-written
|
|
10
10
|
// tool therefore has no authz of its own — it borrows the action tier's, so a rule change cannot
|
|
11
11
|
// apply to routes and miss tools.
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { guardAction, guardActionBeforeInput, InputInvalidError } from '@ultimat3/action';
|
|
14
14
|
import type { Ctx } from '@ultimat3/core';
|
|
15
|
-
import { useContext } from '@ultimat3/core';
|
|
15
|
+
import { actorOf, useContext } from '@ultimat3/core';
|
|
16
16
|
import type { KnownPermission } from '@ultimat3/policy';
|
|
17
17
|
import { can } from '@ultimat3/policy';
|
|
18
18
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
19
|
-
import { formatIssues, toValidationIssues, validateAsync } from '@ultimat3/schema';
|
|
19
|
+
import { formatIssues, toValidationIssues, toWireSchema, validateAsync } from '@ultimat3/schema';
|
|
20
20
|
import { asCallerContext } from './caller-context';
|
|
21
21
|
import { McpToolUnsafeError } from './errors';
|
|
22
22
|
import type { ProjectablePrimitive } from './from-action';
|
|
23
|
-
import { toWireSchema } from './input-schema';
|
|
24
23
|
import type { McpRole, McpToolAnnotations } from './registry';
|
|
25
24
|
|
|
26
25
|
export interface AppToolArgs<TInput extends StandardSchemaV1> {
|
|
@@ -106,7 +105,7 @@ export function appToolPrimitive(name: string, def: AnyAppToolDefinition): Proje
|
|
|
106
105
|
admit: (actor) =>
|
|
107
106
|
asCallerContext(actor, () => {
|
|
108
107
|
const ctx = useContext();
|
|
109
|
-
|
|
108
|
+
guardActionBeforeInput(policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
|
|
110
109
|
}),
|
|
111
110
|
run: ({ input, actor }) =>
|
|
112
111
|
// The caller is the actor for the WHOLE call. A child context is how the framework
|
|
@@ -116,7 +115,7 @@ export function appToolPrimitive(name: string, def: AnyAppToolDefinition): Proje
|
|
|
116
115
|
const ctx = useContext();
|
|
117
116
|
// The policy's actor half first, as in `invoke`: a caller refused whatever they send is
|
|
118
117
|
// told X_FORBIDDEN, never the tool's argument schema.
|
|
119
|
-
|
|
118
|
+
guardActionBeforeInput(policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
|
|
120
119
|
// The AUTHORITATIVE parse, in the same slot `invoke` puts it: before the full policy,
|
|
121
120
|
// before the handler. A projected action re-parses inside `invoke`; a hand-written tool has no
|
|
122
121
|
// second parse, so `handle` was typed `InferOutput<TInput>` and handed whatever
|
|
@@ -124,7 +123,7 @@ export function appToolPrimitive(name: string, def: AnyAppToolDefinition): Proje
|
|
|
124
123
|
// arrived as any string at all. `InputInvalidError` because a tool argument is an action
|
|
125
124
|
// input by another name: one code, X_INPUT_INVALID, whichever surface the call came in on.
|
|
126
125
|
const parsed = await parseInput(def.input, input, name);
|
|
127
|
-
|
|
126
|
+
guardAction(policy, { actor: actorOf(ctx), input: parsed, ctx, action: name }, 'mcp');
|
|
128
127
|
return def.handle({ input: parsed, ctx });
|
|
129
128
|
}),
|
|
130
129
|
};
|