@ultimat3/mcp 24.0.0 → 25.1.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 +42 -44
- package/README.md +147 -18
- package/package.json +13 -11
- package/src/app-tool.ts +6 -6
- package/src/app-tools.ts +32 -8
- 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/dev-ui-interact.ts +5 -4
- package/src/dev-ui-tools.ts +3 -3
- package/src/errors-transport.ts +94 -0
- package/src/errors.ts +44 -102
- package/src/framework-error.ts +1 -1
- package/src/from-action.ts +28 -50
- package/src/index.ts +55 -17
- package/src/meta-call.ts +5 -4
- package/src/meta-surface.ts +87 -22
- package/src/mount-errors.ts +1 -1
- package/src/oauth-metadata.ts +1 -1
- package/src/projectable.ts +18 -34
- package/src/readonly-sql.ts +23 -25
- package/src/registry.ts +6 -5
- package/src/scopes.ts +15 -4
- package/src/server-voice.ts +1 -1
- package/src/server.ts +34 -33
- package/src/tool-thrown.ts +5 -3
- package/src/transport-http.ts +42 -18
- package/src/transport-stdio.ts +1 -1
- package/src/type-pins.ts +52 -0
- package/src/validate-args.ts +9 -0
- package/src/wire.ts +17 -0
package/CLAUDE.md
CHANGED
|
@@ -22,13 +22,15 @@ 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
|
|
33
|
+
| `scopes.ts` | the `scopes:` map — outcome 2's declaration surface and its three boot refusals |
|
|
32
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 |
|
|
33
35
|
| `query-limits.ts` | layer 4 of `db.query` — the row, byte and timeout ceilings, and what truncation reports |
|
|
34
36
|
| `meta-surface.ts` | `surface: 'meta'` catalog: `list_resources` / `describe_resource`, `groups:` boot checks; dispatch stays in `server.ts` |
|
|
@@ -83,8 +85,7 @@ import. The CLI wires it.
|
|
|
83
85
|
`Object.prototype` supplies a value for `constructor`, `toString`, `hasOwnProperty` and
|
|
84
86
|
`__proto__` on every plain object — so the index read answered "declared" for four names no
|
|
85
87
|
schema declares, and `validate-args.ts` accepted them past an `additionalProperties: false` that
|
|
86
|
-
forbids them and then dropped them.
|
|
87
|
-
`@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
|
|
88
89
|
lands on the result through `Object.defineProperty`, because `out[key] = v` for `__proto__` runs
|
|
89
90
|
the setter on `Object.prototype` and re-prototypes the record instead of adding a key.
|
|
90
91
|
- **The RESOURCE surface owes the same three outcomes as the tool surface.** `resources/list` and
|
|
@@ -109,11 +110,11 @@ import. The CLI wires it.
|
|
|
109
110
|
only drift from the action's own parse. `tools/list` published it and `validate-args.ts` ignored
|
|
110
111
|
it, so a tool declaring `t.uuid` accepted `"not-a-uuid"` with `ok: true`. `pattern` is the
|
|
111
112
|
opposite case and is kept: the rule travels with the schema. The narrowing is `@ultimat3/schema`'s
|
|
112
|
-
`toWireSchema` (tier 0, so
|
|
113
|
+
`toWireSchema` (tier 0, so OpenAPI-adjacent readers reach the same document) and the
|
|
113
114
|
type is its `WireJsonSchema`, which `wire.ts` names `JsonSchema`; `validate-args-subset.test.ts`
|
|
114
115
|
asserts every published keyword is one this server enforces, at any depth.
|
|
115
116
|
- **A hand-written app tool parses its own input**, in the slot `invoke` puts it: parse, then
|
|
116
|
-
`
|
|
117
|
+
`guardAction()`, then `handle`. A projected action re-parses inside `invoke`; `app-tool.ts` had no second
|
|
117
118
|
parse, so `handle` was handed whatever the wire subset let through — typed `InferOutput<TInput>`,
|
|
118
119
|
past the policy. One code either way, `X_INPUT_INVALID`, built from `@ultimat3/action`'s own
|
|
119
120
|
`InputInvalidError`.
|
|
@@ -141,15 +142,15 @@ import. The CLI wires it.
|
|
|
141
142
|
and `tools/list` are silent by design (answered pre-filtered). `resource-security.test.ts` reads
|
|
142
143
|
both streams: core's logger puts `error` on stderr.
|
|
143
144
|
- **A tool that renders its OWN `isError` result may NAME the code it refused with**
|
|
144
|
-
(`McpToolResult.code
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
`
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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`.
|
|
153
154
|
- **A `--` comment ends at the first CR *or* LF — Postgres' own boundary set** (`readonly-sql.ts`,
|
|
154
155
|
`endOfLineComment`). A scanner stopping at LF alone read `select 1;--\rupdate …` as one read.
|
|
155
156
|
- `security.test.ts` and `app-security.test.ts` are the executable contract for all of the
|
|
@@ -157,10 +158,9 @@ import. The CLI wires it.
|
|
|
157
158
|
actually declares (`defineAppMcp` projecting real actions and queries). Extend them, never
|
|
158
159
|
weaken them. A gate can only refuse what a declaration can reach, so a new gate needs a test
|
|
159
160
|
in BOTH: the registry half passes while the declaration surface silently drops the field.
|
|
160
|
-
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
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.
|
|
164
164
|
- Exposure is declared at the primitive, never in `defineAppMcp`. A primitive NAMED in
|
|
165
165
|
`actions:`/`queries:` without `mcp: { expose: true }` is `X_MCP_TOOL_UNDECLARED` at boot —
|
|
166
166
|
a written-out list is a request, so filtering it would ship a catalog missing a tool its
|
|
@@ -178,23 +178,24 @@ import. The CLI wires it.
|
|
|
178
178
|
query. Writing a primitive out NAMES a tool; it never re-shapes or re-runs one. An action
|
|
179
179
|
with no export name is `X_ACTION_UNREGISTERED` rather than a tool called `''`, which no
|
|
180
180
|
`tools/call` and no `scopes:` entry could ever address.
|
|
181
|
-
- **This package NAMES a tool,
|
|
182
|
-
`
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
186
187
|
- Every boot-time refusal in `defineAppMcp` is an `UltimateError` with a code, never a bare
|
|
187
188
|
throw: `X_MCP_TOOL_UNDECLARED`, `X_MCP_TOOL_UNSAFE`, `X_MCP_TOOL_DUPLICATE`,
|
|
188
|
-
`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
|
|
189
190
|
that needs `{ code, cause, fix }`.
|
|
190
|
-
- `scopes:` is refused at boot
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
- 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.
|
|
195
196
|
`defineAppMcp`'s `scopes:` may attach one afterward, as a capability of the CONNECTION; that
|
|
196
197
|
is not a second authz path, because the scope gate decides before the policy runs and never
|
|
197
|
-
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
|
|
198
199
|
`@ultimat3/action`, which is the one authz path that reads the input — never a second check
|
|
199
200
|
written for MCP.
|
|
200
201
|
- **A URI is taken once.** `ResourceRegistry.register` throws `X_MCP_RESOURCE_DUPLICATE` on a
|
|
@@ -221,22 +222,14 @@ import. The CLI wires it.
|
|
|
221
222
|
- `db.query` / `db.migrate` refuse structurally, in `readonly-sql.ts`, before the host runs
|
|
222
223
|
(`X_MCP_QUERY_REJECTED` / `X_MCP_NOT_BRANCH_DB` — one code each, because they want different
|
|
223
224
|
next commands).
|
|
224
|
-
- **Banned function families**
|
|
225
|
-
|
|
226
|
-
`
|
|
227
|
-
`pg_wal_replay_*`); replication (`pg_logical_*`, `pg_create_*`, `pg_drop_*`, `pg_replication_*` —
|
|
228
|
-
advancing a slot is a write no `ROLLBACK` undoes); `pg_file_*`; `txid_current` /
|
|
229
|
-
`pg_current_xact_id`; `pg_import_*`; SQL run from a string (`*_to_xml*`, `ts_stat`, `ts_rewrite`). `U&"…"` is
|
|
230
|
-
refused, never decoded; a keyword is a whole identifier (`set2` is a column). Catalog VIEWS
|
|
231
|
-
(`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).
|
|
232
228
|
- Banned SQL functions are matched as a **prefix of a CALLED function name** — add a family, never
|
|
233
|
-
a name (`pg_sleep_for` passed an exact `pg_sleep` ban)
|
|
234
|
-
column `pg_sleep_for_seconds` is fine; the call scan keeps quoted-identifier content
|
|
235
|
-
(`"pg_advisory_lock"(1)` is a call), the keyword scan blanks it. `pg_advisory_*` (a session lock
|
|
236
|
-
survives layer 2's `ROLLBACK`; `packages/testing/src/db-integration.test.ts`), `pg_sleep*` (the
|
|
237
|
-
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.
|
|
238
230
|
- `db.query` is defended four ways: a SELECT-only role and `BEGIN READ ONLY` in `@ultimat3/db`
|
|
239
|
-
(the CLI wires them — this package
|
|
231
|
+
(the CLI wires them — this package never opens a `db` connection; it imports one lexer rule,
|
|
232
|
+
`endOfBlockComment`, so a nested comment ends where Postgres ends it), the parse here, the caps here.
|
|
240
233
|
`limit` is a request, never a permission: `resolveQueryLimits` clamps it into a hard 1000.
|
|
241
234
|
- The caps run in the **tool**, not the host. A host that forgets them answers a million rows
|
|
242
235
|
into a model's context. `guards` names the layers that engaged; a layer that could not engage
|
|
@@ -258,6 +251,11 @@ import. The CLI wires it.
|
|
|
258
251
|
SAME rejected token, which is precisely the oracle the pre-parse 401 exists to remove. The parse
|
|
259
252
|
error still exists — it is what an authenticated agent gets. Failures spend a per-ADDRESS bucket
|
|
260
253
|
(`seen.address`), and an exhausted address is `429` BEFORE `resolveToken` — a valid guess too.
|
|
254
|
+
- **Confirmation order: visibility → scope → args → `admit` → confirmation → policy.** An approval
|
|
255
|
+
binds actor + tool + `keyedFingerprint(args)` (rotation ⇒ new row); `consume` (CAS) makes one approval run one
|
|
256
|
+
call; rejected/expired is told once, then the call asks again. Args sealed; `approve` carries
|
|
257
|
+
viewed args (digest must match); default `check`: same org. An agent never decides (in the
|
|
258
|
+
policy). Statuses < 500, or `forAudience` hides the id.
|
|
261
259
|
- **`transport-stdio.ts`'s default `write` is AWAITED** — fd 1 is a pipe, and an unawaited
|
|
262
260
|
`Bun.stdout.write` lost the tail of a 4 MB frame at exit. It is also the loop's only
|
|
263
261
|
back-pressure; only a child-process test can see it.
|
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
|
|
|
@@ -238,7 +257,7 @@ whitelisted key the input does not declare is `X_MCP_LIST_PARAMS_INVALID` at boo
|
|
|
238
257
|
|
|
239
258
|
| Field | Source | Default |
|
|
240
259
|
|---|---|---|
|
|
241
|
-
| `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 |
|
|
242
261
|
| `title` | `mcp: { title }` · hand-written `title` | none |
|
|
243
262
|
| `annotations` | `deriveAnnotations(primitive)`, then `mcp: { annotations }` key by key | query `{ readOnlyHint: true }`; action `{ readOnlyHint: false, destructiveHint: true, idempotentHint: <idempotent> }`; `openWorldHint` only when declared |
|
|
244
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 |
|
|
@@ -256,7 +275,7 @@ surface over its ceiling. Put the derivation beside the number.
|
|
|
256
275
|
**Whose fix.** `errorAudience: 'caller'` (`defineAppMcp`'s default) renders an error's
|
|
257
276
|
`callerFix` — `X_FORBIDDEN`: ask the account owner for the permission; `X_MCP_SCOPE_DENIED`: ask for
|
|
258
277
|
a token with the scope; `X_INPUT_INVALID`: correct the named fields — where `fix` names something
|
|
259
|
-
only the app's developer can run. `'developer'` (`
|
|
278
|
+
only the app's developer can run. `'developer'` (`mcpServer`'s default, the dev server's)
|
|
260
279
|
keeps `fix`. An error whose `docs` is not the framework's one Error-Codes page — an app's
|
|
261
280
|
`docs://recipes/...` — renders a fourth `docs:` line. Byte-identical to
|
|
262
281
|
`UltimateError.format({ audience, docs })`.
|
|
@@ -265,7 +284,7 @@ keeps `fix`. An error whose `docs` is not the framework's one Error-Codes page
|
|
|
265
284
|
|
|
266
285
|
| Transport | Entry | Auth |
|
|
267
286
|
|---|---|---|
|
|
268
|
-
| 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' }` |
|
|
269
288
|
| stdio | `serveStdio({ server, caller })` | none — the peer already owns the shell |
|
|
270
289
|
|
|
271
290
|
The HTTP transport exports a route *descriptor*, not a mounted handler: a host owns the lifecycle,
|
|
@@ -296,6 +315,54 @@ did not.
|
|
|
296
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 })` |
|
|
297
316
|
| OAuth discovery | `mcpHttpRoute({ oauth })` · `defineAppMcp({ oauth })` | absent: the 401 is `Bearer realm="ultimate-mcp"` |
|
|
298
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
|
+
|
|
299
366
|
### OAuth discovery (RFC 9728)
|
|
300
367
|
|
|
301
368
|
`defineAppMcp({ oauth: { authorizationServers: ['https://www.example.com'] } })` (`As of 22.6.0`;
|
|
@@ -308,6 +375,50 @@ PUBLIC origin (`handle(request, { origin })`, which the boot fills from `ctx.htt
|
|
|
308
375
|
path; `scopes_supported` to the `scopes` map's keys. Issuers must be https (http on loopback):
|
|
309
376
|
`X_MCP_OAUTH_INVALID`. The authorization server is the app's own routes.
|
|
310
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
|
+
|
|
311
422
|
## Resources
|
|
312
423
|
|
|
313
424
|
| URI | Contents |
|
|
@@ -360,6 +471,7 @@ is `X_MCP_IDEMPOTENCY_KEY_SHADOWED` at boot.
|
|
|
360
471
|
| `X_MCP_SCOPE_DENIED` | visible, but the connection's token lacks the scope |
|
|
361
472
|
| `X_MCP_SCOPE_UNKNOWN` | `defineAppMcp`'s `scopes:` names a tool this server does not project |
|
|
362
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 |
|
|
363
475
|
| `X_INPUT_INVALID` | arguments failed the published `inputSchema` — an `isError` result since 22.10 |
|
|
364
476
|
| `X_MCP_ARGS_INVALID` | no longer raised on the wire (22.10); `McpArgsInvalidError` stays exported |
|
|
365
477
|
| `X_MCP_SURFACE_OVER_BUDGET` | `assertMcpSurfaceBudget` measured a surface over its declared ceiling |
|
|
@@ -371,6 +483,14 @@ is `X_MCP_IDEMPOTENCY_KEY_SHADOWED` at boot.
|
|
|
371
483
|
| `X_MCP_GROUP_UNKNOWN` · `X_MCP_GROUP_CONFLICT` | `groups:` names a tool not projected · lists one tool twice |
|
|
372
484
|
| `X_MCP_SURFACE_INVALID` | `surface` and `groups` disagree |
|
|
373
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) |
|
|
374
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` |
|
|
375
495
|
|
|
376
496
|
### Error classes
|
|
@@ -384,6 +504,14 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
384
504
|
| `McpArgsInvalidError` | `X_MCP_ARGS_INVALID` | `src/errors.ts` |
|
|
385
505
|
| `McpBodyTooLargeError` | `X_MCP_BODY_TOO_LARGE` | `src/errors.ts` |
|
|
386
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` |
|
|
387
515
|
| `McpGroupConflictError` | `X_MCP_GROUP_CONFLICT` | `src/meta-errors.ts` |
|
|
388
516
|
| `McpGroupUnknownError` | `X_MCP_GROUP_UNKNOWN` | `src/meta-errors.ts` |
|
|
389
517
|
| `McpListParamsInvalidError` | `X_MCP_LIST_PARAMS_INVALID` | `src/meta-errors.ts` |
|
|
@@ -395,6 +523,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
395
523
|
| `McpResourceDuplicateError` | `X_MCP_RESOURCE_DUPLICATE` | `src/errors.ts` |
|
|
396
524
|
| `McpScopeConflictError` | `X_MCP_SCOPE_CONFLICT` | `src/errors.ts` |
|
|
397
525
|
| `McpScopeDeniedError` | `X_MCP_SCOPE_DENIED` | `src/errors.ts` |
|
|
526
|
+
| `McpScopeUncoveredError` | `X_MCP_SCOPE_UNCOVERED` | `src/errors.ts` |
|
|
398
527
|
| `McpScopeUnknownError` | `X_MCP_SCOPE_UNKNOWN` | `src/errors.ts` |
|
|
399
528
|
| `McpSurfaceOverBudgetError` | `X_MCP_SURFACE_OVER_BUDGET` | `src/errors.ts` |
|
|
400
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.1.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,21 @@
|
|
|
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/
|
|
38
|
-
"@ultimat3/
|
|
39
|
-
"@ultimat3/
|
|
40
|
-
"@ultimat3/
|
|
41
|
-
"@ultimat3/
|
|
42
|
-
"@ultimat3/
|
|
36
|
+
"@ultimat3/action": "25.1.0",
|
|
37
|
+
"@ultimat3/core": "25.1.0",
|
|
38
|
+
"@ultimat3/db": "25.1.0",
|
|
39
|
+
"@ultimat3/entity": "25.1.0",
|
|
40
|
+
"@ultimat3/http": "25.1.0",
|
|
41
|
+
"@ultimat3/jobs": "25.1.0",
|
|
42
|
+
"@ultimat3/policy": "25.1.0",
|
|
43
|
+
"@ultimat3/query": "25.1.0",
|
|
44
|
+
"@ultimat3/schema": "25.1.0"
|
|
43
45
|
}
|
|
44
46
|
}
|
package/src/app-tool.ts
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
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';
|
|
@@ -105,7 +105,7 @@ export function appToolPrimitive(name: string, def: AnyAppToolDefinition): Proje
|
|
|
105
105
|
admit: (actor) =>
|
|
106
106
|
asCallerContext(actor, () => {
|
|
107
107
|
const ctx = useContext();
|
|
108
|
-
|
|
108
|
+
guardActionBeforeInput(policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
|
|
109
109
|
}),
|
|
110
110
|
run: ({ input, actor }) =>
|
|
111
111
|
// The caller is the actor for the WHOLE call. A child context is how the framework
|
|
@@ -115,7 +115,7 @@ export function appToolPrimitive(name: string, def: AnyAppToolDefinition): Proje
|
|
|
115
115
|
const ctx = useContext();
|
|
116
116
|
// The policy's actor half first, as in `invoke`: a caller refused whatever they send is
|
|
117
117
|
// told X_FORBIDDEN, never the tool's argument schema.
|
|
118
|
-
|
|
118
|
+
guardActionBeforeInput(policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
|
|
119
119
|
// The AUTHORITATIVE parse, in the same slot `invoke` puts it: before the full policy,
|
|
120
120
|
// before the handler. A projected action re-parses inside `invoke`; a hand-written tool has no
|
|
121
121
|
// second parse, so `handle` was typed `InferOutput<TInput>` and handed whatever
|
|
@@ -123,7 +123,7 @@ export function appToolPrimitive(name: string, def: AnyAppToolDefinition): Proje
|
|
|
123
123
|
// arrived as any string at all. `InputInvalidError` because a tool argument is an action
|
|
124
124
|
// input by another name: one code, X_INPUT_INVALID, whichever surface the call came in on.
|
|
125
125
|
const parsed = await parseInput(def.input, input, name);
|
|
126
|
-
|
|
126
|
+
guardAction(policy, { actor: actorOf(ctx), input: parsed, ctx, action: name }, 'mcp');
|
|
127
127
|
return def.handle({ input: parsed, ctx });
|
|
128
128
|
}),
|
|
129
129
|
};
|