diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4b4347b..76de9e386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -966,6 +966,38 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major ### Changed +- **BREAKING — an MCP tool is named by the export name, verbatim, on every surface. `snake_case` + tool names are gone, and so is `toToolName`.** One primitive was reachable under one name and + published under another. The served name has only ever been the export name — + `packages/mcp/src/from-action.ts:82` is the one name `tools/call` accepts and the one + `defineAppMcp`'s `scopes:` map is keyed on, and `@ultimat3/ai`'s LLM tool list agrees + (`packages/ai/src/tools.ts:72`) — while three *publishers* spelled the same tool + `publish_post`. So an agent handed `openapi.json` read `"mcpTool": "publish_post"`, called + `tools/call { name: "publish_post" }`, and got ToolNotFound: the catalog it was given was the + wrong one. The served side was right, so the publishers moved. + + | Was | Now | + |---|---| + | `publishPost.tool().name` → `'publish_post'` | `'publishPost'` | + | `liveFeed.tool().name` → `'live_feed'` | `'liveFeed'` | + | `openapi.json` → `"x-ultimate": { "mcpTool": "publish_post" }` | `"mcpTool": "publishPost"` | + | `publishPost.describe().mcp.tool` → `'publish_post'`, and with it `x actions describe --json`, the `actions.describe` MCP dev tool and the `/_x` **Routes** panel | `'publishPost'` | + | `import { toToolName } from '@ultimat3/action'` / `'@ultimat3/query'` | removed from both — there is no derivation left to call | + + **What an app author changes.** Nothing that *worked* moves: a `tools/call`, a `scopes:` entry + and a `visibleTo` list were already spelled verbatim, and a snake_case `scopes:` entry was + already `X_MCP_SCOPE_UNKNOWN` at boot. What moves is everything read off the published contract + — run `x manifest` to regenerate `openapi.json`, and re-point any agent prompt, saved tool + allowlist, generated client or test that took its tool name from `x-ultimate.mcpTool`, + `describe().mcp.tool` or `.tool().name`. **15 committed names change** in the two tracked apps + alone — of 17 published `x-ultimate.mcpTool` values, `examples/dummy` moves 9 of 10 and + `dummy/social-media-clone` 6 of 7; a single-word export (`summarize`, `health`) was already its + own snake_case form and does not move. An app that fed `.tool()` descriptors + into its own MCP host — public API, and the one case where the name was live on a wire — renames + those tools for real. `x.manifest.json` is unaffected: its `mcp` fact is + `{ expose, description }` and never carried a tool name (`packages/manifest/src/schema.ts:85`). + Issue #120. + - **BREAKING — with no `SMTP_URL` and no `RESEND_API_KEY`, `selectMailDriver` refuses instead of falling back to memory.** `development` and `test` are unchanged; `staging` and `production` now install a driver that rejects every send with `X_MAIL_CREDENTIAL_MISSING`. An app that sends no diff --git a/docs/architecture/11-ai-surface.md b/docs/architecture/11-ai-surface.md index 993eb2b87..d7f0f67cf 100644 --- a/docs/architecture/11-ai-surface.md +++ b/docs/architecture/11-ai-surface.md @@ -28,7 +28,7 @@ Neither adapter may parse, authorize, or handle on its own. Both go through `inv | MCP requirement | Source | Notes | |---|---|---| -| tool name | the action's export name, verbatim | `publishPost` — the name `scopes:` and `tools/call` address, and the one an author greps for | +| tool name | the action's export name, verbatim | `publishPost` — the name `scopes:` and `tools/call` address, and the one an author greps for. **Every publisher agrees `As of 2026-08`**: `x-ultimate.mcpTool`, `describe().mcp.tool` and `.tool().name` snake-cased it through a `toToolName` that no longer exists, so `openapi.json` advertised 15 tools this server answers ToolNotFound for across the two tracked apps — every multi-word export name of the 17 it publishes. `packages/mcp/src/cross-surface.test.ts` is the enforcement — it calls `tools/call` with the name OpenAPI published | | input JSON Schema | the action's `input` via Standard Schema → JSON Schema | the same schema HTTP parses | | output schema | `output` | same | | description | `mcp.description` | required when `expose: true` | diff --git a/docs/idea/09-ai-first.md b/docs/idea/09-ai-first.md index 2b6a669f9..ba6b784e3 100644 --- a/docs/idea/09-ai-first.md +++ b/docs/idea/09-ai-first.md @@ -45,7 +45,7 @@ That line is the entire integration. From the existing declaration: | MCP requirement | Source | |---|---| -| tool name | action name | +| tool name | the action's export name, verbatim — `publishPost`. One name across `tools/call`, `scopes:`, the LLM tool list and every published catalog; nothing derives a second spelling | | JSON Schema for input | the action's `input` (Standard Schema → JSON Schema) | | output schema | `output` | | description | `mcp.description` | diff --git a/docs/plans/2026/08/16/101-deep-dive-bug-audit/04-projection-contract.md b/docs/plans/2026/08/16/101-deep-dive-bug-audit/04-projection-contract.md index 5fe6069f0..07ee5b2c4 100644 --- a/docs/plans/2026/08/16/101-deep-dive-bug-audit/04-projection-contract.md +++ b/docs/plans/2026/08/16/101-deep-dive-bug-audit/04-projection-contract.md @@ -9,6 +9,26 @@ job handle + MCP tool + tests, all consistent. This slice is where that is broke `nullable` was found independently by the tier-0/1 sweep and this one — the strongest signal in the audit, and the only finding already visible in committed bytes. +## Status, re-derived against the code `As of 2026-08` + +Three of the ten entries are live, one carries a named residue, and the enforcement-gaps table is +untouched. An entry left asserting a defect the code does not have sends the next reader to fix +something already correct, so a closed one says so and a void one is struck. + +| Entry | State | +|---|---| +| Critical — `nullable` vanishes from every projection | **closed**, both specs regenerated | +| High — `.job()` has no consumer | **open**, unchanged | +| High — the query client encodes what `coerceQuery` cannot decode | **open**, reproduced verbatim | +| High — `useRequestContext()` throws a bare `TypeError` | **closed** — `X_NO_REQUEST` | +| Medium — two input schemas for one tool | **mostly closed**; the residue is named in the entry | +| Medium — `coerceNode` has no `literal` case | **closed** | +| Medium — `normalize()` swallows into a permissive node | **void** — it throws, and did before this slice's PR landed | +| Medium — `sampleString` ignores `pattern` | **closed** — the misattribution is gone | +| Low — `.default(x).optional()` publishes a default `parse` never returns | **closed** | +| Low — `coerceInput` is exported with no caller | **open**, unchanged | +| Enforcement gaps — five unenforced non-negotiables | **open**, all five | + ## Critical - `packages/schema/src/json-schema.ts:102` — `convert()` never reads `node.nullable`, so every @@ -48,16 +68,25 @@ audit, and the only finding already visible in committed bytes. nullable ≠ optional. Add a round-trip test pinning the IR field and its projection together, then regenerate both apps' `openapi.json`. + **CLOSED `As of 2026-08`**, exactly as specified: `annotate` emits + `{ anyOf: [schema, { type: 'null' }], ...annotations }` (`packages/schema/src/json-schema.ts:125-128`), + annotations stay outside the `anyOf`, and `requiredKeys` is untouched. Both specs are regenerated — + `examples/dummy/openapi.json:167` now carries the `anyOf` for `coverUrl`, and `coverUrl` is still + `required` at `:232`. `packages/schema/src/json-schema.test.ts` pins it. + ## High -- `packages/action/src/job-handle.ts:16` — the documented "action as durable work" projection is +- **OPEN, re-verified `As of 2026-08`** — `packages/action/src/job-handle.ts:16` — the documented + "action as durable work" projection is inert: nothing consumes `kind: 'action-job'`. `ActionJobHandle` exposes `{ kind, name, input, idempotencyKey(), invoke() }`; `@ultimat3/jobs` accepts only `JobHandle` - (`packages/jobs/src/job.ts:93`), a disjoint shape requiring `queue`, `retry`, `concurrency`, - `timeoutMs`, `parse()`, `run()`, `enqueue()`, `as()`, `describe()` — and `isJobHandle` - (`job.ts:218`) additionally requires membership in a private `WeakMap` only `job()` writes, so no - `ActionJobHandle` can ever pass. Repo-wide, `'action-job'` occurs in four places: its declaration, - its construction (`:30`), and two assertions in its own tests. `packages/action/CLAUDE.md` calls + (`packages/jobs/src/job.ts:110`), a disjoint shape requiring `queue`, `retry`, `concurrency`, + `timeoutMs`, `parse()`, `idempotencyKeyFor()`, `tenantFor()`, `run()`, `enqueue()`, `as()`, + `describe()` — and `isJobHandle` (`job.ts:256`) additionally requires `kind === 'job'` plus + membership in a private `WeakMap` only `job()` writes, so no + `ActionJobHandle` can ever pass. Repo-wide, `'action-job'` still occurs in four places: its + declaration, its construction (`:30`), and two test assertions + (`job-handle.test.ts:36`, `facade.test.ts:91`). `packages/action/CLAUDE.md:25` calls `job-handle.ts` "the shape `@ultimat3/jobs` consumes" and `job-handle.ts:2` claims "enqueueing an existing action costs zero rewriting" — both false. It is the only one of axiom 2's six projections with no consumer. Fix: build a real `JobHandle` through `job()` — an `action → jobs` @@ -66,12 +95,13 @@ audit, and the only finding already visible in committed bytes. Otherwise delete `.job()` and the claim. Shipping a projection with no consumer is the built-but-never-called defect commit `156a847` closed elsewhere. -- `packages/query/src/client.ts:151` — `searchOf()` cannot encode a nested object or a `null`, so the +- **OPEN, reproduced verbatim `As of 2026-08`** — `packages/query/src/client.ts:151` — `searchOf()` + cannot encode a nested object or a `null`, so the typed query client typechecks against inputs the server's own route rejects. `:156` skips `null` and `undefined`; `:158` encodes a non-array object member as `JSON.stringify(item)`. The server (`packages/query/src/http.ts:43` → `coerceQuery` → `coerceNode`, - `packages/schema/src/coerce.ts:60`) has no inverse — `case 'object'` returns the raw value - untouched and there is no `JSON.parse` on that path. Proven with + `packages/schema/src/coerce.ts:79`) has no inverse — `case 'object'` returns the raw value + untouched when it is a string and there is no `JSON.parse` on that path. Proven with `{ orgId: t.string, filter: t.object({...}), since: t.nullable(t.date) }`: ``` @@ -88,6 +118,9 @@ audit, and the only finding already visible in committed bytes. naming the offending key, or teach `coerceNode`'s `object`/`record` cases to `JSON.parse` a string when the node is structural, and encode `null` as a sentinel the coercer decodes. + The test this entry asks for does not exist: `packages/query/src/client.test.ts` contains no + `searchOf`, `coerceQuery` or nested-object case, so nothing would fail if the asymmetry widened. + - `packages/http/src/context.ts:205` — `useRequestContext()` is an exported public API whose unchecked cast (`useContext() as unknown as RequestContext`) hands back an object with `undefined` in non-optional fields, failing as a **bare `TypeError`**. Exported from @@ -102,29 +135,56 @@ audit, and the only finding already visible in committed bytes. export `assertInRequest`'s behaviour under that name, or return `RequestContext | undefined` — the existing `noRequest(member)` error is already the right instruction. + **CLOSED `As of 2026-08`**, by the first option. `useRequestContext` + (`packages/http/src/context.ts:235-236`) is now `assertInRequest(member, ambientContext())`, and + `assertInRequest` (`:223-224`) throws `noRequest(member)`. Re-run inside a plain + `createContext({})`: `X_NO_REQUEST — the request context was read outside an HTTP request`, with a + runnable `fix:` (`packages/http/src/errors.ts:244-249`). No bare `TypeError`, and the export at + `src/index.ts:13` is now safe to keep. + ## Medium -- `packages/mcp/src/input-schema.ts:21` — the MCP **server** publishes a different input schema than - `action.tool()` does, and `from-action.ts` documents the wrong one as the source. Two projections - of one input exist: `packages/action/src/mcp-tool.ts:43` uses `mcpSchemaOf` (full draft-07 - vocabulary); `packages/mcp/src/projectable.ts:47,60` — the path that actually serves `tools/list` — - uses `toWireSchema`, whose `narrow()` allow-list omits `pattern`, `x-ultimate-refinements` and - `discriminator`. Proven, same schema through both: +- `packages/mcp/src/input-schema.ts:21` — **mostly closed in PR #106; what remains is narrower than + this entry, and this entry missed the larger half of the same split.** As written: the MCP + **server** publishes a different input schema than `action.tool()` does, and `from-action.ts` + documents the wrong one as the source. Two projections of one input exist — + `packages/action/src/mcp-tool.ts` uses `mcpSchemaOf` (full draft-07 vocabulary); + `packages/mcp/src/projectable.ts` — the path that actually serves `tools/list` — uses + `toWireSchema`, whose `narrow()` allow-list omitted `pattern`, `x-ultimate-refinements` and + `discriminator`. Proven at the time, same schema through both: - ``` + ```text action .tool(): "orderRef": { "type":"string","minLength":1,"maxLength":8,"pattern":"^ORD-\\d{4}$" } mcp server: "orderRef": { "type":"string","minLength":1,"maxLength":8 } ``` - Meanwhile `packages/mcp/src/from-action.ts:12` says "`toMcpTool` in @ultimat3/action owns the - schema half" and `:52` says the field is "as produced by `toMcpTool`" — neither true of the code - beside them. An agent is given no way to know the format, calls the tool, and gets - `X_INPUT_INVALID` from the action's own parse — while the OpenAPI spec for that same action *does* - carry the `pattern`, so HTTP and MCP clients hold different contracts for one declaration. Fix: - add `pattern` to `wire.ts`'s `JsonSchema`, to `narrow()`, and to `validate-args.ts`'s `string()`; - then make `mcp-tool.ts` emit through `toWireSchema` too (or delete its schema half) so one function - answers "what is this action's MCP tool schema". Correct both `from-action.ts` comments in the same - change. + **Closed `As of 2026-08`**: `pattern` is in `wire.ts:91`, in `narrow()` (`input-schema.ts:37`) and + enforced at `validate-args.ts:125` — with a pattern the server cannot compile *refused* rather + than skipped, because `tools/list` published it. Both `from-action.ts` comments are corrected; its + header now names `toWireSchema` as the owner of the schema half and says why the two are not + interchangeable. The cross-surface test this slice asked for exists: + `packages/mcp/src/cross-surface.test.ts`. + + **Still open, and smaller**: two functions still answer "what is this action's MCP tool schema". + `narrow()` now drops exactly `discriminator` and `x-ultimate-refinements`, and flattens an + `additionalProperties` *schema* to `true` — deliberately, per `input-schema.ts:1-7`: the subset is + what `validate-args.ts` can enforce, and a keyword the server ignores is worse than an absent one. + The residual exposure is app code feeding `.tool().inputSchema` to its own MCP host, which + publishes keywords this server neither publishes nor checks; nothing inside the framework consumes + `.tool()` (`toMcpTools`/`toQueryTools` have no caller outside their own tests), so no shipped wire + depends on the difference. Closing it is one of: emit `mcp-tool.ts`'s schema through the same + narrowing, delete `.tool()`'s schema half, or lift the subset into `@ultimat3/schema` — `action` is + tier 3 and `mcp` is tier 4, so they cannot share a function anywhere else. + + **The larger half of this split was the tool NAME, and this entry never named it.** The same two + files also disagreed about what the tool is *called*: `toToolName` published `publish_post` from + `mcp-tool.ts`, from `http.ts`'s `x-ultimate.mcpTool` and from `action.ts`'s `describeAction` + (`@ultimat3/query`'s `toQueryTool` had the same bug), while `from-action.ts:82` served + `publishPost` — the only name `tools/call` accepts. Both tracked apps' committed `openapi.json` + carried the unserveable names: 15 of the 17 published, every multi-word one. Filed as **#120** + and fixed on branch `fix/mcp-tool-name-unify`: `toToolName` is deleted from both tier-3 packages, and + `cross-surface.test.ts` drives a `tools/call` with the name OpenAPI published, so a publisher that + re-derives is a failing test. - `packages/schema/src/coerce.ts:29` — `coerceNode` has no `literal` case, so a numeric or boolean `t.literal` in a query input can never be satisfied over its own GET route: the switch covers @@ -136,18 +196,21 @@ audit, and the only finding already visible in committed bytes. String literals happen to work, which makes it look arbitrary. Fix: add `case 'literal':` coercing toward `typeof node.literal`; this also closes `t.union(t.literal(1), t.literal(2))`. -- `packages/action/src/json-schema.ts:25` — `normalize()` swallows a conversion failure into a - permissive node (`catch { }` → `{ type: 'object', additionalProperties: true }`), so a schema the - runtime rejects everything from is published as "any object accepted". `toJsonSchema` throws - `X_SCHEMA_UNSUPPORTED` (`packages/schema/src/provider.ts:55`) exactly when the spec must not claim - anything; instead the OpenAPI component and the MCP `inputSchema` both widen while `validateInput` - still rejects every payload. The stated reason ("a missing OpenAPI detail must not break a - deploy") inverts axiom 3 — the deploy succeeds and every caller is lied to. `packages/ai/src/llm.ts:407` - calls `toMcpInputSchema` directly and does *not* degrade, so one unsupported schema throws in one - projection and silently widens in two others; the `llm.ts:402` comment claiming it "throws HERE, at - declaration time" is true of that call site only. Fix: let the throw propagate at registration - (`registerAction` already refuses a missing policy there), or emit `{ not: {} }` with an - `X_SCHEMA_UNSUPPORTED` description — honest about being unenforceable. + **CLOSED `As of 2026-08`**: `case 'literal':` is at `packages/schema/src/coerce.ts:44-54`, coercing + toward `typeof node.literal` — `numeric` for a number, `booleanish` for a boolean, untouched for a + string. `packages/schema/src/coerce.test.ts` pins it. + +- ~~`packages/action/src/json-schema.ts:25` — `normalize()` swallows a conversion failure into a + permissive node.~~ **VOID `As of 2026-08`: the code does the opposite.** `normalize()` has no + `catch` and builds no permissive node — it throws `SchemaUnsupportedError` + (`packages/action/src/json-schema.ts:31-40`), and the fix this entry asked for is the one that + shipped: `registerAction` calls `assertProjectable` at boot (`packages/action/src/registry.ts:48`, + `:68-84`), which runs both `jsonSchemaOf` and `mcpSchemaOf` over `input:` and `output:` and + re-raises `X_SCHEMA_UNSUPPORTED` naming the action and the field, so a registered action can never + reach a projection that throws. The file header (`:12-21`) and the registry comment (`:60-67`) + both record the old behaviour as fixed. The entry's last clause is void with it: `llm.ts:397-402`'s + claim that an inexpressible schema "throws HERE, at declaration time" is now true of every + projection, not only that call site. **Nothing to do.** - `packages/action/src/sample-input.ts:32` — `sampleString` ignores `node.pattern`, so `x g action`'s generated contract test reports `X_CONTRACT_DRIFT` against a **correct** action: any input using @@ -157,6 +220,15 @@ audit, and the only finding already visible in committed bytes. `undefined` for "cannot construct" and have `contractTestsFor` require an explicit sample, or skip the policy assertion for that action with a distinct non-failing note. + **CLOSED `As of 2026-08`**, by the first option, and the misattribution is what was actually + wrong. `sampleString` still cannot invert a regex — nothing can — but `satisfiesPattern` + (`packages/action/src/sample-input.ts:58-61`) reads `node.pattern` off the IR and `sampleGaps` + (`:145`) reports every field whose synthesized value misses it. `assertSampleable` + (`packages/action/src/contract-test.ts:94-102`) refuses *before* invoking, and its `fix:` is the + call to paste — `contractTestsFor(, { input: { … } })` — instead of blaming an `input:` + that is correct. It is still `X_CONTRACT_DRIFT` and the author still supplies the sample; that is + the design, not the defect. + ## Low - `packages/schema/src/builder.ts:158` — `.default(x).optional()` publishes `default: x` while @@ -166,8 +238,15 @@ audit, and the only finding already visible in committed bytes. `optional()` strip both fields, or refuse the composition — the two modifiers are mutually exclusive by meaning. -- `packages/schema/src/coerce.ts:130` — `coerceInput` is exported from `src/index.ts:19` with no - caller anywhere in the framework (only `coerceQuery` is used, at `packages/query/src/http.ts:43`). + **CLOSED `As of 2026-08`**, by the first option: `optional()` builds from + `withoutDefault({ ...node, optional: true })` (`packages/schema/src/builder.ts:184-192`), so + `hasDefault` and `default` are gone from the node and nothing publishes a default `parse` will not + return. `default()` (`:198-206`) is unchanged and still keeps the declaration. + +- **OPEN, re-verified `As of 2026-08`** — `packages/schema/src/coerce.ts:149` — `coerceInput` is + exported from `src/index.ts:19` with no + caller anywhere in the framework or either tracked app (only `coerceQuery` is used, at + `packages/query/src/http.ts:43`). Documented as the route-params/form-data path; nothing routes through it. Same built-but-never-called shape as the job handle, at much lower cost. @@ -178,12 +257,17 @@ Five conventions the root `CLAUDE.md` lists as non-negotiable have **no check an | Convention | Current state | |---|---| -| no blind `export *` | six package indexes assert it in comments; `X_EXPORT_STAR_FORBIDDEN` exists only in an untracked stale `packages/manifest/dist/` build, in no tracked source, no wiki row, no manifest entry | +| no blind `export *` | six package indexes assert it in comments; `X_EXPORT_STAR_FORBIDDEN` now exists **nowhere at all** — the stale `packages/manifest/dist/` copy this entry cited is gone, and there is still no tracked source, no wiki row and no manifest entry | | no raw colours (semantic tokens only) | no rule, no code, no step | | no hardcoded user-facing strings outside `t()` | no rule, no code, no step | | no date formatted without an explicit IANA `timeZone` | `@ultimat3/time` validates a zone when one is passed; nothing stops a call that passes none | | no float money | `t.money`/`MoneyValue` prevent a float *shape* structurally; no check on arithmetic | +All five are **still open `As of 2026-08`** — re-checked against `scripts/`, `packages/cli/src/` and +`biome.json`, none of which carries a rule for any of them. What did change is the framing: six such +conventions are now *labelled* as unenforced in prose rather than asserted as rules (PR #119), which +is honesty, not enforcement. + All five are decidable by a source scan. Fold them into `package-shape` (which already walks every `src/index.ts`) or the `lint` walk via the existing `eachSourceFile`, each with its own code — or strike them from the non-negotiables list. Enforced-and-verified for contrast: file size, package @@ -194,24 +278,22 @@ eight-primitive closure (`registrar.test.ts:64`), the full error-code contract ## Tests -- `packages/schema/src/json-schema.test.ts` — a `nullable` round-trip pinning the IR field to its - emitted `anyOf`; a `nullable` field stays out of `required`. -- `packages/query/src/client.test.ts` — nested object and `null` inputs survive `searchOf` → - `coerceQuery` → `parse`. -- `packages/schema/src/coerce.test.ts` — `t.literal(2)` and `t.literal(true)` coerce from a query - string. -- `packages/action/src/json-schema.test.ts` — an unsupported schema does not publish - `additionalProperties: true`. -- Cross-surface test: one action with a `pattern` + a `nullable` field, asserted identical across - `.openapi()`, `.tool()` and the MCP server's `tools/list`. This is the test that would have caught - every finding in this slice. +Four of the five exist `As of 2026-08`; the one that does not is the one whose defect is still open. + +| Test | State | +|---|---| +| `packages/schema/src/json-schema.test.ts` — a `nullable` round-trip pinning the IR field to its emitted `anyOf`; a `nullable` field stays out of `required` | written | +| `packages/query/src/client.test.ts` — nested object and `null` inputs survive `searchOf` → `coerceQuery` → `parse` | **missing** — the file names none of `searchOf`, `coerceQuery` or a nested object | +| `packages/schema/src/coerce.test.ts` — `t.literal(2)` and `t.literal(true)` coerce from a query string | written | +| `packages/action/src/json-schema.test.ts` — an unsupported schema does not publish `additionalProperties: true` | written | +| Cross-surface: one action asserted identical across `.openapi()`, `.tool()` and the MCP server's `tools/list` — the test that would have caught every finding in this slice | written, `packages/mcp/src/cross-surface.test.ts`, and it caught the tool-name split | ## Done when -- `nullable` survives to OpenAPI and MCP; both apps' `openapi.json` regenerated and `contract-diff` - green on the new bytes. -- `.job()` either consumes into `@ultimat3/jobs` or is deleted along with its claim in - `packages/action/CLAUDE.md`. -- The query client and `coerceQuery` agree on every `SchemaKind`, proven by one test over all kinds. -- The five unenforced conventions are each a build error or struck from `CLAUDE.md`. -- `bun run verify` green. +| Condition | State `As of 2026-08` | +|---|---| +| `nullable` survives to OpenAPI and MCP; both apps' `openapi.json` regenerated and `contract-diff` green on the new bytes | **done** | +| `.job()` either consumes into `@ultimat3/jobs` or is deleted along with its claim in `packages/action/CLAUDE.md:25` | open — no consumer, and the claim still stands | +| The query client and `coerceQuery` agree on every `SchemaKind`, proven by one test over all kinds | open — `object` and `null` still disagree, and no test exists | +| The five unenforced conventions are each a build error or struck from `CLAUDE.md` | open — all five | +| `bun run verify` green | the gate's own business, not this slice's | diff --git a/docs/plans/2026/08/16/101-deep-dive-bug-audit/status.yml b/docs/plans/2026/08/16/101-deep-dive-bug-audit/status.yml index 1e078779e..8bb0f9f08 100644 --- a/docs/plans/2026/08/16/101-deep-dive-bug-audit/status.yml +++ b/docs/plans/2026/08/16/101-deep-dive-bug-audit/status.yml @@ -11,7 +11,9 @@ current_focus: > Error codes 387 -> 417. Twelve new gate checks built across the sweep, four of them in the last slice alone, because the recurring finding was never a missing rule — it was a correct rule one file away that did not cover the path that mattered. Five slices closed under 100% and every - remainder is named in the deferred blocks below or filed: #120 (the MCP tool name split), + remainder is named in the deferred blocks below or filed: #120 (the MCP tool name split — CLOSED + on branch fix/mcp-tool-name-unify: toToolName deleted from action and query, the export name + wins on every surface, packages/mcp/src/cross-surface.test.ts pins it), #121 (a fresh app cannot run four generators; its first migration is wrong four ways), #122 (two shipped fix: lines that resolve and still do the wrong thing). slices: @@ -45,6 +47,14 @@ slices: status: done percent: 100 pr: 106 + note: > + percent is #106's own scope, not the slice's entry list. Re-derived against the code + 2026-08: six entries closed or void and one mostly closed, THREE still open — `.job()` has no consumer + (action/job-handle.ts:16), the query client encodes what coerceQuery cannot decode + (query/client.ts:151, reproduced), coerceInput is exported with no caller + (schema/coerce.ts:149) — plus all five enforcement gaps. The slice file carries a status + table; one entry there is VOID, not open: `normalize()` throws and always did by the time + the slice landed. - file: 02-tier23-bugs.md tier: 2 status: done diff --git a/dummy/social-media-clone/openapi.json b/dummy/social-media-clone/openapi.json index 513ffcbc0..0d847dde2 100644 --- a/dummy/social-media-clone/openapi.json +++ b/dummy/social-media-clone/openapi.json @@ -754,7 +754,7 @@ "capability": "friend:request", "idempotent": true, "invalidates": [], - "mcpTool": "request_friend", + "mcpTool": "requestFriend", "rateLimit": null } } @@ -834,7 +834,7 @@ "capability": "friend:respond", "idempotent": true, "invalidates": [], - "mcpTool": "respond_friend", + "mcpTool": "respondFriend", "rateLimit": null } } @@ -952,7 +952,7 @@ "capability": "message:send", "idempotent": false, "invalidates": [], - "mcpTool": "send_message", + "mcpTool": "sendMessage", "rateLimit": null } } @@ -1032,7 +1032,7 @@ "capability": "notification:mark-read", "idempotent": true, "invalidates": [], - "mcpTool": "mark_notifications_read", + "mcpTool": "markNotificationsRead", "rateLimit": null } } @@ -1230,7 +1230,7 @@ "capability": "block:create", "idempotent": true, "invalidates": [], - "mcpTool": "block_user", + "mcpTool": "blockUser", "rateLimit": null } } @@ -1289,7 +1289,7 @@ "capability": "block:delete", "idempotent": false, "invalidates": [], - "mcpTool": "unblock_user", + "mcpTool": "unblockUser", "rateLimit": null } } diff --git a/examples/dummy/openapi.json b/examples/dummy/openapi.json index fb152a4b3..32aad9c85 100644 --- a/examples/dummy/openapi.json +++ b/examples/dummy/openapi.json @@ -1389,7 +1389,7 @@ "comment", "post" ], - "mcpTool": "create_comment", + "mcpTool": "createComment", "rateLimit": null } } @@ -1450,7 +1450,7 @@ "invalidates": [ "member" ], - "mcpTool": "toggle_digest_opt_in", + "mcpTool": "toggleDigestOptIn", "rateLimit": null } } @@ -1512,7 +1512,7 @@ "member", "org" ], - "mcpTool": "invite_member", + "mcpTool": "inviteMember", "rateLimit": null } } @@ -1574,7 +1574,7 @@ "org", "plan" ], - "mcpTool": "upgrade_plan", + "mcpTool": "upgradePlan", "rateLimit": null } } @@ -1635,7 +1635,7 @@ "invalidates": [ "feed" ], - "mcpTool": "create_post", + "mcpTool": "createPost", "rateLimit": null } } @@ -1697,7 +1697,7 @@ "feed", "post" ], - "mcpTool": "like_post", + "mcpTool": "likePost", "rateLimit": null } } @@ -1760,7 +1760,7 @@ "feed", "post" ], - "mcpTool": "publish_post", + "mcpTool": "publishPost", "rateLimit": null } } @@ -1821,7 +1821,7 @@ "invalidates": [ "member" ], - "mcpTool": "save_preferences", + "mcpTool": "savePreferences", "rateLimit": null } } @@ -2003,7 +2003,7 @@ "invalidates": [ "member" ], - "mcpTool": "set_theme", + "mcpTool": "setTheme", "rateLimit": null } } diff --git a/packages/action/CLAUDE.md b/packages/action/CLAUDE.md index bbbb8ae0d..b950c16f4 100644 --- a/packages/action/CLAUDE.md +++ b/packages/action/CLAUDE.md @@ -22,7 +22,7 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3. | `openapi.ts` | deterministic OpenAPI 3.1 document | | `client.ts` | typed RPC client (browser-safe: no server imports) | | `mcp-tool.ts` | MCP descriptor, same `invoke` | -| `job-handle.ts` | shape `@ultimat3/jobs` consumes | +| `job-handle.ts` | the `.job()` projection: an action as a queueable payload. **Not** consumed by `@ultimat3/jobs` — see Invariants | | `contract-test.ts` | assertions `x g action` emits | | `sample-input.ts` | a value `input:` accepts, from its own IR — what makes the policy assertion reach a policy | | `idempotency.ts` | the store SEAM: types, the installed-store slot, the scope declaration + `assertIdempotencyScope`, and `withIdempotency` — the replay-or-run gate | @@ -189,6 +189,20 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3. (`spanId: ''`, which `currentSpanContext()` answers when a request context exists but no span does) sends nothing rather than `00---01`. In a browser there is no ambient context, so a cross-origin call acquires no CORS preflight it did not already have. +- **`ActionJobHandle` is not consumed by `@ultimat3/jobs`, and the file said it was until + 2026-08.** The header read "`@ultimat3/jobs` consumes this shape, so enqueueing an existing + action costs zero rewriting" — a claim no code supports. `'action-job'` occurs in four places, + all inside this package (`job-handle.ts:16,30`, `job-handle.test.ts`, `facade.test.ts`), and + `isJobHandle` (`packages/jobs/src/job.ts:256`) requires `kind === 'job'` **and** membership of a + module-private `WeakMap` written only inside `job()` — so this is impossible in principle, not + merely unwired. `JobHandle` shares two members with it. What the shape actually gives: `.job()` + on the façade, `action:` as a durable queue key, a payload-derived idempotency key, and an + `invoke` that runs the one execution path under `surface: 'job'` — so an app that owns a queue + can drive it by hand and still get the action's parse + policy. **The missing halves are + `tenant` and `retry`**, both required on `JobDefinition` with deliberately no default, so "zero + rewriting" was never reachable regardless of wiring. The bridge is one `job({ … })` call, and it + belongs in the app or at tier 4+: `action` and `jobs` are both tier 3. Not built here — that is + code, and this was a comment correction. - An action has no `.def`. Inside the package read it with `defOf(target)`; outside, read the lifted `.input`/`.output`/`.policy`/`.mcp` or `describe()`. - **`AnyAction` projects every surface, `client()` excepted.** The registry answers in the erased @@ -219,6 +233,30 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3. `!== false`) until 2026-08, so an action with no `mcp` block was advertised as a tool by both contract artifacts and refused by the only surface that could serve one. A contract that disagrees with the runtime is worse than no contract; never spell the check inline again. +- **An MCP tool's NAME is the export name verbatim, in all four places — `toToolName` is gone** + (`As of 2026-08`). The same three readers that fail-opened on exposure also *derived* the name: + `toToolName` snake_cased it for `toMcpTool`, for `x-ultimate.mcpTool` and for + `describeAction().mcp.tool`, while `@ultimat3/mcp` has only ever served + `primitive.mcp?.name ?? primitive.name`. So nine of the ten tool names + `examples/dummy/openapi.json` published — `publish_post`, `create_post`, … — were names + `tools/call` answers not-found for; the tenth, `summarize`, is single-word and so was already + its own snake_case form, which is why a count of the wrong names is not a count of the rows. The + DESCRIPTOR said the same: `x actions describe --json`, `x actions list --json`, the + `actions.describe` dev MCP tool and the `/_x` Routes panel all read `.mcp.tool`. **Not the app + manifest** — `ActionFact.mcp` is `{ expose, description? }` and `packages/manifest/src/sources.ts` + copies only those two, so `x.manifest.json` has never carried a tool name and `grep '"tool"'` on + either committed manifest finds none. Do not describe this defect as a manifest defect; the + manifest's stake in the `mcp` block is `expose`, which is the invariant above. `ActionMcp` + carries no `name:`, so + the verbatim name is the whole rule and there is nothing left to derive; the helper is + **deleted** rather than left exported, because a dead name-deriver is a second way to name a + tool. `mcp-tool.test.ts`'s "one name per action, on every surface" asserts the three strings are + one string AND that it is the verbatim name — the equality alone passes on the old behaviour, + where all three agreed on the wrong name. **Neither package derives a tool name any more**: + `@ultimat3/query` carried a same-named twin with the identical defect and deleted it in the same + change, so `packages/query/src/naming.ts` derives PATHS only and its `src/index.ts` exports no + such helper. Both are tier 3, so neither could import the other and each owned its own removal — + which is why the rule is restated in both `CLAUDE.md`s rather than shared. - **The post-commit bust never fails the write it followed.** `cache.invalidates` fans out once the handler has committed, so `bustAfterCommit` — `cache-gate.ts`, the only caller of `invalidateTags` here — absorbs a fan-out that refuses and answers `undefined`: an undeclared tag diff --git a/packages/action/README.md b/packages/action/README.md index 9cc82ecb3..25e863fcc 100644 --- a/packages/action/README.md +++ b/packages/action/README.md @@ -119,24 +119,27 @@ a page's module graph free of any edge to a feature's implementation. ## Path derivation First camelCase word is the verb; the rest is the resource, last word pluralized, -kebab-cased. The **served MCP tool name is not derived** — it is the export name verbatim, because +kebab-cased. The **MCP tool name is not derived at all** — it is the export name verbatim, because that is what `defineAppMcp`'s `scopes:` and a `tools/call` have to spell. -| Action | Route | MCP tool, as served | +| Action | Route | MCP tool | |---|---|---| | `publishPost` | `POST /api/posts/publish` | `publishPost` | | `updateUserProfile` | `POST /api/user-profiles/update` | `updateUserProfile` | | `likePost` | `POST /api/posts/like` | `likePost` | | `checkout` (single word) | `POST /api/checkouts/invoke` | `checkout` | -> **Known defect, `As of 2026-08`: three surfaces in this package say `publish_post` instead.** -> The served name is `@ultimat3/mcp`'s (`toolFromAction` → `primitive.mcp?.name ?? primitive.name`), -> and it is the only one an agent can call. But `toToolName()` snake_cases the name for -> `action.tool().name` (`mcp-tool.ts`), for `openapi.json`'s `x-ultimate.mcpTool` (`http.ts`) and -> for `describeAction().mcp.tool`, which is the manifest row. So the published contract advertises -> a tool no server serves. Unifying on the verbatim name is a **breaking** change to both generated -> artifacts and is not yet made — until it is, read `.tool().name` as a descriptor's own label and -> `defineAppMcp`'s catalog as the wire truth. +**One name, four surfaces** — `.tool().name`, `openapi.json`'s `x-ultimate.mcpTool`, +`describeAction().mcp.tool` (what `x actions describe --json`, `x actions list --json`, the +`actions.describe` dev MCP tool and the `/_x` Routes panel show) and the catalog `@ultimat3/mcp` +serves. It was two until 2026-08: a `toToolName()` here snake_cased the first three to +`publish_post` while the server answered only `publishPost`, so an agent that read the published +contract called a tool that does not exist. `toToolName` is **deleted**, not deprecated — a second +derivation is a second name. `mcp-tool.test.ts`'s "one name per action, on every surface" is what +keeps it that way. + +`x.manifest.json` is **not** one of the four: `ActionFact.mcp` is `{ expose, description? }`, so +the manifest never carried a tool name and was never wrong about one. ## One invocation core diff --git a/packages/action/src/action.test.ts b/packages/action/src/action.test.ts index 07f5c7215..01667981b 100644 --- a/packages/action/src/action.test.ts +++ b/packages/action/src/action.test.ts @@ -52,7 +52,8 @@ describe('action', () => { expect(described.name).toBe('publishPost'); expect(described.path).toBe('/api/posts/publish'); expect(described.method).toBe('POST'); - expect(described.mcp.tool).toBe('publish_post'); + // The export name verbatim — the descriptor and the served catalog are one name. + expect(described.mcp.tool).toBe('publishPost'); expect(described.idempotent).toBe(false); // Reported, not inferred downstream: `mutator.test.ts` holds the `true` half. expect(described.mutator).toBe(false); diff --git a/packages/action/src/action.ts b/packages/action/src/action.ts index 4da91aefa..b258e4376 100644 --- a/packages/action/src/action.ts +++ b/packages/action/src/action.ts @@ -20,7 +20,7 @@ import type { ActionJobHandle } from './job-handle'; import type { JsonSchemaObject } from './json-schema'; import { jsonSchemaOf } from './json-schema'; import type { McpToolDescriptor } from './mcp-tool'; -import { derivePath, toToolName } from './naming'; +import { derivePath } from './naming'; import { type ActionPolicy, policyCapability, @@ -356,7 +356,11 @@ export function describeAction(target: AnyAction): ActionDescriptor { // Fail-open here published every action as a tool no surface would ever serve, which made // a first honest `expose: false` read as a withdrawn capability and demand a major bump. expose: isMcpExposed(mcp), - tool: toToolName(name), + // The export name verbatim, which is the only name `@ultimat3/mcp` will answer a + // `tools/call` for. A derived one made every descriptor reader — `x actions describe + // --json`, the `actions.describe` dev tool, the `/_x` panel — name a tool no surface + // serves. `x.manifest.json` is not among them: it copies `expose` and `description` only. + tool: name, description: mcp?.description ?? null, }, rateLimit: def.rateLimit ?? null, diff --git a/packages/action/src/facade.test.ts b/packages/action/src/facade.test.ts index ec32806f0..8a6a32e17 100644 --- a/packages/action/src/facade.test.ts +++ b/packages/action/src/facade.test.ts @@ -79,7 +79,7 @@ describe('the fluent surface', () => { const tool = target.tool(); // The same policy object on both surfaces — an MCP call cannot reach a second authz path. expect(tool.policy).toBe(target.policy); - expect(tool.name).toBe('publish_post'); + expect(tool.name).toBe('publishPost'); expect(tool.description).toBe('Publish a draft post'); expect(target.openapi().operationId).toBe('publishPost'); expect(target.openapi().summary).toBe('Publish a draft post'); diff --git a/packages/action/src/http.ts b/packages/action/src/http.ts index c1eaa81de..e492e6ca3 100644 --- a/packages/action/src/http.ts +++ b/packages/action/src/http.ts @@ -24,7 +24,6 @@ import { PROBLEM_SCHEMA_NAME, schemaRef, toOperationId, - toToolName, } from './naming'; import { policyCapability } from './policy-gate'; @@ -156,8 +155,11 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation { invalidates: tagKeys(def.cache?.invalidates ?? []), // The tool name an agent would call, or `null` when there is no tool. `!== false` here // advertised one for every action, so an agent reading the spec asked for a tool the MCP - // catalog never listed — `isMcpExposed` is the same answer `toMcpTool` gives. - mcpTool: isMcpExposed(def.mcp) ? toToolName(name) : null, + // catalog never listed — `isMcpExposed` is the same answer `toMcpTool` gives. The NAME was + // the second half of the same defect: `toToolName` published `publish_post` while + // `@ultimat3/mcp` served `publishPost`, so a spec-reading agent called a tool that does not + // exist. Verbatim, and never derived — this is a published contract, not a label. + mcpTool: isMcpExposed(def.mcp) ? name : null, rateLimit: rateLimitMeta(name, def.rateLimit), // The dates as data, beside the boolean flag OpenAPI defines: `deprecated: true` says an // operation is going away and nothing else, so a client that wants to plan the migration diff --git a/packages/action/src/index.test.ts b/packages/action/src/index.test.ts index 3ce93b8e8..483411dc7 100644 --- a/packages/action/src/index.test.ts +++ b/packages/action/src/index.test.ts @@ -11,6 +11,15 @@ import { t } from './index'; */ const PRIVATE_TO_INVOKE = ['defOf', 'stashDef', 'hasDef'] as const; +/** + * The tool-name derivers this package must never grow back. `toToolName` snake_cased an MCP tool + * name until 2026-08 while `@ultimat3/mcp` served the export name verbatim, so `openapi.json` and + * every descriptor reader published a name `tools/call` answers not-found for. The only test that + * caught it lived in `@ultimat3/mcp` — tier 4, which this package cannot import, so the rule was + * enforced one tier above where it can be broken. + */ +const NO_TOOL_NAME_DERIVER = ['toToolName', 'deriveToolName', 'toolNameFor'] as const; + const publishPost = surface .action({ input: schemaT.object({ postId: schemaT.uuid }), @@ -43,6 +52,19 @@ describe('@ultimat3/action public surface', () => { expect(exported).toContain('invoke'); }); + // Two halves of one rule, pinned HERE because tier 3 cannot import the tier-4 test that + // caught the original bug: an MCP tool's name is the export name, and nothing derives one. + test('the tool name is the registered name, and no deriver is exported', () => { + // Structural, with no literal to drift: whatever the action was registered as IS the tool. + expect(surface.describeAction(publishPost).mcp.tool).toBe(publishPost.name); + expect(publishPost.tool().name).toBe(publishPost.name); + + const exported = Object.keys(surface); + for (const name of NO_TOOL_NAME_DERIVER) expect(exported).not.toContain(name); + // The positive half: PATH derivation is still this package's job and stays exported. + expect(exported).toContain('derivePath'); + }); + test('no projection carries the declaration out with it', () => { const projections: readonly [string, object][] = [ ['describe', publishPost.describe()], diff --git a/packages/action/src/index.ts b/packages/action/src/index.ts index d18bebac5..e70714871 100644 --- a/packages/action/src/index.ts +++ b/packages/action/src/index.ts @@ -170,7 +170,7 @@ export type { } from './mutator'; export { custom, isMutator, mutator, resolveConflict, strategyOf } from './mutator'; export type { ActionPath } from './naming'; -export { derivePath, inputSchemaName, outputSchemaName, pluralize, toToolName } from './naming'; +export { derivePath, inputSchemaName, outputSchemaName, pluralize } from './naming'; export type { BuildOpenApiOptions, OpenApiDocument, OpenApiInfo } from './openapi'; export { buildOpenApi, serializeOpenApi } from './openapi'; export type { ActionPolicy, PolicySubject, Surface } from './policy-gate'; diff --git a/packages/action/src/job-handle.ts b/packages/action/src/job-handle.ts index 2af0e9e1c..9ff49df45 100644 --- a/packages/action/src/job-handle.ts +++ b/packages/action/src/job-handle.ts @@ -1,7 +1,8 @@ /** - * Projection 5: an action as durable work. `@ultimat3/jobs` consumes this shape, - * so enqueueing an existing action costs zero rewriting — and the queued run - * goes through the same validation and policy evaluation as the HTTP call. + * Projection 5: an action as durable work — its input schema, a payload-derived + * idempotency key, and an `invoke` that runs the action's one execution path under + * `surface: 'job'`, so a queued run gets the same validation and policy evaluation + * as the HTTP call. Nothing in the framework consumes it — an app bridges it into `job()`. */ import type { Ctx } from '@ultimat3/core'; import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema'; @@ -9,6 +10,24 @@ import type { Action } from './action'; import { actionName, invoke } from './invoke'; import { fingerprint } from './stable'; +/** + * **`@ultimat3/jobs` does not consume this, and cannot as written** (`As of 2026-08`; the header + * claimed it did). `isJobHandle` needs `kind === 'job'` AND membership of a module-private + * `WeakMap` only `job()` writes, so no externally-built object reaches the registry, the queue or + * the worker — and `kind: 'action-job'` is deliberately a different literal, not a near-miss. + * + * What it IS: the three fields plus the body a `JobDefinition` needs — `name`, `input`, + * `idempotencyKey`, and `invoke` as its `run`. An app bridges it in one call: + * `job({ name: h.name, input: h.input, idempotencyKey: h.idempotencyKey, tenant, retry, + * run: ({ input, ctx }) => h.invoke(input, ctx) })`, which yields a real handle `job()` seated. + * + * `tenant` and `retry` are what the bridge cannot fill: both are REQUIRED on `JobDefinition` with + * no default, on purpose — jobs states that every candidate default for `tenant` is a + * cross-tenant read waiting to happen. So "enqueueing an action costs zero rewriting" was never + * reachable; two facts an action does not declare have to come from somewhere. Whoever closes + * this writes the adapter in the app or at tier 4+: `action` and `jobs` are both tier 3, so + * neither may import the other. + */ export interface ActionJobHandle< TInput extends StandardSchemaV1 = StandardSchemaV1, TOutput extends StandardSchemaV1 = StandardSchemaV1, diff --git a/packages/action/src/mcp-tool.test.ts b/packages/action/src/mcp-tool.test.ts index 6f9ff4f00..6d2fc9b4c 100644 --- a/packages/action/src/mcp-tool.test.ts +++ b/packages/action/src/mcp-tool.test.ts @@ -83,7 +83,7 @@ describe('one authz system', () => { test('the tool descriptor mirrors the action declaration', () => { const { target } = defineCounted(); const tool = toMcpTool(target); - expect(tool.name).toBe('publish_post'); + expect(tool.name).toBe('publishPost'); expect(tool.action).toBe('publishPost'); expect(tool.inputSchema['type']).toBe('object'); }); @@ -128,7 +128,7 @@ describe('exposure is opt-in', () => { expect(toOpenApiOperation(declaring())['x-ultimate']['mcpTool']).toBe(null); expect(toOpenApiOperation(declaring({ expose: false }))['x-ultimate']['mcpTool']).toBe(null); expect(toOpenApiOperation(declaring({ expose: true }))['x-ultimate']['mcpTool']).toBe( - 'publish_post', + 'publishPost', ); }); @@ -142,3 +142,43 @@ describe('exposure is opt-in', () => { } }); }); + +// The defect this closes: `@ultimat3/mcp` serves the export name verbatim — it is what a +// `tools/call` spells and what `defineAppMcp`'s `scopes:` is keyed on — while these three +// surfaces published a snake_cased one. An agent reading `openapi.json` for `mcpTool`, or +// `x actions describe --json` for `.mcp.tool`, called a name the server answers not-found. It was +// wrong where it was hardest to notice: nothing in the framework reads its own spec back. +describe('one name per action, on every surface', () => { + const exposedTarget = () => + action({ + input: Input, + output: Output, + policy: can('post:publish'), + mcp: { expose: true }, + handle: () => ({ id: POST_ID, published: true }), + }).named('publishPost'); + + test('.tool(), openapi.json and the descriptor all name the verbatim export name', () => { + const target = exposedTarget(); + const names = [ + target.tool().name, + toOpenApiOperation(target)['x-ultimate']['mcpTool'], + describeAction(target).mcp.tool, + ]; + expect(new Set(names).size).toBe(1); + expect(names).toEqual(['publishPost', 'publishPost', 'publishPost']); + }); + + test('a multi-word name is never re-cased — the served catalog cannot spell it back', () => { + const target = action({ + input: Input, + output: Output, + policy: can('post:publish'), + mcp: { expose: true }, + handle: () => ({ id: POST_ID, published: true }), + }).named('updateUserProfile'); + expect(target.tool().name).toBe('updateUserProfile'); + expect(describeAction(target).mcp.tool).toBe('updateUserProfile'); + expect(toOpenApiOperation(target)['x-ultimate']['mcpTool']).toBe('updateUserProfile'); + }); +}); diff --git a/packages/action/src/mcp-tool.ts b/packages/action/src/mcp-tool.ts index cb6db1b8c..386908c60 100644 --- a/packages/action/src/mcp-tool.ts +++ b/packages/action/src/mcp-tool.ts @@ -8,14 +8,21 @@ import { isMcpExposed } from '@ultimat3/core'; import type { AnyAction } from './action'; import { actionName, defOf, invoke } from './invoke'; import { type JsonSchemaObject, mcpSchemaOf, sortSchema } from './json-schema'; -import { toToolName } from './naming'; import type { ActionPolicy } from './policy-gate'; import { listActions } from './registry'; export interface McpToolDescriptor { + /** + * The export name VERBATIM — `@ultimat3/mcp` serves that name and nothing else, so any + * transformation here would be a label no `tools/call` could spell. See `toMcpTool`. + */ readonly name: string; /** The action's `mcp.description`, or its name when the author gave none. */ readonly description: string; + /** + * The action this tool projects. Equal to `name` by construction since 2026-08 — kept because + * a reader asking "which action is behind this tool?" should not have to know that. + */ readonly action: string; /** * The action's own policy object, not a copy — `tool().policy === action.policy` @@ -32,11 +39,16 @@ export interface McpInvokeOptions { readonly idempotencyKey?: string | null; } +/** + * The tool name is the export name VERBATIM — it was `snake_case`d until 2026-08, and + * `@ultimat3/mcp` has only ever served the verbatim one, so `.tool().name` was a label no + * `tools/call` accepted. One name per action, on every surface. + */ export function toMcpTool(target: AnyAction): McpToolDescriptor { const name = actionName(target); const def = defOf(target); return { - name: toToolName(name), + name, description: def.mcp?.description ?? name, action: name, policy: def.policy, diff --git a/packages/action/src/mutator.test.ts b/packages/action/src/mutator.test.ts index 39097919e..2bff51398 100644 --- a/packages/action/src/mutator.test.ts +++ b/packages/action/src/mutator.test.ts @@ -129,7 +129,7 @@ describe('mutator', () => { expect(renamed.policy).toBe(likePost.policy); expect(renamed.mcp).toEqual({ expose: true, description: 'Like a post' }); expect(await renamed.as(likerActor, { postId: POST_ID })).toEqual({ id: POST_ID, likes: 7 }); - expect(renamed.tool().name).toBe('favorite_post'); + expect(renamed.tool().name).toBe('favoritePost'); expect(renamed.openapi().operationId).toBe('favoritePost'); expect(typeof renamed.client({ baseUrl: 'https://app.test' })).toBe('function'); expect(renamed.job().name).toBe('action:favoritePost'); diff --git a/packages/action/src/naming.test.ts b/packages/action/src/naming.test.ts index 771c2fc00..606e222fd 100644 --- a/packages/action/src/naming.test.ts +++ b/packages/action/src/naming.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { derivePath, inputSchemaName, pluralize, toToolName } from './naming'; +import { derivePath, inputSchemaName, pluralize } from './naming'; describe('action name -> route path', () => { const cases: readonly [string, string][] = [ @@ -27,8 +27,7 @@ describe('action name -> route path', () => { }); }); - test('tool and component names derive from the same words', () => { - expect(toToolName('updateUserProfile')).toBe('update_user_profile'); + test('component names derive from the same words the path does', () => { expect(inputSchemaName('updateUserProfile')).toBe('UpdateUserProfileInput'); }); diff --git a/packages/action/src/naming.ts b/packages/action/src/naming.ts index 732974581..1356277c8 100644 --- a/packages/action/src/naming.ts +++ b/packages/action/src/naming.ts @@ -1,7 +1,8 @@ /** - * The one naming rule: an action's export name derives its HTTP path and MCP - * tool name. Pure string math so the browser client can derive the same path - * without importing a byte of server code. + * The one naming rule: an action's export name derives its HTTP path and its + * OpenAPI component names. Pure string math so the browser client can derive + * the same path without importing a byte of server code. The MCP tool name is + * derived by nothing — it is the export name verbatim. */ /** Irregular plurals we actually hit in domain models. Extend deliberately, not eagerly. */ @@ -66,10 +67,9 @@ export function derivePath(name: string): ActionPath { return { verb: head, resource, path: `/api/${resource}/${head}` }; } -/** MCP tool names are `snake_case`: `publishPost` -> `publish_post`. */ -export function toToolName(name: string): string { - return splitWords(name).join('_'); -} +// There is deliberately no `toToolName`. An MCP tool name is the export name verbatim — the one +// `@ultimat3/mcp` serves and the one a `tools/call` spells — so a second derivation would be a +// second name for one action, which is what shipped `publish_post` in two committed contracts. /** OpenAPI `operationId` is the action name verbatim — it is already unique. */ export function toOperationId(name: string): string { diff --git a/packages/ai/CLAUDE.md b/packages/ai/CLAUDE.md index 31eb9aa4a..2ed6e5d61 100644 --- a/packages/ai/CLAUDE.md +++ b/packages/ai/CLAUDE.md @@ -11,6 +11,11 @@ rather than imported. Same contract, two wire formats — and the same *decision `runLlmToolCall` ask `isMcpExposed` from `@ultimat3/core` (tier 0, reachable by both), never a local `=== true`. An in-app agent and an external one must be offered exactly the same tools. +**And the same NAME**: `toLlmTool` passes `action.name` through untouched, which is the export name +`@ultimat3/mcp` serves. Never derive one here. `llm()` and `agent()` return actions, so their +`.tool()` name is the verbatim export name too — it read `summarize_post` / `projecting_agent` +until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts`). + ## Owns | File | Job | diff --git a/packages/ai/README.md b/packages/ai/README.md index 2afaed6d6..98ee24f00 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -467,12 +467,14 @@ nothing. `scoped()` only ever **tightens** — re-scoping to a different tenant ## Tools: the same projection as MCP ```ts +// `ProjectableAction` — `{ name, mcp?, inputJsonSchema?, run }`, the projection SEAM. const tools = toLlmTools([publishPost, suspendUser]); // only those with mcp.expose const result = await runLlmToolCall(actions, call, actor); ``` -An in-app agent and an external MCP agent both end at `action.run`, so they authorize -identically. The actor comes from the request context, never from the model. +An in-app agent and an external MCP agent both end at the same `invoke` — `run` is the seam that +carries it, and an action facade has no `.run` of its own. So they authorize identically. The +actor comes from the request context, never from the model. ## Errors diff --git a/packages/ai/src/agent.test.ts b/packages/ai/src/agent.test.ts index a3252d888..f48da69ef 100644 --- a/packages/ai/src/agent.test.ts +++ b/packages/ai/src/agent.test.ts @@ -164,7 +164,9 @@ describe('agent() is an action factory, not a ninth primitive', () => { expect(isAction(support)).toBe(true); expect(PRIMITIVE_KINDS).toContain(support.describe().kind); expect(support.describe().kind).toBe('action'); - expect(support.tool().name).toBe('projecting_agent'); + // The export name verbatim — the same rule `llm()` inherits, for the same reason: an + // `agent()` is an action, and `@ultimat3/mcp` serves an action under its export name. + expect(support.tool().name).toBe('projectingAgent'); expect(support.job().name).toBe('action:projectingAgent'); }); diff --git a/packages/ai/src/llm.test.ts b/packages/ai/src/llm.test.ts index 113ea5db5..484b9979a 100644 --- a/packages/ai/src/llm.test.ts +++ b/packages/ai/src/llm.test.ts @@ -97,7 +97,10 @@ describe('llm() is an action factory, not a ninth primitive', () => { expect(isAction(summarize)).toBe(true); expect(typeof summarize).toBe('function'); - expect(summarize.tool().name).toBe('summarize_post'); + // Verbatim, because `llm()` returns an action and an action's tool name is its export name + // — the one `@ultimat3/mcp` serves and the only one `tools/call` answers to. It read + // `summarize_post` until 2026-08, which named a tool no MCP catalog has ever contained. + expect(summarize.tool().name).toBe('summarizePost'); expect(summarize.openapi().operationId).toBe('summarizePost'); expect(summarize.contract().length).toBeGreaterThan(0); expect(summarize.job().name).toBe('action:summarizePost'); diff --git a/packages/ai/src/tools.ts b/packages/ai/src/tools.ts index 16eb63472..3d7eaa9f5 100644 --- a/packages/ai/src/tools.ts +++ b/packages/ai/src/tools.ts @@ -2,9 +2,13 @@ // // This is the SAME projection @ultimat3/mcp performs, in a different wire format: an // in-app agent calling a tool through the gateway and an external agent calling it over -// MCP both end at `action.run`, so they authorize identically. There is no "LLM +// MCP both end at the same `invoke`, so they authorize identically. There is no "LLM // permissions" concept in Ultimate, because there is no second authz system. // +// `run` below is `ProjectableAction`'s — the projection SEAM, which is what carries `invoke`. +// It is not a member of the action facade: an `action()` is `as`/`tool`/`openapi`/`job`/ +// `contract` and the callable itself, and this header claimed `action.run` until 2026-08. +// // The JSON Schema type and the projectable-primitive shape are declared here rather than // imported from @ultimat3/mcp: that package is the same tier, so importing it would be a // boundary error. Both packages describe the same structural contract. diff --git a/packages/mcp/CLAUDE.md b/packages/mcp/CLAUDE.md index d2d719a4b..fb40ca696 100644 --- a/packages/mcp/CLAUDE.md +++ b/packages/mcp/CLAUDE.md @@ -84,6 +84,16 @@ import. The CLI wires it. query. Writing a primitive out NAMES a tool; it never re-shapes or re-runs one. An action with no export name is `X_ACTION_UNREGISTERED` rather than a tool called `''`, which no `tools/call` and no `scopes:` entry could ever address. +- **This package NAMES a tool, it never derives one.** `primitive.mcp?.name ?? primitive.name` in + `from-action.ts` is the whole rule, fed the verbatim export name by `projectable.ts` — a + transform here would be a second spelling of a name that is already an addressable identity. + Every surface that PUBLISHES the name owes the same string: `action.tool()`, `query.tool()`, + `x-ultimate.mcpTool`, `ActionDescriptor.mcp.tool`. The three action publishers snake_cased it + through `toToolName` until 2026-08, so a spec-reading agent called `publish_post` and got + `-32601` from a catalog holding `publishPost`, and nothing noticed because no test compared the + served name to a published one. `cross-surface.test.ts` is that comparison — it reads the + catalog off `tools/list` and drives a `tools/call` with the name OpenAPI published, so a + publisher that re-derives is a failing test and not a wiki note. - Every boot-time refusal in `defineAppMcp` is an `UltimateError` with a code, never a bare throw: `X_MCP_TOOL_UNDECLARED`, `X_MCP_TOOL_UNSAFE`, `X_MCP_TOOL_DUPLICATE`, `X_MCP_SCOPE_UNKNOWN`, `X_MCP_SCOPE_CONFLICT`. The caller reading them is usually an agent diff --git a/packages/mcp/README.md b/packages/mcp/README.md index cddb1cf47..9491a0a79 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -40,12 +40,14 @@ the list**, never assumed. Truncation is never silent. ## One authz system, two surfaces Every `action` with `mcp: { expose: true }` becomes a tool for free, and the tool's `handle` -calls the **same `action.run`** the HTTP route calls. Policy evaluation lives inside `run`. +reaches the **same `invoke`** the HTTP route reaches — the projection's `run` is that call with +`surface: 'mcp'`, nothing more. Policy evaluation lives inside `invoke`. (An action has no `.run` +member; `run` is the projection seam, and a query's half of it is `sourceFor`.) ``` -HTTP POST /api/publishPost ─┐ - ├─→ action.run({ input, actor }) ─→ policy ─→ handler -MCP tools/call publishPost ─┘ +HTTP POST /api/posts/publish ─┐ + ├─→ invoke(action, input, { surface, actor }) ─→ policy ─→ handler +MCP tools/call publishPost ─┘ ``` `mcp: { visibleTo: [...] }` on the action or query travels with the projection too — the only @@ -142,6 +144,17 @@ One adapter serves both routes, so a written-out primitive runs through the same An action that was never handed to `defineApi` has no export name, and is `X_ACTION_UNREGISTERED` rather than a tool called `''` that nothing could call. +**The tool name is the export name, verbatim** — `publishPost`, never `publish_post`. This server +answers `tools/call` for that name and no other, so every surface that PUBLISHES a name has to +publish the same one: `action.tool()`, `query.tool()`, `x-ultimate.mcpTool` in `openapi.json`, and +`ActionDescriptor.mcp.tool`. The projection reads `primitive.mcp?.name ?? primitive.name`, so the +export name is the **default** and `mcp.name` is an explicit override — unreachable from `action()` +or `query()`, whose declarations carry no `name` field, and available only to a hand-authored +`ProjectablePrimitive` passed to `defineAppMcp`'s `tools:`. The three action publishers snake_cased +the name `As of 2026-08`, so an agent reading the spec called a tool the catalog never contained and +got ToolNotFound. +`src/cross-surface.test.ts` is what makes a fourth spelling a failing test rather than a note. + A hand-written tool's `policy` is a permission, evaluated through the same `guard()` an HTTP request goes through, so a tool cannot acquire a second authz path. A tool without one is `X_MCP_TOOL_UNSAFE` at boot, and an unmarked tool is metered as a write. diff --git a/packages/mcp/src/cross-surface.test.ts b/packages/mcp/src/cross-surface.test.ts index 6104a4d5d..fb2429f76 100644 --- a/packages/mcp/src/cross-surface.test.ts +++ b/packages/mcp/src/cross-surface.test.ts @@ -11,6 +11,14 @@ // This file is the cross-surface assertion. A keyword the wire subset cannot enforce is a // deliberate omission (`validate-args.ts` is what makes it a contract at all) — so the assertion // is per keyword and states which surface owes what. +// +// The same claim applies to the tool's NAME, and nothing asserted it until 2026-08: this package +// serves `actionName(target)`/`queryName(target)` verbatim (`projectable.ts`), while `.tool()`, +// `x-ultimate.mcpTool` and `ActionDescriptor.mcp.tool` all published `toToolName(name)` — +// `publishPost` served, `publish_post` published. An agent that read the spec and issued +// `tools/call { name: 'publish_post' }` got ToolNotFound. Every existing test asserted one side +// or the other, never the two against each other, so the second block below compares each +// PUBLISHED name against the catalog the server actually answers. import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { @@ -20,7 +28,7 @@ import { registerAction, resetRegistry as resetActions, } from '@ultimat3/action'; -import { agentActor } from '@ultimat3/core'; +import { agentActor, createContext, runWithContext } from '@ultimat3/core'; import { can, clearPermissions, @@ -28,6 +36,7 @@ import { definePermissions, defineRoles, } from '@ultimat3/policy'; +import { from, query, registerQuery, resetRegistry as resetQueries } from '@ultimat3/query'; import { t } from '@ultimat3/schema'; import { defineAppMcp } from './app-tools'; import type { McpCaller } from './registry'; @@ -57,6 +66,15 @@ const declare = () => handle: () => ({ ok: true }), }); +/** A read, for the name block: the naming rule is one rule and both primitives obey it. */ +const declareRead = () => + query({ + input: t.object({ orgId: t.string }), + policy: can('order:archive'), + mcp: { expose: true, description: 'Recently archived orders' }, + sql: () => from<{ id: string }>('orders', [{ id: 'o1' }]), + }); + describe('one declaration, three schema surfaces', () => { let archiveOrder: ReturnType; @@ -65,7 +83,9 @@ describe('one declaration, three schema surfaces', () => { clearRoles(); clearPermissions(); definePermissions(['order:archive']); - defineRoles({ owner: ['order:archive'] }); + // `{ grants: [...] }`, not a bare array: `expandRoles` reads `.grants` and a bare array + // defines a role that grants nothing — invisible until something actually runs a policy. + defineRoles({ owner: { grants: ['order:archive'] } }); archiveOrder = registerAction('archiveOrder', declare()); }); @@ -81,10 +101,9 @@ describe('one declaration, three schema surfaces', () => { const listedSchema = async (): Promise => { const { server } = defineAppMcp({ actions: [archiveOrder] }); - const response = await server.handle( - { jsonrpc: '2.0', id: 1, method: 'tools/list' }, - { caller }, - ); + // `handle` takes the caller itself, not a wrapper: `{ caller }` carries no `actor`, which + // `tools/list` never reads and `tools/call` dereferences on its way to the audit line. + const response = await server.handle({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, caller); const result = response?.result as | { tools?: { name: string; inputSchema: JsonSchema }[] } | undefined; @@ -139,3 +158,89 @@ describe('one declaration, three schema surfaces', () => { }); }); }); + +describe('one declaration, ONE tool name', () => { + let archiveOrder: ReturnType; + let recentOrders: ReturnType; + + beforeEach(() => { + resetActions(); + resetQueries(); + clearRoles(); + clearPermissions(); + definePermissions(['order:archive']); + defineRoles({ owner: { grants: ['order:archive'] } }); + archiveOrder = registerAction('archiveOrder', declare()); + recentOrders = registerQuery('recentOrders', declareRead()); + }); + + afterEach(() => { + resetActions(); + resetQueries(); + clearRoles(); + clearPermissions(); + }); + + const appMcp = () => defineAppMcp({ actions: [archiveOrder], queries: [recentOrders] }); + + /** The names `tools/call` answers to — read off the catalog, never assumed from an export. */ + const servedNames = async (): Promise => { + const response = await appMcp().server.handle( + { jsonrpc: '2.0', id: 1, method: 'tools/list' }, + caller, + ); + const result = response?.result as { tools?: { name: string }[] } | undefined; + return (result?.tools ?? []).map((tool) => tool.name); + }; + + /** The operation as the published document carries it, located by id rather than by path. */ + const openapiMcpTool = (): unknown => { + for (const item of Object.values(buildOpenApi().paths)) { + const operation = (item as { post?: Record }).post; + if (operation?.['operationId'] === 'archiveOrder') { + return (operation['x-ultimate'] as Record | undefined)?.['mcpTool']; + } + } + return undefined; + }; + + test('every surface that PUBLISHES a tool name publishes one the server answers to', async () => { + const served = await servedNames(); + + // The served name is the export name VERBATIM — no derivation, on either side. + expect([...served].sort()).toEqual(['archiveOrder', 'recentOrders']); + // The three an action publishes. Each was `archive_order` until 2026-08, and none of the + // three is a name this catalog has ever contained. + expect(served).toContain(archiveOrder.tool().name); + expect(served).toContain(openapiMcpTool()); + expect(served).toContain(archiveOrder.describe().mcp.tool); + // A query publishes one; `QueryDescriptor` carries no `mcp` block, so there is no fourth. + expect(served).toContain(recentOrders.tool().name); + }); + + // The failure end to end, and the reason the assertion above is not enough on its own: an + // agent reads `x-ultimate.mcpTool` out of `openapi.json` and calls exactly that name. + test('the name OpenAPI publishes is a name tools/call resolves', async () => { + const name = openapiMcpTool(); + const response = await runWithContext(createContext({}), () => + appMcp().server.handle( + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name, arguments: { orderRef: 'ORD-1234', note: null } }, + }, + caller, + ), + ); + + // `-32601` ToolNotFound is what the published name actually got. Not a policy question: + // this caller holds `order:archive`, so a resolved tool answers with the handler's value. + expect(response?.error).toBeUndefined(); + const result = response?.result as + | { isError?: boolean; content?: { text?: string }[] } + | undefined; + expect(result?.isError).toBeUndefined(); + expect(result?.content?.[0]?.text ?? '').toContain('"ok": true'); + }); +}); diff --git a/packages/mcp/src/from-action.test.ts b/packages/mcp/src/from-action.test.ts index ac7939d40..1c3e128ae 100644 --- a/packages/mcp/src/from-action.test.ts +++ b/packages/mcp/src/from-action.test.ts @@ -54,7 +54,8 @@ describe('one authz system, two surfaces', () => { const tool = toolFromAction(action); const input = { postId: 'p1', notify: false }; - // Surface 1: what the HTTP route does. + // Surface 1: the seam directly, standing in for the HTTP route — which reaches the same + // `invoke` this fake's `run` replaces. (The route calls `invoke`, never an `action.run`.) const viaHttp = await action.run({ input, actor: human }); // Surface 2: what the MCP tool does. const viaMcp = await tool.handle(input, caller(human)); diff --git a/packages/mcp/src/from-action.ts b/packages/mcp/src/from-action.ts index 7e78255c9..f966b8d6e 100644 --- a/packages/mcp/src/from-action.ts +++ b/packages/mcp/src/from-action.ts @@ -1,8 +1,12 @@ // The free-tool projection: an `action` (or `query`) with `mcp.expose` becomes an MCP tool // at zero authorization cost. // -// The whole claim rests on one line in `handle` below: the tool calls `action.run(...)` — -// the SAME entry point the HTTP route calls. Policy evaluation lives inside `run`, so +// The whole claim rests on one line in `handle` below: the tool calls `primitive.run(...)`, and +// `projectable.ts` builds that `run` out of `invoke(target, …, { surface: 'mcp' })` — the SAME +// function `toRoute`'s handler calls for an HTTP request, differing only in the surface it +// declares. (An action has NO `.run` member — it is `as`/`tool`/`openapi`/`job`/`contract` and +// the callable itself; this comment named one until 2026-08. `run` is `ProjectablePrimitive`'s, +// the seam, and a query's half of it is `sourceFor`.) Policy evaluation lives inside `invoke`, so // there is nothing here to keep in sync and no second authz system to drift. This // projection therefore INVENTS no `scope`: a scope is a capability of the connection's // token, which a projection cannot know anything about. An app that wants one names the @@ -93,8 +97,9 @@ export function toolFromAction(primitive: ProjectablePrimitive): AnyMcpTool { ...(visibleTo !== undefined ? { visibleTo } : {}), // No `scope` from here: see the header. `defineAppMcp`'s `scopes:` map may add one. async handle(args: ToolArgs, caller: McpCaller): Promise { - // ONE authz system, TWO surfaces. HTTP does exactly this call with an actor of - // kind 'user'; MCP does it with kind 'agent'. Same policy, same decision. + // ONE authz system, TWO surfaces. This `run` IS `invoke`; the HTTP route reaches the + // same `invoke` with an actor of kind 'user', MCP arrives with kind 'agent'. Same + // policy, same decision. const output = await primitive.run({ input: args, actor: caller.actor }); return jsonResult(output); }, diff --git a/packages/query/CLAUDE.md b/packages/query/CLAUDE.md index 07669f0e6..ec8d8788e 100644 --- a/packages/query/CLAUDE.md +++ b/packages/query/CLAUDE.md @@ -18,7 +18,7 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher. | `http.ts` | route projection (`GET /_x/query/`, `enforcedBy: 'handler'`) | | `mcp-tool.ts` | MCP read descriptor, same `sourceFor` | | `client.ts` | typed read client (browser-safe: no server imports) | -| `naming.ts` | export name → `/_x/query/` + snake_case tool name. Pure string math | +| `naming.ts` | export name → `/_x/query/`. Pure string math. **Paths only** — no tool name | | `registry.ts` | export-name registration, `describeQueries()`, and the `registerPrimitiveRegistrar('query', …)` announcement | | `live.ts` | `LiveQuery` descriptor + cursor arithmetic | | `matcher.ts` | change event → minimal patch, or `X_MATCHER_UNSUPPORTED` | @@ -78,6 +78,17 @@ Owns the `query` primitive: reads, live reads, cursors, the incremental matcher. - `mcp` is opt-in (`expose: true`), exactly as it is for an action: rows reach an agent only when the author said so. `isExposed` here delegates to `isMcpExposed` in `@ultimat3/core` — the one predicate every reader in the framework asks — rather than spelling `=== true` a second time. +- **A read has ONE tool name, and it is the export name verbatim** (`As of 2026-08`). `toQueryTool` + snake_cased it (`liveFeed` → `live_feed`) while `@ultimat3/mcp` serves the read under + `queryName(target)` and answers `tools/call` for nothing else — so anything that read the name off + the descriptor rather than off `tools/list` called a tool the server had never heard of, and the + scope map in `defineAppMcp` is keyed on the verbatim name too. `toToolName` is **deleted**, not + merely unused: an exported derivation is a second way to spell one tool. Two pins, both in this + package because this is where the rule can be broken — `mcp-tool.test.ts` asserts + `toQueryTool(q).name === queryName(q)` (the presence), and `index.test.ts` asserts the barrel + exports no key matching `/tool_?name/i` (the absence, which nothing else catches: the only other + guard is `packages/mcp/src/cross-surface.test.ts`, tier 4 and unimportable from here). + `naming.ts` derives PATHS only. - `client.ts` stays free of server imports — it is bundled into the browser. `@ultimat3/action` is the same tier, so its naming is ported here, never imported. - **`queryClient` is the map-wide read client and the mirror of `rpc`; both spellings run diff --git a/packages/query/README.md b/packages/query/README.md index ec336d2b6..e105df589 100644 --- a/packages/query/README.md +++ b/packages/query/README.md @@ -29,7 +29,7 @@ Every projection is a method on the query itself. A query has no `.def`. | `liveFeed.as(actor, { orgId })` | the same read as another actor — the surrounding context is untouched, `null` is signed out | | `liveFeed.page({ orgId }, { first: 20, after })` | one bounded page plus the signed cursor that continues it. There is no `offset` | | `liveFeed.live({ orgId })` | the `LiveQuery` `@ultimat3/realtime` subscribes to, carrying the same policy object | -| `liveFeed.tool()` | the MCP read tool. `tool().policy === liveFeed.policy`, and it reads fresh | +| `liveFeed.tool()` | the MCP read tool, named `liveFeed`. `tool().policy === liveFeed.policy`, and it reads fresh | | `liveFeed.client({ baseUrl })` | `GET /_x/query/live-feed?orgId=…`, typed both ways | | `liveFeed.describe()` | the manifest row | @@ -84,7 +84,7 @@ forgetting the policy, and the reason is what tells the next reader which of the | `mcp-tool.ts` | the MCP read descriptor | | `client.ts` | the typed read client (browser-safe) | | `http.ts` | the route projection — `GET /_x/query/`, the URL the client derives | -| `naming.ts` | export name → wire path + tool name | +| `naming.ts` | export name → wire path. The MCP tool name is the export name verbatim | | `live.ts` | the `LiveQuery` descriptor `@ultimat3/realtime` subscribes to | | `matcher.ts` | change event → minimal patch (`add` / `update` / `remove` / `refill`) | | `pagination.ts` | `paginate()` — keyset pages over core's cursor codec | diff --git a/packages/query/src/facade.test.ts b/packages/query/src/facade.test.ts index b351099ca..4529ebcce 100644 --- a/packages/query/src/facade.test.ts +++ b/packages/query/src/facade.test.ts @@ -113,7 +113,8 @@ describe('the fluent surface', () => { // The same policy object on both surfaces — an MCP call cannot reach a second authz path. expect(tool.policy).toBe(target.policy); - expect(tool.name).toBe('org_feed'); + // One name: what the descriptor advertises is what `tools/call` accepts. + expect(tool.name).toBe('orgFeed'); expect(tool.query).toBe('orgFeed'); expect(tool.description).toBe('The org feed'); expect(tool.mutates).toBe(false); diff --git a/packages/query/src/index.test.ts b/packages/query/src/index.test.ts index 5e0fe984a..3c7dba567 100644 --- a/packages/query/src/index.test.ts +++ b/packages/query/src/index.test.ts @@ -11,6 +11,17 @@ describe('@ultimat3/query public surface', () => { expect(surface).not.toHaveProperty('paginate'); }); + test('no tool-name deriver on the barrel — a read has ONE name, its export name', () => { + // `@ultimat3/mcp` serves a read under `queryName(target)` and answers `tools/call` for + // nothing else, so any second spelling names a tool the server has never heard of. That + // package is tier 4 and cannot be imported here to prove it, which is exactly why the pin + // has to live in the package where the deriver can be reintroduced. + // `mcp-tool.test.ts` pins the descriptor's name structurally; this pins the ABSENCE. + expect(surface).not.toHaveProperty('toToolName'); + const derivers = Object.keys(surface).filter((key) => /tool_?name/i.test(key)); + expect(derivers).toEqual([]); + }); + test('re-exports the one `t`, not a copy of it', () => { // A spread or a re-implementation would still typecheck but would stop tracking // `configureSchemaProvider()`. Identity is the only assertion that catches that. diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts index 313286ebf..38a6bb7a5 100644 --- a/packages/query/src/index.ts +++ b/packages/query/src/index.ts @@ -57,7 +57,11 @@ export type { ChangeEvent, ChangeOp, Patch } from './matcher'; export { assertMatchable, match, positionFor } from './matcher'; export type { QueryToolDescriptor, QueryToolReadOptions } from './mcp-tool'; export { isExposed, toQueryTool, toQueryTools } from './mcp-tool'; -export { derivePath, toKebabCase, toToolName } from './naming'; +/** + * Path derivation only. There is no `toToolName`: an MCP tool is served under the export name + * verbatim, and an exported derivation would be a second way to spell one tool. + */ +export { derivePath, toKebabCase } from './naming'; /** * The shapes `query.page(input, { first, after })` takes and answers with. `paginate` itself is * deliberately unexported: a page is the read's own answer, and a second, importable way to ask diff --git a/packages/query/src/mcp-tool.test.ts b/packages/query/src/mcp-tool.test.ts new file mode 100644 index 000000000..46097824e --- /dev/null +++ b/packages/query/src/mcp-tool.test.ts @@ -0,0 +1,60 @@ +/** + * One name per query, on every surface. A descriptor that spells the tool + * differently from the name `tools/call` accepts is a tool nothing can reach. + */ + +import { describe, expect, test } from 'bun:test'; +import { can } from '@ultimat3/policy'; +import { t } from '@ultimat3/schema'; +import { toQueryTool, toQueryTools } from './mcp-tool'; +import { query } from './query'; +import { queryName } from './read'; +import { from } from './source'; + +interface Post { + readonly id: string; + readonly orgId: string; +} + +const Input = t.object({ orgId: t.uuid }); +const posts: readonly Post[] = [{ id: 'a', orgId: '00000000-0000-4000-8000-000000000001' }]; + +/** `named` stands in for registration: a projection needs a name, nothing more. */ +function defineRead(name: string) { + return query({ + input: Input, + policy: can('feed:read'), + mcp: { expose: true }, + sql: ({ orgId }) => from('posts', posts).where({ orgId }), + }).named(name); +} + +describe('the MCP read descriptor', () => { + test('the tool name is the export name VERBATIM, never a second spelling', () => { + // `@ultimat3/mcp` serves a query under `queryName(target)` and answers `tools/call` for + // nothing else, so anything reading the name off this descriptor must get the same string. + // A snake_cased descriptor named a tool the server has never heard of. + const target = defineRead('liveFeed'); + expect(toQueryTool(target).name).toBe('liveFeed'); + expect(toQueryTool(target).name).toBe(queryName(target)); + }); + + test('`name` and `query` are the one name, not two derivations of it', () => { + const target = defineRead('publicPostSlugs'); + const tool = toQueryTool(target); + expect(tool.name).toBe(tool.query); + expect(tool.name).toBe('publicPostSlugs'); + }); + + test('a single-word read is untouched, and the description falls back to the name', () => { + const tool = toQueryTool(defineRead('feed')); + expect(tool.name).toBe('feed'); + expect(tool.description).toBe('feed'); + expect(tool.mutates).toBe(false); + }); + + test('the catalog is sorted by the served name', () => { + const tools = toQueryTools([defineRead('publicPostSlugs'), defineRead('liveFeed')]); + expect(tools.map((tool) => tool.name)).toEqual(['liveFeed', 'publicPostSlugs']); + }); +}); diff --git a/packages/query/src/mcp-tool.ts b/packages/query/src/mcp-tool.ts index bd039f241..86614d5e2 100644 --- a/packages/query/src/mcp-tool.ts +++ b/packages/query/src/mcp-tool.ts @@ -8,7 +8,6 @@ import type { Actor, Ctx } from '@ultimat3/core'; import { isMcpExposed } from '@ultimat3/core'; import type { JsonSchema } from '@ultimat3/schema'; import { toMcpInputSchema } from '@ultimat3/schema'; -import { toToolName } from './naming'; import type { QueryPolicy } from './policy-gate'; import type { AnyQuery } from './query'; import { queryName, sourceFor } from './read'; @@ -21,6 +20,11 @@ export interface QueryToolReadOptions { } export interface QueryToolDescriptor { + /** + * The export name VERBATIM, and identical to `query` below. `@ultimat3/mcp` serves a read + * under `queryName(target)` and answers `tools/call` for nothing else, so a snake_cased + * descriptor named a tool the server had never heard of — which it did until 2026-08. + */ readonly name: string; /** The query's `mcp.description`, or its name when the author gave none. */ readonly description: string; @@ -39,7 +43,7 @@ export interface QueryToolDescriptor { export function toQueryTool(target: AnyQuery): QueryToolDescriptor { const name = queryName(target); return { - name: toToolName(name), + name, description: target.mcp?.description ?? name, query: name, policy: target.policy, diff --git a/packages/query/src/naming.test.ts b/packages/query/src/naming.test.ts index 9fb4ab5d5..0b88216c9 100644 --- a/packages/query/src/naming.test.ts +++ b/packages/query/src/naming.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { derivePath, splitWords, toKebabCase, toToolName } from './naming'; +import { derivePath, splitWords, toKebabCase } from './naming'; describe('query naming', () => { test('the wire path is the kebab-cased export name under one prefix', () => { @@ -8,11 +8,6 @@ describe('query naming', () => { expect(derivePath('feed')).toBe('/_x/query/feed'); }); - test('an MCP tool name is snake_case', () => { - expect(toToolName('liveFeed')).toBe('live_feed'); - expect(toToolName('publicPostSlugs')).toBe('public_post_slugs'); - }); - test('acronyms split on the last capital, not the first', () => { expect(splitWords('postHTMLBody')).toEqual(['post', 'html', 'body']); expect(toKebabCase('postHTMLBody')).toBe('post-html-body'); diff --git a/packages/query/src/naming.ts b/packages/query/src/naming.ts index d71c7fdb0..4da2a5811 100644 --- a/packages/query/src/naming.ts +++ b/packages/query/src/naming.ts @@ -1,8 +1,9 @@ /** - * The one naming rule for reads: a query's export name derives its HTTP path and - * its MCP tool name. Pure string math, so the browser client derives the same URL - * without importing a byte of server code. Ported rather than imported from - * @ultimat3/action: that package is the same tier, and tiers never go sideways. + * The one naming rule for reads: a query's export name derives its HTTP path. + * Pure string math, so the browser client derives the same URL without importing + * a byte of server code. Ported rather than imported from @ultimat3/action: that + * package is the same tier, and tiers never go sideways. The MCP tool name is NOT + * derived — it is the export name verbatim, so there is one name to call. */ /** Every read is served under one prefix, so a router can claim it in one rule. */ @@ -31,8 +32,3 @@ export function toKebabCase(name: string): string { export function derivePath(name: string): string { return `${QUERY_PREFIX}/${toKebabCase(name)}`; } - -/** MCP tool names are `snake_case`: `liveFeed` -> `live_feed`. */ -export function toToolName(name: string): string { - return splitWords(name).join('_'); -} diff --git a/wiki/Actions.md b/wiki/Actions.md index 4412bfbb8..c3de9a2e0 100644 --- a/wiki/Actions.md +++ b/wiki/Actions.md @@ -17,7 +17,7 @@ export const publishPost = action({ }); ``` -Declared in `api/` or a feature's `actions.ts`. Named export, never default. The export name is the identity: it derives the HTTP path, the OpenAPI `operationId`, and the MCP tool name, and it must be globally unique. +Declared in `api/` or a feature's `actions.ts`. Named export, never default. The export name is the identity, and it must be globally unique. Only the HTTP path is *derived* from it (`publishPost` → `POST /api/posts/publish`); the OpenAPI `operationId` and the **MCP tool name are the export name verbatim** — `publishPost`, never `publish_post`. `As of 2026-08` that holds on every surface at once: what `tools/call` accepts, what `defineAppMcp`'s `scopes:` map is keyed on, what the LLM tool list offers, what `openapi.json`'s `x-ultimate.mcpTool` publishes, and what `describe().mcp.tool` reports. ## Fields @@ -76,7 +76,7 @@ Every projection needs the name `registerActions()` stamps on. It names the expo | 2 | **OpenAPI operation** | `input` + `output` + `mcp.description` | `publishPost.openapi()`, emitted into `x.manifest.json` and `openapi.json`; contract diff runs in `x verify` | | 3 | **Typed client function** | `input` + `output` | one map-wide client, `export const client = rpc({ baseUrl })`, then `await client.publishPost({ postId })` in `app/`; or `publishPost.client({ baseUrl })` for a single method. `rpc` is the only name for the map-wide client — no `createClient` alias, no fetch, no codegen step to remember | | 4 | **Job handle** | the whole declaration | `publishPost.job()` — a namespaced name, an `idempotencyKey` from the payload, and an `invoke` that runs the same handler durably. Register it with the queue; `.enqueue()` belongs to a declared `job` | -| 5 | **MCP tool** | `mcp` + `input` + `policy` | `publishPost.tool()` — one `publish_post` per exposed action, JSON Schema from `input`, authz unchanged | +| 5 | **MCP tool** | `mcp` + `input` + `policy` | `publishPost.tool()` — one tool named `publishPost` per exposed action, JSON Schema from `input`, authz unchanged | | 6 | **Test** | `input` + `policy` | `publishPost.contract()` — schema round-trip plus a denial test per policy branch, generated green, not as a `TODO` | Plus cache invalidation: `cache.invalidates` fans out to request memo, in-process LRU (all instances, over NATS), Redis, ISR pages, and the CDN purge webhook in one hop ([Caching and invalidation](Caching-And-Invalidation)). @@ -172,7 +172,7 @@ $ x actions describe publishPost --json "notify":{"type":"boolean","default":true}}}, "output":{"$ref":"#/components/schemas/PostView"}, "invalidates":["feed","post"],"idempotent":false, - "mcp":{"expose":true,"tool":"publish_post","description":"Publish a draft post"}, + "mcp":{"expose":true,"tool":"publishPost","description":"Publish a draft post"}, "rateLimit":null}} ``` diff --git a/wiki/Known-Gaps.md b/wiki/Known-Gaps.md index 081946acb..9daeafa2d 100644 --- a/wiki/Known-Gaps.md +++ b/wiki/Known-Gaps.md @@ -9,6 +9,7 @@ The version is the footer's, once — a row that says "on 1.2.0, do X" is naming | Gap | Symptom | Work around it by | |---|---|---| | `docker-compose.prod.yml` | declares `ports: ['3000:3000']` on `web` **and** `replicas: 3` — one host port has exactly one binder, so the second container dies on `Bind for 0.0.0.0:3000 failed: port is already allocated`. **Fixed on `main`, unreleased**: `web` and `sync` declare `replicas: 1` in all four files — the framework's, both tracked apps', and the one `x new` writes — and each header names the two ways up. `sync`'s `PORT` was wrong in the same file and fixed with it: the role binds `PORT + 1`, so `PORT: 3001` opened 3002 while publishing 3001 | on 1.2.0, set `replicas: 1`. To scale those two anyway: drop `ports:` and put your own reverse proxy on the compose network, or climb to the chart's per-role HPA. This is the rung-1 ceiling → [`docs/idea/17-scale-ladder.md`](https://github.com/developerz-ai/ultimate/blob/main/docs/idea/17-scale-ladder.md) | +| MCP tool names are published in `snake_case` and served verbatim | `openapi.json` carries `"x-ultimate": { "mcpTool": "publish_post" }` and `x actions describe --json` reports `mcp.tool: "publish_post"`, while the only name `tools/call` accepts is the export name — `publishPost` ([`packages/mcp/src/from-action.ts`](https://github.com/developerz-ai/ultimate/blob/main/packages/mcp/src/from-action.ts)). An agent handed either catalog calls a tool the server answers ToolNotFound (`-32601`) for. **15 of the 17** `mcpTool` values in the two tracked apps' committed specs name no served tool; the two that work are single-word exports (`summarize`, `health`), which are already their own snake_case form. **Fixed on `main`, unreleased, and BREAKING**: `toToolName` is deleted from `@ultimat3/action` and `@ultimat3/query` and exported by neither, all three publishers spell the export name, and `packages/mcp/src/cross-surface.test.ts` drives a `tools/call` with the name OpenAPI published so a re-derivation is a failing test | on 1.2.0, ignore the published `mcpTool` / `mcp.tool` value and call the **export name** — `x actions list --json` prints it as `name`, and `tools/list` on the app's own MCP surface is the authoritative catalog either way → [MCP and AI](MCP-And-AI) | | Shared cache tier invalidation | the Lua script `DEL`s keys it never declared in `KEYS`, so it fails on **Dragonfly** and on **Redis Cluster** — a cluster cannot route a key it was not told about. **Fixed on `main`, unreleased**: the script returns the member list and the tier deletes the value keys client-side, one key per `DEL`, so every delete is slot-local | on 1.2.0, single-node Redis, or a cache tier that is not the shared one → [Caching and invalidation](Caching-And-Invalidation) | | `resolveEnvironment` twice | the name exists in both `@ultimat3/core` and `@ultimat3/seo` with different parameters and different return unions. **Fixed on `main`, unreleased, and BREAKING**: `@ultimat3/seo` exports neither `resolveEnvironment` nor the type `SeoEnvironment`; `RobotsConfig.environment` and `isIndexable()` take core's `Environment`, `'preview'` is spelled `'staging'`, and core gains `tryResolveEnvironment()` for the callers that must answer rather than throw | on 1.2.0, import one with an alias → [Configuration](Configuration) | | HPAs read `` without a metrics adapter | **the chart half is fixed on `main`, unreleased** — `values.yaml` declares `metricsPort: 9090`, `_helpers.tpl` emits a `metrics` containerPort on every role but `migrate`, `service.yaml` publishes it by name and `templates/servicemonitor.yaml` ships the scrape target. Two things still stand: `serviceMonitor.enabled` defaults **false**, because a cluster without the Prometheus operator has no such CRD and `helm install` would fail on an unknown kind; and turning scraped series into the `Pods` metrics an HPA reads needs a **custom-metrics adapter**, which is the cluster's and never the chart's. App-side this is now closed on `main`, unreleased: `otlpSpanExporter()` and `otlpMetricExporter()` speak OTLP/HTTP JSON and read `OTEL_EXPORTER_OTLP_ENDPOINT` — the variable the chart's `env` already sets — so on 1.2.0 it is read by nothing and traces go to a no-op, and on `main` it works against a collector's `:4318`. gRPC `:4317` is refused by design, and there is no logs signal | set `serviceMonitor.enabled: true` and install a metrics adapter; until then disable the HPAs and pin `replicas`. **Do not hand-add a metrics container port** on `main` — the chart already emits one and a duplicate is rejected by the API server → [Observability](Observability) | diff --git a/wiki/MCP-And-AI.md b/wiki/MCP-And-AI.md index a66830a72..8212b744e 100644 --- a/wiki/MCP-And-AI.md +++ b/wiki/MCP-And-AI.md @@ -45,14 +45,14 @@ That line is the entire integration. From the existing declaration: | MCP requirement | Source | |---|---| -| tool name | action name | +| tool name | the action's export name, **verbatim** — `publishPost`, never `publish_post`. The one name `tools/call` accepts, `scopes:` is keyed on, and every published catalog spells. `As of 2026-08`: `openapi.json`'s `x-ultimate.mcpTool` and `describe().mcp.tool` published a snake_case name until then, and an agent that trusted either called a tool the server answers ToolNotFound for | | JSON Schema for input | the `input` schema (Standard Schema → JSON Schema, via `introspect()`) | | output schema | `output` | | description | `mcp.description` | | **authorization** | the action's `policy` — unchanged, unwrapped, identical | | audit trail | the same OTel span and log line as an HTTP call | -The projected tool calls `action.run(...)` — the same entry point the HTTP route calls. Policy runs inside `run`, so there is nothing to keep in sync. The projection itself adds **no** MCP scope: a second gate hard-coded into the projection would sit in front of the only gate that matters, and the two would eventually disagree. `defineAppMcp`'s `scopes:` map may still attach one from outside — a property of the connection's token, never invented by the projection. +The projected tool's `run` **is** `invoke` — the same entry point the HTTP route calls (`packages/mcp/src/projectable.ts`). Policy runs inside it, so there is nothing to keep in sync. The projection itself adds **no** MCP scope: a second gate hard-coded into the projection would sit in front of the only gate that matters, and the two would eventually disagree. `defineAppMcp`'s `scopes:` map may still attach one from outside — a property of the connection's token, never invented by the projection. No MCP-specific permission table, no service account with broad rights. Exposure is opt-in; silence exposes nothing. @@ -233,6 +233,7 @@ Full list: [Error codes](Error-Codes). CLI surface: [CLI reference](CLI-Referenc ## Rules - One authz system. An MCP call and an HTTP call reach the same `policy` with the same actor resolution. +- One name. A projected tool is called the primitive's export name, verbatim, in `tools/list`, in `scopes:`, in the LLM tool list, in `openapi.json` and in `describe()`. There is no second spelling to derive. - Exposure is opt-in per action; the projection carries no scope of its own — `defineAppMcp`'s `scopes:` map may still attach one, from outside the primitive. - Visibility is fail-closed and computed per connection. A hidden tool answers ToolNotFound, never Forbidden. - Gate order is visibility → scope → arguments → policy, and every outcome is audited. There is no trusted-tool mode. diff --git a/wiki/Queries-And-Live-Queries.md b/wiki/Queries-And-Live-Queries.md index 41eadfec1..70e69a39d 100644 --- a/wiki/Queries-And-Live-Queries.md +++ b/wiki/Queries-And-Live-Queries.md @@ -58,7 +58,7 @@ Every projection is a method on the query — `liveFeed.tool()`, never `toQueryT | Typed client hook | `input` + `sql` return type | `const feed = useLiveFeed({ orgId })` in `app/` — no fetch, no codegen step. Bound once with `liveHookFor`, below | | Live subscription | `live: true` | WS frames `{qid, op, row, lsn}` patched into a Solid signal | | Cache entry | tags from `sql` | key is the query name + a fingerprint of the parsed input + the sorted tag keys — the actor is not in it; see [Caching and invalidation](Caching-And-Invalidation) | -| MCP read tool | `input` + `policy` + name | one read tool per query, identical authz. See [MCP and AI](MCP-And-AI) | +| MCP read tool | `input` + `policy` + name | one read tool per query, named for the export **verbatim** — `liveFeed`, never `live_feed`. The URL is kebab-cased; the tool name is not. Identical authz. See [MCP and AI](MCP-And-AI) | ### The typed client hook, precisely diff --git a/wiki/Tutorial-02-First-Feature.md b/wiki/Tutorial-02-First-Feature.md index 2b267902e..c18723a32 100644 --- a/wiki/Tutorial-02-First-Feature.md +++ b/wiki/Tutorial-02-First-Feature.md @@ -109,7 +109,7 @@ Five artifacts, read off the real registry with `createTodo.describe()`, `.opena |---|---| | HTTP route | `POST /api/todos/create`, capability `todo:write` | | OpenAPI operation | `operationId: "createTodo"`, `summary` from `mcp.description` | -| MCP tool | `create_todo` — and `tool().policy === createTodo.policy` is `true`, one authz object, not a copy | +| MCP tool | `createTodo` — the export name verbatim, which is what `tools/call` spells — and `tool().policy === createTodo.policy` is `true`, one authz object, not a copy | | Job handle | `action:createTodo` — the same handler, run through the queue | | Contract tests | 3 generated assertions: garbage input rejected, anonymous denied, operation present in the spec | | Typed client | `.client({ baseUrl })` derives the path by string math, so the browser imports no server code | diff --git a/wiki/Upgrading.md b/wiki/Upgrading.md index c5ba929a2..a1f2b2705 100644 --- a/wiki/Upgrading.md +++ b/wiki/Upgrading.md @@ -2,7 +2,7 @@ **`As of 2026-08`. Semver applies from here.** A breaking change to a documented API needs a major. Every `@ultimat3/*` version is pinned exactly and moves in lockstep — never mix versions. -**The next release is a major.** [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md)'s `[Unreleased]` section carries **30** entries marked `BREAKING —`, `As of 2026-08`, and each one changes a surface the table below covers. Plan for the major, not for a minor with a long changelog. No version number and no date is promised here — both are decided at the tag. Read `[Unreleased]` before you pin. +**The next release is a major.** [`CHANGELOG.md`](https://github.com/developerz-ai/ultimate/blob/main/CHANGELOG.md)'s `[Unreleased]` section carries **31** entries marked `BREAKING —`, `As of 2026-08`, and each one changes a surface the table below covers. Plan for the major, not for a minor with a long changelog. No version number and no date is promised here — both are decided at the tag. Read `[Unreleased]` before you pin. ## What semver covers