|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [ADR-0090 D10 — maintainer ruling 2026-09-08, #16549 consequence 2] |
| 5 | + * A delegated `query_records` NARROWED by the agent ceiling says so; an |
| 6 | + * un-narrowed one does not. |
| 7 | + * |
| 8 | + * This is the half of the ruling that is INDEPENDENT of the parity half, and |
| 9 | + * the half that matters most, because the deceived consumer on this surface is |
| 10 | + * the AI itself. Measured on the card: `query_records` answered `total: 0` with |
| 11 | + * no note, and the agent faithfully told a decision-maker "there are no |
| 12 | + * opportunities this quarter" — a wrong answer delivered with full confidence, |
| 13 | + * from a tool that was working exactly as specified. |
| 14 | + * |
| 15 | + * The rows are still served. What the notice adds is the one fact the payload |
| 16 | + * could not previously carry: this count describes the CEILING, not the object. |
| 17 | + * The shape mirrors `list_objects`' `partial` / `warning` pair (#6504) so a |
| 18 | + * client branches on the same vocabulary across both tools. |
| 19 | + */ |
| 20 | + |
| 21 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 22 | + |
| 23 | +import { MCPServerRuntime } from './mcp-server-runtime.js'; |
| 24 | +import type { McpDataBridge } from './mcp-http-tools.js'; |
| 25 | + |
| 26 | +const NARROWING = 'This result was narrowed by the ADR-0090 D10 intersection: rows are ABSENT from this result.'; |
| 27 | + |
| 28 | +function makeBridge( |
| 29 | + diagnose?: McpDataBridge['diagnoseDelegation'], |
| 30 | +): McpDataBridge & { calls: string[] } { |
| 31 | + const calls: string[] = []; |
| 32 | + const bridge: any = { |
| 33 | + calls, |
| 34 | + async listObjects() { return [{ name: 'crm_opportunity', label: 'Opportunity' }]; }, |
| 35 | + async describeObject(name: string) { return { name }; }, |
| 36 | + async query(object: string) { |
| 37 | + calls.push('query'); |
| 38 | + return { object, records: [], total: 0 }; |
| 39 | + }, |
| 40 | + async get(object: string, id: string) { return { object, id }; }, |
| 41 | + async create(object: string, data: any) { return { object, data }; }, |
| 42 | + async update(object: string, id: string) { return { object, id }; }, |
| 43 | + async remove(object: string, id: string) { return { object, id, success: true }; }, |
| 44 | + }; |
| 45 | + if (diagnose) bridge.diagnoseDelegation = diagnose; |
| 46 | + return bridge; |
| 47 | +} |
| 48 | + |
| 49 | +async function queryRecords(runtime: MCPServerRuntime, bridge: any): Promise<any> { |
| 50 | + const body = { |
| 51 | + jsonrpc: '2.0', |
| 52 | + id: 1, |
| 53 | + method: 'tools/call', |
| 54 | + params: { name: 'query_records', arguments: { objectName: 'crm_opportunity' } }, |
| 55 | + }; |
| 56 | + const req = new Request('http://localhost/api/v1/mcp', { |
| 57 | + method: 'POST', |
| 58 | + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, |
| 59 | + body: JSON.stringify(body), |
| 60 | + }); |
| 61 | + const res = await runtime.handleHttpRequest(req, { bridge, parsedBody: body }); |
| 62 | + const json: any = await res.json(); |
| 63 | + expect(json.result?.isError).toBeFalsy(); |
| 64 | + return JSON.parse(json.result.content[0].text); |
| 65 | +} |
| 66 | + |
| 67 | +describe('query_records — ADR-0090 D10 delegated-read diagnostic (#16549)', () => { |
| 68 | + let runtime: MCPServerRuntime; |
| 69 | + |
| 70 | + beforeEach(() => { |
| 71 | + runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' }); |
| 72 | + }); |
| 73 | + |
| 74 | + it('a NARROWED delegated read carries the D10 statement beside the rows', async () => { |
| 75 | + const bridge = makeBridge(async () => ({ narrowed: true, statement: NARROWING })); |
| 76 | + const payload = await queryRecords(runtime, bridge); |
| 77 | + // The rows are still served — a partial answer is the most useful true |
| 78 | + // thing here. What is added is the withheld claim. |
| 79 | + expect(payload.object).toBe('crm_opportunity'); |
| 80 | + expect(payload.records).toEqual([]); |
| 81 | + expect(payload.total).toBe(0); |
| 82 | + expect(payload.delegationNarrowed).toBe(true); |
| 83 | + expect(payload.warning).toBe(NARROWING); |
| 84 | + }); |
| 85 | + |
| 86 | + it('an UN-NARROWED delegated read carries NO statement — the payload is byte-identical to before', async () => { |
| 87 | + const bridge = makeBridge(async () => ({ narrowed: false })); |
| 88 | + const payload = await queryRecords(runtime, bridge); |
| 89 | + expect(payload).toEqual({ object: 'crm_opportunity', records: [], total: 0 }); |
| 90 | + expect(payload.delegationNarrowed).toBeUndefined(); |
| 91 | + expect(payload.warning).toBeUndefined(); |
| 92 | + }); |
| 93 | + |
| 94 | + it('a bridge that cannot answer (no member at all) renders exactly what it rendered before', async () => { |
| 95 | + // The stdio API-key host and any deployment whose security service predates |
| 96 | + // the probe. ⛔ Absence must never be rendered as a warning — "cannot say" |
| 97 | + // and "nothing to say" are deliberately the same rendered outcome. |
| 98 | + const bridge = makeBridge(undefined); |
| 99 | + const payload = await queryRecords(runtime, bridge); |
| 100 | + expect(payload).toEqual({ object: 'crm_opportunity', records: [], total: 0 }); |
| 101 | + }); |
| 102 | + |
| 103 | + it('a THROWING probe never fails the query it annotates', async () => { |
| 104 | + const bridge = makeBridge(async () => { throw new Error('security service exploded'); }); |
| 105 | + const payload = await queryRecords(runtime, bridge); |
| 106 | + expect(payload).toEqual({ object: 'crm_opportunity', records: [], total: 0 }); |
| 107 | + expect(bridge.calls).toContain('query'); |
| 108 | + }); |
| 109 | + |
| 110 | + it('narrowed:true with NO statement adds nothing — a notice with no sentence is not a notice', async () => { |
| 111 | + const bridge = makeBridge(async () => ({ narrowed: true })); |
| 112 | + const payload = await queryRecords(runtime, bridge); |
| 113 | + expect(payload).toEqual({ object: 'crm_opportunity', records: [], total: 0 }); |
| 114 | + }); |
| 115 | +}); |
0 commit comments