Skip to content

Commit c4e7fa5

Browse files
fix(mcp)!: the stdio data bridge asks each nested-relation condition's target its exposure for list (#22777) (#22820)
Fixes #22777 Clause-②: yes (narrowing) Census row 8 of #22661: the decision #22737 brought to the data door, carried to the MCP stdio door. Security card: classes, positions and functions only. ## What this changes - **The bridge asks each condition's target.** `createStdioDataBridge` (`packages/mcp/src/stdio-data-bridge.ts`): `query()` and `aggregate()` call the new `enforceRelationTargetExposure` right after the addressed object's gate. It finds the nested-relation conditions in `where` and asks each TARGET `enforceApiExposure(metadataService, target, GATED_ACTIONS.query, context, position)`. That is the gate an addressed object already gets, so the operation is `list` and there is no second rule. - **Refused, not withheld.** An unserved target refuses the call before the engine is asked, in the bridge's existing envelope: `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` with `allowedOperations`. `enforceApiExposure` gains an optional `condition` argument; when it is set, the message names the target, the position (`where.FIELD`) and the remedy, in the data door's words. No new code. - **One walk.** `relationConditionSites` moves unchanged from `packages/metadata-protocol/src/protocol.ts`, where it was module-private, to `packages/core/src/utils/relation-condition-sites.ts`, and is exported from `@objectstack/core`. `protocol.ts` imports it. The data door's behaviour does not change: `refuseUnservedRelationTarget` is untouched. - **Untouched:** `packages/objectql` and `packages/types`. - **Census pin** (`packages/core/src/security/second-object-read-exposure.pin.test.ts`): the moved walk is a new caller of `referenceTargetOf`, classified `not-a-served-read` (it finds sites and reads no record; both doors that call it ask each target the decision). The discriminator does not see the bridge, which resolves targets inside that walk and never calls the arbiter itself. So row 8 does not join as a `decided` row, and the discriminator's semantics are unchanged. The header's not-seen paragraph names the bridge and where it is held. ## Measured on a real stack, before and after The harness was a scratch probe, not committed. It used a real `bootStack` over fixture objects (the #22661 dogfood fixture). An administrator and a member each had an API key minted through the key route. The principal was resolved through the chain `plugin.ts` uses (`resolveAuthzContext`, then `assembleExecutionContext`). The real `createStdioDataBridge` and `registerObjectTools` ran over an in-memory MCP client/server pair. Not exercised: `StdioServerTransport` and the process boundary. - **Before** (base `23419bafb7`), for both personas, on `query_records` and `aggregate_records`: - A condition on a lookup whose target is API-disabled, `get`-only or `create`-only was evaluated: a match answered rows and a miss answered none. The same held under `$and`, `$or` and `$not`. - The same targets addressed directly were refused. - **After**, for both personas and both tools: - Every such condition is refused, match and miss alike. - Controls: the `list`-only and undeclared targets answer as before (rows on a match, none on a miss). A condition through an injected owner lookup to a list-served platform object answers as before. A second-level condition is still refused by the engine before anything is read. - **Caller set.** Every stdio call carried `isSystem: false` and `principalKind: human`. `assembleExecutionContext` emits `isSystem: false` as a literal, and `resolvePrincipal` throws when it resolves nothing. So the bridge's own `isSystem` bypass, which this reuses, is unreachable from the stdio door, and every reachable caller gets the verdict #22737 gives at the data door. - **Tool surface.** `query_records` and `aggregate_records` flatten a thrown error to its message (`errorResult(messageOf(err))`), exactly as they already do for an addressed-object refusal. The caller sees `isError` with text naming the target and the position. The code, status and `allowedOperations` sit on the bridge's error and are pinned there. ## Pins - `packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts` (27 cases): - Refusals: four refused target shapes (API-disabled, `['create']`, `['get']`, deny-all `[]`), each direct, under `$or` and under `$not`, on `query` and `aggregate`. That is 24 cases. Each asserts `code`, `status`, `allowedOperations` on 405, the target and the position in the message, and that neither `engine.find` nor `engine.aggregate` was called. - Controls: a `list`-only target, an undeclared target, an id comparand and an operator bag each reach the engine with `where` unchanged, on both verbs. - Tool surface: one `query_records` leg over an in-memory MCP pair. It is refused naming the target and position; a served target is answered. - `packages/core/src/utils/relation-condition-sites.test.ts` (2 cases): sites at the top level and under each combinator, with targets (including the implicit `user` target), and a control that finds none. ## Ablations (each from a committed state, restore proven) - **A: the bridge's ask removed.** In `enforceRelationTargetExposure`, the walk's result was replaced with an empty list, through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `cf59e766` → `822adfc1`). - The new mcp pin went 25 red (the 24 refusals plus the tool leg) and 2 green (the controls). The existing `stdio-data-bridge.exposure.test.ts` stayed 27/27 green. - Restore: blob == HEAD and `git diff HEAD` empty. - **B: the shared walk stops descending combinators, on `@objectstack/core`'s `dist`.** Mutated with `--hold`; core rebuilt; `ablation-dist-preflight.mjs` found the marker present in 2 built files. - Core walker test: 1 red (combinators), 1 green (control). - mcp pin: 16 red (exactly the `$or` and `$not` cells), 11 green. - metadata-protocol's `protocol.relation-condition-target-exposure.test.ts`: 8 red (its `$or` filter cases, both contexts), 5 green. This proves the data door now runs the moved walk. - Restore: blob == HEAD and `git diff HEAD` empty; core rebuilt; preflight `--absent` exit 0; `git status --porcelain` clean. - **Reverse type check.** A scratch file in mcp assigned an object literal with an extra key to the new `RelationConditionSite` type. `tsc` answered TS2353 on that key, which proves mcp reads the rebuilt core declarations. The file was removed, and `tsc` exits 0. ## Verification (final head `2261c253ec`, `origin/main` `c11b75870a` merged; closure rebuilt after the merge) - mcp vitest: 36 files / 417 passed. - core: `local` 94 files / 2364 passed; `repo` 5 files / 55 passed (the census pin included). - metadata-protocol vitest: 225 files passed, 3 skipped / 28129 tests passed. - dogfood `second-object-exposure.dogfood.test.ts`: 34/34. - Typecheck: mcp (`tsc` and `check:test-typecheck` OK), metadata-protocol (`tsc`) and core (`tsc`, examples and `check:test-typecheck` OK), all exit 0. - Gates: the union of the dispatch list (59) and `dispatch-gates --commands` on this diff (67) is 67 commands. 66 exit 0. `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: the gate needs every package's `dist`, and 19 packages outside this closure are unbuilt). That is NOT MEASURED, not a pass. The narrowed probe: `require()` of the CJS entry of core, mcp and metadata-protocol exits 0, and core's CJS entry exports `relationConditionSites` as a function. `dispatch-gates --ran`: 67 derived, 66 run, 1 NOT-MEASURED, 0 UNRUN. - Lint, narrowed: `eslint --no-inline-config --format json` over the 7 changed `.ts` files reports 7 files, 0 errors, 0 warnings. `--print-config` shows 5 or 6 active rules per file and no `parserOptions.project` or `projectService`, so no untouched file's verdict can move. ## Deviations 1. **Base.** The worktree was cut from `origin/main` `23419bafb7` (it had moved past the dispatch's `14f4912390`). `origin/main` `c11b75870a` was merged as `2261c253ec`. Since then, `main` has gained 3 commits that touch none of these 9 paths. 2. **Line budget.** 394 changed lines over 9 files (+348 / −46), against a suggested 300. - Source is +124 / −45, net +79. mcp is +48 / −3; core's walk is +69, a move offset by −42 / +1 in `protocol.ts`; core's index is +6. - The excess is the moved function's docblock and its exported type, and an mcp pin parameterised over 24 refusal cells. 3. **Positions walked.** The walk covers `where` on `query` and `aggregate`. It does not cover `aggregations[i].filter`: the `aggregate_records` tool's aggregation schema carries no filter member, and the engine refuses a nested-relation condition there. 4. **One hop.** The engine serves one level and refuses a relation beneath a condition before reading. The data door walks deeper only because its internal-field refusal recurses. 5. **The `isSystem` bypass is kept by reuse.** It is unreachable from this door (measured; see above), so no caller gets a different answer from the one #22737 gives. 6. **Changesets.** `@objectstack/mcp` is `minor`, BREAKING, with FROM → TO and the migration, and carries the Clause-② line and the ADR-0087 marker. `@objectstack/core` is `minor` (one export) and `@objectstack/metadata-protocol` is `patch` (import only, no behaviour change), in one non-breaking changeset. 7. **Labels.** None written: the dispatch named none, and changesets exist. ## Acceptance notes - **Not walked:** the MCP HTTP door. Its bridge (`callData`) prefers the protocol service, which is the data door #22737 decided, and falls back to the engine when that service is absent. Whether the fallback is reachable in a real composition was not measured here. - **Tool surface text-only.** `query_records` and `aggregate_records` answer every bridge refusal as text only. `errorResultFromThrown` exists in the same file and is used by other tools, so this refusal's code is not machine-readable at the tool. That is pre-existing and the same for an addressed object; no defect class is claimed. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b7cd1af commit c4e7fa5

