// Tests des 7 outils MCP contre un stub HTTP (aucune API réelle, aucun réseau). // Stub -> NEWTUBE_API_URL, puis pilotage stdio JSON-RPC (tools/call). // Run: npm run test:mcp (inclus via --test mcp/*.test.mjs si appelé en glob, // sinon exécution directe node --test mcp/tools.test.mjs) import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; import { spawn } from 'node:child_process'; const LINES = [ { t: 1, dur: 2, text: 'Bonjour' }, { t: 65, dur: 2.5, text: 'deuxième ligne' }, { t: 120, dur: 2, text: `remplissage ${'x'.repeat(900)}` }, ]; const stub = http.createServer((req, res) => { const u = new URL(req.url, 'http://x'); const json = (code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; if (u.pathname === '/api/healthz') return json(200, { status: 'ok', youtube: { mode: 'test' } }); if (u.pathname === '/api/search') return json(200, { q: 'q', providers: ['yt'], groups: { yt: [{ id: 'v1', title: 'T' }] }, page: 1, pageSize: 10 }); if (u.pathname === '/api/search/suggest') return json(200, { q: 'q', groups: { yt: ['abc'] } }); if (u.pathname === '/api/details/youtube/v1') return json(200, { videoId: 'v1', title: 'Demo', uploaderName: 'U', duration: 60, views: 10 }); if (u.pathname === '/api/trending') return json(200, { provider: 'yt', items: [{ id: 't1' }] }); if (u.pathname === '/api/transcript/youtube/v1') return json(200, { lang: 'fr', available: true, languages: ['fr', 'en'], lines: LINES }); if (u.pathname === '/api/transcript/youtube/none') return json(200, { lang: null, available: false, languages: [], lines: [], reason: 'no_subtitles' }); if (u.pathname === '/api/playlists') return json(200, [{ id: 'p1', title: 'Ma playlist' }]); if (u.pathname === '/api/user/likes') return json(200, [{ provider: 'youtube', videoId: 'v1' }]); if (u.pathname === '/api/subscriptions') return json(200, { items: [{ subscriptionId: 1 }], ttl: 1 }); if (u.pathname === '/api/user/history/watch') return json(200, [{ provider: 'youtube', videoId: 'v1' }]); return json(404, { error: 'not_found' }); }); await new Promise((r) => stub.listen(0, '127.0.0.1', r)); const STUB_URL = `http://127.0.0.1:${stub.address().port}/api`; function startMcp() { return spawn(process.execPath, ['./mcp/server.mjs'], { env: { ...process.env, NEWTUBE_API_URL: STUB_URL } }); } function rpc(proc, id, method, params = {}) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`timeout ${method}`)), 15000); const onData = (buf) => { for (const l of String(buf).split('\n')) { if (!l.trim()) continue; try { const m = JSON.parse(l); if (m.id === id) { clearTimeout(timer); proc.stdout.off('data', onData); resolve(m); return; } } catch {} } }; proc.stdout.on('data', onData); proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); }); } async function call(proc, id, name, args) { const m = await rpc(proc, id, 'tools/call', { name, arguments: args }); assert.ok(!m.error, `RPC error: ${JSON.stringify(m.error)}`); assert.equal(m.result.isError, undefined, `tool ${name} isError: ${m.result.content?.[0]?.text?.slice(0, 300)}`); return m.result.content[0].text; } let proc; before(async () => { proc = startMcp(); await rpc(proc, 1, 'initialize'); }); after(() => { try { proc.kill(); } catch {} stub.close(); }); describe('MCP tools (stub API)', () => { it('search_videos / suggest_queries / get_video_details / get_trending / health_check', async () => { const t1 = await call(proc, 11, 'search_videos', { q: 'test' }); assert.match(t1, /r.sultats|résultats|1 r/); const t2 = await call(proc, 12, 'suggest_queries', { q: 'ab' }); assert.match(t2, /Suggestions/); const t3 = await call(proc, 13, 'get_video_details', { provider: 'youtube', videoId: 'v1' }); assert.match(t3, /Demo/); const t4 = await call(proc, 14, 'get_trending', { provider: 'yt', limit: 5 }); assert.match(t4, /tendances/); const t5 = await call(proc, 15, 'health_check', {}); assert.match(t5, /test/); }); it('get_transcript JSON brut', async () => { const t = await call(proc, 21, 'get_transcript', { provider: 'youtube', videoId: 'v1', lang: 'fr' }); assert.match(t, /3 lignes/); assert.match(t, /Bonjour/); }); it('get_transcript indisponible -> message clair, pas d erreur', async () => { const t = await call(proc, 22, 'get_transcript', { provider: 'youtube', videoId: 'none' }); assert.match(t, /indisponible/); assert.match(t, /no_subtitles/); }); it('get_transcript_text horodaté + srt + troncature', async () => { const txt = await call(proc, 23, 'get_transcript_text', { provider: 'youtube', videoId: 'v1', lang: 'fr' }); assert.match(txt, /\[00:00:01\] Bonjour/); assert.match(txt, /\[00:01:05\]/); const srt = await call(proc, 24, 'get_transcript_text', { provider: 'youtube', videoId: 'v1', format: 'srt' }); assert.match(srt, /-->/); const cut = await call(proc, 25, 'get_transcript_text', { provider: 'youtube', videoId: 'v1', maxChars: 500 }); assert.match(cut, /tronqu/); }); it('outil inconnu -> erreur JSON-RPC -32602', async () => { const m = await rpc(proc, 99, 'tools/call', { name: 'nope', arguments: {} }); assert.ok(m.error); assert.equal(m.error.code, -32602); }); it('outils authentifiés sans token -> isError NEWTUBE_TOKEN', async () => { const m = await rpc(proc, 100, 'tools/call', { name: 'list_playlists', arguments: {} }); assert.equal(m.result.isError, true); assert.match(m.result.content[0].text, /NEWTUBE_TOKEN/); }); it('outils authentifiés avec token (stub)', async () => { const authed = spawn(process.execPath, ['./mcp/server.mjs'], { env: { ...process.env, NEWTUBE_API_URL: STUB_URL, NEWTUBE_TOKEN: 'dummy-jwt' } }); try { await rpc(authed, 1, 'initialize'); for (const [id, name, args, re] of [ [201, 'list_playlists', {}, /playlist/], [202, 'list_likes', {}, /likée/], [203, 'list_subscriptions', {}, /abonnement/], [204, 'list_watch_history', {}, /historique/], ]) { const m = await rpc(authed, id, 'tools/call', { name, arguments: args }); assert.equal(m.result.isError, undefined, `${name}: ${m.result.content?.[0]?.text?.slice(0, 200)}`); assert.match(m.result.content[0].text, re); } } finally { authed.kill(); } }); });