Skip to content

Commit 8010b64

Browse files
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
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
fix(mcp)!: the stdio data bridge reads objects from the protocol's merged view, so `list_objects` and `describe_object` see every object `GET /api/v1/meta/object` lists, a package object declared `apiEnabled: false` is refused by every data verb, and so is every object of a disabled package (`404 OBJECT_PACKAGE_DISABLED`)
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which definition the stdio MCP transport consults: its object discovery and its API-exposure gate now read the protocol's merged view, the definition REST and the `/mcp` HTTP bridge already serve and enforce, instead of the metadata service's copy. An object's own `enable.apiEnabled` / `enable.apiMethods` declaration is now enforced where it was not read; the remedy is the declaration the author already wrote, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** (the stdio data verbs' accept set narrows to the object's own declaration), shipped as `patch`: it changes which requests the stdio transport serves, not what a metadata author can write.
13+
14+
The long-lived stdio MCP server (`objectstack mcp`, `OS_MCP_STDIO_ENABLED`) answered `list_objects` and `describe_object` from the `metadata` service, and gated its data verbs and its record resource on the same service's copy of the object. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package registers during the boot. So `list_objects` answered `{ objects: [], totalCount: 0 }` for such a package, `describe_object` answered "not found" for each of its objects, and the gate read the missing copy as "no declaration" and served them. REST and the `/mcp` HTTP bridge listed and refused the same objects.
15+
16+
- **Before.** On that kernel, an object a package registered during the boot was missing from `list_objects` and answered `Object "NAME" not found` by `describe_object` and `validate_expression`. Declared `apiEnabled: false`, it was served by `query_records`, `get_record`, `aggregate_records` and the `objectstack://objects/NAME/records/ID` resource, and `create_record`, `update_record` and `delete_record` reached the store. Its `internal: true` fields were echoed in the `create_record` and `update_record` responses.
17+
- **Now.** `list_objects` and `describe_object` answer from the listing `GET /api/v1/meta/object` serves. Every data verb and the record resource refuse such an object `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` for a verb its `apiMethods` leaves out, as `RestServer` and the HTTP bridge do. Its `internal: true` fields are omitted from the write responses, and its nested-relation conditions are gated on their targets.
18+
- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described; they are now omitted, as REST omits them. Every data verb and the record resource now refuse a disabled package's object `404 OBJECT_PACKAGE_DISABLED`, whatever the object declares: before, the stdio transport served it on every verb, because each verb asks the engine and never the protocol. When the metadata store cannot be read, `list_objects` and `describe_object` answer the store's `503 SERVICE_UNAVAILABLE`, as `GET /api/v1/meta/object` does, instead of the `metadata` service's listing.
19+
- **Unchanged.** Objects that already had a copy (every object on a kernel without an `environmentId`, a marketplace install on any kernel, and a runtime-authored object) are listed and gated as before. A host with no `protocol` service, or one without the merged reads, keeps reading the `metadata` service. If the protocol's by-name read fails, the gate judges the `metadata` service's copy, as it did before.
20+
- **What to do.** Nothing, if the declarations say what you mean. A stdio client that relied on reaching an object declared `apiEnabled: false` was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. A stdio client that read a disabled package's objects was relying on the same kind of defect: re-enable the package to restore access, as REST says.

