diff --git a/.changeset/25120-mcp-package-objects.md b/.changeset/25120-mcp-package-objects.md new file mode 100644 index 00000000000..97d00343ebf --- /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, 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 + + + +**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. 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. diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 616639b7651..2277af6c756 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,99 @@ 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` → 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 + * 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, + requestContext: HttpProtocolContext, + 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) return await readCopy(); + let envelope: unknown; + try { + envelope = await byName.call(protocol, { type: 'object', name: object }); + } catch (err) { + 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, 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'; +} + /** * ── Object-level API exposure gate (ADR-0049, #1889) ───── * Honour the object's `apiEnabled` / `apiMethods` declarations for external @@ -312,6 +405,26 @@ async function resolveEngineQuietly(deps: ActionExecutionDeps, requestContext: H * 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, 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, @@ -319,13 +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 { - const meta = await deps.resolveService(requestContext, 'metadata', scopeId); - def = await (meta as any)?.getObject?.(object); - } catch { + def = await readExposureDeclaration(deps, requestContext, String(object), scopeId); + } catch (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); @@ -347,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/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); 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..e9af88e88cf --- /dev/null +++ b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts @@ -0,0 +1,549 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#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 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. + * + * 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 + * `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 + * + * 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. 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. + */ + +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, 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'; +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: [] }; + +/** 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`; + 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; +} + +/** + * 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 }); + 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); + 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 })); + } + const host: Plugin = { + name: 'qa.package-host', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + if (mcp) ctx.registerService('mcp', mcp); + if (when === 'boot') await ctx.getService('manifest').register(pkg.artifact()); + }, + }; + await kernel.use(host); + await kernel.bootstrap(); + if (when === 'install') { + await kernel.getService('manifest').register(pkg.artifact(), { source: 'marketplace' }); + } + await engine.syncSchemas(); + return { kernel, engine, root }; +} + +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; +} + +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 { + 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(); + } + }); + } + + 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'); + }); + }); + } +} + +/** 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; + +/** + * 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. + */ +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 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; }; + res.header = () => res; + res.setHeader = noop; + res.send = () => res; + res.end = () => res; + 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({ + request: `${method} ${route} ${JSON.stringify(body) ?? ''}`, + wellFormed, + rest: { status: res.statusCode, code: codeOf(res.body) }, + dispatcher: door, + }); + } + return answers; +} + +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(); + }); + + 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 dataDoorAnswers(booted!.kernel, object)) { + expect(answer.dispatcher, `${answer.request} on ${object}`).toEqual(answer.rest); + } + } + }); + + // 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'); + 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'); + }); + } +}); 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) =>