|
| 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