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

Filter by extension

Filter by extension

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

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which definition two runtime readers consult: the HTTP MCP bridge's object discovery and the data doors' API-exposure gate now read the protocol's merged view, the definition REST already serves and enforces, instead of the metadata service's copy. An object's own `enable.apiEnabled` / `enable.apiMethods` declaration is now enforced where it was not read; the remedy is the declaration the author already wrote, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**BREAKING** (the 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.
129 changes: 124 additions & 5 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<Pick<MetadataProtocol, 'getMetaItem'>> & {
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<unknown> {
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
Expand All @@ -312,20 +405,44 @@ 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,
action: string,
object: unknown,
scopeId?: string,
executionContext?: ExecutionContext,
disabledPackage: 'defer' | 'refuse' = 'defer',
): Promise<void> {
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);
Expand All @@ -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);
Expand Down
Loading
Loading