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
10 changes: 10 additions & 0 deletions .changeset/22777-core-relation-condition-sites.md
Original file line number Diff line number Diff line change
@@ -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 `{ <lookup>: { <field of the related object>: … } }` 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.
31 changes: 31 additions & 0 deletions .changeset/22777-mcp-stdio-relation-condition-target-exposure.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is one runtime read position on one transport: the MCP stdio data bridge stops evaluating a filter condition on a lookup's target object whose existing `enable` declaration already refuses a query of it on every data door, and the remedy is a filter the caller can already write. 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 no exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

A nested-relation filter condition, `{ <lookup>: { <field of the related object>: … } }`, 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.<lookup>`), 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 (`{ <lookup>: '<id>' }` or `{ <lookup>: { $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.**
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -161,6 +168,13 @@ const CALLERS: Record<string, Classification> = {
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',
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/utils/relation-condition-sites.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
69 changes: 69 additions & 0 deletions packages/core/src/utils/relation-condition-sites.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

/**
* [#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<string, any> | undefined,
root: unknown,
): RelationConditionSite[] {
const sites: RelationConditionSite[] = [];
if (!fields || typeof fields !== 'object') return sites;
const seen = new WeakSet<object>();
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<string, unknown>)) {
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 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<string, Record<string, unknown> | undefined> = {
hidden: { apiEnabled: false },
closed: { apiMethods: ['create'] },
getonly: { apiMethods: ['get'] },
none: { apiMethods: [] },
listonly: { apiMethods: ['list'] },
open: undefined,
};
const DEFS: Record<string, unknown> = {
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 (_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,
metadataService: metadataService as unknown as IMetadataService,
resolvePrincipal: async () => ({ userId: 'u1', isSystem: false }) as unknown as ExecutionContext,
});
return { bridge, engine };
}

const VERBS = {
query: (b: ReturnType<typeof makeBridge>['bridge'], where: Record<string, unknown>) => b.query('rcx_source', { where }),
aggregate: (b: ReturnType<typeof makeBridge>['bridge'], where: Record<string, unknown>) =>
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 : engine.aggregate).mock.calls[0]?.[1];
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<string, unknown>) =>
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();
}
});
});
Loading
Loading