Skip to content

Commit 72fad92

Browse files
fix(mcp)!: the objectstack://objects resources read the stdio transport's merged object view, so a package's objects are listed and described (#25268)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu4BM6tVgxhg4qFQsbuRHiy9)_ Fixes #25245 Clause-②: no (narrowing) Metadata-protocol: none The stdio MCP transport's two object resources, `objectstack://objects` and `objectstack://objects/{objectName}`, now read the same objects its `list_objects` and `describe_object` tools answer from since #25243: the protocol's merged listing, the one `GET /api/v1/meta/object` serves. They read the `metadata` service until now, so on a kernel with an `environmentId` they listed none of a package's boot-registered objects and answered `404` for each, while the tools listed and described them. The merged read is shared, not copied: `stdio-data-bridge.ts` exports one view, the tools answer from it, and `plugin.ts` gives the same view to its runtime, whose two object resources read it. No published signature moves. ## The defect, measured on base `0b8bce40e0` `bridgeResources` (`packages/mcp/src/mcp-server-runtime.ts`) built `objectstack://objects` from `metadataService.listObjects()` and `objectstack://objects/NAME` from `metadataService.getObject(NAME)`. 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) nothing copies an object a package registers in the `SchemaRegistry` during the boot. Real kernels (`ObjectKernel` + `MetadataPlugin` + `ObjectQLPlugin`), driven through the real `MCPServerPlugin` stdio session over an in-process transport speaking a stdio client's JSON-RPC, with the fixture package #25243's pins use (two objects, one declared `apiEnabled: false`): | shape, when the package registered | `resources/read objectstack://objects` | `resources/read objectstack://objects/qa_pkg_open` | `list_objects` / `describe_object` on the same kernel | | :-- | :-- | :-- | :-- | | `env_local`, during the boot | **`{ objects: [], totalCount: 0 }`** | **`{ error: 'Object "qa_pkg_open" not found', code: 'RESOURCE_NOT_FOUND', status: 404 }`** | both listed, both described | | `env_local`, installed after the boot (`source: 'marketplace'`) | both listed | described | both listed, both described | | no `environmentId`, during the boot | both listed | described | both listed, both described | | no `environmentId`, installed after the boot | both listed | described | both listed, both described | **A disabled package (`registry.disablePackage`).** On the shape without an `environmentId` the boot copied both objects into the `metadata` service, and the copy outlives the disable: both resources still listed and described them, while `list_objects` and `describe_object` omit them since #25243 and `GET /api/v1/meta/object` omits them. On `env_local` there is no copy, so the resources already omitted them. ## What changed - **`packages/mcp/src/stdio-data-bridge.ts`.** New `stdioObjectView(reads)` returns `{ listObjects, getObject }`: the protocol's merged listing (`readMergedObjects`, unchanged from #25243), and a name looked up in that same listing, so a name the listing omits is absent on both reads. A host with no merged read gets the `metadata` service's `listObjects()` / `getObject()`, as before. The bridge's `listObjects` and `describeObject` now answer through this view; their answers do not change (the #25243 unit pins pass unchanged). Exported within the package only; `index.ts` does not publish it. - **`packages/mcp/src/mcp-server-runtime.ts`.** New type `McpObjectView`: the `listObjects` and `getObject` members of `IMetadataService`, picked. `buildObjectListResource` and `buildObjectSchemaResource` take it as an optional last parameter and read their objects from it; it defaults to the `metadata` service, so every existing caller reads exactly as before. New package-internal `setRuntimeObjectView(runtime, view)` records a runtime's view in a module-level `WeakMap`; the two object resources registered by `bridgeResources` look it up at each `resources/read` and fall back to the `metadata` service. `bridgeResources` keeps its published signature: a third parameter would have made a new type reachable from the exported `MCPServerRuntime`, a public-surface widening for wiring between this package's own plugin and runtime. The completeness verdict is still asked of the `metadata` service (`diagnoseListRead` on the listing, `diagnoseEmptyRead` on a miss), as the `list_objects` tool asks it. - **`packages/mcp/src/plugin.ts`.** The `protocol` accessor #25243 added (resolved at each use, `undefined` when absent or throwing, never captured in `start()`) moves out of the stdio branch, because the resources are bridged whether or not this process starts the stdio transport. Before `bridgeResources`, the plugin calls `setRuntimeObjectView(this.runtime, stdioObjectView({ metadataService, protocol }))`. The data bridge and the record resource's gate keep reading the same accessor. - **`packages/mcp/README.md`.** One paragraph under Resources saying what the two object resources read. - **`.changeset/25245-mcp-stdio-object-resources-merged.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. **`resources/read objectstack://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 `list_objects` lists. 2. **`resources/read objectstack://objects/NAME` on such an object.** Old: `{ error: 'Object "NAME" not found', code: 'RESOURCE_NOT_FOUND', status: 404 }`. New: the schema body, equal to what `describe_object` answers for NAME. 3. **Both resources on a disabled package's objects, where the `metadata` service still holds a copy** (the narrowing). Old: listed, and described from the copy. New: omitted, and `objectstack://objects/NAME` answers the `404 RESOURCE_NOT_FOUND` body, as `describe_object` answers "not found". 4. **An object whose served definition differs from the `metadata` service's copy.** Old: the resources described the copy. New: they describe the definition REST serves. 5. **Both resources while the protocol's merged listing throws.** Old: the `metadata` service's answer. New: the protocol's error travels to the client as a JSON-RPC error, as `list_objects` lets it travel since #25243; 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 described as before. A loader outage still withholds `totalCount` (`partial: true`, `returnedCount`, `503 SERVICE_UNAVAILABLE`), and a miss during an outage still answers `503 SERVICE_UNAVAILABLE`. A host with no `protocol` service, or one without the merged listing, reads the `metadata` service, byte-identically (pinned). A host that builds its own `MCPServerRuntime` and calls `bridgeResources` reads the `metadata` service, as before. The published surface does not move: the built `dist/index.d.ts` declares `bridgeResources` with the same two parameters, and names none of `McpObjectView`, `setRuntimeObjectView` and `stdioObjectView` as declarations. The record resource is untouched. Discovery stays ungated: the resource lists and describes an object declared `apiEnabled: false`, as `list_objects` and `describe_object` do. ## Pins - **`packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts`, 10 new cases on real kernels, beside #25243's tool pins (80 cases in the file).** For each of the four rows above: `objectstack://objects` lists both package objects, has no `partial`, has `totalCount` equal to its length, and lists the same non-`sys_` names `list_objects` lists; `objectstack://objects/NAME` answers both objects, `enableFeatures.apiEnabled: false` included, each body EQUAL to `describe_object`'s, and a name nothing serves answers exactly `{ error: 'Object "qa_pkg_nowhere" not found', code: 'RESOURCE_NOT_FOUND', status: 404 }`. For a disabled package on both shapes: neither object is listed, and each answers that `404` body. - **`packages/mcp/src/mcp-server-runtime.merged-object-resources.test.ts`, 14 cases, doubles.** The listing from the merged read where the copy is absent, the `metadata` service's `listObjects` never asked; a disabled package's objects omitted from both resources although the copy holds them; the `partial` verdict kept from `listDiagnosed`; the `503` on a miss kept from `getDiagnosed`; a throwing merged read propagated by both builders, the copy never asked; the schema body equal to `describeObject`'s for both package objects; no protocol, and a protocol without `getMetaItems`, byte-identical to the `metadata` service alone; a protocol registered after the bridge read on the next call. Over a real `resources/read` on an `MCPServerRuntime`: both resources answer from the view `setRuntimeObjectView` gave, never the `metadata` service; a view given after `bridgeResources` is read on the next call; a runtime given no view reads the `metadata` service, and another runtime's view is not its own. **Red before, green after.** Each leg's direction was predicted before it ran. 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/` (exit 0) and, after the restore and a rebuild at HEAD, that none remained (`--absent`, exit 0 for each). Mutations were landed and restored by `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed; restored blob equals HEAD, `git diff HEAD` empty). | leg | mutation | dogfood (80) | unit (14) | | :-- | :-- | :-- | :-- | | main | the base tree `0b8bce40e0` (source blobs `6ca41ee187`, `f6d8800263`, `e71d1f1c81`) with the new dogfood pins | 3 failed, 77 passed: the `env_local` during-boot listing and schema; the no-`environmentId` disabled package's resources | not run (the file imports names absent on base) | | bridge | the runtime's two resources ignore the view their host gave (`runtimeObjectViews.get(this)` replaced) | the same 3 failed | 2 failed: both resources answer from the view; a view given after `bridgeResources` is read | | plugin | `plugin.ts` gives its runtime no view | the same 3 failed | 14 passed (this file drives the runtime, not the plugin) | | viewcopy | the view's `getObject` falls back to the copy for a name the listing omits | 2 failed: the no-`environmentId` disabled package's resources, and its `describe_object` (#25243's row, which reads the same view) | 1 failed: a name the listing omits is the coded miss | | fix | `a697b07f34` | 80 passed | 14 passed | The legs and the fix row were measured on `a697b07f34`. The final head differs from it by one doc comment in `mcp-server-runtime.ts` (a `{@link}` to the package-internal setter, which the published declaration file would have carried as a dangling link, now plain code text); both pin files are re-run on the final head by the unit suite and the battery below. An earlier local revision passed the view as a third parameter of `bridgeResources`; it was replaced before the first push for the public-surface reason above, and its legs read the same way. ## Local verification On `5cf1aa4ce3`, the final commit, over `origin/main` `0b8bce40e0` (it has since moved two commits, #25258 and #25259, neither touching `packages/mcp`, `packages/metadata-protocol` or this dogfood file), or on `dc7bee12dc` where a line says so; the one commit between them (`5cf1aa4ce3`) changes how the dogfood pin file's resource helper is typed, not what any case asserts. Every heavy run went through the verify lock, and each line cites its `VERDICT`. - `@objectstack/mcp` unit suite (`pnpm --filter @objectstack/mcp test`), on `dc7bee12dc`: `VERDICT command-exit 0`; Test Files 42 passed (42), Tests 532 passed (532). - `pnpm --filter @objectstack/mcp typecheck`, on `dc7bee12dc`: `VERDICT command-exit 0` (`tsc --noEmit`, and `check:test-typecheck` OK: 6 files, 53 errors, 8 pinned signatures held in the ledger). - `turbo run build --filter=!@objectstack/docs`, on `dc7bee12dc`: `VERDICT command-exit 0`, 76 of 76 tasks. - `pnpm --filter @objectstack/dogfood typecheck` with `NODE_OPTIONS=--max-old-space-size=8192`: `VERDICT command-exit 0`. The first run, on `dc7bee12dc`, answered exit 2 with one error (TS2339): the pin's record reader called `this.readResource` inside the returned object literal, whose `this` tsc types as `Booted` or a `PromiseLike` of it. `5cf1aa4ce3` reads both through a local helper. - `pnpm lint` (`eslint . --no-inline-config`), on `dc7bee12dc`: `VERDICT command-exit 0`; `eslint --no-inline-config` on the one file `5cf1aa4ce3` changes: `VERDICT command-exit 0`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 70 families derived; each run, every one `exit 0` (the second pass, on the final head; the first pass, on `dc7bee12dc`, also answered 70 of 70 `exit 0`). - `--ran` reconciliation: `70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN`, 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 #25245` and nothing else, bound by the duplicate-fix guard's and the part-of guard's parsers. - The full dogfood battery, once (`pnpm --filter @objectstack/dogfood test`, no shard), on `dc7bee12dc`: `VERDICT command-exit 0`; Test Files 289 passed, 3 skipped (292); Tests 2491 passed, 18 skipped (2509); no failure. It was not re-run for `5cf1aa4ce3`, which touches that one pin file's helper; the file was re-run on the final head: `VERDICT command-exit 0`, 80 of 80. ## Acceptance notes - **Same family, left as it was: the prompt bridge captures the protocol once.** `bridgePrompts` reads skills through `mergedRead`, which `plugin.ts` resolves once in `start()` (#8328), not at each use as the object reads now are. Not this card's defect (skills, not objects), and not measured here. - **`objectstack://objects/NAME` reads the whole merged listing per call**, as `describe_object` does since #25243 and the HTTP bridge since #25136. - **File surface.** Beside `mcp-server-runtime.ts` and `plugin.ts`, the fix exports the shared view from `stdio-data-bridge.ts` (the card's "not a second copy of the merge"), adds one README paragraph, and adds the real-kernel pins to the file #25243's tool pins live in. Claim revision 6346855817 names this surface and the `(narrowing)` arm, which the first claim (6346581465) wrote as a bare `no` before the disabled-package reading was measured. - **Branch name.** This PR's branch is `claude/project-thread-dkk3hd`, the name the session was given, not `claude/issue-25245-…`; the Duplicate Fix Guard may say so. --- _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 0148071 commit 72fad92

