Repository navigation
Commit fb7501b
fix(runtime)!: the /mcp bridge's list_actions, run_action and resume_run read an object's actions from the listing list_objects serves, so a package object's actions are listed and run (#25291)
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu4BM6tVgxhg4qFQsbuRHiy9)_
Fixes #25139
Clause-②: no (narrowing)
Metadata-protocol: none
The HTTP MCP bridge's three business-action tools, `list_actions`,
`run_action` and `resume_run`, now collect the actions declared on
objects from the object definitions `list_objects` lists: the protocol's
merged listing, the one `GET /api/v1/meta/object` serves (#25120). They
read the `metadata` service's `listObjects()` until now, so on a kernel
with an `environmentId` they saw none of the actions on an object a
package registers during the boot, while `list_objects` listed the
object and REST's `/actions` door ran the actions. The listing is read
once per call through the same function `list_objects` uses, not copied.
## The defect, measured on base `cc8d886d4`
`collectActionDeclarations` (`packages/runtime/src/action-execution.ts`)
built its object-embedded rows from `meta.listObjects()`, and
`list_actions`, `run_action` (through `resolveActionByName`) and
`resume_run` all read it. The `metadata` service holds a copy of an
object only where something put one there. On a kernel with an
`environmentId` (`createStandaloneStack`: `os dev` as `env_local`, `os
serve`, `os start`; and the cloud single-environment runtime) nothing
copies an object a package registers in the `SchemaRegistry` during the
boot.
Real kernels (`ObjectKernel` + `MetadataPlugin` + `ObjectQLPlugin` over
a better-sqlite3 `SqlDriver`), the bridge the real `/mcp` entry hands
the MCP service (`HttpDispatcher.handleMcp`), and REST's door driven
through the same dispatcher (`POST /actions/OBJECT/NAME`). The fixture
package: an object with a declarative update exposed to AI
(`mark_done`), a script action exposed to AI whose handler is registered
(`ping`), a declarative update not exposed (`hidden_done`), and a
top-level action bound to the object (`bound_done`); and an `apiEnabled:
false` object with a declarative update exposed to AI (`gated_done`).
| shape, when the package registered | `list_actions` | `run_action
mark_done` on a record | `run_action ping` | REST `POST
/actions/qa_act_open/mark_done` | REST `ping` |
| :-- | :-- | :-- | :-- | :-- | :-- |
| `env_local`, during the boot | **none of the four** | **`Action
'mark_done' not found on object 'qa_act_open'`** | **`Action 'ping' not
found on object 'qa_act_open'`** | `200`, the record updated | `200`, `{
pong: true }` |
| `env_local`, installed after the boot (`source: 'marketplace'`) | all
four | the record updated | `{ pong: true }` | `200`, updated | `200` |
| no `environmentId`, during the boot | all four | the record updated |
`{ pong: true }` | `200`, updated | `200` |
| no `environmentId`, installed after the boot | all four | the record
updated | `{ pong: true }` | `200`, updated | `200` |
The MCP columns of the first row are the pins' main leg below, verbatim.
The REST columns and the disabled-package reading come from a scratch
probe over the same rig and fixture (object prefix `qa_pkg`), not
committed.
Without `objectName`, `run_action mark_done` answered `Action
'mark_done' not found` on the first row and resolved on the other three.
The bound top-level action rides inside the object's `actions` on every
shape (in the merged listing and in the copy alike), so it followed the
same split.
**A disabled package (`registry.disablePackage`).** On the shape without
an `environmentId` the boot copied the objects into the `metadata`
service, actions and all, and the copy outlives the disable:
`list_actions` listed all four and `run_action ping` ran the handler (`{
pong: true }`), while `list_objects` and `describe_object` omit the
objects since #25120 and `GET /api/v1/meta/object` omits them. On
`env_local` there is no copy, so the bridge already listed none and
answered "not found".
## What changed
- **`packages/runtime/src/domains/mcp.ts`.** `buildMcpBridge` gains
`readServedObjects()`: the merged listing (`readMergedObjects`,
unchanged from #25120), or the `metadata` service's `listObjects()` on a
host with no merged read. `list_objects` now lists through it (its
answer does not change), and `list_actions`, `run_action` and
`resume_run` collect object-embedded actions from it.
`resumeActionRun`'s wiring gains an optional `readObjects`.
- **`packages/runtime/src/action-execution.ts`.**
`collectActionDeclarations(deps, meta, objects?)` and
`resolveActionByName(deps, meta, name, objectName?, objects?)` take the
object listing as an optional last input; left out, they read
`meta.listObjects()` as before, so REST's activation-ambiguity check,
the one other caller, is unchanged. `invokeBusinessAction`'s wiring
gains an optional `readObjects`. Standalone `action` rows are still read
from `meta.loadMany('action')`, and an object-embedded declaration still
wins a clash. None of these functions is published by `index.ts`.
- **`.changeset/25139-mcp-actions-merged-object-view.md`.** A `patch`
for `@objectstack/runtime`, with the BREAKING banner and the ADR-0087
disposition `not-required (no-migration-prescription)`; both declaration
lines are in the body.
## Contract
Every input whose answer changes, old to new, on `POST /api/v1/mcp`:
1. **`list_actions` on a kernel with an `environmentId`, for an action
declared on an object a package or plugin registered during the boot**
(inside the object, or a top-level action bound to it). Old: not listed.
New: listed, behind the same filters (headless-invokable, a registered
handler for a script action, `ai.exposed`, `requiredPermissions`).
2. **`run_action` for such an action.** Old: `Action 'NAME' not found on
object 'OBJECT'`, or `Action 'NAME' not found` without `objectName`.
New: resolved and dispatched behind the same gates as on every other
shape, as REST's `/actions` door runs it.
3. **`resume_run` of a run started by such a flow action.** Old: refused
`403` (no flow action it could see targets that flow). New: admitted
through `run_action`'s gates. Pinned with doubles, not on a real
automation engine.
4. **The three tools on a disabled package's actions, where the
`metadata` service still holds a copy of its objects** (the narrowing;
every kernel without an `environmentId`, and a marketplace install on
any kernel). Old: listed; `run_action` ran a script action's handler,
and refused a declarative update at the record load
(`RECORD_NOT_FOUND`). New: omitted from `list_actions`; `run_action`
answers `Action 'NAME' not found on object 'OBJECT'`; `resume_run`
refuses their runs `403`. As `describe_object` answers their objects
"not found".
5. **An object whose served definition differs from the `metadata`
service's copy.** Old: its actions came from the copy. New: from the
definition REST serves.
6. **The three tools while the protocol's merged listing throws.** Old:
the `metadata` service's listing. New: the protocol's error reaches the
caller, as `list_objects` lets it since #25120; nothing falls back.
Pinned with a throwing double, not on a real store outage.
Unchanged: actions on objects that already had a copy are listed and run
as before. Standalone `action` rows, a host with no `protocol` service
or one without the merged listing (it reads the `metadata` service),
every gate after resolution, and REST's `/actions` door, which still
resolves through `ql.getSchema` and the registry. The published surface
does not move: `@objectstack/runtime`'s `index.ts` exports none of the
four functions, and each gains only an optional trailing input. The
parity target the card names is met: on every row of the table above,
`run_action` now answers what REST's door answers the same request
(pinned: the declarative update, an absent record, the script action).
## Pins
- **`packages/runtime/src/domains/mcp-package-object-discovery.test.ts`,
36 new cases on real kernels, in #25120's rig (106 in the file).** For
each of the four rows above: `list_actions` lists exactly `gated_done`,
`bound_done`, `mark_done` and `ping` on the package's two objects, and
not `hidden_done`; `run_action mark_done` updates a seeded record (read
back through `get`), and REST's door updates another, `200`; without
`objectName` on an absent record both answer `RECORD_NOT_FOUND` / `404`;
`bound_done` updates a record; `ping` answers `{ pong: true }` through
the handler, as REST does; `hidden_done` is resolved and refused at the
exposure gate; and a name nothing declares is still `Action 'qa_nowhere'
not found on object 'qa_act_open'`. For a disabled package on both
shapes: `describe_object` answers null (precondition), `list_actions`
lists none of the package's actions, and `run_action` answers "not
found" for `mark_done`, `ping` and `bound_done`, scoped and unscoped; on
the shape without an `environmentId` a precondition shows the copy still
holds the actions. The rig's `bridgeFor` takes an optional caller
(default unchanged), so these cases carry a `tenantId` and can seed
records.
- **`packages/runtime/src/mcp-resume-run.test.ts`, 3 new cases, doubles
(25 in the file).** With a merged listing that serves the object and a
`metadata` service that lists nothing: `run_action` starts the screen
flow and `resume_run` completes it, the `metadata` service's
`listObjects` never asked; `list_actions` lists the object's three
exposed and permitted flow actions. With a merged listing that throws:
`list_actions`, `run_action` and `resume_run` fail with its error, the
`metadata` service is not asked, and the engine's `resume` is not
called. The harness's `merged` option is additive; every existing case
runs as before.
**Red before, green after.** Each leg's direction was predicted before
it ran; all six runs read both pin files (131 cases), on `c5e95a9d` over
`cc8d886d4`. The final head is that commit cherry-picked onto the newer
`origin/main` (no `packages/runtime` file moved between the two bases)
with one change in the pin file: a precondition's local is typed instead
of `any`, which lint flagged. Both pin files were re-run on the final
head (below). Mutations were landed and restored by
`scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed; restored
blob equals HEAD, `git diff HEAD` empty). These files import the
runtime's source, so no `dist/` is involved.
| leg | mutation | failed | which |
| :-- | :-- | :-- | :-- |
| main | the base tree's `action-execution.ts` and `domains/mcp.ts`,
with the new pins | 13 | the six `env_local` during-boot cases, the four
no-`environmentId` disabled-package cases, the three `resume_run` cases
|
| listactions | `list_actions` collects from the `metadata` service | 4
| the two real-kernel `list_actions` cases (`env_local` during the boot;
disabled without an `environmentId`), the unit listing case, the
throwing case |
| invoke | `run_action` resolves against the `metadata` service | 10 |
the five `env_local` during-boot `run_action` cases, the three
disabled-package `run_action` cases, the unit resume case (its
`run_action` start), the throwing case |
| resume | `resume_run` admits against the `metadata` service | 2 | the
unit resume case (`403`), the throwing case |
| collect | `collectActionDeclarations` ignores the listing it is given
| 12 | every real-kernel case the main leg failed, the unit resume and
listing cases |
| fix | `c5e95a9d` | 0 | 131 passed |
## Local verification
On `ae2b2bc5ff`, the final commit, over `origin/main` `bda53bb0ba`,
unless a line says otherwise. Every heavy run went through the verify
lock, and each line cites its `VERDICT`.
- `pnpm turbo run build --filter='@objectstack/dogfood^...'
--concurrency=2` (the runtime's closure included): `VERDICT command-exit
0`, 66 of 66 tasks.
- Both pin files (`npx vitest run
src/domains/mcp-package-object-discovery.test.ts
src/mcp-resume-run.test.ts` in `packages/runtime`): `VERDICT
command-exit 0`, 131 passed.
- `@objectstack/runtime` unit suite (`pnpm --filter @objectstack/runtime
test`): `VERDICT command-exit 0`; Test Files 435 passed (435); Tests
6924 passed, 19 skipped (6943).
- `pnpm --filter @objectstack/runtime typecheck`: `VERDICT command-exit
0` (`tsc --noEmit`, and `check:test-typecheck` OK).
- `pnpm lint` (`eslint . --no-inline-config`): `VERDICT command-exit 0`.
The first lint of the changed files, on `c5e95a9d`, answered one error
(a service-lookup result erased to `any` in a pin's precondition), fixed
before the final head.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 68 families derived; each run, every one
`exit 0` (a first pass on `efcb85dc` over `cc8d886d4`, the same five
files, also answered 68 of 68 `exit 0`).
- `--ran` reconciliation: `68 derived, 68 run, 0 NOT-MEASURED`, exit 0.
- `node scripts/check-changeset-no-major.mjs --base origin/main --event
PAYLOAD`, with PAYLOAD a `pull_request` event carrying this title and
body: exit 0 (`✓ LEVEL AXIS`: `no (narrowing)`, `Metadata-protocol:
none`, a non-protocol change at `patch`).
- `node scripts/check-closing-keyword-parity.mjs --body` on this body:
exit 2, its "closing declarations found" answer: `Fixes #25139` and
nothing else, bound by the duplicate-fix guard's and the part-of guard's
parsers.
- The full dogfood battery, once, on the final head (`pnpm --filter
@objectstack/dogfood test`, no shard,
`NODE_OPTIONS=--max-old-space-size=8192`): `VERDICT command-exit 0`;
Test Files 289 passed, 3 skipped (292); Tests 2491 passed, 18 skipped
(2509); no failure. Owed by the `!` and the `(narrowing)` arm; the PR's
Dogfood shards skip on pull requests.
## Acceptance notes
- **Found while measuring, not changed here: REST's `/actions` door runs
a disabled package's script action.** `POST /actions/OBJECT/ping`
answered `200 { pong: true }` for a disabled package's object on both
shapes (its declarative update is refused at the record load), while the
data plane refuses the object `404 OBJECT_PACKAGE_DISABLED`. After this
change the MCP door answers "not found" there; the REST door is a
different door and a different card.
- **Found while measuring, not changed here: a package's object-less
action is invisible to MCP on every shape.** A top-level action with no
`objectName` (a `global` action) is in the registry's `action` items and
REST's `POST /actions/global/NAME` runs it, but
`collectActionDeclarations` reads standalone rows only from
`meta.loadMany('action')`, which holds the environment-authored rows
alone, so `list_actions` never lists it and `run_action` answers `Action
'NAME' not found` on both shapes. Not this card's shape defect.
- **REST's activation-ambiguity check still collects from the `metadata`
service.** `refuseAmbiguousActionActivation` (`domains/actions.ts`) is
the one other caller of `collectActionDeclarations`; it passes no
listing, so its reading on an `environmentId` kernel is unchanged. Not
MCP, and not measured here.
- **Each call reads the whole merged listing once**, as `list_objects`
and `describe_object` do since #25120.
- **Branch name.** This PR's branch is `claude/project-thread-dkk3hd`,
the name the session was given, not `claude/issue-25139-…`; the
Duplicate Fix Guard may say so. It carried #25268 (#25245) until that
merged, and was restarted from `origin/main` for this card.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01TgbP2QERdKmh147ohacHbK)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01TgbP2QERdKmh147ohacHbK
Co-authored-by: Claude <noreply@anthropic.com>1 parent f47f7f2 commit fb7501b
5 files changed
Lines changed: 329 additions & 22 deletions
File tree
- .changeset
- packages/runtime/src
- domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2601 | 2601 | | |
2602 | 2602 | | |
2603 | 2603 | | |
| 2604 | + | |
| 2605 | + | |
| 2606 | + | |
| 2607 | + | |
| 2608 | + | |
2604 | 2609 | | |
2605 | 2610 | | |
2606 | 2611 | | |
2607 | | - | |
| 2612 | + | |
2608 | 2613 | | |
2609 | 2614 | | |
2610 | 2615 | | |
2611 | 2616 | | |
2612 | 2617 | | |
2613 | | - | |
| 2618 | + | |
2614 | 2619 | | |
2615 | 2620 | | |
2616 | 2621 | | |
| |||
2822 | 2827 | | |
2823 | 2828 | | |
2824 | 2829 | | |
| 2830 | + | |
| 2831 | + | |
2825 | 2832 | | |
2826 | 2833 | | |
2827 | 2834 | | |
2828 | 2835 | | |
2829 | 2836 | | |
| 2837 | + | |
2830 | 2838 | | |
2831 | | - | |
| 2839 | + | |
2832 | 2840 | | |
2833 | 2841 | | |
2834 | 2842 | | |
| |||
2860 | 2868 | | |
2861 | 2869 | | |
2862 | 2870 | | |
| 2871 | + | |
| 2872 | + | |
| 2873 | + | |
| 2874 | + | |
| 2875 | + | |
| 2876 | + | |
| 2877 | + | |
2863 | 2878 | | |
2864 | 2879 | | |
2865 | 2880 | | |
| 2881 | + | |
2866 | 2882 | | |
2867 | | - | |
| 2883 | + | |
2868 | 2884 | | |
2869 | 2885 | | |
2870 | 2886 | | |
| |||
0 commit comments