mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
Wave-assembled from PR #3850 by @SeanGearin. Co-Authored-By: Sean Gearin <sean@indistinct.ai>
139 lines
5.7 KiB
TypeScript
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();
|
|
});
|
|
});
|