Files
gbrain/test/mcp-dispatch-optional-params.test.ts
2026-08-12 14:38:36 -07:00

139 lines
5.7 KiB
TypeScript

/**
* Optional-param absent-idiom tolerance at the MCP dispatch layer.
*
* Real MCP clients driven by non-Claude models routinely send `""` or `null`
* for optional params instead of omitting them. Before the
* `normalizeOptionalParams` step, those calls slipped past `validateParams`
* (both values type-check or are skipped) and reached handlers whose guards
* disagree: `recall {since: ""}` and `recall {since: null}` took the
* since-branch, parsed to no date, and SILENTLY returned zero facts — same
* call with `since` omitted returns rows. A silent empty is worse than an
* error: the model concludes the brain is empty.
*
* These tests pin the normalized behavior end to end through
* `dispatchToolCall` (the one path both MCP transports share) against a real
* PGLiteEngine, plus the deliberate NON-goals: required params stay loud,
* type-mismatched junk stays loud, undeclared keys stay ignored.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { dispatchToolCall, normalizeOptionalParams, validateParams } from '../src/mcp/dispatch.ts';
import { operations } from '../src/core/operations.ts';
import type { Operation } from '../src/core/operations.ts';
let engine: PGLiteEngine;
const OPTS = { remote: true, sourceId: 'default' } as const;
function factsFrom(r: { content: Array<{ type: string; text: string }> }): unknown[] {
const payload = JSON.parse(r.content[0].text);
return payload.facts;
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Remote callers see visibility=world only — seed accordingly.
await engine.insertFact(
{ fact: 'Optional-param seed: the sky is blue', source: 'test:optional-params', visibility: 'world', embedding: null },
{ source_id: 'default' },
);
});
afterAll(async () => {
await engine.disconnect();
});
describe('recall tolerates the absent-idioms for optional params', () => {
test('baseline: recall with no filters returns the seeded fact', async () => {
const r = await dispatchToolCall(engine, 'recall', {}, OPTS);
expect(r.isError).toBeFalsy();
expect(factsFrom(r).length).toBe(1);
});
test('since: "" behaves like since omitted (was: silent zero facts)', async () => {
const r = await dispatchToolCall(engine, 'recall', { since: '' }, OPTS);
expect(r.isError).toBeFalsy();
expect(factsFrom(r).length).toBe(1);
});
test('since: null behaves like since omitted (was: silent zero facts)', async () => {
const r = await dispatchToolCall(engine, 'recall', { since: null }, OPTS);
expect(r.isError).toBeFalsy();
expect(factsFrom(r).length).toBe(1);
});
test('entity: "" and session_id: "" behave like omitted (matches the handler\'s own length>0 guards)', async () => {
const r = await dispatchToolCall(engine, 'recall', { entity: '', session_id: '' }, OPTS);
expect(r.isError).toBeFalsy();
expect(factsFrom(r).length).toBe(1);
});
test('a real since value still filters (normalization does not blur real dates)', async () => {
const r = await dispatchToolCall(engine, 'recall', { since: '2099-01-01' }, OPTS);
expect(r.isError).toBeFalsy();
expect(factsFrom(r).length).toBe(0);
});
test('undeclared params (e.g. cursor: "") stay ignored, not errors', async () => {
const r = await dispatchToolCall(engine, 'recall', { cursor: '' }, OPTS);
expect(r.isError).toBeFalsy();
expect(factsFrom(r).length).toBe(1);
});
});
describe('deliberate non-goals stay loud', () => {
test('null on a REQUIRED param is still a missing-parameter error', async () => {
const r = await dispatchToolCall(engine, 'get_page', { slug: null }, OPTS);
expect(r.isError).toBe(true);
const payload = JSON.parse(r.content[0].text);
expect(payload.error).toBe('invalid_params');
expect(payload.message).toContain('Missing required parameter: slug');
});
test('type-mismatched junk on an optional param still errors (limit: "")', async () => {
const r = await dispatchToolCall(engine, 'recall', { limit: '' }, OPTS);
expect(r.isError).toBe(true);
const payload = JSON.parse(r.content[0].text);
expect(payload.error).toBe('invalid_params');
expect(payload.message).toContain('"limit" must be a number');
});
});
describe('normalizeOptionalParams unit behavior', () => {
const op = operations.find(o => o.name === 'recall') as Operation;
test('strips null and empty-string optionals; leaves everything else', () => {
const input = { since: '', session_id: null, entity: 'kera', include_expired: false };
const out = normalizeOptionalParams(op, input);
expect('since' in out).toBe(false);
expect('session_id' in out).toBe(false);
expect(out.entity).toBe('kera');
expect(out.include_expired).toBe(false);
});
test('copy-on-write: the caller\'s object is never mutated', () => {
const input = { since: '' };
const out = normalizeOptionalParams(op, input);
expect(out).not.toBe(input);
expect(input.since).toBe('');
});
test('no-op inputs return the same object (no gratuitous copies)', () => {
const input = { entity: 'kera' };
expect(normalizeOptionalParams(op, input)).toBe(input);
});
test('empty string is stripped only for string-typed optionals', () => {
const getPage = operations.find(o => o.name === 'get_page') as Operation;
// get_page.slug is required — "" must survive normalization untouched
// (and required-null likewise), so validateParams stays the judge.
const out = normalizeOptionalParams(getPage, { slug: '' });
expect(out.slug).toBe('');
expect(validateParams(getPage, out)).toBeNull();
});
});