Files
AtreidesAssetsFree/tests/server/mcp.test.mjs
T

380 lines
16 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// 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');
});
});