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/25138-mcp-stdio-package-objects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/mcp': patch
---

fix(mcp)!: the stdio data 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 the stdio MCP transport consults: its object discovery and its API-exposure gate now read the protocol's merged view, the definition REST and the `/mcp` HTTP bridge already serve and enforce, 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 stdio data verbs' accept set narrows to the object's own declaration), shipped as `patch`: it changes which requests the stdio transport serves, not what a metadata author can write.

The long-lived stdio MCP server (`objectstack mcp`, `OS_MCP_STDIO_ENABLED`) answered `list_objects` and `describe_object` from the `metadata` service, and gated its data verbs and its record resource on the same service's copy of the object. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package registers during the boot. So `list_objects` answered `{ objects: [], totalCount: 0 }` for such a package, `describe_object` answered "not found" for each of its objects, and the gate read the missing copy as "no declaration" and served them. REST and the `/mcp` HTTP bridge listed and refused the same objects.

- **Before.** On that kernel, an object a package 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`, `aggregate_records` and the `objectstack://objects/NAME/records/ID` resource, and `create_record`, `update_record` and `delete_record` reached the store. Its `internal: true` fields were echoed in the `create_record` and `update_record` responses.
- **Now.** `list_objects` and `describe_object` answer from the listing `GET /api/v1/meta/object` serves. Every data verb and the record resource refuse such an object `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` for a verb its `apiMethods` leaves out, as `RestServer` and the HTTP bridge do. Its `internal: true` fields are omitted from the write responses, and its nested-relation conditions are gated on their targets.
- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described; they are now omitted, as REST omits them. Every data verb and the record resource now refuse a disabled package's object `404 OBJECT_PACKAGE_DISABLED`, whatever the object declares: before, the stdio transport served it on every verb, because each verb asks the engine and never the protocol. 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. A host with no `protocol` service, or one without the merged reads, keeps reading the `metadata` service. If the protocol's 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. A stdio client that relied on reaching an object declared `apiEnabled: false` was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. A stdio client that read a disabled package's objects was relying on the same kind of defect: re-enable the package to restore access, as REST says.
7 changes: 6 additions & 1 deletion packages/mcp/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,8 +526,13 @@ export interface McpMergedMetadataRead {
* the reason `packages/rest` reads it defensively at every one of its own call
* sites: this is a duck-typed seam across a package boundary, and a host may
* hand back the bare array.
*
* [#25138] Exported within the package for the stdio data bridge's object
* listing (`stdio-data-bridge.ts`), so the two merged reads on this transport
* cannot disagree about which answers they accept. `index.ts` does not
* publish it.
*/
function metaItemsArray(answer: unknown): unknown[] {
export function metaItemsArray(answer: unknown): unknown[] {
if (Array.isArray(answer)) return answer;
const items = (answer as { items?: unknown } | null | undefined)?.items;
return Array.isArray(items) ? items : [];
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/plugin.record-resource-exposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ describe('#8266 the refusal carries the ADR-0112 envelope', () => {
async function refusalFor(def: unknown, action: string): Promise<McpExposureError> {
const metadataService = fakeMetadata(def) as unknown as IMetadataService;
const context = { userId: 'u1', isSystem: false } as unknown as ExecutionContext;
const err = (await enforceApiExposure(metadataService, 'task', action, context).then(
const err = (await enforceApiExposure({ metadataService }, 'task', action, context).then(
() => null,
(e: unknown) => e,
)) as McpExposureError | null;
Expand Down
30 changes: 25 additions & 5 deletions packages/mcp/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
GATED_ACTIONS,
serveStoredMetadataHashes,
serveStoredMetadataRow,
type StdioObjectReads,
type StdioProtocolObjectReads,
type StoredHashDigest,
} from './stdio-data-bridge.js';
import type { McpDataBridge } from './mcp-http-tools.js';
Expand Down Expand Up @@ -703,10 +705,24 @@ export class MCPServerPlugin implements Plugin {
if (gate) throw stdioAuthGateRefusal(gate);
return ec;
};
// [#25138] The protocol's object reads — the merged listing behind
// `list_objects` / `describe_object` and the by-name read behind the
// exposure gate — resolved at each use, never captured here: an absence
// read in `start()` is a verdict the rest of the boot can still
// contradict. `undefined` when this host registers no protocol, and the
// bridge then reads the metadata service, as before.
const protocolReads = (): StdioProtocolObjectReads | undefined => {
try {
return ctx.getService<StdioProtocolObjectReads>('protocol') ?? undefined;
} catch {
return undefined;
}
};
if (metadataService) {
dataBridge = createStdioDataBridge({
engine: scopedQl,
metadataService,
protocol: protocolReads,
resolvePrincipal,
keyedDigest: storedHashDigest,
});
Expand All @@ -725,7 +741,11 @@ export class MCPServerPlugin implements Plugin {
// from, captured once rather than re-read off the outer `let` inside the
// closure — the reader must not be able to see a different service from
// the one `bridgeResources` was registered with.
const gateMetadata = metadataService;
// [#25138] …beside the protocol's by-name read, which the gate asks
// first, as `bridge.get` asks it.
const gateReads: StdioObjectReads | undefined = metadataService
? { metadataService, protocol: protocolReads }
: undefined;
getRecord = async (objectName, recordId) => {
const ec = await resolvePrincipal();
// [#8266] The ADR-0049 exposure gate, applied BEFORE the read: the same
Expand All @@ -740,12 +760,12 @@ export class MCPServerPlugin implements Plugin {
// leaking is the author's exposure DECLARATION.
//
// No metadata service ⇒ no declaration to read ⇒ open, exactly as
// `enforceApiExposure` itself falls open when `getObject` throws or
// resolves nothing. Unreachable in practice: `bridgeResources` — the
// `enforceApiExposure` itself falls open when the definition cannot be
// read or resolves nothing. Unreachable in practice: `bridgeResources` — the
// only consumer of this reader — is called under `if (metadataService)`
// below, so the resource is never registered without one.
if (gateMetadata) {
await enforceApiExposure(gateMetadata, objectName, GATED_ACTIONS.get, ec);
if (gateReads) {
await enforceApiExposure(gateReads, objectName, GATED_ACTIONS.get, ec);
}
const res = (await scopedQl.find(objectName, {
where: { id: recordId },
Expand Down
7 changes: 4 additions & 3 deletions packages/mcp/src/stdio-data-bridge.exposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,9 @@ const ALL_GATED = Object.keys(GATED_ACTIONS) as Array<keyof typeof GATED_ACTIONS
describe('#8083 the gated verb set is the HTTP one', () => {
it('gates exactly the six methods `buildMcpBridge` routes through callData', () => {
// `listObjects` / `describeObject` are absent BY DESIGN: the HTTP bridge
// answers both straight off the metadata service, so gating them here
// would be a fresh divergence in the opposite direction.
// answers both without `callData` (from the protocol's merged listing
// since #25120), so gating them here would be a fresh divergence in the
// opposite direction.
expect(ALL_GATED.sort()).toEqual(
['aggregate', 'create', 'get', 'query', 'remove', 'update'].sort(),
);
Expand Down Expand Up @@ -420,7 +421,7 @@ describe('#15416 the stdio refusal never names an operation it also allows', ()
for (const action of Object.values(GATED_ACTIONS)) {
let err: McpExposureError | undefined;
try {
await enforceApiExposure(metadataFor(apiMethods), 'task', action, {} as ExecutionContext);
await enforceApiExposure({ metadataService: metadataFor(apiMethods) }, 'task', action, {} as ExecutionContext);
} catch (e) {
err = e as McpExposureError;
}
Expand Down
Loading
Loading