381 lines
16 KiB
JavaScript
381 lines
16 KiB
JavaScript
// Generated, do not edit. sha256:c1f8a76bc1578f782076e85f682098b660b8aa2852380e51aa99da1608e59043
|
|
import { describe, it, beforeEach, mock } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { mkdtemp } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { createDbHarness } from '../helpers/db-node.mjs';
|
|
import { CONFIG, ENUMS } from '../../shared/config.js';
|
|
|
|
process.env.ASSETAPP_DATA_DIR = await mkdtemp(join(tmpdir(), 'assetapp-mcp-'));
|
|
|
|
const harness = createDbHarness({ seedPreferences: true });
|
|
mock.module('../../server/db.js', { namedExports: harness.exports });
|
|
harness.recreate();
|
|
|
|
const { createMcpServer } = await import('../../server/mcp/protocol.mjs');
|
|
const { TOOLS, allTools } = await import('../../server/mcp/tools.mjs');
|
|
await import('../../server/edition.mjs');
|
|
|
|
let logboek;
|
|
const maak = (tools = TOOLS, extra = {}) => {
|
|
logboek = [];
|
|
return createMcpServer({ tools, logCall: async (r) => { logboek.push(r); }, ...extra });
|
|
};
|
|
|
|
const verzoek = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, params });
|
|
|
|
beforeEach(() => { logboek = []; });
|
|
|
|
describe('mcp: elke tool declareert een scope (fase 1d)', () => {
|
|
const kaal = { name: 'naamloos', title: 'x', description: 'x', inputSchema: {}, run: async () => ({ text: 'x' }) };
|
|
|
|
it('weigert te bestaan met een tool zonder scope', () => {
|
|
assert.throws(() => createMcpServer({ tools: [kaal], logCall: async () => {} }), /scope/i);
|
|
});
|
|
|
|
it('weigert een verzonnen scope', () => {
|
|
assert.throws(
|
|
() => createMcpServer({ tools: [{ ...kaal, scope: 'admin' }], logCall: async () => {} }),
|
|
/scope/i);
|
|
});
|
|
|
|
it('noemt de tool bij naam, zodat de fout te vinden is', () => {
|
|
assert.throws(
|
|
() => createMcpServer({ tools: [{ ...kaal, name: 'mijn_tool' }], logCall: async () => {} }),
|
|
/mijn_tool/);
|
|
});
|
|
|
|
it('accepteert elke scope uit de SSOT', () => {
|
|
for (const scope of ENUMS.AI_SCOPE) {
|
|
assert.doesNotThrow(() => createMcpServer({ tools: [{ ...kaal, scope }], logCall: async () => {} }),
|
|
`${scope} wordt geweigerd terwijl hij in de enum staat`);
|
|
}
|
|
});
|
|
|
|
it('en de echte tool-lijst voldoet eraan', () => {
|
|
assert.doesNotThrow(() => createMcpServer({ tools: TOOLS, logCall: async () => {} }));
|
|
for (const tool of TOOLS) assert.ok(ENUMS.AI_SCOPE.includes(tool.scope), `${tool.name} mist een scope`);
|
|
});
|
|
});
|
|
|
|
describe('mcp: het vangnet op de lengte', () => {
|
|
const bulk = (n) => [{
|
|
...TOOLS.find(t => t.name === 'connection_test'),
|
|
run: async () => ({ text: 'x'.repeat(n), fieldsShared: [] }),
|
|
}];
|
|
|
|
it('kapt een te lang antwoord af vóór het de deur uit gaat', async () => {
|
|
const server = maak(bulk(CONFIG.AI.LIMITS.MAX_TEXT_CHARS * 3));
|
|
const a = await server.handle(verzoek('tools/call', { name: 'connection_test' }));
|
|
assert.ok(a.result.content[0].text.length <= CONFIG.AI.LIMITS.MAX_TEXT_CHARS);
|
|
assert.match(a.result.content[0].text, /Afgekapt/);
|
|
});
|
|
|
|
it('logt de INGEKORTE lengte, want dat is wat er verstuurd is', async () => {
|
|
const server = maak(bulk(CONFIG.AI.LIMITS.MAX_TEXT_CHARS * 3));
|
|
await server.handle(verzoek('tools/call', { name: 'connection_test' }));
|
|
assert.ok(logboek[0].charactersOut <= CONFIG.AI.LIMITS.MAX_TEXT_CHARS);
|
|
assert.match(logboek[0].detail, /ingekort/i);
|
|
});
|
|
|
|
it('laat een normaal antwoord met rust, en zet er geen bijzonderheid bij', async () => {
|
|
const server = maak();
|
|
await server.handle(verzoek('tools/call', { name: 'connection_test', arguments: { note: 'hoi' } }));
|
|
assert.equal(logboek[0].outcome, 'ok');
|
|
assert.equal(logboek[0].detail ?? null, null, 'een niet-ingekort antwoord hoort geen detail te krijgen');
|
|
});
|
|
});
|
|
|
|
describe('mcp: een geweigerde aanroep is iets anders dan een mislukte', () => {
|
|
it('logt een weigering van de poort als refused, niet als error', async () => {
|
|
const stuk = [{
|
|
...TOOLS.find(t => t.name === 'connection_test'),
|
|
run: async () => { const e = new Error('De AI-koppeling staat uit.'); e.code = 'ai_disabled'; throw e; },
|
|
}];
|
|
const server = maak(stuk);
|
|
const a = await server.handle(verzoek('tools/call', { name: 'connection_test' }));
|
|
assert.equal(a.error, undefined, 'geen protocolfout: het model hoort de reden te lezen');
|
|
assert.equal(a.result.isError, true);
|
|
assert.equal(logboek[0].outcome, 'refused');
|
|
});
|
|
|
|
it('logt een gewone tool-fout nog steeds als error', async () => {
|
|
const stuk = [{
|
|
...TOOLS.find(t => t.name === 'connection_test'),
|
|
run: async () => { throw new Error('de bron is onbereikbaar'); },
|
|
}];
|
|
await maak(stuk).handle(verzoek('tools/call', { name: 'connection_test' }));
|
|
assert.equal(logboek[0].outcome, 'error');
|
|
});
|
|
});
|
|
|
|
describe('mcp: de server kan niet zonder auditlog bestaan', () => {
|
|
it('weigert aangemaakt te worden zonder logCall', () => {
|
|
assert.throws(() => createMcpServer({ tools: TOOLS }), /without an audit log/);
|
|
assert.throws(() => createMcpServer({ tools: TOOLS, logCall: 'niet-een-functie' }), /without an audit log/);
|
|
});
|
|
|
|
it('weigert aangemaakt te worden zonder tools', () => {
|
|
assert.throws(() => createMcpServer({ tools: [], logCall: async () => {} }), /without tools/);
|
|
});
|
|
});
|
|
|
|
describe('mcp: initialize', () => {
|
|
it('echoot de protocolversie die de client vraagt', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('initialize', { protocolVersion: '2099-01-01' }));
|
|
assert.equal(a.result.protocolVersion, '2099-01-01');
|
|
});
|
|
|
|
it('valt terug op de SSOT-versie als de client er geen noemt', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('initialize', {}));
|
|
assert.equal(a.result.protocolVersion, CONFIG.AI.PROTOCOL_VERSION);
|
|
});
|
|
|
|
it('belooft alleen tools, en niets wat wij niet waar kunnen maken', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('initialize', {}));
|
|
assert.deepEqual(Object.keys(a.result.capabilities), ['tools']);
|
|
assert.equal(a.result.serverInfo.name, CONFIG.AI.SERVER_NAME);
|
|
assert.equal(a.result.serverInfo.version, CONFIG.AI.SERVER_VERSION);
|
|
assert.notEqual(a.result.serverInfo.version, String(CONFIG.SCHEMA_VERSION));
|
|
});
|
|
|
|
it('onthoudt welke client er aan de andere kant zit', async () => {
|
|
const server = maak();
|
|
await server.handle(verzoek('initialize', { clientInfo: { name: 'voorbeeld-client' } }));
|
|
assert.equal(server.session.client, 'voorbeeld-client');
|
|
await server.handle(verzoek('tools/call', { name: 'connection_test' }, 2));
|
|
assert.equal(logboek[0].client, 'voorbeeld-client', 'en zet hem in het logboek');
|
|
});
|
|
|
|
it('antwoordt op id 0, want dat is een geldige id en geen notificatie', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('initialize', { protocolVersion: '2025-06-18' }, 0));
|
|
assert.ok(a, 'er hoort een antwoord te komen');
|
|
assert.equal(a.id, 0, 'en met dezelfde id, niet met null');
|
|
const b = await server.handle(verzoek('tools/call', { name: 'connection_test' }, 0));
|
|
assert.equal(b.id, 0);
|
|
});
|
|
|
|
it('overleeft een initialize zonder clientInfo', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('initialize', { protocolVersion: '2025-06-18' }));
|
|
assert.ok(a.result);
|
|
assert.equal(server.session.client, null);
|
|
});
|
|
});
|
|
|
|
describe('mcp: notificaties krijgen geen antwoord', () => {
|
|
it('antwoordt niet op notifications/initialized', async () => {
|
|
const server = maak();
|
|
assert.equal(await server.handle({ jsonrpc: '2.0', method: 'notifications/initialized' }), null);
|
|
});
|
|
|
|
it('antwoordt niet op een onbekende notificatie', async () => {
|
|
const server = maak();
|
|
assert.equal(await server.handle({ jsonrpc: '2.0', method: 'iets/onbekends' }), null);
|
|
});
|
|
});
|
|
|
|
describe('mcp: tools/list', () => {
|
|
it('geeft naam, titel, beschrijving en schema van elke tool', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('tools/list'));
|
|
assert.equal(a.result.tools.length, TOOLS.length);
|
|
for (const t of a.result.tools) {
|
|
assert.ok(t.name && t.description && t.inputSchema, `${t.name} is niet volledig`);
|
|
assert.equal(t.run, undefined, 'de implementatie hoort niet over de lijn te gaan');
|
|
}
|
|
});
|
|
|
|
it('houdt het oppervlak klein', () => {
|
|
assert.equal(TOOLS.length, 18,'het oppervlak groeit alleen met een concrete use-case');
|
|
});
|
|
|
|
it('blijft binnen het tekenbudget, en dát is de maat die §6a noemt', () => {
|
|
const tekens = allTools().reduce((n, t) => n + JSON.stringify({
|
|
name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema,
|
|
}).length, 0);
|
|
assert.ok(tekens <= 16000,
|
|
`het tool-oppervlak kost ${tekens} tekens per verzoek; de grens is 16000`);
|
|
});
|
|
});
|
|
|
|
describe('mcp: het gesprek per aanroep', () => {
|
|
const gesprekVan = (regel) => regel.messages ?? [];
|
|
|
|
it('legt twee momenten vast: wat binnenkwam en wat terugging', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('tools/call', { name: 'connection_test', arguments: { note: 'hallo' } }));
|
|
const m = gesprekVan(logboek[0]);
|
|
assert.equal(m.length, 2);
|
|
assert.deepEqual(m.map(x => x.kind), ['request', 'response']);
|
|
assert.equal(m[1].text, a.result.content[0].text,
|
|
'het bewaarde antwoord is letterlijk wat de client kreeg');
|
|
assert.match(m[0].text, /hallo/, 'en de binnenkomst draagt de argumenten zoals ze binnenkwamen');
|
|
});
|
|
|
|
it('pakt de binnenkomst vóórdat de tool draait, en niet erna', async () => {
|
|
const traag = [{
|
|
...TOOLS.find(t => t.name === 'connection_test'),
|
|
run: async () => { await new Promise(r => setTimeout(r, 15)); return { text: 'x', fieldsShared: [] }; },
|
|
}];
|
|
const server = maak(traag);
|
|
await server.handle(verzoek('tools/call', { name: 'connection_test' }));
|
|
const [binnen, uit] = gesprekVan(logboek[0]);
|
|
assert.ok(binnen.occurredAt < uit.occurredAt,
|
|
`binnenkomst ${binnen.occurredAt} hoort vóór antwoord ${uit.occurredAt} te liggen`);
|
|
});
|
|
|
|
it('bewaart ook het gesprek van een geweigerde aanroep', async () => {
|
|
const server = maak();
|
|
await server.handle(verzoek('tools/call', { name: 'lees_alle_xpubs' }));
|
|
const m = gesprekVan(logboek[0]);
|
|
assert.equal(m.length, 2);
|
|
assert.match(m[1].text, /bestaat niet/);
|
|
});
|
|
|
|
it('bewaart bij een mislukte tool de melding die terugging', async () => {
|
|
const stuk = [{
|
|
...TOOLS.find(t => t.name === 'connection_test'),
|
|
run: async () => { throw new Error('de tool viel om'); },
|
|
}];
|
|
const server = maak(stuk);
|
|
await server.handle(verzoek('tools/call', { name: 'connection_test' }));
|
|
const m = gesprekVan(logboek[0]);
|
|
assert.equal(m[1].text, 'de tool viel om');
|
|
});
|
|
|
|
it('bewaart de INGEKORTE tekst en niet de volledige', async () => {
|
|
const server = maak([{
|
|
...TOOLS.find(t => t.name === 'connection_test'),
|
|
run: async () => ({ text: 'x'.repeat(CONFIG.AI.LIMITS.MAX_TEXT_CHARS * 2), fieldsShared: [] }),
|
|
}]);
|
|
const a = await server.handle(verzoek('tools/call', { name: 'connection_test' }));
|
|
const m = gesprekVan(logboek[0]);
|
|
assert.equal(m[1].text, a.result.content[0].text);
|
|
assert.ok(m[1].text.length <= CONFIG.AI.LIMITS.MAX_TEXT_CHARS);
|
|
});
|
|
|
|
it('schrijft geen gesprek bij een dry-run', async () => {
|
|
const server = maak();
|
|
await server.dryRun('connection_test', {});
|
|
assert.equal(logboek.length, 0);
|
|
});
|
|
});
|
|
|
|
describe('mcp: tools/call', () => {
|
|
it('voert de tool uit, geeft tekst terug en logt de aanroep', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('tools/call', { name: 'connection_test', arguments: { note: 'hallo' } }));
|
|
assert.equal(a.result.isError, undefined);
|
|
assert.match(a.result.content[0].text, /werkt/);
|
|
assert.match(a.result.content[0].text, /hallo/, 'de notitie komt terug, zodat de gebruiker hem kan herkennen');
|
|
assert.equal(logboek.length, 1);
|
|
assert.equal(logboek[0].tool, 'connection_test');
|
|
assert.equal(logboek[0].outcome, 'ok');
|
|
assert.deepEqual(logboek[0].fieldsShared, ['portfolio'],
|
|
'de naam van de portefeuille gaat mee en hoort dus gelogd te worden');
|
|
assert.equal(logboek[0].charactersOut, a.result.content[0].text.length,
|
|
'het aantal verstuurde tekens hoort te kloppen met wat er echt verstuurd is');
|
|
});
|
|
|
|
it('logt een aanroep zonder argumenten net zo goed', async () => {
|
|
const server = maak();
|
|
await server.handle(verzoek('tools/call', { name: 'connection_test' }));
|
|
assert.equal(logboek.length, 1);
|
|
assert.deepEqual(logboek[0].arguments, {});
|
|
});
|
|
|
|
it('weigert een onbekende tool, en legt die poging vast', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('tools/call', { name: 'lees_alle_xpubs' }));
|
|
assert.ok(a.error, 'een onbekende tool is een protocolfout');
|
|
assert.match(a.error.message, /Onbekende tool/);
|
|
assert.equal(logboek.length, 1);
|
|
assert.equal(logboek[0].tool, 'lees_alle_xpubs', 'mét de naam die verzonnen is');
|
|
assert.equal(logboek[0].outcome, 'refused');
|
|
assert.equal(logboek[0].fieldsShared, undefined, 'er ging niets naar buiten');
|
|
});
|
|
|
|
it('weigert een verzoek zonder toolnaam, en legt dat vast', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('tools/call', {}));
|
|
assert.ok(a.error);
|
|
assert.equal(logboek.length, 1);
|
|
assert.equal(logboek[0].outcome, 'refused');
|
|
});
|
|
|
|
it('maakt van een tool-fout een geslaagd antwoord met isError, en logt hem als error', async () => {
|
|
const echt = TOOLS.find(t => t.name === 'connection_test');
|
|
const stuk = [{ ...echt, run: async () => { throw new Error('de bron is onbereikbaar'); } }];
|
|
const server = maak(stuk);
|
|
const a = await server.handle(verzoek('tools/call', { name: 'connection_test' }));
|
|
assert.equal(a.error, undefined, 'geen protocolfout');
|
|
assert.equal(a.result.isError, true);
|
|
assert.match(a.result.content[0].text, /onbereikbaar/);
|
|
assert.equal(logboek[0].outcome, 'error');
|
|
assert.equal(logboek[0].detail, 'de bron is onbereikbaar');
|
|
});
|
|
|
|
it('laat een falend auditlog niet stil gebeuren', async () => {
|
|
const kapot = { tools: TOOLS, logCall: async () => { throw new Error('schijf vol'); } };
|
|
const gooit = createMcpServer(kapot);
|
|
await assert.rejects(() => gooit.handle(verzoek('tools/call', { name: 'connection_test' })), /schijf vol/);
|
|
|
|
const gezien = [];
|
|
const meldt = createMcpServer({ ...kapot, onLogError: (e) => gezien.push(e.message) });
|
|
const a = await meldt.handle(verzoek('tools/call', { name: 'connection_test' }));
|
|
assert.ok(a.result, 'de aanroep zelf slaagt nog');
|
|
assert.deepEqual(gezien, ['schijf vol']);
|
|
});
|
|
});
|
|
|
|
describe('mcp: het protocol zelf', () => {
|
|
it('antwoordt op ping, ook vóór initialize', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('ping'));
|
|
assert.deepEqual(a.result, {});
|
|
});
|
|
|
|
it('geeft een onbekende methode de juiste JSON-RPC-code', async () => {
|
|
const server = maak();
|
|
const a = await server.handle(verzoek('resources/list'));
|
|
assert.equal(a.error.code, -32601, 'method not found');
|
|
});
|
|
|
|
it('maakt van ongeldige JSON een parse-fout in plaats van een crash', async () => {
|
|
const server = maak();
|
|
const a = await server.handleRegel('{dit is geen json');
|
|
assert.equal(a.error.code, -32700);
|
|
assert.equal(a.id, null);
|
|
});
|
|
|
|
it('weigert iets dat geen JSON-RPC-object is', async () => {
|
|
const server = maak();
|
|
for (const rommel of [null, 42, 'tekst', [1, 2]]) {
|
|
const a = await server.handle(rommel);
|
|
assert.equal(a.error.code, -32600, `${JSON.stringify(rommel)} hoort geweigerd te worden`);
|
|
}
|
|
});
|
|
|
|
it('geeft elke sessie een eigen id', () => {
|
|
const a = maak(), b = maak();
|
|
assert.notEqual(a.session.id, b.session.id);
|
|
assert.match(a.session.id, /^[0-9a-f-]{36}$/);
|
|
});
|
|
|
|
it('laat de sessie niet van buiten aanpassen', async () => {
|
|
const server = maak();
|
|
await server.handle(verzoek('initialize', { clientInfo: { name: 'echt' } }));
|
|
server.session.client = 'gelogen';
|
|
await server.handle(verzoek('tools/call', { name: 'connection_test' }, 2));
|
|
assert.equal(logboek[0].client, 'echt');
|
|
});
|
|
});
|