‎packages/mcp/src/mcp-server-runtime.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,8 +526,13 @@ export interface McpMergedMetadataRead {
526526
* the reason `packages/rest` reads it defensively at every one of its own call
527527
* sites: this is a duck-typed seam across a package boundary, and a host may
528528
* hand back the bare array.
529+
*
530+
* [#25138] Exported within the package for the stdio data bridge's object
531+
* listing (`stdio-data-bridge.ts`), so the two merged reads on this transport
532+
* cannot disagree about which answers they accept. `index.ts` does not
533+
* publish it.
529534
*/
530-
function metaItemsArray(answer: unknown): unknown[] {
535+
export function metaItemsArray(answer: unknown): unknown[] {
531536
if (Array.isArray(answer)) return answer;
532537
const items = (answer as { items?: unknown } | null | undefined)?.items;
533538
return Array.isArray(items) ? items : [];

‎packages/mcp/src/plugin.record-resource-exposure.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ describe('#8266 the refusal carries the ADR-0112 envelope', () => {
457457
async function refusalFor(def: unknown, action: string): Promise<McpExposureError> {
458458
const metadataService = fakeMetadata(def) as unknown as IMetadataService;
459459
const context = { userId: 'u1', isSystem: false } as unknown as ExecutionContext;
460-
const err = (await enforceApiExposure(metadataService, 'task', action, context).then(
460+
const err = (await enforceApiExposure({ metadataService }, 'task', action, context).then(
461461
() => null,
462462
(e: unknown) => e,
463463
)) as McpExposureError | null;

‎packages/mcp/src/plugin.ts‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import {
4242
GATED_ACTIONS,
4343
serveStoredMetadataHashes,
4444
serveStoredMetadataRow,
45+
type StdioObjectReads,
46+
type StdioProtocolObjectReads,
4547
type StoredHashDigest,
4648
} from './stdio-data-bridge.js';
4749
import type { McpDataBridge } from './mcp-http-tools.js';
@@ -703,10 +705,24 @@ export class MCPServerPlugin implements Plugin {
703705
if (gate) throw stdioAuthGateRefusal(gate);
704706
return ec;
705707
};
708+
// [#25138] The protocol's object reads — the merged listing behind
709+
// `list_objects` / `describe_object` and the by-name read behind the
710+
// exposure gate — resolved at each use, never captured here: an absence
711+
// read in `start()` is a verdict the rest of the boot can still
712+
// contradict. `undefined` when this host registers no protocol, and the
713+
// bridge then reads the metadata service, as before.
714+
const protocolReads = (): StdioProtocolObjectReads | undefined => {
715+
try {
716+
return ctx.getService<StdioProtocolObjectReads>('protocol') ?? undefined;
717+
} catch {
718+
return undefined;
719+
}
720+
};
706721
if (metadataService) {
707722
dataBridge = createStdioDataBridge({
708723
engine: scopedQl,
709724
metadataService,
725+
protocol: protocolReads,
710726
resolvePrincipal,
711727
keyedDigest: storedHashDigest,
712728
});
@@ -725,7 +741,11 @@ export class MCPServerPlugin implements Plugin {
725741
// from, captured once rather than re-read off the outer `let` inside the
726742
// closure — the reader must not be able to see a different service from
727743
// the one `bridgeResources` was registered with.
728-
const gateMetadata = metadataService;
744+
// [#25138] …beside the protocol's by-name read, which the gate asks
745+
// first, as `bridge.get` asks it.
746+
const gateReads: StdioObjectReads | undefined = metadataService
747+
? { metadataService, protocol: protocolReads }
748+
: undefined;
729749
getRecord = async (objectName, recordId) => {
730750
const ec = await resolvePrincipal();
731751
// [#8266] The ADR-0049 exposure gate, applied BEFORE the read: the same
@@ -740,12 +760,12 @@ export class MCPServerPlugin implements Plugin {
740760
// leaking is the author's exposure DECLARATION.
741761
//
742762
// No metadata service ⇒ no declaration to read ⇒ open, exactly as
743-
// `enforceApiExposure` itself falls open when `getObject` throws or
744-
// resolves nothing. Unreachable in practice: `bridgeResources` — the
763+
// `enforceApiExposure` itself falls open when the definition cannot be
764+
// read or resolves nothing. Unreachable in practice: `bridgeResources` — the
745765
// only consumer of this reader — is called under `if (metadataService)`
746766
// below, so the resource is never registered without one.
747-
if (gateMetadata) {
748-
await enforceApiExposure(gateMetadata, objectName, GATED_ACTIONS.get, ec);
767+
if (gateReads) {
768+
await enforceApiExposure(gateReads, objectName, GATED_ACTIONS.get, ec);
749769
}
750770
const res = (await scopedQl.find(objectName, {
751771
where: { id: recordId },

‎packages/mcp/src/stdio-data-bridge.exposure.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,9 @@ const ALL_GATED = Object.keys(GATED_ACTIONS) as Array<keyof typeof GATED_ACTIONS
159159
describe('#8083 the gated verb set is the HTTP one', () => {
160160
it('gates exactly the six methods `buildMcpBridge` routes through callData', () => {
161161
// `listObjects` / `describeObject` are absent BY DESIGN: the HTTP bridge
162-
// answers both straight off the metadata service, so gating them here
163-
// would be a fresh divergence in the opposite direction.
162+
// answers both without `callData` (from the protocol's merged listing
163+
// since #25120), so gating them here would be a fresh divergence in the
164+
// opposite direction.
164165
expect(ALL_GATED.sort()).toEqual(
165166
['aggregate', 'create', 'get', 'query', 'remove', 'update'].sort(),
166167
);
@@ -420,7 +421,7 @@ describe('#15416 the stdio refusal never names an operation it also allows', ()
420421
for (const action of Object.values(GATED_ACTIONS)) {
421422
let err: McpExposureError | undefined;
422423
try {
423-
await enforceApiExposure(metadataFor(apiMethods), 'task', action, {} as ExecutionContext);
424+
await enforceApiExposure({ metadataService: metadataFor(apiMethods) }, 'task', action, {} as ExecutionContext);
424425
} catch (e) {
425426
err = e as McpExposureError;
426427
}

0 commit comments

Comments
 (0)