From d694aadb9a0953ca96de64bf945314a9695c81d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:52:29 +0000 Subject: [PATCH 01/11] wip(runtime): MCP object discovery and the exposure gate read the protocol's merged view Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- packages/runtime/src/action-execution.ts | 71 ++++++++- .../mcp-package-object-discovery.test.ts | 135 ++++++++++++++++++ packages/runtime/src/domains/mcp.ts | 112 +++++++++++---- 3 files changed, 287 insertions(+), 31 deletions(-) create mode 100644 packages/runtime/src/domains/mcp-package-object-discovery.test.ts diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 616639b7651..740ecadd69d 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -21,7 +21,7 @@ import { validateActionParams, type ActionSession, type ResolvedActionParam } fr import { EngineQueryOptionsSchema, DATA_ACTION_TO_API_OPERATION } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; -import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +import { standardErrorCodeForHttpStatus, type MetadataProtocol } from '@objectstack/spec/api'; // [#15942 / #16293] The confirmation member's SPELLING is the contract, so it // is imported, never hand-spelled: the door that refuses and the client that // retries have to agree on it, and two doors each writing their own 'confirm' @@ -287,6 +287,72 @@ async function resolveEngineQuietly(deps: ActionExecutionDeps, requestContext: H } } +/** + * [#25120] The by-name object reads {@link readExposureDeclaration} asks of + * the `protocol` slot, both optional: the slot is unmapped in + * `ServiceSlotContracts`, and a host may fill it with a partial object. + * `getMetaItemForExecution` is the server-only face `RestProtocol` declares the + * same way (`packages/rest/src/rest-server.ts`). + */ +type ExposureDeclarationRead = Partial> & { + getMetaItemForExecution?: MetadataProtocol['getMetaItem']; +}; + +/** + * [#25120] The definition {@link refuseUnexposedDataAction} judges: the object, + * by name, through the protocol's MERGED read — the read `RestServer`'s own + * exposure gate makes (`enforceApiAccess`), on the execution face where the + * protocol has one (no serving decorations: the gate reads `enable` and serves + * nothing). The metadata service's copy is read only on a host whose protocol + * has no by-name read: structural absence, answered as before. + * + * ── The defect this closes ──────────────────────────────────────────────── + * + * This gate read `IMetadataService.getObject(object)`, and an absent copy is + * "no declaration", i.e. allowed. On a kernel with an `environmentId` nothing + * copies into that service the objects a package or a plugin registers in the + * `SchemaRegistry` during the boot, so such an object declared + * `apiEnabled: false` was served: measured on that shape, `query_records` + * answered and `create_record` reached the driver, through the `/mcp` bridge + * and every other door that asks this gate, while `RestServer` refused the + * same object `404 OBJECT_API_DISABLED`. One declaration, two verdicts. + * + * ── Answers, by what the read returns ───────────────────────────────────── + * + * - the item under its own name → judged; + * - nothing, or an item under another name → `undefined`: no declared + * exposure policy, and the data plane's registry gate answers existence + * (#3770), as before; + * - refused `OBJECT_PACKAGE_DISABLED` → `undefined`, as `RestServer`'s gate + * reads it (`loadObjectItem`): the list read omits that object, and the data + * plane refuses the request with that same code; + * - any other throw → to the caller, whose `catch` falls open, unchanged. + */ +async function readExposureDeclaration( + deps: ActionExecutionDeps, + requestContext: HttpProtocolContext, + object: string, + scopeId?: string, +): Promise { + const protocol = await deps.resolveService(requestContext, 'protocol', scopeId) as ExposureDeclarationRead | undefined; + const byName = typeof protocol?.getMetaItemForExecution === 'function' + ? protocol.getMetaItemForExecution + : typeof protocol?.getMetaItem === 'function' ? protocol.getMetaItem : undefined; + if (!byName) { + const meta = await deps.resolveService(requestContext, 'metadata', scopeId); + return await (meta as any)?.getObject?.(object); + } + let envelope: unknown; + try { + envelope = await byName.call(protocol, { type: 'object', name: object }); + } catch (err) { + if ((err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED') return undefined; + throw err; + } + const item = (envelope as { item?: unknown } | null | undefined)?.item; + return item !== null && typeof item === 'object' && (item as { name?: unknown }).name === object ? item : undefined; +} + /** * ── Object-level API exposure gate (ADR-0049, #1889) ───── * Honour the object's `apiEnabled` / `apiMethods` declarations for external @@ -323,8 +389,7 @@ export async function refuseUnexposedDataAction(deps: ActionExecutionDeps, if (executionContext?.isSystem || !object) return; let def: any; try { - const meta = await deps.resolveService(requestContext, 'metadata', scopeId); - def = await (meta as any)?.getObject?.(object); + def = await readExposureDeclaration(deps, requestContext, String(object), scopeId); } catch { def = undefined; // fall open to schema defaults (apiEnabled=true) } diff --git a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts new file mode 100644 index 00000000000..4c4f3dd1b34 --- /dev/null +++ b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// MEASUREMENT SCAFFOLD — rewritten into the pin before commit. + +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectKernel } from '@objectstack/core'; +import { MetadataPlugin } from '@objectstack/metadata'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { HttpDispatcher } from '../http-dispatcher.js'; +import { refuseUnexposedDataAction } from '../action-execution.js'; + +const PKG = 'com.qa.pkgobj'; +const OPEN = 'qa_pkg_open'; +const GATED = 'qa_pkg_gated'; + +const pkgArtifact = () => ({ + id: PKG, + name: 'qa pkg', + version: '1.0.0', + type: 'app', + objects: [ + { name: OPEN, label: 'Open', fields: { name: { name: 'name', type: 'text', label: 'Name' } } }, + { name: GATED, label: 'Gated', enable: { apiEnabled: false }, fields: { name: { name: 'name', type: 'text', label: 'Name' } } }, + ], +}); + +const live: ObjectKernel[] = []; +const roots: string[] = []; +afterEach(async () => { + while (live.length) { + const k = live.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } + while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +async function boot(environmentId: string | undefined, when: 'boot' | 'late') { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + live.push(kernel); + const root = mkdtempSync(join(tmpdir(), 'os-25120-')); + roots.push(root); + const engine = new ObjectQL(); + engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as any, true); + const mcp: any = { + lastOpts: undefined, + handleHttpRequest: async (_r: Request, o: any) => { + mcp.lastOpts = o; + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }, + }; + if (environmentId === undefined) { + await kernel.use(new MetadataPlugin({ watch: false, rootDir: root })); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + } else { + const artifact = join(root, 'objectstack.json'); + writeFileSync(artifact, JSON.stringify({ + manifest: { id: 'com.qa.empty', name: 'empty', version: '0.0.0', type: 'app' }, + objects: [], views: [], apps: [], flows: [], requires: [], + })); + await kernel.use(new MetadataPlugin({ + watch: false, artifactWatch: false, environmentId, + artifactSource: { mode: 'local-file', path: artifact }, rootDir: root, + } as any)); + await kernel.use(new ObjectQLPlugin({ ql: engine, environmentId, hydrateMetadataFromDb: true })); + } + await kernel.use({ + name: 'qa.package-host', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: any) => { + ctx.registerService('mcp', mcp); + if (when === 'boot') ctx.getService('manifest').register(pkgArtifact()); + }, + } as any); + await kernel.bootstrap(); + if (when === 'late') await (kernel.getService('manifest') as any).register(pkgArtifact(), { source: 'marketplace' }); + await engine.syncSchemas(); + return { kernel, engine, mcp }; +} + +const ctx = (environmentId?: string): any => ({ + request: new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: '{}', + }), + response: {}, + environmentId, + executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [], systemPermissions: [] }, +}); + +describe('measure', () => { + for (const environmentId of [undefined, 'env_local']) { + for (const when of ['boot', 'late'] as const) { + it(`env=${environmentId ?? 'none'} when=${when}`, async () => { + const { kernel, mcp } = await boot(environmentId, when); + const meta: any = kernel.getService('metadata'); + const protocol: any = kernel.getService('protocol'); + const d = new HttpDispatcher(kernel as any, undefined, { enforceProjectMembership: false }); + const r = await d.handleMcp({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, ctx()); + expect(r.response?.status).toBe(200); + const bridge = mcp.lastOpts.bridge; + const out: Record = {}; + out.metaGetOpen = Boolean(await meta.getObject(OPEN)); + out.metaGetGated = Boolean(await meta.getObject(GATED)); + const metaNames = ((await meta.listObjects()) as any[]).map((o) => o.name); + out.metaListHasPkg = metaNames.filter((n) => n === OPEN || n === GATED); + const merged = await protocol.getMetaItems({ type: 'object' }); + const mergedNames = merged.items.map((o: any) => o.name); + out.mergedHasPkg = mergedNames.filter((n: string) => n === OPEN || n === GATED); + out.mergedNonSys = mergedNames.filter((n: string) => !/^sys_/i.test(n)).length; + const listed = await bridge.listObjectsDiagnosed(); + out.mcpList = listed.objects.map((o: any) => o.name).filter((n: string) => !/^sys_/i.test(n)); + out.mcpDescribeOpen = Boolean(await bridge.describeObject(OPEN)); + const rest = await d.handleMetadata('/object', ctx(), 'GET'); + out.restStatus = rest.response?.status; + const restItems = (rest.response?.body?.data?.items ?? rest.response?.body?.data ?? []) as any[]; + out.restNonSys = Array.isArray(restItems) ? restItems.filter((o: any) => !/^sys_/i.test(o?.name)).map((o: any) => o.name) : restItems; + for (const verb of ['query', 'create'] as const) { + try { + if (verb === 'query') await bridge.query(GATED, {}); + else await bridge.create(GATED, { name: 'x' }); + out[`mcp_${verb}_gated`] = 'SERVED'; + } catch (e: any) { + out[`mcp_${verb}_gated`] = `${e?.code ?? ''} ${e?.status ?? ''} ${String(e?.message).slice(0, 80)}`; + } + } + console.log(`READING env=${environmentId ?? 'none'} when=${when}`, JSON.stringify(out)); + }, 60_000); + } + } +}); diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index 5d19bd3b7aa..720242c7e73 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -430,24 +430,44 @@ async function readMergedSkillRows( protocol: McpMergedMetadataRead | undefined | null, getMeta: () => Promise, ): Promise { - if (!protocol || typeof protocol.getMetaItems !== 'function') { + const items = await readMergedItems(protocol, 'skill'); + if (items === undefined) { const meta: any = await getMeta(); return (await meta?.list?.('skill')) ?? []; } - const answer = await protocol.getMetaItems({ type: 'skill' }); + await warnIfSkillListIncomplete(deps, getMeta, items.length); + return items; +} + +/** + * [commit e783e163d, #25120] One type's merged listing, as `GET /api/v1/meta/TYPE` + * reads it — or `undefined` when this host has no merged read to offer. + * + * The one place this file unwraps `getMetaItems`, shared by the skill read + * above and the object reads in {@link buildMcpBridge}, so the three cannot + * disagree about which answers they accept. Its three outcomes are the ones + * {@link readMergedSkillRows} documents: `undefined` is STRUCTURAL absence (no + * protocol in the slot, or one without the list verb), and the caller answers + * from the metadata service exactly as before; a throw travels to the caller, + * ⛔ never a fallback to the un-merged read. + */ +async function readMergedItems( + protocol: McpMergedMetadataRead | undefined | null, + type: 'skill' | 'object', +): Promise { + if (!protocol || typeof protocol.getMetaItems !== 'function') return undefined; + const answer = await protocol.getMetaItems({ type }); // The declared `GetMetaItemsResponse` is `{ type, items }`, and the two // other `getMetaItems` consumers in this package read `res.items` the same // way. The bare-array arm matches what `packages/mcp` accepts for the stdio // half of this same read: the slot is filled by name from a host-owned // registry, and the two surfaces answering one question must not disagree // about the shape they accept. - const items = Array.isArray(answer) + return Array.isArray(answer) ? (answer as unknown[]) : Array.isArray(answer?.items) ? answer.items : []; - await warnIfSkillListIncomplete(deps, getMeta, items.length); - return items; } /** @@ -521,7 +541,8 @@ async function warnIfSkillListIncomplete( /** * Build a principal-bound {@link McpDataBridge}: every method runs AS the * request's ExecutionContext through {@link callData} (RLS/permissions) and - * the per-env metadata service. Keeps the MCP tool layer free of any direct + * the per-env metadata reads (the protocol's merged listing, or the metadata + * service on a host with none). Keeps the MCP tool layer free of any direct * engine access. * * Also carries the action seam (`listActions` / `runAction`) and the skill seam @@ -536,10 +557,54 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon // makes stays pinned to THIS request's kernel. const callData = actionExec.callData.bind(null, deps, context); const getMeta = () => deps.resolveService(context, 'metadata', envId); + // The protocol layer's merged read, resolved per request on the SAME + // per-environment seam `getMeta` uses — never captured once at boot, + // which would serve one environment's rows to every other one. + // + // [#15238] Typed at the RESOLVE, not just at the callee's parameter. + // `resolveService` answers `any` for `'protocol'` (the slot is deliberately + // unmapped in `ServiceSlotContracts`), so naming {@link McpMergedMetadataRead} + // here is what keeps any verb, spelt any way, from being reached through + // this handle. ⛔ Still `| undefined` and still every member optional: + // {@link readMergedItems} keeps its own `typeof protocol.getMetaItems !== + // 'function'` probe, because a host may occupy the slot with a partial + // object. + const getProtocol = async (): Promise => + await deps.resolveService(context, 'protocol', envId); + + /** + * [#25120] The objects `list_objects` and `describe_object` answer from: + * the protocol's MERGED listing, the read `GET /api/v1/meta/object` serves + * — never `IMetadataService.listObjects()` / `getObject()`. + * + * The metadata service holds a copy of an object only where something put + * one there, and on a kernel with an `environmentId` nothing copies the + * objects a package or a plugin contributes to the `SchemaRegistry` during + * the boot (the full boot bridge is off on that shape). Those objects live + * in the registry, which the merged read reads and the metadata service + * does not. So this read served `list_objects` the environment-authored + * objects alone, as a complete `totalCount`, and `describe_object` answered + * "not found" for every package object, while REST listed them and the data + * verbs served them. Measured on that shape: REST listed both of a + * boot-registered package's objects, `list_objects` neither. The repair is + * the consumer moving up to the read REST serves — the shape commit + * e783e163d gave the skill read — ⛔ NOT a second copy of each package + * object in the metadata service, which would be one more thing to keep in + * sync. + * + * `undefined` = no merged read on this host (structural absence, see + * {@link readMergedItems}): `list_objects` and `describe_object` then read + * the metadata service, exactly as before. + */ + const readMergedObjects = async (): Promise => + (await readMergedItems(await getProtocol(), 'object')) as any[] | undefined; const listObjectSummaries = async (): Promise => { - const meta: any = await getMeta(); - const objs: any[] = (await meta?.listObjects?.()) ?? []; + let objs: any[] | undefined = await readMergedObjects(); + if (objs === undefined) { + const meta: any = await getMeta(); + objs = ((await meta?.listObjects?.()) ?? []) as any[]; + } return objs.map((o) => ({ name: o.name, label: o.label ?? o.name, @@ -589,9 +654,15 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon return { objects, degraded: false, errors: [] }; } }, + // [#25120] Answered from the listing `list_objects` serves + // ({@link readMergedObjects}), so every object an agent is listed it + // can describe, and a name the listing omits (a disabled package's + // object among them) is "not found" here too. describeObject: async (name: string) => { - const meta: any = await getMeta(); - const def: any = await meta?.getObject?.(name); + const merged = await readMergedObjects(); + const def: any = merged !== undefined + ? merged.find((o: any) => o?.name === name) + : await ((await getMeta()) as any)?.getObject?.(name); if (!def) return null; const fields = def.fields ?? {}; return { @@ -687,24 +758,9 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon // of #8328, whose own reproduction runs through THIS endpoint. See // {@link readMergedSkillRows}. listSkills: async () => { - // Resolved per request on the SAME per-environment seam `getMeta` - // uses — never captured once at boot, which would serve one - // environment's overlay rows to every other one. - // - // [#15238] Typed at the RESOLVE, not just at the callee's - // parameter. `resolveService` answers `any` for `'protocol'` (the - // slot is deliberately unmapped in `ServiceSlotContracts`), and - // this file was half-typed: {@link McpMergedMetadataRead} was - // already declared for the merged-read seam below, while the handle - // feeding it was annotated `any` — so any verb, spelt any way, - // could be reached from this site. Naming the existing type here - // closes that half. ⛔ Still `| undefined` and still every member - // optional: `readMergedSkillRows` keeps its own - // `typeof protocol.getMetaItems !== 'function'` probe, because a - // host may occupy the slot with a partial object. - const protocol: McpMergedMetadataRead | undefined = - await deps.resolveService(context, 'protocol', envId); - return await readMergedSkillRows(deps, protocol, getMeta); + // Resolved per request through {@link getProtocol}, the typed + // per-environment handle the object reads above use too. + return await readMergedSkillRows(deps, await getProtocol(), getMeta); }, create: async (object: string, data: any) => From 1273eb4e39f8ce3bcb9c088a6dd8ebbd17a514b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:57:51 +0000 Subject: [PATCH 02/11] wip(runtime): exposure gate falls back to the copy on a merged-read outage; rig reads the protocol Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- packages/runtime/src/action-execution.ts | 18 +++-- .../domains/data-write-request-gate.test.ts | 67 +++++++++++-------- 2 files changed, 50 insertions(+), 35 deletions(-) diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 740ecadd69d..c5f2b5499b9 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -326,7 +326,12 @@ type ExposureDeclarationRead = Partial> & * - refused `OBJECT_PACKAGE_DISABLED` → `undefined`, as `RestServer`'s gate * reads it (`loadObjectItem`): the list read omits that object, and the data * plane refuses the request with that same code; - * - any other throw → to the caller, whose `catch` falls open, unchanged. + * - any other throw (the metadata store unreadable) → the metadata service's + * copy, the source this gate read before. An outage of the merged read must + * not WIDEN exposure: failing open at once would serve an object the copy + * still declares off, where this gate refused it before the move. The copy + * can only add a refusal the healthy read would also make or a stale one, never + * an allowance the caller's fail-open `catch` would not already give. */ async function readExposureDeclaration( deps: ActionExecutionDeps, @@ -334,20 +339,21 @@ async function readExposureDeclaration( object: string, scopeId?: string, ): Promise { + const readCopy = async () => { + const meta = await deps.resolveService(requestContext, 'metadata', scopeId); + return await (meta as any)?.getObject?.(object); + }; const protocol = await deps.resolveService(requestContext, 'protocol', scopeId) as ExposureDeclarationRead | undefined; const byName = typeof protocol?.getMetaItemForExecution === 'function' ? protocol.getMetaItemForExecution : typeof protocol?.getMetaItem === 'function' ? protocol.getMetaItem : undefined; - if (!byName) { - const meta = await deps.resolveService(requestContext, 'metadata', scopeId); - return await (meta as any)?.getObject?.(object); - } + if (!byName) return await readCopy(); let envelope: unknown; try { envelope = await byName.call(protocol, { type: 'object', name: object }); } catch (err) { if ((err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED') return undefined; - throw err; + return await readCopy(); } const item = (envelope as { item?: unknown } | null | undefined)?.item; return item !== null && typeof item === 'object' && (item as { name?: unknown }).name === object ? item : undefined; diff --git a/packages/runtime/src/domains/data-write-request-gate.test.ts b/packages/runtime/src/domains/data-write-request-gate.test.ts index fa033e7f3d4..540b0713263 100644 --- a/packages/runtime/src/domains/data-write-request-gate.test.ts +++ b/packages/runtime/src/domains/data-write-request-gate.test.ts @@ -64,15 +64,16 @@ * `{}`). Every row asserts the store, not just the door's word. * * [#23574] Both doors read each object's exposure from the one definition the - * engine's registry holds: `RestServer` through the protocol's by-name object - * read beside its object list (`getMetaItems`, and both by-name faces, - * `getMetaItemForExecution`, which the exposure gate reads when the protocol - * has it, and `getMetaItem`, which it reads otherwise; this rig's protocol - * cannot read without a metadata store, so all three are answered from the - * registry), and the dispatcher through the - * kernel's `metadata` service (`getObject`, answered from the same registry). - * Without them each door's exposure gate fails open, and every exposure row - * below would read as an allowed object. + * engine's registry holds, through the protocol's by-name object read + * (`RestServer` beside its object list): `getMetaItems`, and both by-name + * faces, `getMetaItemForExecution`, which each door's exposure gate reads when + * the protocol has it, and `getMetaItem`, which it reads otherwise. This rig's + * protocol cannot read without a metadata store, so all three are answered + * from the registry ({@link stubObjectReads}). [#25120] The dispatcher's gate + * read the kernel's `metadata` service until it moved to the protocol's read; + * this rig gives it no `metadata` service, so a gate that stopped reading the + * protocol would fail open and every exposure row below would read as an + * allowed object. */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -155,7 +156,29 @@ async function bootStore() { }; /** Each object's definition as the registry holds it: the one exposure source both doors read. */ const definition = (name: string) => engine.registry.getObject(name); - return { protocol, updateData, createData, findData, state, definition }; + stubObjectReads(protocol, definition); + return { protocol, updateData, createData, findData, state }; +} + +/** + * The protocol's object reads, answered from the registry: both doors' exposure + * gates read the object by name through the protocol, and `RestServer`'s beside + * its object list; see the rig note above. + */ +function stubObjectReads(protocol: ObjectStackProtocolImplementation, definition: (name: string) => unknown): void { + (protocol as any).getMetaItems = async (request: any) => request?.type === 'object' + ? { type: 'object', items: OBJECTS.map((o) => definition(o.name)) } + : { type: request?.type, items: [] }; + // [#24535] ONE registry-backed stub on BOTH by-name faces: each gate reads + // `getMetaItemForExecution` when the protocol has one (this real one does), + // and `getMetaItem` otherwise. Stubbing one face alone leaves the gate on + // the other, which fails open here. + for (const face of ['getMetaItem', 'getMetaItemForExecution'] as const) { + const readByName = (protocol as any)[face].bind(protocol); + (protocol as any)[face] = async (request: any) => request?.type === 'object' + ? { type: 'object', name: request.name, item: OBJECTS.some((o) => o.name === request.name) ? definition(request.name) : undefined } + : readByName(request); + } } // ── The two doors ───────────────────────────────────────────────────────────── @@ -198,21 +221,7 @@ function makeRes() { } async function bootRestServer(): Promise { - const { protocol, updateData, createData, findData, state, definition } = await bootStore(); - // `enforceApiAccess` reads the object by name through the protocol, beside its object list; see the rig note above. - (protocol as any).getMetaItems = async (request: any) => request?.type === 'object' - ? { type: 'object', items: OBJECTS.map((o) => definition(o.name)) } - : { type: request?.type, items: [] }; - // [#24535] ONE registry-backed stub on BOTH by-name faces: `enforceApiAccess` - // reads `getMetaItemForExecution` when the protocol has one (this real one - // does), and `getMetaItem` otherwise. Stubbing one face alone leaves the - // gate on the other, which fails open here. - for (const face of ['getMetaItem', 'getMetaItemForExecution'] as const) { - const readByName = (protocol as any)[face].bind(protocol); - (protocol as any)[face] = async (request: any) => request?.type === 'object' - ? { type: 'object', name: request.name, item: OBJECTS.some((o) => o.name === request.name) ? definition(request.name) : undefined } - : readByName(request); - } + const { protocol, updateData, createData, findData, state } = await bootStore(); const noop = () => {}; const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; const rest: any = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); @@ -274,10 +283,10 @@ async function catchAll(dispatcher: HttpDispatcher, method: string, path: string } async function bootCatchAll(): Promise { - const { protocol, updateData, createData, findData, state, definition } = await bootStore(); - // `callData`'s exposure gate reads the kernel's `metadata` service; see the rig note above. - const metadata = { getObject: async (name: string) => definition(name) }; - const services: Record = { protocol, metadata }; + const { protocol, updateData, createData, findData, state } = await bootStore(); + // `callData`'s exposure gate reads the protocol's by-name object read, and + // no `metadata` service is given; see the rig note above. + const services: Record = { protocol }; const get = (n: string) => services[n] ?? null; const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; const dispatcher = new HttpDispatcher(kernel); From 81dcb723d9679b087735c98dbe56e274a7d71b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:00:22 +0000 Subject: [PATCH 03/11] test(runtime): pin the /mcp bridge's view of a package's objects on every kernel shape Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .../mcp-package-object-discovery.test.ts | 408 ++++++++++++++---- 1 file changed, 318 insertions(+), 90 deletions(-) diff --git a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts index 4c4f3dd1b34..594df1266a1 100644 --- a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts +++ b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts @@ -1,8 +1,69 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// MEASUREMENT SCAFFOLD — rewritten into the pin before commit. +/** + * [#25120] The `/mcp` bridge sees the objects a PACKAGE contributes: listed, + * described, and refused by every data verb when the package declares their + * API exposure off — on every kernel shape, the `environmentId` kernel + * included. + * + * ## The defect + * + * `buildMcpBridge` answered `list_objects` / `describe_object` from the + * `metadata` service (`listObjects()` / `getObject()`), and the data verbs' + * exposure gate (`refuseUnexposedDataAction`) read the same service. That + * service holds a copy of an object only where something put one there. On a + * kernel with an `environmentId` — `createStandaloneStack`'s composition + * (`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 or a plugin registers in the `SchemaRegistry` during the boot gets + * no copy. `GET /api/v1/meta/object` reads the registry through the protocol's + * merged read and lists it; the data door serves it. Measured on this file's + * rig at the base tree (`4e2c42f363`), a package of two objects (one declared + * `apiEnabled: false`): + * + * | shape · when the package registered | `metadata.getObject` | REST lists | bridge lists | describe | gated `query` | gated `create` | + * | :-- | :-- | :-- | :-- | :-- | :-- | :-- | + * | `env_local` · during the boot | absent | 2 | **0** | **null** | **served** | **reached the driver** | + * | `env_local` · installed after the boot | copied | 2 | 2 | served | refused 404 | refused 404 | + * | no `environmentId` · during the boot | copied | 2 | 2 | served | refused 404 | refused 404 | + * | no `environmentId` · installed after the boot | copied | 2 | 2 | served | refused 404 | refused 404 | + * + * The first row is the card's live reading (`list_objects` answering only the + * environment-authored objects as a complete `totalCount`, `describe_object` + * "not found" for every package object, `query_records` served). The other + * three are the CONTROLS: the boot bridge (no `environmentId`) and the + * late-manifest bridge (a marketplace install, on any shape) copy the package's + * objects, so they were already right and must stay right. + * + * ## The fix, and where these pins sit + * + * The bridge reads the protocol's MERGED listing (`getMetaItems`), the read + * REST serves, and the gate reads the protocol's by-name object read, the read + * `RestServer`'s own exposure gate makes. ⛔ Not a copy of every package + * object in the `metadata` service: a second copy is a second thing to keep in + * sync. The pins read the bridge the dispatcher hands the MCP service, driven + * through the real `/mcp` entry (`HttpDispatcher.handleMcp`): `list_objects` + * renders `totalCount` as the count of the non-`sys_` objects in + * `listObjectsDiagnosed().objects` (`packages/mcp`, unchanged), and each data + * tool calls the bridge verb of its name. + * + * ## The rig + * + * Real kernels: `ObjectKernel` with `MetadataPlugin` and `ObjectQLPlugin` over + * a better-sqlite3 `:memory:` `SqlDriver`, in the two shapes the + * `mcp-stdio-runtime-object-exposure` dogfood suite pins (the `environmentId` + * one with its options verbatim from `createStandaloneStack`). The package is + * registered through the kernel's `manifest` service, either from a plugin's + * `init()` (during the boot, the way capability plugins register their + * objects) or after the boot with `{ source: 'marketplace' }` (the way + * marketplace install registers a package). The one seam not real is the MCP + * service, which records the bridge it is handed. + * + * The negative control (red with the fix ablated) is a one-time ablation, + * cited in the PR, not a permanent case. + */ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,47 +72,59 @@ import { MetadataPlugin } from '@objectstack/metadata'; import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { HttpDispatcher } from '../http-dispatcher.js'; -import { refuseUnexposedDataAction } from '../action-execution.js'; - -const PKG = 'com.qa.pkgobj'; -const OPEN = 'qa_pkg_open'; -const GATED = 'qa_pkg_gated'; - -const pkgArtifact = () => ({ - id: PKG, - name: 'qa pkg', - version: '1.0.0', - type: 'app', - objects: [ - { name: OPEN, label: 'Open', fields: { name: { name: 'name', type: 'text', label: 'Name' } } }, - { name: GATED, label: 'Gated', enable: { apiEnabled: false }, fields: { name: { name: 'name', type: 'text', label: 'Name' } } }, - ], -}); -const live: ObjectKernel[] = []; -const roots: string[] = []; -afterEach(async () => { - while (live.length) { - const k = live.pop()!; - if (k.getState() === 'running') await k.shutdown(); - } - while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }); -}); +const CALLER = { userId: 'u_mcp', isSystem: false, positions: [], permissions: [], systemPermissions: [] }; + +/** A package of two objects, one of them declared off the API. */ +function packageOf(prefix: string) { + const open = `${prefix}_open`; + const gated = `${prefix}_gated`; + return { + open, + gated, + artifact: () => ({ + id: `com.qa.${prefix}`, + name: `${prefix} package`, + version: '1.0.0', + type: 'app', + objects: [ + { name: open, label: 'Open', fields: { name: { name: 'name', type: 'text', label: 'Name' } } }, + { + name: gated, + label: 'Gated', + enable: { apiEnabled: false }, + fields: { name: { name: 'name', type: 'text', label: 'Name' } }, + }, + ], + }), + }; +} + +interface Booted { + kernel: ObjectKernel; + engine: ObjectQL; + root: string; +} -async function boot(environmentId: string | undefined, when: 'boot' | 'late') { +/** + * One kernel in the shape under test, with `pkg` registered through the + * `manifest` service `when` asked. `mcp` is the recording MCP service, given to + * this kernel unless a host serves it. + */ +async function bootKernel( + environmentId: string | undefined, + when: 'boot' | 'install', + pkg: ReturnType, + mcp?: unknown, +): Promise { const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); - live.push(kernel); - const root = mkdtempSync(join(tmpdir(), 'os-25120-')); - roots.push(root); + const root = mkdtempSync(join(tmpdir(), 'os-mcp-package-objects-')); const engine = new ObjectQL(); - engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as any, true); - const mcp: any = { - lastOpts: undefined, - handleHttpRequest: async (_r: Request, o: any) => { - mcp.lastOpts = o; - return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); - }, - }; + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }) as any, true); if (environmentId === undefined) { await kernel.use(new MetadataPlugin({ watch: false, rootDir: root })); await kernel.use(new ObjectQLPlugin({ ql: engine })); @@ -71,65 +144,220 @@ async function boot(environmentId: string | undefined, when: 'boot' | 'late') { name: 'qa.package-host', dependencies: ['com.objectstack.engine.objectql'], init: async (ctx: any) => { - ctx.registerService('mcp', mcp); - if (when === 'boot') ctx.getService('manifest').register(pkgArtifact()); + if (mcp) ctx.registerService('mcp', mcp); + if (when === 'boot') ctx.getService('manifest').register(pkg.artifact()); }, } as any); await kernel.bootstrap(); - if (when === 'late') await (kernel.getService('manifest') as any).register(pkgArtifact(), { source: 'marketplace' }); + if (when === 'install') { + await (kernel.getService('manifest') as any).register(pkg.artifact(), { source: 'marketplace' }); + } await engine.syncSchemas(); - return { kernel, engine, mcp }; + return { kernel, engine, root }; } -const ctx = (environmentId?: string): any => ({ - request: new Request('http://localhost/api/v1/mcp', { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, - body: '{}', - }), - response: {}, - environmentId, - executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [], systemPermissions: [] }, -}); +async function shutdown(booted: Booted | undefined): Promise { + if (!booted) return; + if (booted.kernel.getState() === 'running') await booted.kernel.shutdown(); + rmSync(booted.root, { recursive: true, force: true }); +} + +/** The MCP service: records the bridge the dispatcher hands it. */ +function recordingMcp() { + const mcp: any = { + lastOpts: undefined, + handleHttpRequest: async (_req: Request, opts: any) => { + mcp.lastOpts = opts; + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }, + }; + return mcp; +} + +function requestContext(environmentId?: string): any { + return { + request: new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: '{}', + }), + response: {}, + environmentId, + executionContext: { ...CALLER }, + }; +} + +/** The bridge the real `/mcp` entry builds for `CALLER` and hands the MCP service. */ +async function bridgeFor(dispatcher: HttpDispatcher, mcp: any, environmentId?: string): Promise { + const res = await dispatcher.handleMcp({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, requestContext(environmentId)); + expect(res.response?.status, 'precondition: the /mcp entry served the request').toBe(200); + return mcp.lastOpts.bridge; +} -describe('measure', () => { - for (const environmentId of [undefined, 'env_local']) { - for (const when of ['boot', 'late'] as const) { - it(`env=${environmentId ?? 'none'} when=${when}`, async () => { - const { kernel, mcp } = await boot(environmentId, when); - const meta: any = kernel.getService('metadata'); - const protocol: any = kernel.getService('protocol'); - const d = new HttpDispatcher(kernel as any, undefined, { enforceProjectMembership: false }); - const r = await d.handleMcp({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, ctx()); - expect(r.response?.status).toBe(200); - const bridge = mcp.lastOpts.bridge; - const out: Record = {}; - out.metaGetOpen = Boolean(await meta.getObject(OPEN)); - out.metaGetGated = Boolean(await meta.getObject(GATED)); - const metaNames = ((await meta.listObjects()) as any[]).map((o) => o.name); - out.metaListHasPkg = metaNames.filter((n) => n === OPEN || n === GATED); - const merged = await protocol.getMetaItems({ type: 'object' }); - const mergedNames = merged.items.map((o: any) => o.name); - out.mergedHasPkg = mergedNames.filter((n: string) => n === OPEN || n === GATED); - out.mergedNonSys = mergedNames.filter((n: string) => !/^sys_/i.test(n)).length; - const listed = await bridge.listObjectsDiagnosed(); - out.mcpList = listed.objects.map((o: any) => o.name).filter((n: string) => !/^sys_/i.test(n)); - out.mcpDescribeOpen = Boolean(await bridge.describeObject(OPEN)); - const rest = await d.handleMetadata('/object', ctx(), 'GET'); - out.restStatus = rest.response?.status; - const restItems = (rest.response?.body?.data?.items ?? rest.response?.body?.data ?? []) as any[]; - out.restNonSys = Array.isArray(restItems) ? restItems.filter((o: any) => !/^sys_/i.test(o?.name)).map((o: any) => o.name) : restItems; - for (const verb of ['query', 'create'] as const) { +const nonSystem = (names: readonly string[]) => names.filter((n) => !/^sys_/i.test(n)); + +/** The object names `GET /meta/object` serves `CALLER`, `sys_` objects left out as `list_objects` leaves them out. */ +async function restObjectNames(dispatcher: HttpDispatcher, environmentId?: string): Promise { + const res = await dispatcher.handleMetadata('/object', requestContext(environmentId), 'GET'); + expect(res.response?.status, 'precondition: GET /meta/object answered').toBe(200); + const items = res.response?.body?.data?.items; + expect(Array.isArray(items), 'precondition: GET /meta/object served a list').toBe(true); + return nonSystem((items as any[]).map((o) => o?.name)); +} + +/** The six MCP data verbs, each as its tool calls the bridge. */ +const DATA_VERBS: ReadonlyArray Promise]> = [ + ['query_records', (b, o) => b.query(o, {})], + ['get_record', (b, o) => b.get(o, 'r_any')], + ['aggregate_records', (b, o) => b.aggregate(o, { aggregations: [{ function: 'count', alias: 'n' }] })], + ['create_record', (b, o) => b.create(o, { name: 'written' })], + ['update_record', (b, o) => b.update(o, 'r_any', { name: 'written' })], + ['delete_record', (b, o) => b.remove(o, 'r_any')], +]; + +/** Every protocol / engine call a data verb could reach the store through. */ +function storeSpies(kernel: ObjectKernel, engine: ObjectQL) { + const protocol: any = kernel.getService('protocol'); + return [ + ...(['findData', 'getData', 'createData', 'updateData', 'deleteData'] as const) + .map((m) => vi.spyOn(protocol, m)), + vi.spyOn(engine as any, 'aggregate'), + ]; +} + +/** The ADR-0112 envelope a refusal carries, or `'SERVED'`. */ +async function outcome(call: () => Promise): Promise<{ code?: unknown; status?: unknown } | 'SERVED'> { + try { + await call(); + return 'SERVED'; + } catch (e: any) { + return { code: e?.code, status: e?.status }; + } +} + +const SHAPES = [ + { shape: 'environmentId env_local (createStandaloneStack)', environmentId: 'env_local' }, + { shape: 'no environmentId', environmentId: undefined }, +] as const; +const REGISTRATIONS = [ + { label: 'registered during the boot', when: 'boot' }, + { label: 'installed after the boot, as marketplace install registers it', when: 'install' }, +] as const; + +for (const { shape, environmentId } of SHAPES) { + for (const { label, when } of REGISTRATIONS) { + describe(`[#25120] the /mcp bridge and a package's objects — ${shape}, ${label}`, () => { + const pkg = packageOf('qa_pkg'); + const mcp = recordingMcp(); + let booted: Booted | undefined; + let dispatcher: HttpDispatcher; + + beforeAll(async () => { + booted = await bootKernel(environmentId, when, pkg, mcp); + dispatcher = new HttpDispatcher(booted.kernel as any, undefined, { enforceProjectMembership: false }); + }, 60_000); + afterAll(async () => { + vi.restoreAllMocks(); + await shutdown(booted); + }, 60_000); + + it('list_objects lists the package\'s objects, its count the non-sys_ count GET /meta/object serves the same caller', async () => { + const bridge = await bridgeFor(dispatcher, mcp); + const listing = await bridge.listObjectsDiagnosed(); + const listed: string[] = nonSystem(listing.objects.map((o: any) => o.name)); + const served = await restObjectNames(dispatcher); + + expect(served, 'precondition: REST lists both package objects').toEqual(expect.arrayContaining([pkg.open, pkg.gated])); + expect(listed).toEqual(expect.arrayContaining([pkg.open, pkg.gated])); + // `totalCount` is this count (`list_objects` renders the non-`sys_` objects it was handed). + expect(listed.length).toBe(served.length); + expect([...listed].sort()).toEqual([...served].sort()); + expect(listing.degraded, 'a complete read: totalCount is rendered, not partial').toBe(false); + }); + + it('describe_object answers each package object, its declared exposure included', async () => { + const bridge = await bridgeFor(dispatcher, mcp); + const open = await bridge.describeObject(pkg.open); + const gated = await bridge.describeObject(pkg.gated); + + expect(open?.name).toBe(pkg.open); + expect(open?.fields?.map((f: any) => f.name)).toContain('name'); + expect(gated?.name).toBe(pkg.gated); + expect(gated?.enableFeatures?.apiEnabled).toBe(false); + // The control: a name nothing serves is still "not found". + expect(await bridge.describeObject('qa_pkg_nowhere')).toBeNull(); + }); + + for (const [tool, call] of DATA_VERBS) { + it(`${tool} refuses the object the package declared apiEnabled: false — 404 OBJECT_API_DISABLED, the store never asked`, async () => { + const bridge = await bridgeFor(dispatcher, mcp); + const spies = storeSpies(booted!.kernel, booted!.engine); try { - if (verb === 'query') await bridge.query(GATED, {}); - else await bridge.create(GATED, { name: 'x' }); - out[`mcp_${verb}_gated`] = 'SERVED'; - } catch (e: any) { - out[`mcp_${verb}_gated`] = `${e?.code ?? ''} ${e?.status ?? ''} ${String(e?.message).slice(0, 80)}`; + expect(await outcome(() => call(bridge, pkg.gated))).toEqual({ code: 'OBJECT_API_DISABLED', status: 404 }); + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + } finally { + for (const spy of spies) spy.mockRestore(); } - } - console.log(`READING env=${environmentId ?? 'none'} when=${when}`, JSON.stringify(out)); - }, 60_000); - } + }); + } + + it('CONTROL: query_records serves the package\'s exposed object', async () => { + const bridge = await bridgeFor(dispatcher, mcp); + expect(await outcome(() => bridge.query(pkg.open, {}))).toBe('SERVED'); + }); + }); + } +} + +describe('[#25120] two environments behind one host: each bridge reads its own environment\'s merged view', () => { + const pkgA = packageOf('qa_a'); + const pkgB = packageOf('qa_b'); + const mcp = recordingMcp(); + const envs = new Map(); + let dispatcher: HttpDispatcher; + + beforeAll(async () => { + envs.set('env_a', await bootKernel('env_a', 'boot', pkgA)); + envs.set('env_b', await bootKernel('env_b', 'boot', pkgB)); + // The shared host resolves each request's services on its environment's + // kernel, the way the dispatcher's scoped lookup asks a multi-environment + // host (`getServiceAsync(name, scopeId)`). + const service = (name: string, scopeId?: string): unknown => { + if (name === 'mcp') return mcp; + const env = scopeId ? envs.get(scopeId) : undefined; + if (!env) return undefined; + try { + return env.kernel.getService(name); + } catch { + return undefined; + } + }; + const host: any = { + getService: (name: string) => service(name), + getServiceAsync: async (name: string, scopeId?: string) => service(name, scopeId), + }; + dispatcher = new HttpDispatcher(host, undefined, { enforceProjectMembership: false }); + }, 120_000); + afterAll(async () => { + for (const env of envs.values()) await shutdown(env); + }, 60_000); + + for (const [envId, own, other] of [['env_a', pkgA, pkgB], ['env_b', pkgB, pkgA]] as const) { + it(`${envId} lists and describes its own package's objects and none of the other environment's`, async () => { + const bridge = await bridgeFor(dispatcher, mcp, envId); + const listed = nonSystem((await bridge.listObjectsDiagnosed()).objects.map((o: any) => o.name)); + + expect(listed).toEqual(expect.arrayContaining([own.open, own.gated])); + expect(listed).not.toContain(other.open); + expect(listed).not.toContain(other.gated); + expect((await bridge.describeObject(own.open))?.name).toBe(own.open); + expect(await bridge.describeObject(other.open)).toBeNull(); + }); + + it(`${envId} refuses its own package's apiEnabled: false object and serves its exposed one`, async () => { + const bridge = await bridgeFor(dispatcher, mcp, envId); + expect(await outcome(() => bridge.query(own.gated, {}))).toEqual({ code: 'OBJECT_API_DISABLED', status: 404 }); + expect(await outcome(() => bridge.query(own.open, {}))).toBe('SERVED'); + }); } }); From c3745e049da38478e7280d847d9a479f8b737fa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:08:21 +0000 Subject: [PATCH 04/11] test(runtime): a disabled package's objects are omitted by the /mcp bridge as by GET /meta/object Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .../mcp-package-object-discovery.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts index 594df1266a1..7a637b61a35 100644 --- a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts +++ b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts @@ -35,6 +35,10 @@ * late-manifest bridge (a marketplace install, on any shape) copy the package's * objects, so they were already right and must stay right. * + * The same split showed the other way round for a DISABLED package on the + * shape that copies: the copy outlives the disable, so the bridge listed and + * described both objects while `GET /meta/object` listed neither. + * * ## The fix, and where these pins sit * * The bridge reads the protocol's MERGED listing (`getMetaItems`), the read @@ -309,6 +313,39 @@ for (const { shape, environmentId } of SHAPES) { } } +describe('[#25120] a DISABLED package\'s objects: the bridge omits them as GET /meta/object does', () => { + // No `environmentId`, so the boot bridge HAS copied both objects into the + // `metadata` service: the copy outlives the disable, and the bridge used to + // list and describe both while REST listed neither. + const pkg = packageOf('qa_off'); + const mcp = recordingMcp(); + let booted: Booted | undefined; + let dispatcher: HttpDispatcher; + + beforeAll(async () => { + booted = await bootKernel(undefined, 'boot', pkg, mcp); + expect((booted.engine as any).registry.disablePackage('com.qa.qa_off'), 'precondition: disabled').toBeTruthy(); + expect( + await (booted.kernel.getService('metadata') as any).getObject(pkg.open), + 'precondition: the metadata service still holds its copy', + ).toBeTruthy(); + dispatcher = new HttpDispatcher(booted.kernel as any, undefined, { enforceProjectMembership: false }); + }, 60_000); + afterAll(async () => { + await shutdown(booted); + }, 60_000); + + it('neither listed nor described', async () => { + const bridge = await bridgeFor(dispatcher, mcp); + const listed = nonSystem((await bridge.listObjectsDiagnosed()).objects.map((o: any) => o.name)); + + expect(await restObjectNames(dispatcher), 'precondition: REST omits them').not.toContain(pkg.open); + expect(listed).not.toContain(pkg.open); + expect(listed).not.toContain(pkg.gated); + expect(await bridge.describeObject(pkg.open)).toBeNull(); + }); +}); + describe('[#25120] two environments behind one host: each bridge reads its own environment\'s merged view', () => { const pkgA = packageOf('qa_a'); const pkgB = packageOf('qa_b'); From db6cf6a3e25108f54b24e9615649c3168b4a9a38 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:10:12 +0000 Subject: [PATCH 05/11] chore(changeset): runtime patch for the /mcp merged-view object reads Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .changeset/25120-mcp-package-objects.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/25120-mcp-package-objects.md diff --git a/.changeset/25120-mcp-package-objects.md b/.changeset/25120-mcp-package-objects.md new file mode 100644 index 00000000000..70f63cc5836 --- /dev/null +++ b/.changeset/25120-mcp-package-objects.md @@ -0,0 +1,20 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime)!: the `/mcp` bridge reads objects from the protocol's merged view, so `list_objects` and `describe_object` see every object `GET /api/v1/meta/object` lists, and a package object declared `apiEnabled: false` is refused by every data verb + +Clause-②: no (narrowing) +Metadata-protocol: none + + + +**BREAKING** (the data doors' accept set narrows to the object's own declaration), shipped as `patch`: it changes which requests a data door serves, not what a metadata author can write. + +The `/mcp` bridge answered `list_objects` and `describe_object` from the `metadata` service, and the API-exposure gate every runtime data door asks (`callData`) read the same 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 or a plugin registers during the boot. So `list_objects` answered a short list as a complete `totalCount`, `describe_object` answered "not found" for every such object, and the gate read the missing copy as "no declaration" and served it. REST listed and refused the same objects. + +- **Before.** On that kernel, an object a package or a plugin 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` and `aggregate_records`, and `create_record`, `update_record` and `delete_record` reached the store. The runtime dispatcher's `/data` door served it the same way. +- **Now.** `list_objects` lists exactly the non-`sys_` objects `GET /api/v1/meta/object` serves the same caller, and `describe_object` answers each of them. Every data verb, on `/mcp` and on the dispatcher's `/data` door, refuses such an object `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` for a verb its `apiMethods` leaves out, as `RestServer` does. +- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described by `/mcp`; they are now omitted, as REST omits them. Such an object declared `apiEnabled: false` is refused `404 OBJECT_PACKAGE_DISABLED` where it was refused `404 OBJECT_API_DISABLED`, the code `RestServer` answers. 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. +- **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, and `describe_object` answers the definition `GET /api/v1/meta/object` serves for them. A host whose `protocol` service has no merged read keeps reading the `metadata` service. If the merged by-name read fails, the gate judges the `metadata` service's copy, as it did before. +- **What to do.** Nothing, if the declarations say what you mean. An agent that relied on reaching an object declared `apiEnabled: false` through `/mcp` or the dispatcher's `/data` door was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. From af62db1cb4a036d1b90ff6dd20c2f4ef5a20b7b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:42:37 +0000 Subject: [PATCH 06/11] test(runtime): type the pin's slot lookups with their contracts Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .../mcp-package-object-discovery.test.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts index 7a637b61a35..e13ecb42021 100644 --- a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts +++ b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts @@ -71,7 +71,8 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { ObjectKernel } from '@objectstack/core'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import type { IMetadataService } from '@objectstack/spec/contracts'; import { MetadataPlugin } from '@objectstack/metadata'; import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; @@ -79,6 +80,11 @@ import { HttpDispatcher } from '../http-dispatcher.js'; const CALLER = { userId: 'u_mcp', isSystem: false, positions: [], permissions: [], systemPermissions: [] }; +/** The `manifest` service door a package registers through (`ObjectQLPlugin`'s `register`). */ +interface ManifestDoor { + register(artifact: unknown, options?: { source?: 'code' | 'marketplace' }): Promise | void; +} + /** A package of two objects, one of them declared off the API. */ function packageOf(prefix: string) { const open = `${prefix}_open`; @@ -144,17 +150,18 @@ async function bootKernel( } as any)); await kernel.use(new ObjectQLPlugin({ ql: engine, environmentId, hydrateMetadataFromDb: true })); } - await kernel.use({ + const host: Plugin = { name: 'qa.package-host', dependencies: ['com.objectstack.engine.objectql'], - init: async (ctx: any) => { + init: async (ctx: PluginContext) => { if (mcp) ctx.registerService('mcp', mcp); - if (when === 'boot') ctx.getService('manifest').register(pkg.artifact()); + if (when === 'boot') await ctx.getService('manifest').register(pkg.artifact()); }, - } as any); + }; + await kernel.use(host); await kernel.bootstrap(); if (when === 'install') { - await (kernel.getService('manifest') as any).register(pkg.artifact(), { source: 'marketplace' }); + await kernel.getService('manifest').register(pkg.artifact(), { source: 'marketplace' }); } await engine.syncSchemas(); return { kernel, engine, root }; @@ -326,7 +333,7 @@ describe('[#25120] a DISABLED package\'s objects: the bridge omits them as GET / booted = await bootKernel(undefined, 'boot', pkg, mcp); expect((booted.engine as any).registry.disablePackage('com.qa.qa_off'), 'precondition: disabled').toBeTruthy(); expect( - await (booted.kernel.getService('metadata') as any).getObject(pkg.open), + await booted.kernel.getService('metadata').getObject(pkg.open), 'precondition: the metadata service still holds its copy', ).toBeTruthy(); dispatcher = new HttpDispatcher(booted.kernel as any, undefined, { enforceProjectMembership: false }); From dd89b45b5fff7c6ebe6a685cdc5286a5ea80794b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:50:45 +0000 Subject: [PATCH 07/11] fix(runtime): the exposure gate refuses a disabled package's object itself, so aggregate_records cannot serve it readExposureDeclaration read the protocol's OBJECT_PACKAGE_DISABLED refusal as "no declaration", on the premise that the data plane then refuses with that code. callData's aggregate arm asks the engine and never the protocol, so aggregate_records served a disabled package's object, one declared apiEnabled: false included. The refusal is now thrown past the gate's fail-open catch, so every arm answers the protocol's own 404 OBJECT_PACKAGE_DISABLED. Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- packages/runtime/src/action-execution.ts | 45 +++++++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index c5f2b5499b9..6863044a2fa 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -323,9 +323,20 @@ type ExposureDeclarationRead = Partial> & * - nothing, or an item under another name → `undefined`: no declared * exposure policy, and the data plane's registry gate answers existence * (#3770), as before; - * - refused `OBJECT_PACKAGE_DISABLED` → `undefined`, as `RestServer`'s gate - * reads it (`loadObjectItem`): the list read omits that object, and the data - * plane refuses the request with that same code; + * - refused `OBJECT_PACKAGE_DISABLED` → that refusal, THROWN, and + * {@link refuseUnexposedDataAction} lets it through its fail-open `catch` + * ({@link isObjectPackageDisabledRefusal}): a disabled package's object is + * refused at this gate, on every verb, with the protocol's own error (its + * `code`, `status` 404 and `object`). `RestServer`'s gate reads that refusal + * as "no declaration" (`loadObjectItem`) because every REST data route then + * reaches the protocol, whose registry gate (`assertObjectRegistered`) + * refuses with that same code. `callData`'s `aggregate` arm takes no + * protocol: it asks the engine, which serves a disabled package's objects. + * So reading the refusal as "no declaration" here served `aggregate_records` + * on such an object, one declared `apiEnabled: false` included, where the + * metadata service's copy had refused it `OBJECT_API_DISABLED`. Refused here, + * every arm answers the code and status REST answers, whether or not it + * reaches the protocol; * - any other throw (the metadata store unreadable) → the metadata service's * copy, the source this gate read before. An outage of the merged read must * not WIDEN exposure: failing open at once would serve an object the copy @@ -352,13 +363,26 @@ async function readExposureDeclaration( try { envelope = await byName.call(protocol, { type: 'object', name: object }); } catch (err) { - if ((err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED') return undefined; + if (isObjectPackageDisabledRefusal(err)) throw err; return await readCopy(); } const item = (envelope as { item?: unknown } | null | undefined)?.item; return item !== null && typeof item === 'object' && (item as { name?: unknown }).name === object ? item : undefined; } +/** + * [#25120] Whether `err` is the protocol's refusal of an object whose owning + * package is disabled: the one constructor `@objectstack/metadata-protocol` + * raises from its by-name reads and its data plane (`code` + * `OBJECT_PACKAGE_DISABLED`, `status` 404, `object`). A refusal, not an outage + * and not an absence: {@link readExposureDeclaration} throws it and + * {@link refuseUnexposedDataAction} answers it, where every other read failure + * falls back or falls open. + */ +function isObjectPackageDisabledRefusal(err: unknown): boolean { + return (err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED'; +} + /** * ── Object-level API exposure gate (ADR-0049, #1889) ───── * Honour the object's `apiEnabled` / `apiMethods` declarations for external @@ -384,6 +408,12 @@ async function readExposureDeclaration( * rendered the message alone, where `RestServer` and the MCP stdio face answer * the same declaration `OBJECT_API_DISABLED` / `OBJECT_API_METHOD_NOT_ALLOWED`. * The statuses do not move, and neither does the message. + * + * [#25120] An object whose owning package is disabled is refused here too, + * whatever it declares, with the protocol's own `404 OBJECT_PACKAGE_DISABLED` + * error: the answer `RestServer` gives the same object, on every arm of + * {@link callData}, the `aggregate` arm that never reaches the protocol + * included. */ export async function refuseUnexposedDataAction(deps: ActionExecutionDeps, requestContext: HttpProtocolContext, @@ -396,7 +426,12 @@ export async function refuseUnexposedDataAction(deps: ActionExecutionDeps, let def: any; try { def = await readExposureDeclaration(deps, requestContext, String(object), scopeId); - } catch { + } catch (err) { + // [#25120] A disabled package's object is refused, on every verb, with + // the protocol's own `404 OBJECT_PACKAGE_DISABLED` (see + // {@link readExposureDeclaration}): `callData`'s `aggregate` arm never + // reaches the protocol that refuses it on the other arms. + if (isObjectPackageDisabledRefusal(err)) throw err; def = undefined; // fall open to schema defaults (apiEnabled=true) } const gate = checkApiExposure(def, action); From e598c1d3bc7cd9d7ad6c334b72ce63017bef7c21 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:50:49 +0000 Subject: [PATCH 08/11] test(runtime): pin every /mcp data verb on a disabled package's objects to REST's 404 OBJECT_PACKAGE_DISABLED Both shapes, both objects (the one declared apiEnabled: false and the exposed one), all six data verbs: refused at the gate with the store never asked. REST's answer for the same objects is read off a RestServer served over the same kernel's protocol. Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- .../mcp-package-object-discovery.test.ts | 152 ++++++++++++++---- 1 file changed, 123 insertions(+), 29 deletions(-) diff --git a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts index e13ecb42021..6a4a7691690 100644 --- a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts +++ b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts @@ -39,6 +39,16 @@ * shape that copies: the copy outlives the disable, so the bridge listed and * described both objects while `GET /meta/object` listed neither. * + * A disabled package's object is refused by the data plane `404 + * OBJECT_PACKAGE_DISABLED` on every REST route, and on five of `callData`'s six + * arms. The sixth, `aggregate`, asks the engine and never the protocol, and the + * engine serves a disabled package's objects: `aggregate_records` served the + * package's exposed object on every shape, and once the gate read the protocol + * (which refuses the by-name read of such an object) its `apiEnabled: false` + * object too, where the copy had refused it `OBJECT_API_DISABLED`. The gate + * now refuses that object itself, with the protocol's own error, so every verb + * answers what REST answers. + * * ## The fix, and where these pins sit * * The bridge reads the protocol's MERGED listing (`getMetaItems`), the read @@ -61,7 +71,9 @@ * `init()` (during the boot, the way capability plugins register their * objects) or after the boot with `{ source: 'marketplace' }` (the way * marketplace install registers a package). The one seam not real is the MCP - * service, which records the bridge it is handed. + * service, which records the bridge it is handed. REST's answer for a disabled + * package's objects is read off a `RestServer` served over the same kernel's + * protocol. * * The negative control (red with the fix ablated) is a one-time ablation, * cited in the PR, not a permanent case. @@ -76,6 +88,7 @@ import type { IMetadataService } from '@objectstack/spec/contracts'; import { MetadataPlugin } from '@objectstack/metadata'; import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { RestServer } from '@objectstack/rest'; import { HttpDispatcher } from '../http-dispatcher.js'; const CALLER = { userId: 'u_mcp', isSystem: false, positions: [], permissions: [], systemPermissions: [] }; @@ -320,38 +333,119 @@ for (const { shape, environmentId } of SHAPES) { } } -describe('[#25120] a DISABLED package\'s objects: the bridge omits them as GET /meta/object does', () => { - // No `environmentId`, so the boot bridge HAS copied both objects into the - // `metadata` service: the copy outlives the disable, and the bridge used to - // list and describe both while REST listed neither. - const pkg = packageOf('qa_off'); - const mcp = recordingMcp(); - let booted: Booted | undefined; - let dispatcher: HttpDispatcher; +/** The refusal the data plane answers an object of a disabled package (`@objectstack/metadata-protocol`). */ +const PACKAGE_DISABLED = { code: 'OBJECT_PACKAGE_DISABLED', status: 404 } as const; - beforeAll(async () => { - booted = await bootKernel(undefined, 'boot', pkg, mcp); - expect((booted.engine as any).registry.disablePackage('com.qa.qa_off'), 'precondition: disabled').toBeTruthy(); - expect( - await booted.kernel.getService('metadata').getObject(pkg.open), - 'precondition: the metadata service still holds its copy', - ).toBeTruthy(); - dispatcher = new HttpDispatcher(booted.kernel as any, undefined, { enforceProjectMembership: false }); - }, 60_000); - afterAll(async () => { - await shutdown(booted); - }, 60_000); +/** + * What `RestServer` answers on each of its `/data` record routes for `object`, + * served over this kernel's own protocol (the request `plugin-hono-server` + * hands a route handler, as `data-write-request-gate.test.ts` drives it). + * REST has no aggregate route, so the five routes are its whole data face. + */ +async function restDataAnswers(kernel: ObjectKernel, object: string): Promise> { + const noop = () => {}; + const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; + const rest: any = new RestServer(server as any, kernel.getService('protocol') as any, { api: { requireAuth: false } } as any); + rest.resolveExecCtx = async () => ({ ...CALLER }); + rest.registerRoutes(); + const data = '/api/v1/data'; + const routes: ReadonlyArray, body: unknown]> = [ + ['GET', `${data}/:object`, { object }, undefined], + ['GET', `${data}/:object/:id`, { object, id: 'r_any' }, undefined], + ['POST', `${data}/:object/query`, { object }, {}], + ['POST', `${data}/:object`, { object }, { name: 'written' }], + ['PATCH', `${data}/:object/:id`, { object, id: 'r_any' }, { name: 'written' }], + ['DELETE', `${data}/:object/:id`, { object, id: 'r_any' }, undefined], + ]; + const answers: Array<{ route: string; status: unknown; code: unknown }> = []; + for (const [method, path, params, body] of routes) { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + expect(route, `precondition: RestServer registers ${method} ${path}`).toBeTruthy(); + const res: any = { statusCode: 200, body: undefined }; + res.status = (code: number) => { res.statusCode = code; return res; }; + res.json = (out: unknown) => { res.body = out; return res; }; + res.header = () => res; + res.setHeader = noop; + res.send = () => res; + res.end = () => res; + await route.handler({ method, path, params, headers: { 'content-type': 'application/json' }, query: {}, body }, res); + const out = res.body; + answers.push({ + route: `${method} ${path}`, + status: res.statusCode, + code: out?.error && typeof out.error === 'object' ? out.error.code : out?.code, + }); + } + return answers; +} - it('neither listed nor described', async () => { - const bridge = await bridgeFor(dispatcher, mcp); - const listed = nonSystem((await bridge.listObjectsDiagnosed()).objects.map((o: any) => o.name)); +for (const { shape, environmentId } of SHAPES) { + describe(`[#25120] a DISABLED package's objects — ${shape}: omitted as GET /meta/object omits them, refused as REST refuses them`, () => { + const pkg = packageOf('qa_off'); + const mcp = recordingMcp(); + let booted: Booted | undefined; + let dispatcher: HttpDispatcher; + + beforeAll(async () => { + booted = await bootKernel(environmentId, 'boot', pkg, mcp); + expect((booted.engine as any).registry.disablePackage('com.qa.qa_off'), 'precondition: disabled').toBeTruthy(); + if (environmentId === undefined) { + // No `environmentId`, so the boot bridge HAS copied both objects + // into the `metadata` service: the copy outlives the disable, so + // the bridge used to list and describe both while REST listed + // neither, and the gate judged the copy's declaration. + for (const name of [pkg.open, pkg.gated]) { + expect( + await booted.kernel.getService('metadata').getObject(name), + `precondition: the metadata service still holds ${name}'s copy`, + ).toBeTruthy(); + } + } + dispatcher = new HttpDispatcher(booted.kernel as any, undefined, { enforceProjectMembership: false }); + }, 60_000); + afterAll(async () => { + vi.restoreAllMocks(); + await shutdown(booted); + }, 60_000); + + it('neither listed nor described', async () => { + const bridge = await bridgeFor(dispatcher, mcp); + const listed = nonSystem((await bridge.listObjectsDiagnosed()).objects.map((o: any) => o.name)); - expect(await restObjectNames(dispatcher), 'precondition: REST omits them').not.toContain(pkg.open); - expect(listed).not.toContain(pkg.open); - expect(listed).not.toContain(pkg.gated); - expect(await bridge.describeObject(pkg.open)).toBeNull(); + expect(await restObjectNames(dispatcher), 'precondition: REST omits them').not.toContain(pkg.open); + expect(listed).not.toContain(pkg.open); + expect(listed).not.toContain(pkg.gated); + expect(await bridge.describeObject(pkg.open)).toBeNull(); + }); + + it('REST answers both objects 404 OBJECT_PACKAGE_DISABLED on every /data record route: the answer the rows below give', async () => { + for (const object of [pkg.gated, pkg.open]) { + for (const answer of await restDataAnswers(booted!.kernel, object)) { + expect(answer, `${answer.route} on ${object}`).toEqual({ route: answer.route, ...PACKAGE_DISABLED }); + } + } + }); + + // The object declared `apiEnabled: false`, and the exposed one: a + // disabled package's object is refused at the gate whatever it + // declares, `aggregate_records` included (its `callData` arm asks the + // engine, never the protocol that refuses it on the other five). + for (const [declared, object] of [['apiEnabled: false', pkg.gated], ['exposed', pkg.open]] as const) { + for (const [tool, call] of DATA_VERBS) { + it(`${tool} refuses the ${declared} object — 404 OBJECT_PACKAGE_DISABLED, refused at the gate, the store never asked`, async () => { + const bridge = await bridgeFor(dispatcher, mcp); + const spies = storeSpies(booted!.kernel, booted!.engine); + try { + expect(await outcome(() => call(bridge, object))).toEqual(PACKAGE_DISABLED); + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); + } + } }); -}); +} describe('[#25120] two environments behind one host: each bridge reads its own environment\'s merged view', () => { const pkgA = packageOf('qa_a'); From 9554f1e7f12ca0b2a9d5c24aaeb93622904ec45e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:51:17 +0000 Subject: [PATCH 09/11] chore(changeset): the runtime patch states the disabled-package refusal on every data verb Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- .changeset/25120-mcp-package-objects.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/25120-mcp-package-objects.md b/.changeset/25120-mcp-package-objects.md index 70f63cc5836..d781ede467f 100644 --- a/.changeset/25120-mcp-package-objects.md +++ b/.changeset/25120-mcp-package-objects.md @@ -2,7 +2,7 @@ '@objectstack/runtime': patch --- -fix(runtime)!: the `/mcp` bridge reads objects from the protocol's merged view, so `list_objects` and `describe_object` see every object `GET /api/v1/meta/object` lists, and a package object declared `apiEnabled: false` is refused by every data verb +fix(runtime)!: the `/mcp` 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`) Clause-②: no (narrowing) Metadata-protocol: none @@ -15,6 +15,6 @@ The `/mcp` bridge answered `list_objects` and `describe_object` from the `metada - **Before.** On that kernel, an object a package or a plugin 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` and `aggregate_records`, and `create_record`, `update_record` and `delete_record` reached the store. The runtime dispatcher's `/data` door served it the same way. - **Now.** `list_objects` lists exactly the non-`sys_` objects `GET /api/v1/meta/object` serves the same caller, and `describe_object` answers each of them. Every data verb, on `/mcp` and on the dispatcher's `/data` door, refuses such an object `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` for a verb its `apiMethods` leaves out, as `RestServer` does. -- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described by `/mcp`; they are now omitted, as REST omits them. Such an object declared `apiEnabled: false` is refused `404 OBJECT_PACKAGE_DISABLED` where it was refused `404 OBJECT_API_DISABLED`, the code `RestServer` answers. 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. +- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described by `/mcp`; they are now omitted, as REST omits them. Every data verb, on `/mcp` and on the dispatcher's `/data` door, now refuses a disabled package's object `404 OBJECT_PACKAGE_DISABLED` at the exposure gate, whatever the object declares: the code and status `RestServer` answers for it on every `/data` route. Such an object declared `apiEnabled: false` was refused `404 OBJECT_API_DISABLED` where the copy existed. `aggregate_records`, whose arm asks the engine and never the protocol, served a disabled package's exposed object on every kernel, and its object declared `apiEnabled: false` wherever no copy existed. 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. - **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, and `describe_object` answers the definition `GET /api/v1/meta/object` serves for them. A host whose `protocol` service has no merged read keeps reading the `metadata` service. If the merged by-name read fails, the gate judges the `metadata` service's copy, as it did before. -- **What to do.** Nothing, if the declarations say what you mean. An agent that relied on reaching an object declared `apiEnabled: false` through `/mcp` or the dispatcher's `/data` door was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. +- **What to do.** Nothing, if the declarations say what you mean. An agent that relied on reaching an object declared `apiEnabled: false` through `/mcp` or the dispatcher's `/data` door was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. An agent that read aggregates of a disabled package's objects through `aggregate_records` was relying on the same kind of defect: re-enable the package to restore access, as REST says. From e471998c53c7dd4c9b2df743650d2a4c1d7b09fa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:00:42 +0000 Subject: [PATCH 10/11] fix(runtime): the /data door's early exposure ask reads a disabled package as REST's enforceApiAccess does Measured: with the refusal thrown at every ask, the dispatcher's /data door answered a body that is not a record 404 OBJECT_PACKAGE_DISABLED on a disabled package's object, where RestServer answers 400 VALIDATION_FAILED (its enforceApiAccess reads the refusal as "no declaration" and its request gate answers first). The early ask, the door's twin of enforceApiAccess, now defers that refusal to callData, which refuses it on every arm. Pinned: the door answers each request what REST answers. Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- packages/runtime/src/action-execution.ts | 69 ++++++----- .../mcp-package-object-discovery.test.ts | 110 +++++++++++++----- 2 files changed, 120 insertions(+), 59 deletions(-) diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 6863044a2fa..2277af6c756 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -323,20 +323,17 @@ type ExposureDeclarationRead = Partial> & * - nothing, or an item under another name → `undefined`: no declared * exposure policy, and the data plane's registry gate answers existence * (#3770), as before; - * - refused `OBJECT_PACKAGE_DISABLED` → that refusal, THROWN, and - * {@link refuseUnexposedDataAction} lets it through its fail-open `catch` - * ({@link isObjectPackageDisabledRefusal}): a disabled package's object is - * refused at this gate, on every verb, with the protocol's own error (its - * `code`, `status` 404 and `object`). `RestServer`'s gate reads that refusal - * as "no declaration" (`loadObjectItem`) because every REST data route then - * reaches the protocol, whose registry gate (`assertObjectRegistered`) - * refuses with that same code. `callData`'s `aggregate` arm takes no - * protocol: it asks the engine, which serves a disabled package's objects. - * So reading the refusal as "no declaration" here served `aggregate_records` - * on such an object, one declared `apiEnabled: false` included, where the - * metadata service's copy had refused it `OBJECT_API_DISABLED`. Refused here, - * every arm answers the code and status REST answers, whether or not it - * reaches the protocol; + * - refused `OBJECT_PACKAGE_DISABLED` → that refusal, THROWN + * ({@link isObjectPackageDisabledRefusal}): a refusal, not an outage, so it + * never falls back to the copy. {@link refuseUnexposedDataAction} decides + * who answers it. `RestServer`'s gate reads it as "no declaration" + * (`loadObjectItem`) because every REST data route then reaches the + * protocol, whose registry gate (`assertObjectRegistered`) refuses with that + * same code. `callData`'s `aggregate` arm takes no protocol: it asks the + * engine, which serves a disabled package's objects. So the reading this + * gate first took from REST served `aggregate_records` on such an object, + * one declared `apiEnabled: false` included, where the metadata service's + * copy had refused it `OBJECT_API_DISABLED`; * - any other throw (the metadata store unreadable) → the metadata service's * copy, the source this gate read before. An outage of the merged read must * not WIDEN exposure: failing open at once would serve an object the copy @@ -375,9 +372,9 @@ async function readExposureDeclaration( * package is disabled: the one constructor `@objectstack/metadata-protocol` * raises from its by-name reads and its data plane (`code` * `OBJECT_PACKAGE_DISABLED`, `status` 404, `object`). A refusal, not an outage - * and not an absence: {@link readExposureDeclaration} throws it and - * {@link refuseUnexposedDataAction} answers it, where every other read failure - * falls back or falls open. + * and not an absence: {@link readExposureDeclaration} throws it, where every + * other read failure falls back to the copy, and + * {@link refuseUnexposedDataAction} decides who answers it. */ function isObjectPackageDisabledRefusal(err: unknown): boolean { return (err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED'; @@ -409,11 +406,25 @@ function isObjectPackageDisabledRefusal(err: unknown): boolean { * the same declaration `OBJECT_API_DISABLED` / `OBJECT_API_METHOD_NOT_ALLOWED`. * The statuses do not move, and neither does the message. * - * [#25120] An object whose owning package is disabled is refused here too, - * whatever it declares, with the protocol's own `404 OBJECT_PACKAGE_DISABLED` - * error: the answer `RestServer` gives the same object, on every arm of - * {@link callData}, the `aggregate` arm that never reaches the protocol - * included. + * [#25120] An object whose owning package is disabled, whatever it declares: + * the protocol refuses its by-name read `404 OBJECT_PACKAGE_DISABLED` + * ({@link readExposureDeclaration}), and `disabledPackage` says who answers + * that refusal, so every door answers what `RestServer` answers: + * + * - `'defer'`, the default: the dispatcher's `/data` door, which asks this gate + * BEFORE its request-schema gate as the twin of `RestServer`'s + * `enforceApiAccess`. "No declaration", as `enforceApiAccess` reads it + * (`loadObjectItem`), so a body that is not a record is answered `400 + * VALIDATION_FAILED` first, and a well-formed request is refused `404 + * OBJECT_PACKAGE_DISABLED` when it reaches {@link callData}, as REST's data + * plane refuses it. + * - `'refuse'`: {@link callData}, which every data door reaches where REST + * reaches the protocol, and which the `/mcp` bridge asks alone. The + * protocol's own error, thrown, on every arm: the `aggregate` arm asks the + * engine and never the protocol, and the engine serves a disabled package's + * objects, so reading the refusal as "no declaration" there served + * `aggregate_records` on such an object, one declared `apiEnabled: false` + * included. */ export async function refuseUnexposedDataAction(deps: ActionExecutionDeps, requestContext: HttpProtocolContext, @@ -421,17 +432,17 @@ export async function refuseUnexposedDataAction(deps: ActionExecutionDeps, object: unknown, scopeId?: string, executionContext?: ExecutionContext, + disabledPackage: 'defer' | 'refuse' = 'defer', ): Promise { if (executionContext?.isSystem || !object) return; let def: any; try { def = await readExposureDeclaration(deps, requestContext, String(object), scopeId); } catch (err) { - // [#25120] A disabled package's object is refused, on every verb, with - // the protocol's own `404 OBJECT_PACKAGE_DISABLED` (see - // {@link readExposureDeclaration}): `callData`'s `aggregate` arm never - // reaches the protocol that refuses it on the other arms. - if (isObjectPackageDisabledRefusal(err)) throw err; + // [#25120] The protocol's `404 OBJECT_PACKAGE_DISABLED` is a refusal, + // never a read failure to fall open on: thrown where this gate stands + // in for the data plane, "no declaration" where the data plane follows. + if (isObjectPackageDisabledRefusal(err) && disabledPackage === 'refuse') throw err; def = undefined; // fall open to schema defaults (apiEnabled=true) } const gate = checkApiExposure(def, action); @@ -453,7 +464,9 @@ export async function callData(deps: ActionExecutionDeps, // condition's target, asked below on the two arms that hand the engine a // caller's `where` ({@link refuseUnservedRelationTargets}). const exposureGated = !executionContext?.isSystem; - await refuseUnexposedDataAction(deps, requestContext, action, params?.object, scopeId, executionContext); + // [#25120] `'refuse'`: a disabled package's object is refused here, on + // every arm, the `aggregate` arm that never reaches the protocol included. + await refuseUnexposedDataAction(deps, requestContext, action, params?.object, scopeId, executionContext, 'refuse'); const protocol = await deps.resolveService(requestContext, 'protocol', scopeId); const qlService = dataDriver ?? await deps.getObjectQL(requestContext, scopeId); diff --git a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts index 6a4a7691690..e9af88e88cf 100644 --- a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts +++ b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts @@ -46,8 +46,12 @@ * package's exposed object on every shape, and once the gate read the protocol * (which refuses the by-name read of such an object) its `apiEnabled: false` * object too, where the copy had refused it `OBJECT_API_DISABLED`. The gate - * now refuses that object itself, with the protocol's own error, so every verb - * answers what REST answers. + * `callData` asks now refuses that object itself, with the protocol's own + * error, so every verb answers what REST answers. The dispatcher's `/data` + * door asks the same gate earlier too, before its request gate, as the twin of + * REST's `enforceApiAccess`; there it reads that refusal as REST's gate does, + * so a body that is not a record is answered by the request gate first, on + * both doors. * * ## The fix, and where these pins sit * @@ -71,9 +75,9 @@ * `init()` (during the boot, the way capability plugins register their * objects) or after the boot with `{ source: 'marketplace' }` (the way * marketplace install registers a package). The one seam not real is the MCP - * service, which records the bridge it is handed. REST's answer for a disabled - * package's objects is read off a `RestServer` served over the same kernel's - * protocol. + * service, which records the bridge it is handed. What the two `/data` doors + * answer a disabled package's objects is read off a `RestServer` and the + * runtime dispatcher, both served over the same kernel's protocol. * * The negative control (red with the fix ablated) is a one-time ablation, * cited in the PR, not a permanent case. @@ -89,6 +93,7 @@ import { MetadataPlugin } from '@objectstack/metadata'; import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { RestServer } from '@objectstack/rest'; +import { resolveThrownHttpError } from '@objectstack/types'; import { HttpDispatcher } from '../http-dispatcher.js'; const CALLER = { userId: 'u_mcp', isSystem: false, positions: [], permissions: [], systemPermissions: [] }; @@ -337,30 +342,46 @@ for (const { shape, environmentId } of SHAPES) { const PACKAGE_DISABLED = { code: 'OBJECT_PACKAGE_DISABLED', status: 404 } as const; /** - * What `RestServer` answers on each of its `/data` record routes for `object`, - * served over this kernel's own protocol (the request `plugin-hono-server` - * hands a route handler, as `data-write-request-gate.test.ts` drives it). - * REST has no aggregate route, so the five routes are its whole data face. + * The `/data` record requests both data doors serve: the five well-formed ones + * (REST has no aggregate route, so they are its whole data face), and three + * whose body is not what the request schema declares, which each door's + * request gate answers. */ -async function restDataAnswers(kernel: ObjectKernel, object: string): Promise> { +const DATA_REQUESTS: ReadonlyArray<{ method: string; route: string; id?: string; body: unknown; wellFormed: boolean }> = [ + { method: 'GET', route: '/data/:object', body: undefined, wellFormed: true }, + { method: 'GET', route: '/data/:object/:id', id: 'r_any', body: undefined, wellFormed: true }, + { method: 'POST', route: '/data/:object/query', body: {}, wellFormed: true }, + { method: 'POST', route: '/data/:object', body: { name: 'written' }, wellFormed: true }, + { method: 'PATCH', route: '/data/:object/:id', id: 'r_any', body: { name: 'written' }, wellFormed: true }, + { method: 'DELETE', route: '/data/:object/:id', id: 'r_any', body: undefined, wellFormed: true }, + { method: 'POST', route: '/data/:object', body: [{ name: 'written' }], wellFormed: false }, + { method: 'PATCH', route: '/data/:object/:id', id: 'r_any', body: [{ name: 'written' }], wellFormed: false }, + { method: 'POST', route: '/data/:object/query', body: { limit: 'ten' }, wellFormed: false }, +]; + +/** The refusal's `code` in either door's envelope: nested under `error`, or beside a string `error`. */ +const codeOf = (body: any): unknown => (body?.error && typeof body.error === 'object' ? body.error.code : body?.code); + +/** + * What each data door answers `object` on each of {@link DATA_REQUESTS}, both + * served over this kernel's own protocol: `RestServer` through its registered + * handler, and the runtime dispatcher's `/data` door through `dispatch()`, the + * way `data-write-request-gate.test.ts` drives the two. + */ +async function dataDoorAnswers(kernel: ObjectKernel, object: string) { const noop = () => {}; const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; const rest: any = new RestServer(server as any, kernel.getService('protocol') as any, { api: { requireAuth: false } } as any); rest.resolveExecCtx = async () => ({ ...CALLER }); rest.registerRoutes(); - const data = '/api/v1/data'; - const routes: ReadonlyArray, body: unknown]> = [ - ['GET', `${data}/:object`, { object }, undefined], - ['GET', `${data}/:object/:id`, { object, id: 'r_any' }, undefined], - ['POST', `${data}/:object/query`, { object }, {}], - ['POST', `${data}/:object`, { object }, { name: 'written' }], - ['PATCH', `${data}/:object/:id`, { object, id: 'r_any' }, { name: 'written' }], - ['DELETE', `${data}/:object/:id`, { object, id: 'r_any' }, undefined], - ]; - const answers: Array<{ route: string; status: unknown; code: unknown }> = []; - for (const [method, path, params, body] of routes) { - const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); - expect(route, `precondition: RestServer registers ${method} ${path}`).toBeTruthy(); + const dispatcher = new HttpDispatcher(kernel as any, undefined, { enforceProjectMembership: false }); + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...CALLER }); + + const answers: Array<{ request: string; wellFormed: boolean; rest: unknown; dispatcher: unknown }> = []; + for (const { method, route, id, body, wellFormed } of DATA_REQUESTS) { + const path = `/data/${object}${route.endsWith('/query') ? '/query' : id ? `/${id}` : ''}`; + const handler = rest.getRoutes().find((r: any) => r.method === method && r.path === `/api/v1${route}`)?.handler; + expect(handler, `precondition: RestServer registers ${method} ${route}`).toBeTruthy(); const res: any = { statusCode: 200, body: undefined }; res.status = (code: number) => { res.statusCode = code; return res; }; res.json = (out: unknown) => { res.body = out; return res; }; @@ -368,12 +389,27 @@ async function restDataAnswers(kernel: ObjectKernel, object: string): Promise res; res.end = () => res; - await route.handler({ method, path, params, headers: { 'content-type': 'application/json' }, query: {}, body }, res); - const out = res.body; + await handler({ method, path: `/api/v1${path}`, params: { object, ...(id ? { id } : {}) }, headers: { 'content-type': 'application/json' }, query: {}, body }, res); + + let door: { status: unknown; code: unknown }; + try { + const raw = new Request(`http://localhost/api/v1${path}`, { + method, + headers: { 'content-type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const out: any = await dispatcher.dispatch(method, path, body ?? {}, {}, { request: raw } as any, '/api/v1'); + door = { status: out.response?.status, code: codeOf(out.response?.body) }; + } catch (e) { + // A throw out of `dispatch()` is rendered by the host's exit, through this one rule. + const thrown = resolveThrownHttpError(e); + door = { status: thrown.status, code: thrown.code }; + } answers.push({ - route: `${method} ${path}`, - status: res.statusCode, - code: out?.error && typeof out.error === 'object' ? out.error.code : out?.code, + request: `${method} ${route} ${JSON.stringify(body) ?? ''}`, + wellFormed, + rest: { status: res.statusCode, code: codeOf(res.body) }, + dispatcher: door, }); } return answers; @@ -418,10 +454,22 @@ for (const { shape, environmentId } of SHAPES) { expect(await bridge.describeObject(pkg.open)).toBeNull(); }); - it('REST answers both objects 404 OBJECT_PACKAGE_DISABLED on every /data record route: the answer the rows below give', async () => { + it('REST answers both objects 404 OBJECT_PACKAGE_DISABLED on every well-formed /data record request: the answer the rows below give', async () => { + for (const object of [pkg.gated, pkg.open]) { + for (const answer of (await dataDoorAnswers(booted!.kernel, object)).filter((a) => a.wellFormed)) { + expect(answer.rest, `${answer.request} on ${object}`).toEqual(PACKAGE_DISABLED); + } + } + }); + + it('the dispatcher\'s /data door answers each request what REST answers, a body that is not a record included', async () => { + // The door asks the exposure gate before its request gate, as the + // twin of REST's `enforceApiAccess`, which reads the disabled + // package's refusal as "no declaration": so a malformed body is + // answered by the request gate first on both doors. for (const object of [pkg.gated, pkg.open]) { - for (const answer of await restDataAnswers(booted!.kernel, object)) { - expect(answer, `${answer.route} on ${object}`).toEqual({ route: answer.route, ...PACKAGE_DISABLED }); + for (const answer of await dataDoorAnswers(booted!.kernel, object)) { + expect(answer.dispatcher, `${answer.request} on ${object}`).toEqual(answer.rest); } } }); From c0fb1c715f485d6f58a6eaf5ec5363db054c97c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:04:24 +0000 Subject: [PATCH 11/11] chore(changeset): the /data door's request gate still answers a malformed body first, as on REST Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- .changeset/25120-mcp-package-objects.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/25120-mcp-package-objects.md b/.changeset/25120-mcp-package-objects.md index d781ede467f..97d00343ebf 100644 --- a/.changeset/25120-mcp-package-objects.md +++ b/.changeset/25120-mcp-package-objects.md @@ -15,6 +15,6 @@ The `/mcp` bridge answered `list_objects` and `describe_object` from the `metada - **Before.** On that kernel, an object a package or a plugin 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` and `aggregate_records`, and `create_record`, `update_record` and `delete_record` reached the store. The runtime dispatcher's `/data` door served it the same way. - **Now.** `list_objects` lists exactly the non-`sys_` objects `GET /api/v1/meta/object` serves the same caller, and `describe_object` answers each of them. Every data verb, on `/mcp` and on the dispatcher's `/data` door, refuses such an object `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` for a verb its `apiMethods` leaves out, as `RestServer` does. -- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described by `/mcp`; they are now omitted, as REST omits them. Every data verb, on `/mcp` and on the dispatcher's `/data` door, now refuses a disabled package's object `404 OBJECT_PACKAGE_DISABLED` at the exposure gate, whatever the object declares: the code and status `RestServer` answers for it on every `/data` route. Such an object declared `apiEnabled: false` was refused `404 OBJECT_API_DISABLED` where the copy existed. `aggregate_records`, whose arm asks the engine and never the protocol, served a disabled package's exposed object on every kernel, and its object declared `apiEnabled: false` wherever no copy existed. 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. +- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described by `/mcp`; they are now omitted, as REST omits them. Every data verb on `/mcp`, and every well-formed request on the dispatcher's `/data` door, now refuses a disabled package's object `404 OBJECT_PACKAGE_DISABLED` at the exposure gate, whatever the object declares: the code and status `RestServer` answers for it on every `/data` route. On that door, as on REST, a body that is not a record is still answered `400 VALIDATION_FAILED` first. Such an object declared `apiEnabled: false` was refused `404 OBJECT_API_DISABLED` where the copy existed. `aggregate_records`, whose arm asks the engine and never the protocol, served a disabled package's exposed object on every kernel, and its object declared `apiEnabled: false` wherever no copy existed. 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. - **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, and `describe_object` answers the definition `GET /api/v1/meta/object` serves for them. A host whose `protocol` service has no merged read keeps reading the `metadata` service. If the merged by-name read fails, the gate judges the `metadata` service's copy, as it did before. - **What to do.** Nothing, if the declarations say what you mean. An agent that relied on reaching an object declared `apiEnabled: false` through `/mcp` or the dispatcher's `/data` door was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. An agent that read aggregates of a disabled package's objects through `aggregate_records` was relying on the same kind of defect: re-enable the package to restore access, as REST says.