Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/25245-mcp-stdio-object-resources-merged.md
Original file line number Diff line number Diff line change
@@ -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

<!-- 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). -->

**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.
6 changes: 6 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
299 changes: 299 additions & 0 deletions packages/mcp/src/mcp-server-runtime.merged-object-resources.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> = 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<Record<string, unknown>> = []) {
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<Record<string, unknown>> } = {}) {
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<number, (frame: any) => 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<any>((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();
});
});
Loading
Loading