7 files changed

Lines changed: 495 additions & 31 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 `objectstack://objects` resources read the stdio transport's merged object view, so they list and describe every object `list_objects` and `describe_object` answer, and omit a disabled package's objects as those tools do
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's two object resources read: the protocol's merged listing, the one its `list_objects` / `describe_object` tools and `GET /api/v1/meta/object` already serve, instead of the metadata service's copy. No published signature moves: `MCPServerRuntime.bridgeResources` keeps its two parameters, and the view reaches it through a package-internal function `index.ts` does not publish. 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 object resources stop serving a disabled package's objects), shipped as `patch`: it changes which reads the stdio transport answers, not what a metadata author can write.
13+
14+
The long-lived stdio MCP server (`objectstack mcp`, `OS_MCP_STDIO_ENABLED`) answered `resources/read` of `objectstack://objects` and `objectstack://objects/NAME` from the `metadata` service. 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 `objectstack://objects` answered `{ objects: [], totalCount: 0 }` for such a package and `objectstack://objects/NAME` answered `404 RESOURCE_NOT_FOUND` for each of its objects, while `list_objects` and `describe_object` listed and described them.
15+
16+
- **Before.** On that kernel, an object a package registered during the boot was missing from `objectstack://objects`, and `objectstack://objects/NAME` answered `{ error: 'Object "NAME" not found', code: 'RESOURCE_NOT_FOUND', status: 404 }` for it.
17+
- **Now.** Both resources read the view `list_objects` and `describe_object` answer from: the protocol's merged listing, the one `GET /api/v1/meta/object` serves. `objectstack://objects/NAME` answers the same body `describe_object` answers for that name.
18+
- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed by `objectstack://objects` and described by `objectstack://objects/NAME`. They are now omitted, and `objectstack://objects/NAME` answers `404 RESOURCE_NOT_FOUND` for them, as `describe_object` answers "not found". When the protocol's merged listing throws, both resource reads fail with that error, as `list_objects` does, instead of answering 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 described as before. The completeness verdict is still the `metadata` service's: a loader outage still withholds `totalCount` and answers `partial: true`, and a miss during an outage still answers `503 SERVICE_UNAVAILABLE`. A host with no `protocol` service, or one without the merged listing, keeps reading the `metadata` service. A host that calls `MCPServerRuntime.bridgeResources` itself reads the `metadata` service as before.
20+
- **What to do.** Nothing. A stdio client that read a disabled package's object through these resources was reading a stale copy the rest of the platform no longer serves: re-enable the package to restore it, as REST says.

