Repository navigation
Commit 882372d
fix(runtime)!: the /mcp bridge's list_actions, run_action and resume_run read the actions no object embeds from the merged action listing, so a package's object-less action and an environment-authored one are listed and run (#25304)
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu4BM6tVgxhg4qFQsbuRHiy9)_
Fixes #25297
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 `action` items that no
object embeds (the object-less `global` actions among them) from the
protocol's merged `action` listing, the one `GET /api/v1/meta/action`
serves. They read the `metadata` service's `loadMany('action')` until
now. That call reads the service's loaders alone, so it never held an
action a package registers through the `manifest` service, nor an
environment-authored one, while REST's `POST
/api/v1/actions/global/NAME` (`OBJECT/NAME` for a bound one) ran both.
The listing is read through the same `readMergedItems` the object
listing uses since #25120 and #25139, not copied.
## The defect, measured on PR #25291's head `ae2b2bc5ff`
`collectActionDeclarations` (`packages/runtime/src/action-execution.ts`)
built its second shape, the standalone rows, from
`meta.loadMany('action')`. `MetadataManager.loadMany` aggregates
`loader.loadMany(type)` over the registered loaders and nothing else:
not the in-memory `register()` registry and not `sys_metadata`. On these
rigs, and in the in-repo compositions, the loaders are the filesystem
loader and, on an artifact boot, the artifact door's in-memory loader.
No `DatabaseLoader` is wired (an `environmentId` kernel skips it by
design).
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 through
the same dispatcher. Both shapes (`environmentId` `env_local` as
`createStandaloneStack` composes it, and none) answered the same on
every row:
| the standalone action, and where it came from | `loadMany('action')` |
`GET /meta/action` | `list_actions` | `run_action` | REST `POST
/actions/global/NAME` (`OBJECT/NAME` when bound) |
| :-- | :-- | :-- | :-- | :-- | :-- |
| a package's, registered through the `manifest` service (a plugin
during the boot, or a marketplace install) | absent | listed | **not
listed** | **`Action 'NAME' not found`** | `200`, the handler's answer |
| an environment-authored row (`protocol.saveMetaItem`, the `PUT
/api/v1/meta/action/NAME` write) | absent | listed | **not listed** |
**`Action 'NAME' not found`** | `200` |
| the same, bound to a package object (the merged object listing does
not fold an authored row into the object's `actions`) | absent | listed
| **not listed** | **`… not found on object 'OBJECT'`** | `200` |
| the compiled artifact's, its `packages[]` included (the artifact door
and `AppPlugin` over one bundle) | held | listed | listed | run | `200`
|
| the same, its package disabled (`registry.disablePackage`) | still
held | omitted | **listed** | **run** | `200` |
| a `manifest`-registered package's, the package disabled | absent |
omitted | not listed | `not found` | `200` |
Scoped to `objectName: 'global'`, `run_action` answered `Action 'NAME'
not found on object 'global'` on the first two rows. The MCP columns are
what the pins read; the bold cells are what the main leg below fails on.
The `loadMany` and `GET /meta/action` columns are the pins'
preconditions and a scratch probe over the same rig, not committed. The
last column of the last two rows is REST's door running a disabled
package's action, the defect #25296 tracks.
## What changed
- **`packages/runtime/src/domains/mcp.ts`.** `buildMcpBridge` gains
`readServedActions()`: `readMergedItems(protocol, 'action')`, whose type
union widens by `'action'`. `list_actions` hands it to
`collectActionDeclarations`, and `run_action` and `resume_run` pass it
as `readActions`. It answers `undefined` on a host with no merged read,
and the collection then reads `loadMany('action')` as before.
`resumeActionRun`'s wiring gains an optional `readActions`.
- **`packages/runtime/src/action-execution.ts`.**
`collectActionDeclarations(deps, meta, objects?, actions?)` and
`resolveActionByName(deps, meta, name, objectName?, objects?, actions?)`
take the standalone items as an optional last input; left out, they read
`meta.loadMany('action')` as before (its failure still answers none), so
REST's activation-ambiguity check, the one other caller, is unchanged.
`invokeBusinessAction`'s wiring gains an optional `readActions`. An
object-embedded declaration still wins over a standalone one with the
same object and name, and every gate after collection is untouched. None
of these functions is published by `index.ts`.
- **`.changeset/25297-mcp-standalone-actions-merged-listing.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`, on
both kernel shapes:
1. **`list_actions`, for an object-less action a package registered
through the `manifest` service, or an environment-authored action
(object-less, or bound to an object).** 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`
(`… not found on object 'global'`, or on its object, when scoped). New:
resolved and dispatched behind the same gates, as REST's `POST
/actions/OBJECT/NAME` 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. **A package's top-level action bound to one of its objects.** Old:
collected from the object's `actions`, where the registry carries it
(#25139). New: collected there and from the merged `action` listing too;
the object-embedded declaration wins the clash, so its answer does not
change.
5. **The three tools on a disabled compiled-artifact package's
object-less action** (the narrowing). Old: listed and run, from the
artifact loader's copy. New: omitted from `list_actions`; `run_action`
answers `Action 'NAME' not found` (`… on object 'global'` when scoped);
`resume_run` refuses its runs `403` (derived from the empty candidate
set, not pinned). As `GET /api/v1/meta/action` omits it and
`list_objects` omits the package's objects.
6. **The three tools while the merged `action` listing throws.** Old:
`loadMany('action')`, a failure of which answered none. New: the
protocol's error reaches the caller, as a throwing merged object listing
does since #25139; nothing falls back. Pinned with a throwing double,
not on a real store outage.
Unchanged: the compiled artifact's object-less actions are listed and
run as before (pinned as a control, its `loadMany` copy shown as the
precondition). Object-embedded actions, a host with no `protocol`
service or one without the merged listing (it reads
`loadMany('action')`), every gate after resolution, and REST's
`/actions` door, which still resolves through `ql.getSchema`, the
registry and `loadDiagnosed`. 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.
## Pins
- **`packages/runtime/src/domains/mcp-package-object-discovery.test.ts`,
38 new cases on real kernels, in #25120's rig (144 in the file).** A
package of two objects with two top-level object-less actions,
`qa_glb_ping` (exposed to AI, a registered handler) and `qa_glb_hidden`
(not exposed), and two environment-authored actions saved through the
protocol after the boot, `qa_authored_ping` (object-less) and
`qa_authored_bound` (bound to the package's object). For each of the
four shape×registration rows: `list_actions` lists exactly
`qa_authored_ping` and `qa_glb_ping` among the object-less actions;
`run_action qa_glb_ping` answers the handler's result unscoped and
scoped to `global`, and REST's door answers it `200`; `run_action
qa_authored_ping` likewise, as REST does; `qa_authored_bound` is listed
on its object and run there, as REST runs it; `qa_glb_hidden` is
resolved and refused at the exposure gate; and a name nothing declares
is still `Action 'qa_nowhere' not found`. For a disabled package on both
shapes: `list_actions` lists `qa_authored_ping` alone, and `run_action`
answers `qa_glb_ping` "not found", scoped and unscoped, while the
authored one still runs. For a compiled artifact booted as
`createStandaloneStack` boots one (the artifact door and an `AppPlugin`
over the same bundle), on both shapes: a precondition shows the metadata
service's loaders hold both object-less actions; as a control,
`list_actions` lists and `run_action` runs both, as REST does; with the
artifact's `packages[]` entry disabled, the loaders still hold its
action (precondition), `list_actions` lists only the project's own, and
`run_action` answers the package's "not found", scoped and unscoped, and
still runs the project's own.
- **`packages/runtime/src/mcp-resume-run.test.ts`, 3 new cases, doubles
(28 in the file).** With a merged listing that serves an object-less
flow action and a `metadata` service whose `loadMany` holds none:
`run_action` starts the screen flow and `resume_run` completes it,
`getMetaItems({ type: 'action' })` asked and `loadMany` never;
`list_actions` lists it. With a merged `action` listing that throws (the
object listing still served): `list_actions`, `run_action` and
`resume_run` fail with its error, `loadMany` is not asked, and the
engine's `resume` is not called. The harness's `standalone` option and
`'action-throws'` arm are additive; every existing case runs as before.
**Red before, green after.** Each leg's failing set was itemized before
it ran; all six runs read both pin files (172 cases), on this PR's head
`ee2cd6e7` over `origin/main` `fb7501bc6` (PR #25291's merge). A first
pass on a local commit over PR #25291's unmerged head, before the
bound-action case was added, answered the same legs with the same
itemized sets (27, 10, 18, 2, 26, 0 of 168). Mutations were landed and
restored by `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed;
restored blob equals HEAD, `git diff HEAD` empty); the main leg wrote
the base blobs of the two files and restored them by checkout from 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 | 31 | on the four rigs every case but the control
(20); the two disabled-package rigs (4, the authored action missing);
the two disabled-artifact-package cases on each shape (4); the three
unit cases |
| listactions | `list_actions` hands no standalone items | 14 | every
real-kernel case that reads `list_actions`, but the artifact control
(12), the unit listing case, the throwing case |
| invoke | `run_action` hands no standalone items | 22 | the four
`run_action` cases on the four rigs (16), the disabled-package and
disabled-artifact-package `run_action` cases (4), the unit resume case
(its `run_action` start), the throwing case |
| resume | `resume_run` hands no standalone items | 2 | the unit resume
case (`403`), the throwing case |
| collect | `collectActionDeclarations` ignores the items it is given |
30 | every case the main leg failed but the throwing one (the bridge
still asks the throwing listing) |
| fix | `ee2cd6e7` | 0 | 172 passed |
## Local verification
On `ee2cd6e7`, this PR's head, over `origin/main` `fb7501bc6`. 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`, 172 passed.
- `@objectstack/runtime` unit suite (`pnpm --filter @objectstack/runtime
test`): `VERDICT command-exit 0`; Test Files 436 passed (436); Tests
6969 passed, 19 skipped (6988).
- `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`.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 68 families derived; each run, every one
`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-adr-0087-registration.mjs --base origin/main` on
this head: exit 0 (one declared-breaking changeset, `not-required
(no-migration-prescription)`).
- `node scripts/check-closing-keyword-parity.mjs --body` on this body:
exit 2, its "closing declarations found" answer: the one `Fixes` line at
the top of this body and nothing else, bound by the duplicate-fix
guard's and the part-of guard's parsers.
- The full dogfood battery (owed by the `!` and the `(narrowing)` arm;
the PR's Dogfood shards skip on pull requests) runs once on this head
after the PR opens, and its counts are posted on this PR as the
`dogfood_battery` comment.
## Acceptance notes
- **The card's environment-authored control was not green at the base.**
The card assumed `loadMany('action')` holds the environment-authored
rows. It does not on either shape: the row is in `sys_metadata`, which
no loader of the metadata service reads here, so the authored action was
invisible to MCP by the same cause, and this change makes it listed and
run. The control that is green at the base, and stays green, is the
compiled artifact's object-less action; the pins carry both.
- **The narrowing is the artifact loader's copy.** A disabled artifact
package's object-less action was listed and run through the copy the
artifact door keeps, as #25139 found for a disabled package's objects. A
`manifest`-registered package's was never there, so its disable already
hid it from MCP.
- **REST's door still runs a disabled package's object-less action**
(`200` on the last two rows of the table). That is #25296's door, not
this one.
- **The served listing, not the execution one.** `run_action` resolves
its declaration from `getMetaItems`, the served face that answers
callers, as `list_actions` lists from it and as #25139 resolves
object-embedded actions. `action` has no registered redactor, so the
served body is the stored one today. `getMetaItemsForExecution` is
documented as never to be called from an MCP door.
- **Each call now reads the merged listing twice**, once for `object`
and once for `action`.
- **REST's activation-ambiguity check still collects from
`loadMany('action')`.** `refuseAmbiguousActionActivation`
(`domains/actions.ts`) passes no items, so its reading is unchanged. Not
MCP, and not measured here.
- **Branch name.** This PR's branch is `claude/project-thread-dkk3hd`,
the name the session was given, not `claude/issue-25297-…`; the
Duplicate Fix Guard may say so. It carried #25291 (#25139) 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 829cb77 commit 882372d
5 files changed
Lines changed: 426 additions & 19 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 | |
|---|---|---|---|
| |||
2606 | 2606 | | |
2607 | 2607 | | |
2608 | 2608 | | |
| 2609 | + | |
| 2610 | + | |
| 2611 | + | |
| 2612 | + | |
| 2613 | + | |
2609 | 2614 | | |
2610 | 2615 | | |
2611 | 2616 | | |
2612 | | - | |
| 2617 | + | |
2613 | 2618 | | |
2614 | 2619 | | |
2615 | 2620 | | |
2616 | 2621 | | |
2617 | 2622 | | |
2618 | | - | |
| 2623 | + | |
| 2624 | + | |
| 2625 | + | |
2619 | 2626 | | |
2620 | 2627 | | |
2621 | 2628 | | |
| |||
2828 | 2835 | | |
2829 | 2836 | | |
2830 | 2837 | | |
2831 | | - | |
| 2838 | + | |
| 2839 | + | |
2832 | 2840 | | |
2833 | 2841 | | |
2834 | 2842 | | |
2835 | 2843 | | |
2836 | 2844 | | |
2837 | 2845 | | |
| 2846 | + | |
2838 | 2847 | | |
2839 | | - | |
| 2848 | + | |
2840 | 2849 | | |
2841 | 2850 | | |
2842 | 2851 | | |
| |||
2874 | 2883 | | |
2875 | 2884 | | |
2876 | 2885 | | |
2877 | | - | |
| 2886 | + | |
| 2887 | + | |
| 2888 | + | |
| 2889 | + | |
| 2890 | + | |
| 2891 | + | |
| 2892 | + | |
| 2893 | + | |
| 2894 | + | |
| 2895 | + | |
| 2896 | + | |
| 2897 | + | |
| 2898 | + | |
2878 | 2899 | | |
2879 | 2900 | | |
2880 | 2901 | | |
2881 | 2902 | | |
| 2903 | + | |
2882 | 2904 | | |
2883 | 2905 | | |
2884 | 2906 | | |
| |||
2896 | 2918 | | |
2897 | 2919 | | |
2898 | 2920 | | |
2899 | | - | |
2900 | | - | |
2901 | | - | |
2902 | | - | |
2903 | | - | |
| 2921 | + | |
| 2922 | + | |
| 2923 | + | |
| 2924 | + | |
| 2925 | + | |
| 2926 | + | |
| 2927 | + | |
| 2928 | + | |
| 2929 | + | |
2904 | 2930 | | |
2905 | 2931 | | |
2906 | 2932 | | |
| |||
0 commit comments