diff --git a/.changeset/25245-mcp-stdio-object-resources-merged.md b/.changeset/25245-mcp-stdio-object-resources-merged.md new file mode 100644 index 00000000000..241ef166aea --- /dev/null +++ b/.changeset/25245-mcp-stdio-object-resources-merged.md @@ -0,0 +1,20 @@ +--- +'@objectstack/mcp': patch +--- + +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 + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**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. + +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. + +- **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. +- **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. +- **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. +- **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. +- **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. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 81ee7609189..a73b2ab9f8d 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -178,6 +178,12 @@ objectstack://metadata/types # List all metadata typ The record resource is registered only when the host supplies a record reader; without one, the schema and listing resources are served alone. +The listing and schema resources read the same objects `list_objects` and +`describe_object` answer: the protocol's merged listing, the one +`GET /api/v1/meta/object` serves, so a package's objects appear on every kernel +shape and a disabled package's objects do not. A host with no `protocol` +service reads the metadata service instead. + ## Prompts Two prompt families are bridged — there is no per-item prompt registration call: diff --git a/packages/mcp/src/mcp-server-runtime.merged-object-resources.test.ts b/packages/mcp/src/mcp-server-runtime.merged-object-resources.test.ts new file mode 100644 index 00000000000..2f73757f067 --- /dev/null +++ b/packages/mcp/src/mcp-server-runtime.merged-object-resources.test.ts @@ -0,0 +1,299 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25245] The `objectstack://objects` resources read the stdio transport's ONE + * object view (`stdioObjectView`, `stdio-data-bridge.ts`): the protocol's + * merged listing, the read `list_objects` / `describe_object` answer from since + * #25138, and the metadata service only on a host with no merged read. + * + * The defect, in the doubles' terms: an object a package registers during the + * boot of a kernel with an `environmentId` lives in the `SchemaRegistry`, which + * the protocol reads, and has no copy in the `metadata` service, which both + * resources read. So the metadata double below holds NO copy of the package + * objects and the protocol double serves them. Measured on the real kernel + * through the `MCPServerPlugin` stdio session before the fix: `resources/read` + * of `objectstack://objects` answered `{ objects: [], totalCount: 0 }` and of + * `objectstack://objects/qa_pkg_open` `404 RESOURCE_NOT_FOUND`, while the tools + * listed and described both. That wire reading is pinned on real kernels in the + * dogfood suite (`packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts`); + * this file pins each branch of the two builders over the view, and the + * runtime's two object resources reading the view their host gave it + * (`setRuntimeObjectView`). + * + * What stays the metadata service's: the completeness verdict (#6504 on the + * listing, #6055 on a miss). The protocol's listing cannot answer it, and the + * `list_objects` tool asks the metadata service for it on the same items. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { + buildObjectListResource, + buildObjectSchemaResource, + MCPServerRuntime, + setRuntimeObjectView, +} from './mcp-server-runtime.js'; +import { + createStdioDataBridge, + stdioObjectView, + type StdioProtocolObjectReads, +} from './stdio-data-bridge.js'; + +const PKG_OPEN = 'qa_pkg_open'; +const PKG_GATED = 'qa_pkg_gated'; + +/** The package's two objects, as the protocol serves them; one declared off the API. */ +const PACKAGE_OBJECTS = [ + { + name: PKG_OPEN, + label: 'Open', + fields: { + name: { type: 'text', label: 'Name' }, + vault_secret: { type: 'text', internal: true }, + }, + }, + { name: PKG_GATED, label: 'Gated', enable: { apiEnabled: false }, fields: { name: { type: 'text', label: 'Name' } } }, +]; + +/** A protocol serving `objects` through its merged listing. */ +function makeProtocol(objects: Array> = PACKAGE_OBJECTS) { + return { + getMetaItems: vi.fn(async (_request: { type: string }) => ({ type: 'object', items: objects })), + }; +} + +/** A metadata service holding `copies` and nothing else (the package objects have none by default). */ +function makeMetadata(copies: Array> = []) { + return { + listObjects: vi.fn(async () => copies), + getObject: vi.fn(async (name: string) => copies.find((o) => o.name === name)), + listDiagnosed: vi.fn(async () => ({ data: copies, degraded: false, errors: [] as string[] })), + getDiagnosed: vi.fn(async (_type: string, name: string) => ({ + data: copies.find((o) => o.name === name), + degraded: false, + errors: [] as string[], + })), + }; +} + +/** The view and the metadata service the builders read, over `protocol` resolved at each use. */ +function makeView(opts: { protocol?: () => StdioProtocolObjectReads | undefined; copies?: Array> } = {}) { + const metadataService = makeMetadata(opts.copies); + const view = stdioObjectView({ + metadataService: metadataService as unknown as IMetadataService, + protocol: opts.protocol, + }); + return { view, metadataService, md: metadataService as unknown as IMetadataService }; +} + +const parsed = (result: { contents: Array<{ text: string }> }) => JSON.parse(result.contents[0].text); + +describe('[#25245] objectstack://objects lists the merged view', () => { + it('lists the package objects the metadata service holds no copy of, with a complete totalCount', async () => { + const protocol = makeProtocol(); + const { view, md, metadataService } = makeView({ protocol: () => protocol }); + + const body = parsed(await buildObjectListResource(md, undefined, view)); + + expect(body).toEqual({ + objects: [ + { name: PKG_OPEN, label: 'Open', fieldCount: 2 }, + { name: PKG_GATED, label: 'Gated', fieldCount: 1 }, + ], + totalCount: 2, + }); + expect(protocol.getMetaItems).toHaveBeenCalledWith({ type: 'object' }); + expect(metadataService.listObjects).not.toHaveBeenCalled(); + }); + + it('omits what the listing omits, even where the metadata service still holds a copy (a disabled package)', async () => { + const protocol = makeProtocol([]); + const { view, md } = makeView({ protocol: () => protocol, copies: [PACKAGE_OBJECTS[0]] }); + + expect(parsed(await buildObjectListResource(md, undefined, view))).toEqual({ objects: [], totalCount: 0 }); + }); + + it('keeps the completeness verdict from the metadata service, the items from the merged listing', async () => { + const protocol = makeProtocol(); + const { view, md, metadataService } = makeView({ protocol: () => protocol }); + metadataService.listDiagnosed.mockResolvedValueOnce({ data: [], degraded: true, errors: ['loader x failed'] }); + + const body = parsed(await buildObjectListResource(md, undefined, view)); + + expect(body.objects.map((o: { name: string }) => o.name)).toEqual([PKG_OPEN, PKG_GATED]); + expect(body).toMatchObject({ partial: true, returnedCount: 2, code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect(body).not.toHaveProperty('totalCount'); + }); + + it('a merged read that throws travels to the caller, never a fallback to the copy', async () => { + const boom = new Error('sys_metadata unreadable'); + const { view, md, metadataService } = makeView({ + protocol: () => ({ getMetaItems: vi.fn(async () => { throw boom; }) }), + copies: PACKAGE_OBJECTS, + }); + + await expect(buildObjectListResource(md, undefined, view)).rejects.toBe(boom); + expect(metadataService.listObjects).not.toHaveBeenCalled(); + }); +}); + +describe('[#25245] objectstack://objects/{objectName} answers from the same listing', () => { + it('answers each package object as describe_object does, the one declared apiEnabled: false included', async () => { + const protocol = makeProtocol(); + const { view, md, metadataService } = makeView({ protocol: () => protocol }); + const bridge = createStdioDataBridge({ + engine: {} as IDataEngine, + metadataService: md, + protocol: () => protocol, + resolvePrincipal: async () => ({ userId: 'u1' }) as unknown as ExecutionContext, + }); + + for (const name of [PKG_OPEN, PKG_GATED]) { + expect(parsed(await buildObjectSchemaResource(md, name, undefined, view))).toEqual(await bridge.describeObject(name)); + } + expect(parsed(await buildObjectSchemaResource(md, PKG_GATED, undefined, view))) + .toMatchObject({ name: PKG_GATED, enableFeatures: { apiEnabled: false } }); + expect(metadataService.getObject).not.toHaveBeenCalled(); + }); + + it('a name the listing omits is the coded miss, even where the metadata service still holds a copy', async () => { + const protocol = makeProtocol([]); + const { view, md } = makeView({ protocol: () => protocol, copies: [PACKAGE_OBJECTS[0]] }); + + expect(parsed(await buildObjectSchemaResource(md, PKG_OPEN, undefined, view))).toEqual({ + error: `Object "${PKG_OPEN}" not found`, + code: 'RESOURCE_NOT_FOUND', + status: 404, + }); + }); + + it('a miss during a metadata outage is still SERVICE_UNAVAILABLE, the verdict asked of the metadata service', async () => { + const protocol = makeProtocol([]); + const { view, md, metadataService } = makeView({ protocol: () => protocol }); + metadataService.getDiagnosed.mockResolvedValueOnce({ data: undefined, degraded: true, errors: ['loader x failed'] }); + + expect(parsed(await buildObjectSchemaResource(md, PKG_OPEN, undefined, view))) + .toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect(metadataService.getDiagnosed).toHaveBeenCalledWith('object', PKG_OPEN); + }); + + it('a merged read that throws travels to the caller, never a fallback to the copy', async () => { + const boom = new Error('sys_metadata unreadable'); + const { view, md, metadataService } = makeView({ + protocol: () => ({ getMetaItems: vi.fn(async () => { throw boom; }) }), + copies: PACKAGE_OBJECTS, + }); + + await expect(buildObjectSchemaResource(md, PKG_OPEN, undefined, view)).rejects.toBe(boom); + expect(metadataService.getObject).not.toHaveBeenCalled(); + }); +}); + +describe('[#25245] a host with no merged read reads the metadata service, as before', () => { + for (const [label, protocol] of [ + ['no protocol registered', () => undefined], + ['a protocol without getMetaItems', () => ({})], + ] as const) { + it(`${label}: both resources answer byte-identically to the metadata service alone`, async () => { + const { view, md } = makeView({ protocol, copies: PACKAGE_OBJECTS }); + + expect(await buildObjectListResource(md, undefined, view)).toEqual(await buildObjectListResource(md)); + for (const name of [PKG_OPEN, 'qa_pkg_nowhere']) { + expect(await buildObjectSchemaResource(md, name, undefined, view)).toEqual(await buildObjectSchemaResource(md, name)); + } + }); + } + + it('the protocol is resolved at each read, never captured: one registered later is read on the next call', async () => { + let registered: StdioProtocolObjectReads | undefined; + const { view, md } = makeView({ protocol: () => registered }); + + expect(parsed(await buildObjectListResource(md, undefined, view)).objects).toEqual([]); + registered = makeProtocol(); + expect(parsed(await buildObjectListResource(md, undefined, view)).totalCount).toBe(2); + }); +}); + +/** An in-process MCP transport: the server's replies are matched back to the request that asked. */ +function inProcessTransport() { + const waiting = new Map void>(); + let nextId = 1; + const transport: any = { + onmessage: undefined, + onclose: undefined, + onerror: undefined, + async start() {}, + async close() { transport.onclose?.(); }, + async send(message: any) { + const resolve = typeof message?.id === 'number' ? waiting.get(message.id) : undefined; + if (resolve) { waiting.delete(message.id); resolve(message); } + }, + }; + const rpc = (method: string, params?: unknown) => new Promise((resolve, reject) => { + const id = nextId++; + const giveUp = setTimeout(() => reject(new Error(`no answer to "${method}" (id ${id}) within 10s`)), 10_000); + waiting.set(id, (frame) => { clearTimeout(giveUp); resolve(frame); }); + transport.onmessage?.({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) }); + }); + return { transport, rpc, notify: (method: string) => transport.onmessage?.({ jsonrpc: '2.0', method }) }; +} + +/** A runtime with the resources bridged over `md`, and a session reading them over JSON-RPC. */ +async function openResources(md: IMetadataService, before?: (runtime: MCPServerRuntime) => void) { + const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + before?.(runtime); + runtime.bridgeResources(md); + const session = inProcessTransport(); + await runtime.server.connect(session.transport as never); + await session.rpc('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'merged-object-resources', version: '0.0.0' }, + }); + session.notify('notifications/initialized'); + const read = async (uri: string) => { + const frame = await session.rpc('resources/read', { uri }); + expect(frame.error, `resources/read ${uri} failed at the protocol level`).toBeUndefined(); + return JSON.parse(frame.result.contents[0].text as string); + }; + return { runtime, read, close: () => session.transport.close() }; +} + +describe('[#25245] the runtime\'s object resources read the view its host gave (setRuntimeObjectView)', () => { + it('resources/read of both URIs answers from that view, never the metadata service', async () => { + const protocol = makeProtocol(); + const { view, md, metadataService } = makeView({ protocol: () => protocol }); + const s = await openResources(md, (runtime) => setRuntimeObjectView(runtime, view)); + + expect((await s.read('objectstack://objects')).totalCount).toBe(2); + expect(await s.read(`objectstack://objects/${PKG_OPEN}`)).toMatchObject({ name: PKG_OPEN, label: 'Open' }); + expect(metadataService.listObjects).not.toHaveBeenCalled(); + expect(metadataService.getObject).not.toHaveBeenCalled(); + await s.close(); + }); + + it('the view is read at each resources/read, so one given after bridgeResources is read too', async () => { + const protocol = makeProtocol(); + const { view, md } = makeView({ protocol: () => protocol }); + const s = await openResources(md); + + expect((await s.read('objectstack://objects')).totalCount).toBe(0); + setRuntimeObjectView(s.runtime, view); + expect((await s.read('objectstack://objects')).totalCount).toBe(2); + await s.close(); + }); + + it('a runtime whose host gave no view reads the metadata service, and another runtime\'s view is not its own', async () => { + const protocol = makeProtocol(); + const { view } = makeView({ protocol: () => protocol }); + const { md, metadataService } = makeView({ copies: [PACKAGE_OBJECTS[1]] }); + setRuntimeObjectView(new MCPServerRuntime({ name: 'objectstack-other', version: '9.9.9' }), view); + const s = await openResources(md); + + expect((await s.read('objectstack://objects')).objects.map((o: { name: string }) => o.name)).toEqual([PKG_GATED]); + expect(await s.read(`objectstack://objects/${PKG_OPEN}`)).toMatchObject({ code: 'RESOURCE_NOT_FOUND', status: 404 }); + expect(metadataService.listObjects).toHaveBeenCalled(); + await s.close(); + }); +}); diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 6ca41ee1872..8f432968ac6 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -780,6 +780,38 @@ export interface ObjectSchemaResourceResult { contents: Array<{ uri: string; mimeType: string; text: string }>; } +/** + * [#25245] Where the two object resources read their objects from: the same + * two reads {@link IMetadataService} names, so the service itself is one (the + * default, and what a host with no merged read reads). The stdio host gives the + * merged view its data bridge answers `list_objects` / `describe_object` from + * (`stdioObjectView`, `stdio-data-bridge.ts`), so the resources and the tools + * are one reading of the environment's objects. The completeness verdict is + * never asked of it: it stays the metadata service's. + */ +export type McpObjectView = Pick; + +/** + * [#25245] The object view each runtime's object resources read, where its host + * gave one ({@link setRuntimeObjectView}). + * + * Package-internal on purpose, and not a parameter of `bridgeResources`: that + * method is published (`MCPServerRuntime` is exported), so a new parameter would + * widen the package's public surface, a new type reachable from it, for wiring + * between this package's own plugin and its own runtime. Keyed weakly, so a + * runtime and its view are collected together. + */ +const runtimeObjectViews = new WeakMap(); + +/** + * [#25245] Give `runtime`'s two object resources `objects` to read in place of + * the metadata service. Read at each `resources/read`, so the call may come + * before or after `bridgeResources`. `index.ts` does not publish it. + */ +export function setRuntimeObjectView(runtime: MCPServerRuntime, objects: McpObjectView): void { + runtimeObjectViews.set(runtime, objects); +} + /** * [#6055] Resolve the `objectstack://objects/{objectName}` answer for one call. * @@ -800,13 +832,16 @@ export async function buildObjectSchemaResource( metadataService: IMetadataService, objectName: string, logger?: Logger, + objects: McpObjectView = metadataService, ): Promise { const uri = `objectstack://objects/${objectName}`; const body = (payload: unknown): ObjectSchemaResourceResult => ({ contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(payload) }], }); - const objectDef = await metadataService.getObject(objectName); + // [#25245] The object from `objects` ({@link McpObjectView}); the verdict + // on a miss is still the metadata service's. + const objectDef = await objects.getObject(objectName); if (!objectDef) { const { degraded, errors } = await diagnoseEmptyRead(metadataService, 'object', objectName); @@ -893,8 +928,11 @@ export async function buildObjectSchemaResource( export async function buildObjectListResource( metadataService: IMetadataService, logger?: Logger, + view: McpObjectView = metadataService, ): Promise { - const objects = await metadataService.listObjects(); + // [#25245] The items from `view` ({@link McpObjectView}); the verdict below + // is still the metadata service's. + const objects = await view.listObjects(); const summary = ((objects ?? []) as ObjectDef[]).map(o => ({ name: o.name, label: o.label ?? o.name, @@ -1183,12 +1221,17 @@ export class MCPServerRuntime { * - `objectstack://objects/{objectName}` — Get object schema * - `objectstack://objects/{objectName}/records/{recordId}` — Get a specific record * - `objectstack://metadata/types` — List all metadata types + * + * [#25245] The two object resources read the view this runtime's host gave + * (`setRuntimeObjectView`, package-internal), at each read; without one they + * read `metadataService`, as before. */ bridgeResources( metadataService: IMetadataService, getRecord?: (objectName: string, recordId: string) => Promise | null>, ): void { const logger = this.config.logger; + const objects = (): McpObjectView => runtimeObjectViews.get(this) ?? metadataService; let resourceCount = 0; // ── Static resource: List all objects ── @@ -1202,7 +1245,7 @@ export class MCPServerRuntime { async () => // [#6504] The completeness of the count lives in the builder — see // {@link buildObjectListResource}. - buildObjectListResource(metadataService, logger), + buildObjectListResource(metadataService, logger, objects()), ); resourceCount++; @@ -1217,7 +1260,7 @@ export class MCPServerRuntime { async (_uri, variables) => // [#6055] Outage vs. miss lives in the builder — see // {@link buildObjectSchemaResource}. - buildObjectSchemaResource(metadataService, String(variables.objectName), logger), + buildObjectSchemaResource(metadataService, String(variables.objectName), logger, objects()), ); resourceCount++; diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts index f6d88002630..45cf8fd92be 100644 --- a/packages/mcp/src/plugin.ts +++ b/packages/mcp/src/plugin.ts @@ -32,7 +32,7 @@ import { readEnvWithDeprecation, isMcpServerEnabled, resolveMcpStdioAutoStart } import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { TenancyPosture } from '@objectstack/spec/security'; import type { IAIService, IAuthService, IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; -import { MCPServerRuntime } from './mcp-server-runtime.js'; +import { MCPServerRuntime, setRuntimeObjectView } from './mcp-server-runtime.js'; import { PACKAGE_VERSION, DEFAULT_SERVER_VERSION } from './package-version.js'; import type { MCPServerRuntimeConfig, McpMergedMetadataRead } from './mcp-server-runtime.js'; import type { ToolRegistry } from './types.js'; @@ -42,6 +42,7 @@ import { GATED_ACTIONS, serveStoredMetadataHashes, serveStoredMetadataRow, + stdioObjectView, type StdioObjectReads, type StdioProtocolObjectReads, type StoredHashDigest, @@ -441,6 +442,23 @@ export class MCPServerPlugin implements Plugin { ); } + // [#25138] The protocol's object reads — the merged listing behind + // `list_objects` / `describe_object` and the by-name read behind the + // exposure gate — resolved at each use, never captured here: an absence + // read in `start()` is a verdict the rest of the boot can still + // contradict. `undefined` when this host registers no protocol, and the + // bridge then reads the metadata service, as before. + // [#25245] Hoisted out of the stdio branch: the `objectstack://objects` + // resources read the same merged view, and they are bridged below whether + // or not this process starts the stdio transport. + const protocolReads = (): StdioProtocolObjectReads | undefined => { + try { + return ctx.getService('protocol') ?? undefined; + } catch { + return undefined; + } + }; + // ── stdio auto-start decision (opt-in, its OWN switch) ── // Deliberately stricter than the HTTP-surface default (`isMcpServerEnabled`, // default-on): start() attaches a long-lived transport claiming the @@ -705,19 +723,6 @@ export class MCPServerPlugin implements Plugin { if (gate) throw stdioAuthGateRefusal(gate); return ec; }; - // [#25138] The protocol's object reads — the merged listing behind - // `list_objects` / `describe_object` and the by-name read behind the - // exposure gate — resolved at each use, never captured here: an absence - // read in `start()` is a verdict the rest of the boot can still - // contradict. `undefined` when this host registers no protocol, and the - // bridge then reads the metadata service, as before. - const protocolReads = (): StdioProtocolObjectReads | undefined => { - try { - return ctx.getService('protocol') ?? undefined; - } catch { - return undefined; - } - }; if (metadataService) { dataBridge = createStdioDataBridge({ engine: scopedQl, @@ -792,6 +797,11 @@ export class MCPServerPlugin implements Plugin { } if (metadataService) { + // [#25245] The object resources read the view `list_objects` / + // `describe_object` answer from (`stdioObjectView`), never the metadata + // service alone: on a kernel with an `environmentId` that service holds + // no copy of an object a package registered during the boot. + setRuntimeObjectView(this.runtime, stdioObjectView({ metadataService, protocol: protocolReads })); this.runtime.bridgeResources(metadataService, getRecord); // Awaited: the prompt bridge reads `skill` metadata to project each // skill's instructions onto an MCP prompt (#3905), so the surface must be diff --git a/packages/mcp/src/stdio-data-bridge.ts b/packages/mcp/src/stdio-data-bridge.ts index e71d1f1c81d..956357a9eee 100644 --- a/packages/mcp/src/stdio-data-bridge.ts +++ b/packages/mcp/src/stdio-data-bridge.ts @@ -122,7 +122,7 @@ import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts' // there is no reason to add a second import path to the same function. import { recordNotFoundError } from '@objectstack/core'; import type { McpDataBridge, McpObjectSummary } from './mcp-http-tools.js'; -import { metaItemsArray } from './mcp-server-runtime.js'; +import { metaItemsArray, type McpObjectView } from './mcp-server-runtime.js'; import { diagnoseObjectListRead, type DiagnosedObjectListing, @@ -376,6 +376,35 @@ async function readMergedObjects(reads: StdioObjectReads): Promise o?.name === name) + : (((await reads.metadataService.getObject(name)) ?? undefined) as ObjectDef | undefined); + }, + }; +} + /** * The ADR-0049 object exposure gate, applied before a data verb dispatches * (#8083). Throws {@link McpExposureError} when the object's own declaration @@ -836,13 +865,13 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge // [#25138] One definition source for the gate, the relation-target gate and // the write-response strip, and one listing for discovery. const reads: StdioObjectReads = { metadataService, protocol }; + // [#25245] …and that listing is the view the object resources read too. + const objectView = stdioObjectView(reads); const bridge: McpDataBridge = { async listObjects(): Promise { // [#25138] The protocol's merged listing ({@link readMergedObjects}). - const objects = (await readMergedObjects(reads)) - ?? (((await metadataService.listObjects()) ?? []) as ObjectDef[]); - return objects.map((o) => ({ + return ((await objectView.listObjects()) as ObjectDef[]).map((o) => ({ name: o.name, label: o.label ?? o.name, fieldCount: o.fields ? Object.keys(o.fields).length : undefined, @@ -870,10 +899,7 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge // describe, and a name the listing omits (a disabled package's object among // them) is "not found" here too, as on the HTTP bridge. async describeObject(name: string): Promise { - const merged = await readMergedObjects(reads); - const def = merged !== undefined - ? merged.find((o) => o?.name === name) - : ((await metadataService.getObject(name)) as ObjectDef | undefined | null); + const def = (await objectView.getObject(name)) as ObjectDef | undefined; if (!def) return null; const fields = def.fields ?? {}; // The field list is an ARRAY here, not the stored map: `validate_expression` diff --git a/packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts b/packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts index d7a16ea1dd8..f5a7c6ca1ab 100644 --- a/packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts +++ b/packages/qa/dogfood/test/mcp-stdio-package-object-discovery.test.ts @@ -42,6 +42,14 @@ * the plugin's own stdio server over an in-process transport speaking the * JSON-RPC a stdio client speaks. * + * [#25245] The `objectstack://objects` resources read the same view. They read + * the `metadata` service until then: measured on the `env_local` boot row at + * the base tree, `resources/read` of `objectstack://objects` answered + * `{ objects: [], totalCount: 0 }` and of `objectstack://objects/qa_pkg_open` + * `404 RESOURCE_NOT_FOUND`, while the tools listed and described both; and on + * the shape that copies, they still listed and described a disabled package's + * objects. Their pins sit beside the tools' and assert the two answer alike. + * * ## What is real, and the one seam that is not * * As in `mcp-stdio-runtime-object-exposure.test.ts`: the kernel is real @@ -220,6 +228,8 @@ interface Booted { call(name: string, args: Record): Promise; /** What `resources/read` on a record URI answered, parsed. */ readRecord(object: string, id: string): Promise; + /** [#25245] What `resources/read` on `uri` answered, parsed. */ + readResource(uri: string): Promise; } /** @@ -320,6 +330,12 @@ async function boot( }); session.notify('notifications/initialized'); + const readResource = async (uri: string): Promise => { + const frame = await session.rpc('resources/read', { uri }); + expect(frame.error, `resources/read ${uri} failed at the protocol level`).toBeUndefined(); + return JSON.parse(frame.result.contents[0].text as string); + }; + return { kernel, engine, @@ -334,10 +350,9 @@ async function boot( return { isError: frame.result.isError === true, text, json }; }, async readRecord(object, id) { - const frame = await session.rpc('resources/read', { uri: `objectstack://objects/${object}/records/${id}` }); - expect(frame.error, 'resources/read failed at the protocol level').toBeUndefined(); - return JSON.parse(frame.result.contents[0].text as string); + return readResource(`objectstack://objects/${object}/records/${id}`); }, + readResource, }; } @@ -351,8 +366,15 @@ async function shutdown(booted: Booted | undefined): Promise { const outcome = (answer: ToolAnswer) => (answer.isError ? { code: answer.json?.error?.code, status: answer.json?.error?.status } : 'SERVED'); -const nonSystem = (answer: ToolAnswer) => - ((answer.json?.objects as Array<{ name: string }> | undefined) ?? []).map((o) => o.name).filter((n) => !/^sys_/i.test(n)); +/** The non-system names of an `{ objects }` listing. */ +const nonSystemNames = (listing: { objects?: Array<{ name: string }> } | undefined) => + (listing?.objects ?? []).map((o) => o.name).filter((n) => !/^sys_/i.test(n)); + +const nonSystem = (answer: ToolAnswer) => nonSystemNames(answer.json); + +/** [#25245] The two object resources' URIs. */ +const OBJECT_LIST_URI = 'objectstack://objects'; +const objectUri = (name: string) => `objectstack://objects/${name}`; /** The six MCP data tools, each called on `object`. */ const DATA_TOOLS: ReadonlyArray Record]> = [ @@ -427,6 +449,31 @@ for (const { shape, environmentId } of SHAPES) { .toMatchObject({ isError: true, text: expect.stringContaining('not found') }); }); + it('[#25245] resources/read objectstack://objects lists both package objects, with a complete totalCount — the listing list_objects serves', async () => { + const listed = await k!.readResource(OBJECT_LIST_URI); + expect(nonSystemNames(listed)).toEqual(expect.arrayContaining([pkg.open, pkg.gated])); + expect(listed).not.toHaveProperty('partial'); + expect(listed.totalCount).toBe(listed.objects.length); + expect(nonSystemNames(listed).sort()).toEqual(nonSystem(await k!.call('list_objects', {})).sort()); + }); + + it('[#25245] resources/read objectstack://objects/NAME answers both package objects as describe_object does, the declared exposure included', async () => { + const open = await k!.readResource(objectUri(pkg.open)); + const gated = await k!.readResource(objectUri(pkg.gated)); + + expect(open.name).toBe(pkg.open); + expect(open.fields.map((f: any) => f.name)).toContain('name'); + expect(gated).toMatchObject({ name: pkg.gated, enableFeatures: { apiEnabled: false } }); + expect(open).toEqual((await k!.call('describe_object', { objectName: pkg.open })).json); + expect(gated).toEqual((await k!.call('describe_object', { objectName: pkg.gated })).json); + // The control: a name nothing serves is still the coded miss. + expect(await k!.readResource(objectUri('qa_pkg_nowhere'))).toEqual({ + error: 'Object "qa_pkg_nowhere" not found', + code: 'RESOURCE_NOT_FOUND', + status: 404, + }); + }); + for (const [tool, args] of DATA_TOOLS) { it(`${tool} refuses the object the package declared apiEnabled: false — 404 OBJECT_API_DISABLED, the store never asked`, async () => { const store = storeSpies(k!.engine, pkg.gated); @@ -489,6 +536,19 @@ for (const { shape, environmentId } of SHAPES) { } }); + it('[#25245] neither listed nor described by the objectstack://objects resources — the coded miss, as describe_object answers', async () => { + const listed = nonSystemNames(await k!.readResource(OBJECT_LIST_URI)); + expect(listed).not.toContain(pkg.open); + expect(listed).not.toContain(pkg.gated); + for (const name of [pkg.open, pkg.gated]) { + expect(await k!.readResource(objectUri(name))).toEqual({ + error: `Object "${name}" not found`, + code: 'RESOURCE_NOT_FOUND', + status: 404, + }); + } + }); + for (const object of ['open', 'gated'] as const) { for (const [tool, args] of DATA_TOOLS) { it(`${tool} refuses its ${object} object — 404 OBJECT_PACKAGE_DISABLED, the store never asked`, async () => {