From d1e229123282bd2da14328a1241c7c5fea743845 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:32:50 +0000 Subject: [PATCH 1/3] fix(mcp): the stdio data bridge asks each nested-relation condition's target its exposure for list The data door's relation-condition walk moves to @objectstack/core (relationConditionSites) unchanged, and both doors import it. The stdio bridge's query and aggregate ask each condition's target the same gate an addressed object gets, for list, and refuse an unserved target. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T --- packages/core/src/index.ts | 6 + .../second-object-read-exposure.pin.test.ts | 16 ++- .../utils/relation-condition-sites.test.ts | 32 +++++ .../src/utils/relation-condition-sites.ts | 69 +++++++++ ...relation-condition-target-exposure.test.ts | 135 ++++++++++++++++++ packages/mcp/src/stdio-data-bridge.ts | 51 ++++++- packages/metadata-protocol/src/protocol.ts | 43 +----- 7 files changed, 306 insertions(+), 46 deletions(-) create mode 100644 packages/core/src/utils/relation-condition-sites.test.ts create mode 100644 packages/core/src/utils/relation-condition-sites.ts create mode 100644 packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0324073a701..4aeda7bd7a8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -180,6 +180,12 @@ export * from './utils/record-not-found.js'; // resolve with it (one name space for both doors). export * from './utils/object-not-found.js'; +// [#22777] The one walk that finds a filter's nested-relation conditions and +// their target objects: the data door (`@objectstack/metadata-protocol`) and +// the MCP stdio data bridge (`@objectstack/mcp`) ask each target its exposure +// before the engine evaluates the condition, and share no other package. +export * from './utils/relation-condition-sites.js'; + // Export in-memory fallbacks for core-criticality services export * from './fallbacks/index.js'; diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index cc7223a107c..f70aef4e926 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -41,7 +41,14 @@ * for `aggregate` over `queryObjects`); and the reads whose target is a FIXED * platform object rather than an authored one (a file field's `sys_file` * hydration, the `sys_user` display names the approvals and audit surfaces - * resolve). They are named here, not held. + * resolve). They are named here, not held. Nor does it see a door that reaches a + * classified read through the shared condition walk rather than calling the + * arbiter itself: the MCP stdio data bridge hands a caller's filter to the + * engine's relation lowering (the `relation-filter-lowering.ts` row), and its + * targets are resolved inside `relationConditionSites` (`@objectstack/core`), + * which is the caller classified below. The bridge asks each target the + * decision for `list` (`enforceRelationTargetExposure`, #22777), held by its + * own package's pin rather than by a row here. * * The scan surface and its prefilter follow `row-serving-door-exposure.pin.test.ts` * in this directory: git's authored-file list, never a directory crawl, and `.ts` @@ -161,6 +168,13 @@ const CALLERS: Record = { wiring: { file: 'packages/plugins/plugin-audit/src/audit-writers.ts', call: 'servesSummaryTitleTarget(objectName' }, pin: 'packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts', }, + 'packages/core/src/utils/relation-condition-sites.ts': { + kind: 'not-a-served-read', + reason: + "finds a filter's nested-relation conditions and each one's target; reads no record. Both doors that call it ask " + + "each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget` and the MCP " + + "stdio data bridge's `enforceRelationTargetExposure`", + }, 'packages/lint/src/object-graph.ts': { kind: 'not-a-served-read', reason: 'authoring-time static analysis of metadata; reads no record', diff --git a/packages/core/src/utils/relation-condition-sites.test.ts b/packages/core/src/utils/relation-condition-sites.test.ts new file mode 100644 index 00000000000..a625b2c82dd --- /dev/null +++ b/packages/core/src/utils/relation-condition-sites.test.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { relationConditionSites } from './relation-condition-sites.js'; + +const FIELDS = { + name: { type: 'text' }, + account: { type: 'lookup', reference: 'rcs_account' }, + owner: { type: 'user' }, + tags: { type: 'lookup', reference: 'rcs_tag', multiple: true }, +}; + +describe('[#22777] relationConditionSites — the one walk both exposure-asking doors share', () => { + it('finds a condition on a related object, at the top level and under every combinator, with its target', () => { + const where = { + account: { name: 'a' }, + $or: [{ owner: { email: 'e' } }, { $not: { tags: { label: 't' } } }], + $and: [{ name: 'n' }], + }; + const sites = relationConditionSites(FIELDS, where).map((s) => [s.field, s.target, s.condition]); + expect(sites.sort()).toEqual([ + ['account', 'rcs_account', { name: 'a' }], + ['owner', 'sys_user', { email: 'e' }], + ['tags', 'rcs_tag', { label: 't' }], + ]); + }); + + it('CONTROL finds no site in an id comparand, an operator bag, a non-reference field or without a field map', () => { + expect(relationConditionSites(FIELDS, { account: 'a1', owner: { $in: ['u1'] }, name: { first: 'x' } })).toEqual([]); + expect(relationConditionSites(undefined, { account: { name: 'a' } })).toEqual([]); + }); +}); diff --git a/packages/core/src/utils/relation-condition-sites.ts b/packages/core/src/utils/relation-condition-sites.ts new file mode 100644 index 00000000000..7ab785d5470 --- /dev/null +++ b/packages/core/src/utils/relation-condition-sites.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { REFERENCE_VALUE_TYPES, referenceTargetOf } from '@objectstack/spec/data'; + +/** One nested-relation condition a filter holds, with the object it is evaluated on. */ +export interface RelationConditionSite { + /** The reference field of the filtered object — the filter key. */ + field: string; + /** The related object the field points at, by the spec's one arbiter (`referenceTargetOf`). */ + target: string; + /** The condition on the related object: the plain record beneath the field. */ + condition: Record; +} + +/** + * [#22646] The NESTED-RELATION conditions a filter holds — a key naming a + * reference field of the object (`REFERENCE_VALUE_TYPES`) whose value is a + * plain record carrying at least one non-`$` key, i.e. a condition on the + * related object rather than an operator bag — with each site's target object. + * Logical combinators (and any other `$` key) are walked as conditions, so a + * site under `$or` / `$not` is found. The engine's own relation lowering + * (`ObjectQL.lowerRelationConditions`) is the authority on whether a site RUNS; + * this only finds what a filter would evaluate on the related object. + * + * [#22777] The ONE walk for every door that asks each site's target the spec's + * exposure decision before handing the filter to the engine: the data door + * (`@objectstack/metadata-protocol`, where it also feeds the internal-field + * refusal) and the MCP stdio data bridge (`@objectstack/mcp`). It lives here + * because the two doors share no other package, and a second walk is how two + * doors come to disagree about which conditions a filter holds. + * + * `fields` is the filtered object's declared field map; with none, there is + * nothing to resolve a target with and no site is found. + */ +export function relationConditionSites( + fields: Record | undefined, + root: unknown, +): RelationConditionSite[] { + const sites: RelationConditionSite[] = []; + if (!fields || typeof fields !== 'object') return sites; + const seen = new WeakSet(); + const pending: unknown[] = [root]; + while (pending.length > 0) { + const node = pending.pop(); + if (node === null || typeof node !== 'object' || seen.has(node)) continue; + seen.add(node); + if (Array.isArray(node)) { + for (const member of node) pending.push(member); + continue; + } + for (const [key, value] of Object.entries(node as Record)) { + if (key.startsWith('$')) { + pending.push(value); + continue; + } + const def = fields[key]; + if (def == null || !REFERENCE_VALUE_TYPES.has(def.type) || !isPlainRecord(value)) continue; + if (!Object.keys(value).some((k) => !k.startsWith('$'))) continue; + const target = referenceTargetOf(def); + if (target) sites.push({ field: key, target, condition: value }); + } + } + return sites; +} + +/** A JSON object — not an array, not `null`. */ +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts b/packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts new file mode 100644 index 00000000000..69d21a5c2a0 --- /dev/null +++ b/packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22777] A nested-relation condition in a stdio caller's filter is evaluated + * on its TARGET object (the engine lowers it by reading the target as the + * caller), so the stdio data bridge asks each condition's target the spec's one + * exposure decision for `list` — the same gate an addressed object gets — and + * REFUSES an unserved target before the engine is asked. The data door asks + * the same of the same sites (`@objectstack/metadata-protocol`). Fixture objects + * only; refusals are asserted by envelope (`code` + `status`), never by a bare + * throw, and every refusal asserts the engine was never reached. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { effectiveOperationsArray, resolveEffectiveApiMethods } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; +import { createStdioDataBridge, type McpExposureError } from './stdio-data-bridge.js'; +import { registerObjectTools } from './mcp-http-tools.js'; + +const lookup = (reference: string) => ({ type: 'lookup', reference }); +/** Each source lookup → its target's `enable` block (`undefined`: none declared). */ +const TARGETS: Record | undefined> = { + hidden: { apiEnabled: false }, + closed: { apiMethods: ['create'] }, + getonly: { apiMethods: ['get'] }, + none: { apiMethods: [] }, + listonly: { apiMethods: ['list'] }, + open: undefined, +}; +const DEFS: Record = { + rcx_source: { + name: 'rcx_source', + fields: { name: { type: 'text' }, ...Object.fromEntries(Object.keys(TARGETS).map((rel) => [rel, lookup(`rcx_${rel}`)])) }, + }, + ...Object.fromEntries(Object.entries(TARGETS).map(([rel, enable]) => [ + `rcx_${rel}`, { name: `rcx_${rel}`, fields: { name: { type: 'text' } }, ...(enable ? { enable } : {}) }, + ])), +}; +const REFUSED = [ + ['hidden', 'OBJECT_API_DISABLED', 404], + ['closed', 'OBJECT_API_METHOD_NOT_ALLOWED', 405], + ['getonly', 'OBJECT_API_METHOD_NOT_ALLOWED', 405], + ['none', 'OBJECT_API_METHOD_NOT_ALLOWED', 405], +] as const; + +function makeBridge() { + const engine = { find: vi.fn(async () => [{ id: 's1' }]), aggregate: vi.fn(async () => [{ n: 1 }]) }; + const metadataService = { getObject: vi.fn(async (name: string) => DEFS[name]) }; + const bridge = createStdioDataBridge({ + engine: engine as unknown as IDataEngine, + metadataService: metadataService as unknown as IMetadataService, + resolvePrincipal: async () => ({ userId: 'u1', isSystem: false }) as unknown as ExecutionContext, + }); + return { bridge, engine }; +} + +const VERBS = { + query: (b: ReturnType['bridge'], where: Record) => b.query('rcx_source', { where }), + aggregate: (b: ReturnType['bridge'], where: Record) => + b.aggregate!('rcx_source', { where, aggregations: [{ function: 'count', alias: 'n' }] }), +}; + +describe('[#22777] the stdio bridge refuses a condition on a target the decision does not serve for list', () => { + for (const [verb, run] of Object.entries(VERBS)) { + for (const [rel, code, status] of REFUSED) { + for (const [shape, where] of [ + ['direct', { [rel]: { name: 'x' } }], + ['under $or', { $or: [{ [rel]: { name: 'x' } }, { name: 'y' }] }], + ['under $not', { $not: { [rel]: { name: 'x' } } }], + ] as const) { + it(`${verb}: ${rel} (${shape}) answers ${status} and never reaches the engine`, async () => { + const { bridge, engine } = makeBridge(); + const err = (await run(bridge, where).then(() => null, (e: unknown) => e)) as McpExposureError | null; + expect(err, 'the call resolved: no refusal was raised').toBeTruthy(); + expect(err!.code).toBe(code); + expect(err!.status).toBe(status); + if (status === 405) { + expect(err!.allowedOperations).toEqual(effectiveOperationsArray(resolveEffectiveApiMethods(TARGETS[rel]!))); + } + expect(err!.message).toContain(`'rcx_${rel}'`); + expect(err!.message).toContain(`'where.${rel}'`); + expect(engine.find).not.toHaveBeenCalled(); + expect(engine.aggregate).not.toHaveBeenCalled(); + }); + } + } + + it(`${verb}: CONTROL a served target, an id comparand and an operator bag reach the engine unchanged`, async () => { + for (const where of [ + { listonly: { name: 'x' } }, + { $or: [{ open: { name: 'x' } }] }, + { hidden: 'h1' }, + { hidden: { $in: ['h1'] } }, + ]) { + const { bridge, engine } = makeBridge(); + await run(bridge, where); + const call = (verb === 'query' ? engine.find.mock.calls[0]?.[1] : engine.aggregate.mock.calls[0]?.[1]) as + | { where?: unknown } + | undefined; + expect(call?.where, JSON.stringify(where)).toBe(where); + } + }); + } + + it('query_records answers the refusal naming the target and the position; CONTROL a served target is answered', async () => { + const { bridge, engine } = makeBridge(); + const server = new McpServer({ name: 'pin', version: '0' }); + registerObjectTools(server, bridge); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await server.connect(serverSide); + const client = new Client({ name: 'pin', version: '0' }); + await client.connect(clientSide); + try { + const call = (where: Record) => + client.callTool({ name: 'query_records', arguments: { objectName: 'rcx_source', where } }) as Promise<{ + isError?: boolean; + content: Array<{ text: string }>; + }>; + const refused = await call({ hidden: { name: 'x' } }); + expect(refused.isError).toBe(true); + expect(refused.content[0]!.text).toContain("Object 'rcx_hidden' is not exposed via the API"); + expect(refused.content[0]!.text).toContain("'where.hidden'"); + expect(engine.find).not.toHaveBeenCalled(); + const served = await call({ listonly: { name: 'x' } }); + expect(served.isError).toBeFalsy(); + expect(engine.find).toHaveBeenCalledTimes(1); + } finally { + await client.close(); + } + }); +}); diff --git a/packages/mcp/src/stdio-data-bridge.ts b/packages/mcp/src/stdio-data-bridge.ts index db03321184f..cf59e76683a 100644 --- a/packages/mcp/src/stdio-data-bridge.ts +++ b/packages/mcp/src/stdio-data-bridge.ts @@ -81,7 +81,7 @@ * remains of the divergence above is genuinely not security. */ -import { omitInternalFieldsFromWriteResponse } from '@objectstack/core'; +import { omitInternalFieldsFromWriteResponse, relationConditionSites } from '@objectstack/core'; import { resolveEffectiveApiMethods, isApiOperationAllowed, @@ -271,12 +271,18 @@ function exposureError( * legacy/test doubles. Reading only the nested shape here would let a flat * definition be gated on HTTP and ungated on stdio — the very divergence * this function closes, re-opened one shape down. + * + * [#22777] `condition` is set when `object` is not addressed but is the TARGET + * of a nested-relation condition at that filter position + * ({@link enforceRelationTargetExposure}); the refusal then names the position + * and the way out, as the data door's does for the same condition. */ export async function enforceApiExposure( metadataService: IMetadataService, object: string, action: string, context: ExecutionContext, + condition?: string, ): Promise { if (context?.isSystem) return; @@ -291,10 +297,14 @@ export async function enforceApiExposure( const enable = ( def.enable && typeof def.enable === 'object' ? def.enable : def ) as unknown as EnableLike; + const consequence = condition === undefined + ? '' + : `, so the condition on it at '${condition}' was not evaluated and the query was not run. ` + + 'Filter on the lookup\'s stored id instead, or remove the condition.'; if (enable.apiEnabled === false) { throw exposureError( - `Object '${object}' is not exposed via the API`, + `Object '${object}' is not exposed via the API${consequence}`, OBJECT_API_DISABLED, 404, ); @@ -307,13 +317,44 @@ export async function enforceApiExposure( if (isApiOperationAllowed(eff, operation)) return; throw exposureError( - `API operation '${operation}' is not allowed on object '${object}'`, + `API operation '${operation}' is not allowed on object '${object}'${consequence}`, OBJECT_API_METHOD_NOT_ALLOWED, 405, effectiveOperationsArray(eff), ); } +/** + * [#22777] A NESTED-RELATION condition in the caller's filter is evaluated on + * its TARGET object: the engine lowers it by reading the target as the caller + * (`ObjectQL.lowerRelationConditions`) and keeps the rows whose related record + * matched. So each condition's target is asked {@link enforceApiExposure}'s + * decision for {@link GATED_ACTIONS}`.query` — `list`, because a condition is a + * predicate over the whole target, the read the target's own filtered query + * performs — and an unserved target REFUSES the call: dropping the condition + * would widen the result. The data door asks the same of the same sites + * (`refuseUnservedRelationTarget`), found by the one walk both doors import + * (`relationConditionSites`). One hop, as the engine serves one: a relation + * beneath a condition is refused by the engine before anything is read. + */ +async function enforceRelationTargetExposure( + metadataService: IMetadataService, + object: string, + where: unknown, + context: ExecutionContext, +): Promise { + if (where == null) return; + let def: ObjectDef | null | undefined; + try { + def = (await metadataService.getObject(object)) as ObjectDef | null | undefined; + } catch { + def = undefined; // no field map, no site: the gate above falls open the same way + } + for (const site of relationConditionSites(def?.fields, where)) { + await enforceApiExposure(metadataService, site.target, GATED_ACTIONS.query, context, `where.${site.field}`); + } +} + // --------------------------------------------------------------------------- // [#21207] The stored-metadata-body family, at this engine-only reader // --------------------------------------------------------------------------- @@ -683,6 +724,8 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge async query(object, opts) { const context = await resolvePrincipal(); await enforceApiExposure(metadataService, object, GATED_ACTIONS.query, context); + // [#22777] …and each nested-relation condition's target, before the engine reads it. + await enforceRelationTargetExposure(metadataService, object, opts?.where, context); // [#21207] After the exposure gate, before the engine: a filter or sort // on a stored metadata body is refused rather than evaluated. const bodyRefusal = storedMetadataBodyRefusal(object, { where: opts?.where, orderBy: opts?.orderBy }); @@ -790,6 +833,8 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge // `list` must not leak row statistics through GROUP BY either. The // derivation lives in the spec helpers, so that holds here for free. await enforceApiExposure(metadataService, object, GATED_ACTIONS.aggregate, context); + // [#22777] …and each nested-relation condition's target, as `query` asks it. + await enforceRelationTargetExposure(metadataService, object, opts?.where, context); // [#21207] A grouping, filter or aggregate member on a stored metadata // body computes over the stored bytes, so it is refused, not run. const bodyRefusal = storedMetadataBodyRefusal(object, { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index b9d275e8fee..7bf63334d68 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3,7 +3,7 @@ import type { DataProtocol, MetadataProtocol, PackageProtocol, } from '@objectstack/spec/api'; -import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError } from '@objectstack/core'; +import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError, relationConditionSites } from '@objectstack/core'; import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types'; // [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?". // `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering @@ -4474,47 +4474,6 @@ const EXPANSION_TARGET_OPERATION = 'get'; */ const RELATION_CONDITION_TARGET_OPERATION = 'list'; -/** - * [#22646] The NESTED-RELATION conditions a filter holds — a key naming a - * reference field of the object (`REFERENCE_VALUE_TYPES`) whose value is a - * plain record carrying at least one non-`$` key, i.e. a condition on the - * related object rather than an operator bag — with each site's target object. - * Logical combinators (and any other `$` key) are walked as conditions, so a - * site under `$or` / `$not` is found. Shared by the door's internal-field - * refusal; the engine's own relation lowering is the authority on whether a - * site RUNS, this only finds what a filter would evaluate on the related object. - */ -function relationConditionSites( - fields: Record | undefined, - root: unknown, -): Array<{ field: string; target: string; condition: Record }> { - const sites: Array<{ field: string; target: string; condition: Record }> = []; - if (!fields || typeof fields !== 'object') return sites; - const seen = new WeakSet(); - const pending: unknown[] = [root]; - while (pending.length > 0) { - const node = pending.pop(); - if (node === null || typeof node !== 'object' || seen.has(node)) continue; - seen.add(node); - if (Array.isArray(node)) { - for (const member of node) pending.push(member); - continue; - } - for (const [key, value] of Object.entries(node as Record)) { - if (key.startsWith('$')) { - pending.push(value); - continue; - } - const def = fields[key]; - if (def == null || !REFERENCE_VALUE_TYPES.has(def.type) || !isPlainRecord(value)) continue; - if (!Object.keys(value).some((k) => !k.startsWith('$'))) continue; - const target = referenceTargetOf(def); - if (target) sites.push({ field: key, target, condition: value }); - } - } - return sites; -} - /** * The walk {@link collectStoredMetadataFilterFields} runs over one filter * condition. A node is visited as a CONDITION (its non-`$` keys are columns) From ebdb49c81ccc2b712e74610ba86bcd5256eb0103 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:50:58 +0000 Subject: [PATCH 2/3] chore(changeset): mcp stdio relation-condition target exposure; core relationConditionSites export Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T --- .../22777-core-relation-condition-sites.md | 10 ++++++ ...tdio-relation-condition-target-exposure.md | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 .changeset/22777-core-relation-condition-sites.md create mode 100644 .changeset/22777-mcp-stdio-relation-condition-target-exposure.md diff --git a/.changeset/22777-core-relation-condition-sites.md b/.changeset/22777-core-relation-condition-sites.md new file mode 100644 index 00000000000..39fc3beb530 --- /dev/null +++ b/.changeset/22777-core-relation-condition-sites.md @@ -0,0 +1,10 @@ +--- +'@objectstack/core': minor +'@objectstack/metadata-protocol': patch +--- + +feat(core): export `relationConditionSites`, the one walk that finds a filter's nested-relation conditions and each one's target object (#22777) + +`relationConditionSites(fields, filter)` returns every `{ : { : … } }` condition a filter holds, at the top level and under `$and` / `$or` / `$not`, with the reference field, the target object (resolved by the spec's `referenceTargetOf`) and the condition. An id comparand, an operator bag and a non-reference field are not conditions. + +It moved unchanged from `@objectstack/metadata-protocol`, where it was module-private, so that the MCP stdio data bridge (`@objectstack/mcp`) and the generic data door ask each condition's target its exposure over the same sites. `@objectstack/metadata-protocol` now imports it; its behaviour does not change. diff --git a/.changeset/22777-mcp-stdio-relation-condition-target-exposure.md b/.changeset/22777-mcp-stdio-relation-condition-target-exposure.md new file mode 100644 index 00000000000..c0d766169a3 --- /dev/null +++ b/.changeset/22777-mcp-stdio-relation-condition-target-exposure.md @@ -0,0 +1,31 @@ +--- +'@objectstack/mcp': minor +--- + +fix(mcp)!: on the stdio transport, a nested-relation filter condition asks the related object its declared exposure, and is refused when that object does not serve `list` (#22777) + +Clause-②: yes (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. + +**FROM.** On the MCP stdio transport (`OS_MCP_STDIO_API_KEY`), the `query_records` and `aggregate_records` tools judged only the object they address against its `enable` block, so such a condition in `where` was evaluated whatever the related object's `enable` block declared — including a related object those same tools refuse when it is addressed. + +**TO.** The stdio data bridge asks each condition's related object the same exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is asked. A related object the gate does not serve refuses the call with the bridge's existing exposure refusal, the one an addressed object gets: + +- `enable.apiEnabled: false` → `OBJECT_API_DISABLED` (404). +- an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `OBJECT_API_METHOD_NOT_ALLOWED` (405), with the effective operation set. + +The tool's error text names the related object and the condition's position (`where.`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. The generic data door has given the same answer for the same condition since #22737. + +**Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. + +**Bindings.** + +- **Refused, not withheld.** Dropping a condition would widen the result. +- **One decision, one walk.** The gate is the one the bridge already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door uses. +- **The engine is untouched.** A flow's or a hook's own query calls the engine directly and never passes this bridge. +- **No new error code.** From 7a7b6034099124ff7c927a6d331acb22bc77a89f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:54:34 +0000 Subject: [PATCH 3/3] test(mcp): type the relation-condition pin's engine double Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T --- ...ata-bridge.relation-condition-target-exposure.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts b/packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts index 69d21a5c2a0..6e56811f2d9 100644 --- a/packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts +++ b/packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts @@ -48,7 +48,10 @@ const REFUSED = [ ] as const; function makeBridge() { - const engine = { find: vi.fn(async () => [{ id: 's1' }]), aggregate: vi.fn(async () => [{ n: 1 }]) }; + const engine = { + find: vi.fn(async (_object: string, _query?: { where?: unknown }) => [{ id: 's1' }]), + aggregate: vi.fn(async (_object: string, _query?: { where?: unknown }) => [{ n: 1 }]), + }; const metadataService = { getObject: vi.fn(async (name: string) => DEFS[name]) }; const bridge = createStdioDataBridge({ engine: engine as unknown as IDataEngine, @@ -98,9 +101,7 @@ describe('[#22777] the stdio bridge refuses a condition on a target the decision ]) { const { bridge, engine } = makeBridge(); await run(bridge, where); - const call = (verb === 'query' ? engine.find.mock.calls[0]?.[1] : engine.aggregate.mock.calls[0]?.[1]) as - | { where?: unknown } - | undefined; + const call = (verb === 'query' ? engine.find : engine.aggregate).mock.calls[0]?.[1]; expect(call?.where, JSON.stringify(where)).toBe(where); } });