9 files changed

Lines changed: 348 additions & 46 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
feat(core): export `relationConditionSites`, the one walk that finds a filter's nested-relation conditions and each one's target object (#22777)
7+
8+
`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.
9+
10+
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.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/mcp': minor
3+
---
4+
5+
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)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- 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). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
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.
14+
15+
**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.
16+
17+
**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:
18+
19+
- `enable.apiEnabled: false` → `OBJECT_API_DISABLED` (404).
20+
- 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.
21+
22+
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.
23+
24+
**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`.
25+
26+
**Bindings.**
27+
28+
- **Refused, not withheld.** Dropping a condition would widen the result.
29+
- **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.
30+
- **The engine is untouched.** A flow's or a hook's own query calls the engine directly and never passes this bridge.
31+
- **No new error code.**

‎packages/core/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@ export * from './utils/record-not-found.js';
180180
// resolve with it (one name space for both doors).
181181
export * from './utils/object-not-found.js';
182182

183+
// [#22777] The one walk that finds a filter's nested-relation conditions and
184+
// their target objects: the data door (`@objectstack/metadata-protocol`) and
185+
// the MCP stdio data bridge (`@objectstack/mcp`) ask each target its exposure
186+
// before the engine evaluates the condition, and share no other package.
187+
export * from './utils/relation-condition-sites.js';
188+
183189
// Export in-memory fallbacks for core-criticality services
184190
export * from './fallbacks/index.js';
185191

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@
4141
* for `aggregate` over `queryObjects`); and the reads whose target is a FIXED
4242
* platform object rather than an authored one (a file field's `sys_file`
4343
* hydration, the `sys_user` display names the approvals and audit surfaces
44-
* resolve). They are named here, not held.
44+
* resolve). They are named here, not held. Nor does it see a door that reaches a
45+
* classified read through the shared condition walk rather than calling the
46+
* arbiter itself: the MCP stdio data bridge hands a caller's filter to the
47+
* engine's relation lowering (the `relation-filter-lowering.ts` row), and its
48+
* targets are resolved inside `relationConditionSites` (`@objectstack/core`),
49+
* which is the caller classified below. The bridge asks each target the
50+
* decision for `list` (`enforceRelationTargetExposure`, #22777), held by its
51+
* own package's pin rather than by a row here.
4552
*
4653
* The scan surface and its prefilter follow `row-serving-door-exposure.pin.test.ts`
4754
* in this directory: git's authored-file list, never a directory crawl, and `.ts`
@@ -161,6 +168,13 @@ const CALLERS: Record<string, Classification> = {
161168
wiring: { file: 'packages/plugins/plugin-audit/src/audit-writers.ts', call: 'servesSummaryTitleTarget(objectName' },
162169
pin: 'packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts',
163170
},
171+
'packages/core/src/utils/relation-condition-sites.ts': {
172+
kind: 'not-a-served-read',
173+
reason:
174+
"finds a filter's nested-relation conditions and each one's target; reads no record. Both doors that call it ask " +
175+
"each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget` and the MCP " +
176+
"stdio data bridge's `enforceRelationTargetExposure`",
177+
},
164178
'packages/lint/src/object-graph.ts': {
165179
kind: 'not-a-served-read',
166180
reason: 'authoring-time static analysis of metadata; reads no record',
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { relationConditionSites } from './relation-condition-sites.js';
5+
6+
const FIELDS = {
7+
name: { type: 'text' },
8+
account: { type: 'lookup', reference: 'rcs_account' },
9+
owner: { type: 'user' },
10+
tags: { type: 'lookup', reference: 'rcs_tag', multiple: true },
11+
};
12+
13+
describe('[#22777] relationConditionSites — the one walk both exposure-asking doors share', () => {
14+
it('finds a condition on a related object, at the top level and under every combinator, with its target', () => {
15+
const where = {
16+
account: { name: 'a' },
17+
$or: [{ owner: { email: 'e' } }, { $not: { tags: { label: 't' } } }],
18+
$and: [{ name: 'n' }],
19+
};
20+
const sites = relationConditionSites(FIELDS, where).map((s) => [s.field, s.target, s.condition]);
21+
expect(sites.sort()).toEqual([
22+
['account', 'rcs_account', { name: 'a' }],
23+
['owner', 'sys_user', { email: 'e' }],
24+
['tags', 'rcs_tag', { label: 't' }],
25+
]);
26+
});
27+
28+
it('CONTROL finds no site in an id comparand, an operator bag, a non-reference field or without a field map', () => {
29+
expect(relationConditionSites(FIELDS, { account: 'a1', owner: { $in: ['u1'] }, name: { first: 'x' } })).toEqual([]);
30+
expect(relationConditionSites(undefined, { account: { name: 'a' } })).toEqual([]);
31+
});
32+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { REFERENCE_VALUE_TYPES, referenceTargetOf } from '@objectstack/spec/data';
4+
5+
/** One nested-relation condition a filter holds, with the object it is evaluated on. */
6+
export interface RelationConditionSite {
7+
/** The reference field of the filtered object — the filter key. */
8+
field: string;
9+
/** The related object the field points at, by the spec's one arbiter (`referenceTargetOf`). */
10+
target: string;
11+
/** The condition on the related object: the plain record beneath the field. */
12+
condition: Record<string, unknown>;
13+
}
14+
15+
/**
16+
* [#22646] The NESTED-RELATION conditions a filter holds — a key naming a
17+
* reference field of the object (`REFERENCE_VALUE_TYPES`) whose value is a
18+
* plain record carrying at least one non-`$` key, i.e. a condition on the
19+
* related object rather than an operator bag — with each site's target object.
20+
* Logical combinators (and any other `$` key) are walked as conditions, so a
21+
* site under `$or` / `$not` is found. The engine's own relation lowering
22+
* (`ObjectQL.lowerRelationConditions`) is the authority on whether a site RUNS;
23+
* this only finds what a filter would evaluate on the related object.
24+
*
25+
* [#22777] The ONE walk for every door that asks each site's target the spec's
26+
* exposure decision before handing the filter to the engine: the data door
27+
* (`@objectstack/metadata-protocol`, where it also feeds the internal-field
28+
* refusal) and the MCP stdio data bridge (`@objectstack/mcp`). It lives here
29+
* because the two doors share no other package, and a second walk is how two
30+
* doors come to disagree about which conditions a filter holds.
31+
*
32+
* `fields` is the filtered object's declared field map; with none, there is
33+
* nothing to resolve a target with and no site is found.
34+
*/
35+
export function relationConditionSites(
36+
fields: Record<string, any> | undefined,
37+
root: unknown,
38+
): RelationConditionSite[] {
39+
const sites: RelationConditionSite[] = [];
40+
if (!fields || typeof fields !== 'object') return sites;
41+
const seen = new WeakSet<object>();
42+
const pending: unknown[] = [root];
43+
while (pending.length > 0) {
44+
const node = pending.pop();
45+
if (node === null || typeof node !== 'object' || seen.has(node)) continue;
46+
seen.add(node);
47+
if (Array.isArray(node)) {
48+
for (const member of node) pending.push(member);
49+
continue;
50+
}
51+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
52+
if (key.startsWith('$')) {
53+
pending.push(value);
54+
continue;
55+
}
56+
const def = fields[key];
57+
if (def == null || !REFERENCE_VALUE_TYPES.has(def.type) || !isPlainRecord(value)) continue;
58+
if (!Object.keys(value).some((k) => !k.startsWith('$'))) continue;
59+
const target = referenceTargetOf(def);
60+
if (target) sites.push({ field: key, target, condition: value });
61+
}
62+
}
63+
return sites;
64+
}
65+
66+
/** A JSON object — not an array, not `null`. */
67+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
68+
return typeof value === 'object' && value !== null && !Array.isArray(value);
69+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22777] A nested-relation condition in a stdio caller's filter is evaluated
5+
* on its TARGET object (the engine lowers it by reading the target as the
6+
* caller), so the stdio data bridge asks each condition's target the spec's one
7+
* exposure decision for `list` — the same gate an addressed object gets — and
8+
* REFUSES an unserved target before the engine is asked. The data door asks
9+
* the same of the same sites (`@objectstack/metadata-protocol`). Fixture objects
10+
* only; refusals are asserted by envelope (`code` + `status`), never by a bare
11+
* throw, and every refusal asserts the engine was never reached.
12+
*/
13+
14+
import { describe, it, expect, vi } from 'vitest';
15+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
16+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
17+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
18+
import { effectiveOperationsArray, resolveEffectiveApiMethods } from '@objectstack/spec/data';
19+
import type { ExecutionContext } from '@objectstack/spec/kernel';
20+
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
21+
import { createStdioDataBridge, type McpExposureError } from './stdio-data-bridge.js';
22+
import { registerObjectTools } from './mcp-http-tools.js';
23+
24+
const lookup = (reference: string) => ({ type: 'lookup', reference });
25+
/** Each source lookup → its target's `enable` block (`undefined`: none declared). */
26+
const TARGETS: Record<string, Record<string, unknown> | undefined> = {
27+
hidden: { apiEnabled: false },
28+
closed: { apiMethods: ['create'] },
29+
getonly: { apiMethods: ['get'] },
30+
none: { apiMethods: [] },
31+
listonly: { apiMethods: ['list'] },
32+
open: undefined,
33+
};
34+
const DEFS: Record<string, unknown> = {
35+
rcx_source: {
36+
name: 'rcx_source',
37+
fields: { name: { type: 'text' }, ...Object.fromEntries(Object.keys(TARGETS).map((rel) => [rel, lookup(`rcx_${rel}`)])) },
38+
},
39+
...Object.fromEntries(Object.entries(TARGETS).map(([rel, enable]) => [
40+
`rcx_${rel}`, { name: `rcx_${rel}`, fields: { name: { type: 'text' } }, ...(enable ? { enable } : {}) },
41+
])),
42+
};
43+
const REFUSED = [
44+
['hidden', 'OBJECT_API_DISABLED', 404],
45+
['closed', 'OBJECT_API_METHOD_NOT_ALLOWED', 405],
46+
['getonly', 'OBJECT_API_METHOD_NOT_ALLOWED', 405],
47+
['none', 'OBJECT_API_METHOD_NOT_ALLOWED', 405],
48+
] as const;
49+
50+
function makeBridge() {
51+
const engine = {
52+
find: vi.fn(async (_object: string, _query?: { where?: unknown }) => [{ id: 's1' }]),
53+
aggregate: vi.fn(async (_object: string, _query?: { where?: unknown }) => [{ n: 1 }]),
54+
};
55+
const metadataService = { getObject: vi.fn(async (name: string) => DEFS[name]) };
56+
const bridge = createStdioDataBridge({
57+
engine: engine as unknown as IDataEngine,
58+
metadataService: metadataService as unknown as IMetadataService,
59+
resolvePrincipal: async () => ({ userId: 'u1', isSystem: false }) as unknown as ExecutionContext,
60+
});
61+
return { bridge, engine };
62+
}
63+
64+
const VERBS = {
65+
query: (b: ReturnType<typeof makeBridge>['bridge'], where: Record<string, unknown>) => b.query('rcx_source', { where }),
66+
aggregate: (b: ReturnType<typeof makeBridge>['bridge'], where: Record<string, unknown>) =>
67+
b.aggregate!('rcx_source', { where, aggregations: [{ function: 'count', alias: 'n' }] }),
68+
};
69+
70+
describe('[#22777] the stdio bridge refuses a condition on a target the decision does not serve for list', () => {
71+
for (const [verb, run] of Object.entries(VERBS)) {
72+
for (const [rel, code, status] of REFUSED) {
73+
for (const [shape, where] of [
74+
['direct', { [rel]: { name: 'x' } }],
75+
['under $or', { $or: [{ [rel]: { name: 'x' } }, { name: 'y' }] }],
76+
['under $not', { $not: { [rel]: { name: 'x' } } }],
77+
] as const) {
78+
it(`${verb}: ${rel} (${shape}) answers ${status} and never reaches the engine`, async () => {
79+
const { bridge, engine } = makeBridge();
80+
const err = (await run(bridge, where).then(() => null, (e: unknown) => e)) as McpExposureError | null;
81+
expect(err, 'the call resolved: no refusal was raised').toBeTruthy();
82+
expect(err!.code).toBe(code);
83+
expect(err!.status).toBe(status);
84+
if (status === 405) {
85+
expect(err!.allowedOperations).toEqual(effectiveOperationsArray(resolveEffectiveApiMethods(TARGETS[rel]!)));
86+
}
87+
expect(err!.message).toContain(`'rcx_${rel}'`);
88+
expect(err!.message).toContain(`'where.${rel}'`);
89+
expect(engine.find).not.toHaveBeenCalled();
90+
expect(engine.aggregate).not.toHaveBeenCalled();
91+
});
92+
}
93+
}
94+
95+
it(`${verb}: CONTROL a served target, an id comparand and an operator bag reach the engine unchanged`, async () => {
96+
for (const where of [
97+
{ listonly: { name: 'x' } },
98+
{ $or: [{ open: { name: 'x' } }] },
99+
{ hidden: 'h1' },
100+
{ hidden: { $in: ['h1'] } },
101+
]) {
102+
const { bridge, engine } = makeBridge();
103+
await run(bridge, where);
104+
const call = (verb === 'query' ? engine.find : engine.aggregate).mock.calls[0]?.[1];
105+
expect(call?.where, JSON.stringify(where)).toBe(where);
106+
}
107+
});
108+
}
109+
110+
it('query_records answers the refusal naming the target and the position; CONTROL a served target is answered', async () => {
111+
const { bridge, engine } = makeBridge();
112+
const server = new McpServer({ name: 'pin', version: '0' });
113+
registerObjectTools(server, bridge);
114+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
115+
await server.connect(serverSide);
116+
const client = new Client({ name: 'pin', version: '0' });
117+
await client.connect(clientSide);
118+
try {
119+
const call = (where: Record<string, unknown>) =>
120+
client.callTool({ name: 'query_records', arguments: { objectName: 'rcx_source', where } }) as Promise<{
121+
isError?: boolean;
122+
content: Array<{ text: string }>;
123+
}>;
124+
const refused = await call({ hidden: { name: 'x' } });
125+
expect(refused.isError).toBe(true);
126+
expect(refused.content[0]!.text).toContain("Object 'rcx_hidden' is not exposed via the API");
127+
expect(refused.content[0]!.text).toContain("'where.hidden'");
128+
expect(engine.find).not.toHaveBeenCalled();
129+
const served = await call({ listonly: { name: 'x' } });
130+
expect(served.isError).toBeFalsy();
131+
expect(engine.find).toHaveBeenCalledTimes(1);
132+
} finally {
133+
await client.close();
134+
}
135+
});
136+
});

0 commit comments

Comments
 (0)