‎packages/mcp/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@ objectstack://metadata/types # List all metadata typ
178178
The record resource is registered only when the host supplies a record reader;
179179
without one, the schema and listing resources are served alone.
180180

181+
The listing and schema resources read the same objects `list_objects` and
182+
`describe_object` answer: the protocol's merged listing, the one
183+
`GET /api/v1/meta/object` serves, so a package's objects appear on every kernel
184+
shape and a disabled package's objects do not. A host with no `protocol`
185+
service reads the metadata service instead.
186+
181187
## Prompts
182188

183189
Two prompt families are bridged — there is no per-item prompt registration call:
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25245] The `objectstack://objects` resources read the stdio transport's ONE
5+
* object view (`stdioObjectView`, `stdio-data-bridge.ts`): the protocol's
6+
* merged listing, the read `list_objects` / `describe_object` answer from since
7+
* #25138, and the metadata service only on a host with no merged read.
8+
*
9+
* The defect, in the doubles' terms: an object a package registers during the
10+
* boot of a kernel with an `environmentId` lives in the `SchemaRegistry`, which
11+
* the protocol reads, and has no copy in the `metadata` service, which both
12+
* resources read. So the metadata double below holds NO copy of the package
13+
* objects and the protocol double serves them. Measured on the real kernel
14+
* through the `MCPServerPlugin` stdio session before the fix: `resources/read`
15+
* of `objectstack://objects` answered `{ objects: [], totalCount: 0 }` and of
16+
* `objectstack://objects/qa_pkg_open` `404 RESOURCE_NOT_FOUND`, while the tools
17+
* listed and described both. That wire reading is pinned on real kernels in the
18+
* dogfood suite (`packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts`);
19+
* this file pins each branch of the two builders over the view, and the
20+
* runtime's two object resources reading the view their host gave it
21+
* (`setRuntimeObjectView`).
22+
*
23+
* What stays the metadata service's: the completeness verdict (#6504 on the
24+
* listing, #6055 on a miss). The protocol's listing cannot answer it, and the
25+
* `list_objects` tool asks the metadata service for it on the same items.
26+
*/
27+
28+
import { describe, it, expect, vi } from 'vitest';
29+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
30+
import type { ExecutionContext } from '@objectstack/spec/kernel';
31+
import {
32+
buildObjectListResource,
33+
buildObjectSchemaResource,
34+
MCPServerRuntime,
35+
setRuntimeObjectView,
36+
} from './mcp-server-runtime.js';
37+
import {
38+
createStdioDataBridge,
39+
stdioObjectView,
40+
type StdioProtocolObjectReads,
41+
} from './stdio-data-bridge.js';
42+
43+
const PKG_OPEN = 'qa_pkg_open';
44+
const PKG_GATED = 'qa_pkg_gated';
45+
46+
/** The package's two objects, as the protocol serves them; one declared off the API. */
47+
const PACKAGE_OBJECTS = [
48+
{
49+
name: PKG_OPEN,
50+
label: 'Open',
51+
fields: {
52+
name: { type: 'text', label: 'Name' },
53+
vault_secret: { type: 'text', internal: true },
54+
},
55+
},
56+
{ name: PKG_GATED, label: 'Gated', enable: { apiEnabled: false }, fields: { name: { type: 'text', label: 'Name' } } },
57+
];
58+
59+
/** A protocol serving `objects` through its merged listing. */
60+
function makeProtocol(objects: Array<Record<string, unknown>> = PACKAGE_OBJECTS) {
61+
return {
62+
getMetaItems: vi.fn(async (_request: { type: string }) => ({ type: 'object', items: objects })),
63+
};
64+
}
65+
66+
/** A metadata service holding `copies` and nothing else (the package objects have none by default). */
67+
function makeMetadata(copies: Array<Record<string, unknown>> = []) {
68+
return {
69+
listObjects: vi.fn(async () => copies),
70+
getObject: vi.fn(async (name: string) => copies.find((o) => o.name === name)),
71+
listDiagnosed: vi.fn(async () => ({ data: copies, degraded: false, errors: [] as string[] })),
72+
getDiagnosed: vi.fn(async (_type: string, name: string) => ({
73+
data: copies.find((o) => o.name === name),
74+
degraded: false,
75+
errors: [] as string[],
76+
})),
77+
};
78+
}
79+
80+
/** The view and the metadata service the builders read, over `protocol` resolved at each use. */
81+
function makeView(opts: { protocol?: () => StdioProtocolObjectReads | undefined; copies?: Array<Record<string, unknown>> } = {}) {
82+
const metadataService = makeMetadata(opts.copies);
83+
const view = stdioObjectView({
84+
metadataService: metadataService as unknown as IMetadataService,
85+
protocol: opts.protocol,
86+
});
87+
return { view, metadataService, md: metadataService as unknown as IMetadataService };
88+
}
89+
90+
const parsed = (result: { contents: Array<{ text: string }> }) => JSON.parse(result.contents[0].text);
91+
92+
describe('[#25245] objectstack://objects lists the merged view', () => {
93+
it('lists the package objects the metadata service holds no copy of, with a complete totalCount', async () => {
94+
const protocol = makeProtocol();
95+
const { view, md, metadataService } = makeView({ protocol: () => protocol });
96+
97+
const body = parsed(await buildObjectListResource(md, undefined, view));
98+
99+
expect(body).toEqual({
100+
objects: [
101+
{ name: PKG_OPEN, label: 'Open', fieldCount: 2 },
102+
{ name: PKG_GATED, label: 'Gated', fieldCount: 1 },
103+
],
104+
totalCount: 2,
105+
});
106+
expect(protocol.getMetaItems).toHaveBeenCalledWith({ type: 'object' });
107+
expect(metadataService.listObjects).not.toHaveBeenCalled();
108+
});
109+
110+
it('omits what the listing omits, even where the metadata service still holds a copy (a disabled package)', async () => {
111+
const protocol = makeProtocol([]);
112+
const { view, md } = makeView({ protocol: () => protocol, copies: [PACKAGE_OBJECTS[0]] });
113+
114+
expect(parsed(await buildObjectListResource(md, undefined, view))).toEqual({ objects: [], totalCount: 0 });
115+
});
116+
117+
it('keeps the completeness verdict from the metadata service, the items from the merged listing', async () => {
118+
const protocol = makeProtocol();
119+
const { view, md, metadataService } = makeView({ protocol: () => protocol });
120+
metadataService.listDiagnosed.mockResolvedValueOnce({ data: [], degraded: true, errors: ['loader x failed'] });
121+
122+
const body = parsed(await buildObjectListResource(md, undefined, view));
123+
124+
expect(body.objects.map((o: { name: string }) => o.name)).toEqual([PKG_OPEN, PKG_GATED]);
125+
expect(body).toMatchObject({ partial: true, returnedCount: 2, code: 'SERVICE_UNAVAILABLE', status: 503 });
126+
expect(body).not.toHaveProperty('totalCount');
127+
});
128+
129+
it('a merged read that throws travels to the caller, never a fallback to the copy', async () => {
130+
const boom = new Error('sys_metadata unreadable');
131+
const { view, md, metadataService } = makeView({
132+
protocol: () => ({ getMetaItems: vi.fn(async () => { throw boom; }) }),
133+
copies: PACKAGE_OBJECTS,
134+
});
135+
136+
await expect(buildObjectListResource(md, undefined, view)).rejects.toBe(boom);
137+
expect(metadataService.listObjects).not.toHaveBeenCalled();
138+
});
139+
});
140+
141+
describe('[#25245] objectstack://objects/{objectName} answers from the same listing', () => {
142+
it('answers each package object as describe_object does, the one declared apiEnabled: false included', async () => {
143+
const protocol = makeProtocol();
144+
const { view, md, metadataService } = makeView({ protocol: () => protocol });
145+
const bridge = createStdioDataBridge({
146+
engine: {} as IDataEngine,
147+
metadataService: md,
148+
protocol: () => protocol,
149+
resolvePrincipal: async () => ({ userId: 'u1' }) as unknown as ExecutionContext,
150+
});
151+
152+
for (const name of [PKG_OPEN, PKG_GATED]) {
153+
expect(parsed(await buildObjectSchemaResource(md, name, undefined, view))).toEqual(await bridge.describeObject(name));
154+
}
155+
expect(parsed(await buildObjectSchemaResource(md, PKG_GATED, undefined, view)))
156+
.toMatchObject({ name: PKG_GATED, enableFeatures: { apiEnabled: false } });
157+
expect(metadataService.getObject).not.toHaveBeenCalled();
158+
});
159+
160+
it('a name the listing omits is the coded miss, even where the metadata service still holds a copy', async () => {
161+
const protocol = makeProtocol([]);
162+
const { view, md } = makeView({ protocol: () => protocol, copies: [PACKAGE_OBJECTS[0]] });
163+
164+
expect(parsed(await buildObjectSchemaResource(md, PKG_OPEN, undefined, view))).toEqual({
165+
error: `Object "${PKG_OPEN}" not found`,
166+
code: 'RESOURCE_NOT_FOUND',
167+
status: 404,
168+
});
169+
});
170+
171+
it('a miss during a metadata outage is still SERVICE_UNAVAILABLE, the verdict asked of the metadata service', async () => {
172+
const protocol = makeProtocol([]);
173+
const { view, md, metadataService } = makeView({ protocol: () => protocol });
174+
metadataService.getDiagnosed.mockResolvedValueOnce({ data: undefined, degraded: true, errors: ['loader x failed'] });
175+
176+
expect(parsed(await buildObjectSchemaResource(md, PKG_OPEN, undefined, view)))
177+
.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 });
178+
expect(metadataService.getDiagnosed).toHaveBeenCalledWith('object', PKG_OPEN);
179+
});
180+
181+
it('a merged read that throws travels to the caller, never a fallback to the copy', async () => {
182+
const boom = new Error('sys_metadata unreadable');
183+
const { view, md, metadataService } = makeView({
184+
protocol: () => ({ getMetaItems: vi.fn(async () => { throw boom; }) }),
185+
copies: PACKAGE_OBJECTS,
186+
});
187+
188+
await expect(buildObjectSchemaResource(md, PKG_OPEN, undefined, view)).rejects.toBe(boom);
189+
expect(metadataService.getObject).not.toHaveBeenCalled();
190+
});
191+
});
192+
193+
describe('[#25245] a host with no merged read reads the metadata service, as before', () => {
194+
for (const [label, protocol] of [
195+
['no protocol registered', () => undefined],
196+
['a protocol without getMetaItems', () => ({})],
197+
] as const) {
198+
it(`${label}: both resources answer byte-identically to the metadata service alone`, async () => {
199+
const { view, md } = makeView({ protocol, copies: PACKAGE_OBJECTS });
200+
201+
expect(await buildObjectListResource(md, undefined, view)).toEqual(await buildObjectListResource(md));
202+
for (const name of [PKG_OPEN, 'qa_pkg_nowhere']) {
203+
expect(await buildObjectSchemaResource(md, name, undefined, view)).toEqual(await buildObjectSchemaResource(md, name));
204+
}
205+
});
206+
}
207+
208+
it('the protocol is resolved at each read, never captured: one registered later is read on the next call', async () => {
209+
let registered: StdioProtocolObjectReads | undefined;
210+
const { view, md } = makeView({ protocol: () => registered });
211+
212+
expect(parsed(await buildObjectListResource(md, undefined, view)).objects).toEqual([]);
213+
registered = makeProtocol();
214+
expect(parsed(await buildObjectListResource(md, undefined, view)).totalCount).toBe(2);
215+
});
216+
});
217+
218+
/** An in-process MCP transport: the server's replies are matched back to the request that asked. */
219+
function inProcessTransport() {
220+
const waiting = new Map<number, (frame: any) => void>();
221+
let nextId = 1;
222+
const transport: any = {
223+
onmessage: undefined,
224+
onclose: undefined,
225+
onerror: undefined,
226+
async start() {},
227+
async close() { transport.onclose?.(); },
228+
async send(message: any) {
229+
const resolve = typeof message?.id === 'number' ? waiting.get(message.id) : undefined;
230+
if (resolve) { waiting.delete(message.id); resolve(message); }
231+
},
232+
};
233+
const rpc = (method: string, params?: unknown) => new Promise<any>((resolve, reject) => {
234+
const id = nextId++;
235+
const giveUp = setTimeout(() => reject(new Error(`no answer to "${method}" (id ${id}) within 10s`)), 10_000);
236+
waiting.set(id, (frame) => { clearTimeout(giveUp); resolve(frame); });
237+
transport.onmessage?.({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) });
238+
});
239+
return { transport, rpc, notify: (method: string) => transport.onmessage?.({ jsonrpc: '2.0', method }) };
240+
}
241+
242+
/** A runtime with the resources bridged over `md`, and a session reading them over JSON-RPC. */
243+
async function openResources(md: IMetadataService, before?: (runtime: MCPServerRuntime) => void) {
244+
const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' });
245+
before?.(runtime);
246+
runtime.bridgeResources(md);
247+
const session = inProcessTransport();
248+
await runtime.server.connect(session.transport as never);
249+
await session.rpc('initialize', {
250+
protocolVersion: '2025-06-18',
251+
capabilities: {},
252+
clientInfo: { name: 'merged-object-resources', version: '0.0.0' },
253+
});
254+
session.notify('notifications/initialized');
255+
const read = async (uri: string) => {
256+
const frame = await session.rpc('resources/read', { uri });
257+
expect(frame.error, `resources/read ${uri} failed at the protocol level`).toBeUndefined();
258+
return JSON.parse(frame.result.contents[0].text as string);
259+
};
260+
return { runtime, read, close: () => session.transport.close() };
261+
}
262+
263+
describe('[#25245] the runtime\'s object resources read the view its host gave (setRuntimeObjectView)', () => {
264+
it('resources/read of both URIs answers from that view, never the metadata service', async () => {
265+
const protocol = makeProtocol();
266+
const { view, md, metadataService } = makeView({ protocol: () => protocol });
267+
const s = await openResources(md, (runtime) => setRuntimeObjectView(runtime, view));
268+
269+
expect((await s.read('objectstack://objects')).totalCount).toBe(2);
270+
expect(await s.read(`objectstack://objects/${PKG_OPEN}`)).toMatchObject({ name: PKG_OPEN, label: 'Open' });
271+
expect(metadataService.listObjects).not.toHaveBeenCalled();
272+
expect(metadataService.getObject).not.toHaveBeenCalled();
273+
await s.close();
274+
});
275+
276+
it('the view is read at each resources/read, so one given after bridgeResources is read too', async () => {
277+
const protocol = makeProtocol();
278+
const { view, md } = makeView({ protocol: () => protocol });
279+
const s = await openResources(md);
280+
281+
expect((await s.read('objectstack://objects')).totalCount).toBe(0);
282+
setRuntimeObjectView(s.runtime, view);
283+
expect((await s.read('objectstack://objects')).totalCount).toBe(2);
284+
await s.close();
285+
});
286+
287+
it('a runtime whose host gave no view reads the metadata service, and another runtime\'s view is not its own', async () => {
288+
const protocol = makeProtocol();
289+
const { view } = makeView({ protocol: () => protocol });
290+
const { md, metadataService } = makeView({ copies: [PACKAGE_OBJECTS[1]] });
291+
setRuntimeObjectView(new MCPServerRuntime({ name: 'objectstack-other', version: '9.9.9' }), view);
292+
const s = await openResources(md);
293+
294+
expect((await s.read('objectstack://objects')).objects.map((o: { name: string }) => o.name)).toEqual([PKG_GATED]);
295+
expect(await s.read(`objectstack://objects/${PKG_OPEN}`)).toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404 });
296+
expect(metadataService.listObjects).toHaveBeenCalled();
297+
await s.close();
298+
});
299+
});

0 commit comments

Comments
 (0)