Repository navigation
Commit 8010b64
fix(mcp)!: the stdio data bridge reads objects from the protocol's merged view, so a package's objects are listed, described and gated (#25243)
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu4BM6tVgxhg4qFQsbuRHiy9)_
Fixes #25138
Clause-②: no (narrowing)
Metadata-protocol: none
The stdio MCP transport (`objectstack mcp`, `OS_MCP_STDIO_ENABLED`) now
reads objects from the protocol's merged view, as the `/mcp` HTTP bridge
does since #25120. `list_objects` and `describe_object` answer from the
listing `GET /api/v1/meta/object` serves. The API-exposure gate every
stdio data verb and the record resource ask reads the object by name
through the protocol, the read `RestServer`'s own gate makes. So a
package object declared `apiEnabled: false` is refused on stdio as REST
refuses it, and every object of a disabled package is refused `404
OBJECT_PACKAGE_DISABLED` on every verb. Nothing is copied into the
`metadata` service.
## The defect, measured on base `123ff6f4e2`
`createStdioDataBridge` answered `list_objects` from
`metadataService.listObjects()` and `describe_object` from
`getObject()`. `enforceApiExposure`, the gate on all six data verbs, on
the record resource (`plugin.ts` `getRecord`) and on nested-relation
targets, read the same `getObject()` and treated a missing copy as "no
declaration", that is, allowed. That 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) the full boot bridge
is off, so an object a package registers in the `SchemaRegistry` during
the boot gets no copy.
Real kernels (`ObjectKernel` + `MetadataPlugin` + `ObjectQLPlugin`, the
same composition the HTTP pins use), driven through the real
`MCPServerPlugin` stdio session over an in-process transport speaking a
stdio client's JSON-RPC. The fixture package has two objects, one
declared `apiEnabled: false`, registered through the kernel's `manifest`
service:
| shape, when the package registered | `list_objects` |
`describe_object` | six data verbs on the `apiEnabled: false` object |
record resource |
| :-- | :-- | :-- | :-- | :-- |
| `env_local`, during the boot | **`{ objects: [], totalCount: 0 }`** |
**`Object "qa_pkg_open" not found`** | **served**: `query_records`,
`aggregate_records` and `create_record` answered, `get_record` read the
store, `update_record` and `delete_record` reached it
(`RECORD_NOT_FOUND` for the absent id) | **read the store** (`Record
"r_any" not found`) |
| `env_local`, installed after the boot (`source: 'marketplace'`) | both
| both | `404 OBJECT_API_DISABLED` | `404` |
| no `environmentId`, during the boot | both | both | `404
OBJECT_API_DISABLED` | `404` |
| no `environmentId`, installed after the boot | both | both | `404
OBJECT_API_DISABLED` | `404` |
The first row is the card's reading. The other three are controls:
something already copied the package's objects there, so they were right
and must stay right.
**A disabled package (`registry.disablePackage`), on both shapes.** The
protocol omits its objects from its listing and refuses them by name
`404 OBJECT_PACKAGE_DISABLED`, and REST refuses them on every `/data`
route. Every stdio data verb asks the ENGINE, never the protocol, and
the engine serves them. All six verbs and the record resource served the
exposed object on both shapes, and the `apiEnabled: false` object where
it had no copy (`env_local`); where it had one, the copy refused it `404
OBJECT_API_DISABLED`. On the shape that copies, the copy outlives the
disable, so `list_objects` listed both objects and `describe_object`
described them. This is the posture the HTTP bridge's `aggregate` arm
had before #25136 closed it, here on every verb.
## What changed
- **`packages/mcp/src/stdio-data-bridge.ts`.**
- `readMergedObjects` reads `protocol.getMetaItems({ type: 'object' })`,
unwrapped by the same `metaItemsArray` the skill read on this transport
uses. `listObjects` serves it; `describeObject` answers from the same
listing, so every listed object can be described and a name the listing
omits is "not found". A host with no `protocol` service, or one without
`getMetaItems`, reads the `metadata` service as before. A merged read
that throws travels to the caller, as on the HTTP bridge; nothing falls
back. `listObjectsDiagnosed` keeps its `partial` verdict from
`diagnoseObjectListRead(metadataService)`.
- `readObjectDeclaration` reads the object by name through the protocol,
on the execution face (`getMetaItemForExecution`) where the protocol has
one and `getMetaItem` otherwise: the item under its own name is the
definition; nothing, or an item under another name, is "no declaration"
as before; a refusal `OBJECT_PACKAGE_DISABLED` is THROWN; any other
throw judges the `metadata` service's copy, the source this gate read
before, so an outage never widens exposure. A host whose protocol has no
by-name read keeps reading the copy.
- `enforceApiExposure` takes `StdioObjectReads` (`{ metadataService,
protocol }`) in place of the bare service, reads through
`readObjectDeclaration`, and lets the disabled-package refusal through
its fail-open `catch`. The relation-target gate and the write-response
strip (`internal: true` fields omitted from the `create_record` /
`update_record` echo) read the same definition.
- The `GATED_ACTIONS` docblock no longer says the HTTP bridge reads
discovery "straight off the metadata service" (the stale wording
#25136's notes left on this card).
- **`packages/mcp/src/plugin.ts`.** The bridge and the record resource's
gate get the `protocol` service through one accessor that resolves it at
each use (`ctx.getService('protocol')`, `undefined` when absent or
throwing), never captured in `start()`.
- **`packages/mcp/src/mcp-server-runtime.ts`.** `metaItemsArray` is
exported within the package for the bridge; `index.ts` does not publish
it.
- **`packages/mcp/src/stdio-data-bridge.exposure.test.ts`,
`plugin.record-resource-exposure.test.ts`.** The two direct callers of
`enforceApiExposure` pass `{ metadataService }`; their assertions are
unchanged, and on a host with no protocol the gate reads the copy
exactly as before.
- **`scripts/engine-double-contract.pinned.json`.** Records the new unit
pin file's engine double (written by `check-engine-double-contract.mjs
--write`).
- **`.changeset/25138-mcp-stdio-package-objects.md`.** A `patch` for
`@objectstack/mcp`, 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 the stdio transport:
1. **`list_objects` on a kernel with an `environmentId`, for an object a
package or plugin registered during the boot.** Old: omitted,
`totalCount` short. New: listed; the non-`sys_` set is what `GET
/api/v1/meta/object` lists.
2. **`describe_object` on such an object.** Old: `Object "NAME" not
found`. New: described.
3. **`query_records`, `get_record`, `aggregate_records`,
`create_record`, `update_record`, `delete_record` and the
`objectstack://objects/NAME/records/ID` resource on such an object
declared `enable.apiEnabled: false`.** Old: served, the writes reaching
the engine. New: `404 OBJECT_API_DISABLED`, the engine never asked; `405
OBJECT_API_METHOD_NOT_ALLOWED` for a verb an `apiMethods` whitelist
leaves out. The codes REST and the HTTP bridge answer.
4. **`create_record` / `update_record` on such an object with an
`internal: true` field.** Old: the field echoed in the response. New:
omitted, as on every other object.
5. **A nested-relation condition on such an object whose target is
unexposed.** Old: the condition was not found (no field map), so its
target was not gated. New: the target is gated.
6. **A disabled package's objects, where the `metadata` service still
holds a copy.** Old: listed and described. New: omitted and not found,
as REST answers.
7. **Every data verb and the record resource on an object of a disabled
package, whatever it declares, on every kernel shape.** Old: an exposed
object served on every shape; one declared `apiEnabled: false` served
where it had no copy and refused `404 OBJECT_API_DISABLED` where it had
one. New: `404 OBJECT_PACKAGE_DISABLED`, the protocol's own error,
refused at the gate with the engine never asked. No refusal's status
moves.
8. **An object whose served definition differs from the `metadata`
service's copy.** Old: discovery and the gate read the copy. New: they
read the definition REST serves.
9. **`list_objects` / `describe_object` while the protocol's merged
listing throws** (the metadata store cannot be read). Old: the
`metadata` service's listing. New: the protocol's error travels to the
client (its `503 SERVICE_UNAVAILABLE`, what `GET /api/v1/meta/object`
answers); nothing falls back. Pinned with a throwing double, not on a
real store outage.
Unchanged: objects that already had a copy (every object on a kernel
without an `environmentId`, a marketplace install on any kernel, a
runtime-authored object) are listed and gated as before. A host with no
`protocol` service, or one without the merged reads, reads the
`metadata` service. The gate's outage branch judges the copy. A system
caller (`isSystem`) still bypasses the gate.
## Pins
-
**`packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts`,
70 cases, real kernels.** For each of the four rows above (10 each):
`list_objects` lists both package objects with a complete `totalCount`;
`describe_object` answers both, `enableFeatures.apiEnabled: false`
included, and a name nothing serves is still "not found"; each of the
six data verbs refuses the gated object `{ code: 'OBJECT_API_DISABLED',
status: 404 }` with no engine `find` / `findOne` / `insert` / `update` /
`delete` / `count` / `aggregate` call on it; the record resource refuses
it; the exposed object is queried and created, and `create_record` does
not echo its `internal: true` field. For a disabled package on both
shapes (15 each, the copy's existence a precondition on the shape that
copies): neither object is listed nor described; each of the six verbs
and the record resource refuses both objects `{ code:
'OBJECT_PACKAGE_DISABLED', status: 404 }`, the engine never reached.
- **`packages/mcp/src/stdio-data-bridge.merged-object-reads.test.ts`, 39
cases, doubles.** The engine double's `findOne`, `update` and `delete`
open with the shared `assertEngine*` dispatch asserts. Each branch of
the two reads: listing from the merged read where the copy is absent,
the bare-array answer, the `partial` verdict kept, a throwing merged
read propagated; the gate on all six verbs and the record resource's
gate, the served face where there is no execution face, the served
definition over a stale copy, an item under another name; the
disabled-package refusal on every verb with and without a copy, the
system caller still let through; the outage branch judging the copy and
falling open with none; the relation-target gate and both write-response
strips.
**Red before, green after**, measured on `eb3b93d9bd`. The later commits
change one pin title (`fbb65d51`), type two test doubles (`2c6cb414`)
and one lookup (`1261513f`), and route the unit double through the
engine asserts (`476460a8`); no source blob or assertion moved, and both
pin files pass in full on the final head (above). Each leg's direction
was predicted before it ran. 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 swapped the three
source files to their base blobs under an `EXIT`/`INT`/`TERM` trap and
checked blob equality and an empty `git diff HEAD` after. The dogfood
file consumes `@objectstack/mcp`'s `dist/`, so every leg rebuilt it and
`scripts/ablation-dist-preflight.mjs` proved the leg's marker reached
`dist/` (and, after the restore, that none remained, exit 0 for each).
| leg | mutation | dogfood (70) | unit (39) |
| :-- | :-- | :-- | :-- |
| main | `stdio-data-bridge.ts`, `plugin.ts`, `mcp-server-runtime.ts` at
base blobs `cf59e76683`, `18a0afbe7f`, `3ef6ce641e` | 39 failed, 31
passed: all 10 `env_local` during-boot rows; all 28 disabled-package
verb and resource rows; the no-`environmentId` disabled package's
listing | 31 failed |
| discovery | `readMergedObjects` answers `undefined` (the `metadata`
service read) | 3 failed: `env_local` during-boot list and describe; the
no-`environmentId` disabled package's listing | 6 failed |
| gate | `readObjectDeclaration` always reads the copy | 36 failed: the
`env_local` during-boot six verbs, record resource and write-response
strip; all 28 disabled-package rows | 25 failed |
| refusal | `OBJECT_PACKAGE_DISABLED` read as an outage again (judged by
the copy) | 28 failed: every disabled-package verb and resource row on
both shapes | 13 failed |
| fix | `eb3b93d9bd` | 70 passed | 39 passed |
The first attempt at the discovery and refusal legs was a no-op reading.
Their marker was a bare string expression (`void 'ABLATION_…'`), which
the bundler drops, so the preflight answered exit 1 (marker absent from
`dist/`) although the source mutation landed. They were re-run with a
marker the bundler keeps (a `globalThis` member read): preflight exit 0,
and the counts above are those runs (the same counts as the first
attempt). The dogfood row now titled "the package's exposed object is
queried and written, and create_record does not echo its internal: true
field" was titled "CONTROL: …" in those runs; `fbb65d51` renamed it
because it also pins the strip.
## Local verification
On `476460a86c`, the final commit, over `origin/main` `123ff6f4e2`
(unmoved since the branch was cut), with the workspace built on
`1261513f93` (`turbo run build --filter=!@objectstack/docs`, 76 tasks,
`VERDICT command-exit 0`). Every heavy run went through the verify lock,
and each line cites its `VERDICT`.
- `@objectstack/mcp` unit suite (`pnpm --filter @objectstack/mcp test`):
41 files, 518 tests passed, 0 failed (`VERDICT command-exit 0`).
- `pnpm --filter @objectstack/mcp typecheck`: exit 0 (`tsc --noEmit`,
and `check:test-typecheck` OK: 6 files, 53 errors, 8 pinned signatures
held in the ledger).
- `pnpm --filter @objectstack/dogfood typecheck` with
`NODE_OPTIONS=--max-old-space-size=8192`: exit 0. A first run at the
default heap died at exit 134 (out of memory), before any type verdict.
- `pnpm lint` (`eslint . --no-inline-config`): exit 0 on `1261513f93`.
The first run (on `2c6cb4141b`) found one error, a `metadata` service
lookup typed `any` in the dogfood pin's precondition; `1261513f93` types
it `IMetadataService`. The last commit touches one unit pin file, and
`eslint --no-inline-config` on it answers exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 79 commands on `476460a86c` (the pinned
ledger brings 9 more families than the earlier tree's 70), each exit
code captured before any pipe, all exit 0.
- `--ran` reconciliation: `✓ dispatch-gates --ran: 79 derived famil(ies)
accounted for — 79 run, 0 NOT-MEASURED (a DERIVED zero — all 79 recorded
an exit code and none of them is 3).`
- `node scripts/check-changeset-no-major.mjs --base origin/main --event
PAYLOAD`, with PAYLOAD a `pull_request` event carrying this body: exit
0. `✓ This diff introduces no major bump.` and `✓ LEVEL AXIS: this PR
declares clause-② no (narrowing) and metadata-protocol none ...`. `node
scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0 (in
the derived list).
- `node scripts/check-closing-keyword-parity.mjs --body` on this body
binds `#25138` alone.
- The full dogfood battery, once (`pnpm --filter @objectstack/dogfood
test`, no shard), on `1261513f93`: `VERDICT command-exit 0`; Test Files
288 passed, 3 skipped (291); Tests 2468 passed, 18 skipped (2486); no
failure. The last commit changes one `@objectstack/mcp` unit pin file
and the engine-double ledger
(`scripts/engine-double-contract.pinned.json`), neither of which the
battery reads (the battery consumes `@objectstack/mcp`'s `dist/`, and a
test file is not in it), so it was not re-run.
- The first pass of the derived gates, on `2c6cb4141b`, found three
things, each handled before the pass above:
`check:engine-double-contract` refused the unit pin's engine double,
whose `findOne` / `update` / `delete` were looser than ObjectQL's
(`476460a8`, the last commit, opens each with the shared `assertEngine*`
asserts and records the file in the pinned ledger, as the gate's own
message prescribes); `check-plugin-teardown-shape.mjs --self-test`
answered 3 because this shallow clone lacked its pinned fixture commit
(fetched); `check:dual-build-cjs-loads` answered 3 before the full
build.
## Acceptance notes
- **Finding, same defect class, not fixed here: the stdio metadata
resources.** `objectstack://objects` and `objectstack://objects/NAME`
(`bridgeResources` in `packages/mcp/src/mcp-server-runtime.ts`) read the
`metadata` service. Measured at this PR's head on the `env_local`
during-boot fixture: `resources/read` of `objectstack://objects` answers
`{ objects: [], totalCount: 0 }`, and of
`objectstack://objects/qa_pkg_open` answers `404 RESOURCE_NOT_FOUND`,
while `list_objects` and `describe_object` now list and describe both.
Reach: the stdio `resources/read` door, measured. It reveals no record
and serves no data verb, so it was left for its own card rather than
widened in here.
- **Stricter than REST, by design: a disabled package's object as a
nested-relation target.** The relation-target gate refuses `404
OBJECT_PACKAGE_DISABLED` when a condition's target belongs to a disabled
package, because the engine would read that target to lower the
condition. Code-read, not pinned.
- **The internal-field strip on a package object was a leak, closed in
passing.** On the `env_local` during-boot shape the strip read the
missing copy, so `create_record` echoed an `internal: true` field.
Pinned by the exposed-object row, which the gate leg turns red.
- **`describe_object` reads the whole served listing per call**, as the
HTTP bridge does since #25136.
- **File surface.** The fix reaches `plugin.ts` (the accessor) and
`mcp-server-runtime.ts` (one export) beside `stdio-data-bridge.ts`, and
the real-kernel pins live in `packages/qa/dogfood`, where the stdio
exposure twin already lives.
- **Branch name.** This PR's branch is `claude/project-thread-dkk3hd`,
the name the session was given, not `claude/issue-25138-…`; the
Duplicate Fix Guard may say so.
- **Deviation.** The card's dispatch marks (claim comment, labels,
assignee) were written through the session's MCP GitHub tools as
`os-sales`, before the fleet-write relay was found available in this
container. This PR and its follow-up writes go through the relay.
---
_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 dfab597 commit 8010b64
9 files changed
Lines changed: 1190 additions & 40 deletions
File tree
- .changeset
- packages
- mcp/src
- qa/dogfood/test
- scripts
| 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 | |
|---|---|---|---|
| |||
526 | 526 | | |
527 | 527 | | |
528 | 528 | | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
529 | 534 | | |
530 | | - | |
| 535 | + | |
531 | 536 | | |
532 | 537 | | |
533 | 538 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
457 | 457 | | |
458 | 458 | | |
459 | 459 | | |
460 | | - | |
| 460 | + | |
461 | 461 | | |
462 | 462 | | |
463 | 463 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
| 45 | + | |
| 46 | + | |
45 | 47 | | |
46 | 48 | | |
47 | 49 | | |
| |||
703 | 705 | | |
704 | 706 | | |
705 | 707 | | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
706 | 721 | | |
707 | 722 | | |
708 | 723 | | |
709 | 724 | | |
| 725 | + | |
710 | 726 | | |
711 | 727 | | |
712 | 728 | | |
| |||
725 | 741 | | |
726 | 742 | | |
727 | 743 | | |
728 | | - | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
729 | 749 | | |
730 | 750 | | |
731 | 751 | | |
| |||
740 | 760 | | |
741 | 761 | | |
742 | 762 | | |
743 | | - | |
744 | | - | |
| 763 | + | |
| 764 | + | |
745 | 765 | | |
746 | 766 | | |
747 | | - | |
748 | | - | |
| 767 | + | |
| 768 | + | |
749 | 769 | | |
750 | 770 | | |
751 | 771 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
159 | 159 | | |
160 | 160 | | |
161 | 161 | | |
162 | | - | |
163 | | - | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
164 | 165 | | |
165 | 166 | | |
166 | 167 | | |
| |||
420 | 421 | | |
421 | 422 | | |
422 | 423 | | |
423 | | - | |
| 424 | + | |
424 | 425 | | |
425 | 426 | | |
426 | 427 | | |
| |||
0 commit comments