feat(mcp): serveur MCP stdio 7 outils + guide API/MCP + couverture tests
CI / build-and-test (push) Successful in 14m3s
CI / build-and-test (push) Successful in 14m3s
- mcp/server.mjs : search, suggest, details, trending, transcript, transcript_text, health_check (client fin vers API REST) - docs/API_MCP_GUIDE.md : reference complete REST + MCP + transcript - tests : server/tests/api_coverage.test.mjs (17 cas HTTP), mcp/tools.test.mjs (7 outils sur stub), scripts test:mcp/test:api - fix(db): DELETE playlist 500 FK -> metrique avant delete + cleanup enfants + recordPlaylistMetric tolerant (observabilite)
This commit is contained in:
+330
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* NewTube MCP Server (stdio, zero-dependency).
|
||||
*
|
||||
* Expose l'API REST NewTube comme outils MCP :
|
||||
* - search_videos, suggest_queries, get_video_details, get_trending
|
||||
* - get_transcript, get_transcript_text (JSON + texteLLM-friendly)
|
||||
* - health_check
|
||||
*
|
||||
* Protocole : JSON-RPC 2.0 sur stdio (1 objet JSON par ligne), compatible
|
||||
* MCP 2024-11-05 : initialize / notifications/initialized / tools/list /
|
||||
* tools/call / ping.
|
||||
*
|
||||
* Prérequis : API NewTube démarrée (`npm run api`, défaut http://localhost:4000/api).
|
||||
*
|
||||
* Env :
|
||||
* NEWTUBE_API_URL (défaut http://localhost:4000/api)
|
||||
* NEWTUBE_TOKEN (JWT optionnel -> Authorization: Bearer, requis pour /download, /user/*)
|
||||
* MCP_TOOL_TIMEOUT_MS (défaut 60000, transcript = appels yt-dlp longs)
|
||||
*
|
||||
* Config clients :
|
||||
* Claude Desktop / VS Code / opencode :
|
||||
* { "mcpServers": { "newtube": { "command": "node", "args": ["C:/dev/git/web/NewTube/mcp/server.mjs"],
|
||||
* "env": { "NEWTUBE_API_URL": "http://localhost:4000/api" } } } }
|
||||
*/
|
||||
|
||||
const API_BASE = (process.env.NEWTUBE_API_URL || 'http://localhost:4000/api').replace(/\/+$/, '');
|
||||
const TOKEN = (process.env.NEWTUBE_TOKEN || '').trim();
|
||||
const TIMEOUT_MS = Number(process.env.MCP_TOOL_TIMEOUT_MS || 60000);
|
||||
const SERVER_VERSION = '1.0.0';
|
||||
|
||||
const PROVIDERS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
|
||||
const TRANSCRIPT_PROVIDERS = ['youtube', 'yt', 'dailymotion', 'dm', 'peertube', 'pt', 'twitch', 'tw', 'odysee', 'od', 'rumble', 'ru'];
|
||||
|
||||
// ---------------------------------------------------------------- HTTP helper
|
||||
|
||||
async function apiFetch(path, { method = 'GET', body, auth = false } = {}) {
|
||||
const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const headers = { Accept: 'application/json', 'Content-Type': 'application/json' };
|
||||
if (auth && TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
signal: ctrl.signal,
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try { data = text ? JSON.parse(text) : null; }
|
||||
catch { data = { _raw: text.slice(0, 2000) }; }
|
||||
if (!res.ok) {
|
||||
const msg = data?.error || data?.message || `http_${res.status}`;
|
||||
const details = data?.details ? ` — ${String(data.details).slice(0, 300)}` : '';
|
||||
throw new Error(`${msg}${details} (HTTP ${res.status} ${method} ${path})`);
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
if (e?.name === 'AbortError') throw new Error(`timeout_after_${TIMEOUT_MS}ms (${method} ${path}) — API démarrée ? npm run api`);
|
||||
if (e?.cause?.code === 'ECONNREFUSED') throw new Error(`API injoignable à ${API_BASE} — lancez 'npm run api' d'abord`);
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTs(sec) {
|
||||
const s = Math.max(0, Number(sec || 0));
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = Math.floor(s % 60);
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function transcriptToText(data, { withTimestamps = true, maxChars = 12000 } = {}) {
|
||||
const lines = Array.isArray(data?.lines) ? data.lines : [];
|
||||
let out = lines.map((l) => (withTimestamps ? `[${fmtTs(l.t)}] ${l.text}` : String(l.text || ''))).join('\n');
|
||||
if (out.length > maxChars) out = out.slice(0, maxChars) + '\n…[tronqué]';
|
||||
return out;
|
||||
}
|
||||
|
||||
function transcriptToSrt(data) {
|
||||
const lines = Array.isArray(data?.lines) ? data.lines : [];
|
||||
const fmt = (s) => {
|
||||
const ms = Math.round(Number(s || 0) * 1000);
|
||||
const h = String(Math.floor(ms / 3600000)).padStart(2, '0');
|
||||
const m = String(Math.floor((ms % 3600000) / 60000)).padStart(2, '0');
|
||||
const sec = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
|
||||
const milli = String(ms % 1000).padStart(3, '0');
|
||||
return `${h}:${m}:${sec},${milli}`;
|
||||
};
|
||||
return lines.map((l, i) => `${i + 1}\n${fmt(l.t)} --> ${fmt(Number(l.t) + Number(l.dur || 2))}\n${l.text}\n`).join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Tools
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'search_videos',
|
||||
description: 'Recherche unifiée multi-providers (YouTube, Dailymotion, Twitch, PeerTube, Odysee, Rumble). q min 2 caractères.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', description: 'Requête (min 2 caractères)' },
|
||||
providers: { type: 'string', description: 'CSV parmi yt,dm,tw,pt,od,ru. Défaut: tous.' },
|
||||
page: { type: 'integer', minimum: 1, default: 1 },
|
||||
pageSize: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
|
||||
sort: { type: 'string', enum: ['relevance', 'date', 'views'], default: 'relevance' },
|
||||
},
|
||||
required: ['q'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'suggest_queries',
|
||||
description: 'Typeahead de requêtes groupées par provider. Idéal avant search_videos.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', description: 'Début de requête (min 2 caractères)' },
|
||||
providers: { type: 'string', description: 'CSV providers, défaut tous' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 20, default: 10 },
|
||||
},
|
||||
required: ['q'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_video_details',
|
||||
description: "Métadonnées d'une vidéo + vidéos connexes YouTube (watch-next InnerTube).",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
provider: { type: 'string', description: 'youtube|dailymotion|twitch|peertube|odysee|rumble (alias yt,dm,tw,pt,od,ru acceptés)' },
|
||||
videoId: { type: 'string' },
|
||||
instance: { type: 'string', description: 'Requis pour PeerTube (ex. peertube.example.com)' },
|
||||
slug: { type: 'string', description: 'Slug Odysee si différent de videoId' },
|
||||
sourceUrl: { type: 'string', description: "URL canonique (prioritaire sur la reconstruction)" },
|
||||
related: { type: 'boolean', default: true, description: 'Inclure related[] YouTube (related=0 pour désactiver)' },
|
||||
},
|
||||
required: ['provider', 'videoId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_trending',
|
||||
description: 'Tendances YouTube sans clé API (scrape InnerTube, phase 1 : provider yt uniquement).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { provider: { type: 'string', default: 'yt' }, limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_transcript',
|
||||
description: "Transcript brut JSON d'une vidéo : { lang, available, languages, lines: [{t,dur,text}] }. Langues filtrées par préférences serveur (défaut fr,en). 200 available:false si absent, 502 retryable:true si YouTube rate-limite.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
provider: { type: 'string', description: TRANSCRIPT_PROVIDERS.join(' | ') },
|
||||
videoId: { type: 'string' },
|
||||
lang: { type: 'string', default: 'fr', description: "Langue préférée (ex. fr, en, fr-CA). Fallback auto + traduction serveur &tlang" },
|
||||
instance: { type: 'string', description: 'Instance PeerTube si provider peertube' },
|
||||
slug: { type: 'string', description: 'Slug Odysee' },
|
||||
sourceUrl: { type: 'string', description: 'URL canonique (prioritaire)' },
|
||||
},
|
||||
required: ['provider', 'videoId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_transcript_text',
|
||||
description: "Transcript mis en forme pour LLM : texte plein horodaté (ou SRT), tronqué proprement. Recommandé pour résumer/analyser une vidéo.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
provider: { type: 'string' },
|
||||
videoId: { type: 'string' },
|
||||
lang: { type: 'string', default: 'fr' },
|
||||
instance: { type: 'string' },
|
||||
slug: { type: 'string' },
|
||||
sourceUrl: { type: 'string' },
|
||||
format: { type: 'string', enum: ['text', 'srt'], default: 'text' },
|
||||
withTimestamps: { type: 'boolean', default: true },
|
||||
maxChars: { type: 'integer', minimum: 500, maximum: 60000, default: 12000 },
|
||||
},
|
||||
required: ['provider', 'videoId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'health_check',
|
||||
description: "Santé API : mode YouTube (YT_SEARCH_MODE), binaire yt-dlp, cache, quota jour, clés. Équivalent GET /healthz.",
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
];
|
||||
|
||||
async function callTool(name, args = {}) {
|
||||
switch (name) {
|
||||
case 'search_videos': {
|
||||
const q = String(args.q || '').trim();
|
||||
if (q.length < 2) throw new Error('invalid_query: q min 2 caractères');
|
||||
const qs = new URLSearchParams({
|
||||
q,
|
||||
...(args.providers ? { providers: String(args.providers) } : {}),
|
||||
page: String(args.page ?? 1),
|
||||
pageSize: String(args.pageSize ?? 10),
|
||||
sort: String(args.sort ?? 'relevance'),
|
||||
});
|
||||
const data = await apiFetch(`/search?${qs}`);
|
||||
const counts = Object.fromEntries(Object.entries(data.groups || {}).map(([k, v]) => [k, v.length]));
|
||||
return { summary: `${data.groups ? Object.values(data.groups).flat().length : 0} résultats pour "${q}" ${JSON.stringify(counts)}`, data };
|
||||
}
|
||||
case 'suggest_queries': {
|
||||
const q = String(args.q || '').trim();
|
||||
if (q.length < 2) throw new Error('invalid_query: q min 2 caractères');
|
||||
const qs = new URLSearchParams({ q, ...(args.providers ? { providers: String(args.providers) } : {}), limit: String(args.limit ?? 10) });
|
||||
return { summary: `Suggestions pour "${q}"`, data: await apiFetch(`/search/suggest?${qs}`) };
|
||||
}
|
||||
case 'get_video_details': {
|
||||
const provider = String(args.provider || '').toLowerCase();
|
||||
const videoId = String(args.videoId || '');
|
||||
if (!provider || !videoId) throw new Error('provider et videoId requis');
|
||||
const qs = new URLSearchParams({
|
||||
...(args.instance ? { instance: String(args.instance) } : {}),
|
||||
...(args.slug ? { slug: String(args.slug) } : {}),
|
||||
...(args.sourceUrl ? { sourceUrl: String(args.sourceUrl) } : {}),
|
||||
...(args.related === false ? { related: '0' } : {}),
|
||||
});
|
||||
const qstr = qs.toString() ? `?${qs}` : '';
|
||||
const data = await apiFetch(`/details/${encodeURIComponent(provider)}/${encodeURIComponent(videoId)}${qstr}`);
|
||||
return { summary: `${data.title || videoId} — ${data.uploaderName || '?'} (${data.duration || 0}s, ${data.views || 0} vues)`, data };
|
||||
}
|
||||
case 'get_trending': {
|
||||
const provider = String(args.provider || 'yt');
|
||||
const limit = Math.min(50, Math.max(1, Number(args.limit ?? 10)));
|
||||
const data = await apiFetch(`/trending?provider=${encodeURIComponent(provider)}&limit=${limit}`);
|
||||
return { summary: `${(data.items || []).length} tendances (${provider})`, data };
|
||||
}
|
||||
case 'get_transcript':
|
||||
case 'get_transcript_text': {
|
||||
const provider = String(args.provider || '').toLowerCase();
|
||||
const videoId = String(args.videoId || '');
|
||||
if (!provider || !videoId) throw new Error('provider et videoId requis');
|
||||
const qs = new URLSearchParams({
|
||||
...(args.lang ? { lang: String(args.lang) } : {}),
|
||||
...(args.instance ? { instance: String(args.instance) } : {}),
|
||||
...(args.slug ? { slug: String(args.slug) } : {}),
|
||||
...(args.sourceUrl ? { sourceUrl: String(args.sourceUrl) } : {}),
|
||||
});
|
||||
const qstr = qs.toString() ? `?${qs}` : '';
|
||||
const data = await apiFetch(`/transcript/${encodeURIComponent(provider)}/${encodeURIComponent(videoId)}${qstr}`);
|
||||
if (name === 'get_transcript') {
|
||||
const n = (data.lines || []).length;
|
||||
const summary = data.available
|
||||
? `Transcript ${data.lang} : ${n} lignes, langues=${(data.languages || []).join(',')}`
|
||||
: `Transcript indisponible (${data.reason || data.error || 'no_subtitles'}), langues=${(data.languages || []).join(',') || '—'}`;
|
||||
return { summary, data };
|
||||
}
|
||||
// text variant
|
||||
if (!data.available) {
|
||||
return { summary: `Transcript indisponible (${data.reason || data.error})`, data, text: '' };
|
||||
}
|
||||
const format = args.format === 'srt' ? 'srt' : 'text';
|
||||
const text = format === 'srt'
|
||||
? transcriptToSrt(data).slice(0, Number(args.maxChars ?? 12000))
|
||||
: transcriptToText(data, { withTimestamps: args.withTimestamps !== false, maxChars: Number(args.maxChars ?? 12000) });
|
||||
return { summary: `Transcript ${data.lang} (${(data.lines || []).length} lignes) en ${format}`, data, text };
|
||||
}
|
||||
case 'health_check': {
|
||||
const data = await apiFetch('/healthz');
|
||||
return { summary: `API ${data.status} — YT mode=${data.youtube?.mode}, yt-dlp=${data.youtube?.ytdlp?.version || 'n/a'}`, data };
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown_tool: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- JSON-RPC stdio
|
||||
|
||||
const readline = await import('node:readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
let initialized = false;
|
||||
|
||||
function send(obj) {
|
||||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
const ok = (id, result) => send({ jsonrpc: '2.0', id, result });
|
||||
const err = (id, code, message, data) => send({ jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } });
|
||||
|
||||
function mcpText(summary, data, extraText) {
|
||||
const payload = extraText ? `${summary}\n\n${extraText}\n\n--- JSON ---\n${JSON.stringify(data).slice(0, 8000)}`
|
||||
: `${summary}\n\n${JSON.stringify(data).slice(0, 8000)}`;
|
||||
return { content: [{ type: 'text', text: payload }] };
|
||||
}
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
if (!line.trim()) return;
|
||||
let msg;
|
||||
try { msg = JSON.parse(line); }
|
||||
catch { return; /* ignore */ }
|
||||
const { id, method, params } = msg;
|
||||
try {
|
||||
if (method === 'initialize') {
|
||||
ok(id, {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'newtube', version: SERVER_VERSION },
|
||||
});
|
||||
} else if (method === 'notifications/initialized') {
|
||||
initialized = true;
|
||||
// notification : pas de réponse
|
||||
} else if (method === 'ping') {
|
||||
ok(id, {});
|
||||
} else if (method === 'tools/list') {
|
||||
ok(id, { tools: TOOLS });
|
||||
} else if (method === 'tools/call') {
|
||||
const toolName = params?.name;
|
||||
const toolArgs = params?.arguments || {};
|
||||
if (!TOOLS.find((t) => t.name === toolName)) { err(id, -32602, `unknown_tool: ${toolName}`); return; }
|
||||
try {
|
||||
const { summary, data, text } = await callTool(toolName, toolArgs);
|
||||
ok(id, mcpText(summary, data, text));
|
||||
} catch (e) {
|
||||
ok(id, { content: [{ type: 'text', text: `Erreur ${toolName} : ${e.message}` }], isError: true });
|
||||
}
|
||||
} else {
|
||||
if (id !== undefined) err(id, -32601, `method_not_found: ${method}`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (id !== undefined) err(id, -32603, String(e?.message || e));
|
||||
}
|
||||
});
|
||||
|
||||
// Log de démarrage sur stderr (ne jamais polluer stdout = canal JSON-RPC)
|
||||
console.error(`[newtube-mcp] v${SERVER_VERSION} — API=${API_BASE} (stdio)`);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
function rpc(proc, obj) {
|
||||
return new Promise((resolve) => {
|
||||
const onData = (buf) => {
|
||||
const lines = String(buf).split('\n').filter(Boolean);
|
||||
for (const l of lines) {
|
||||
try {
|
||||
const msg = JSON.parse(l);
|
||||
if (msg.id === obj.id) {
|
||||
proc.stdout.off('data', onData);
|
||||
resolve(msg);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
proc.stdout.on('data', onData);
|
||||
proc.stdin.write(JSON.stringify(obj) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
describe('NewTube MCP server (stdio)', () => {
|
||||
it('initialize + tools/list expose 7 outils dont transcript', async () => {
|
||||
const proc = spawn(process.execPath, ['./mcp/server.mjs'], { env: { ...process.env, NEWTUBE_API_URL: 'http://127.0.0.1:9/api' } });
|
||||
try {
|
||||
const init = await rpc(proc, { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
||||
assert.equal(init.result.serverInfo.name, 'newtube');
|
||||
assert.ok(init.result.capabilities.tools);
|
||||
|
||||
const list = await rpc(proc, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
||||
const names = list.result.tools.map((t) => t.name);
|
||||
for (const expected of ['search_videos', 'get_video_details', 'get_trending', 'get_transcript', 'get_transcript_text', 'health_check']) {
|
||||
assert.ok(names.includes(expected), `outil manquant: ${expected}`);
|
||||
}
|
||||
const tr = list.result.tools.find((t) => t.name === 'get_transcript');
|
||||
assert.ok(tr.inputSchema.properties.provider);
|
||||
assert.ok(tr.inputSchema.properties.videoId);
|
||||
|
||||
const ping = await rpc(proc, { jsonrpc: '2.0', id: 3, method: 'ping', params: {} });
|
||||
assert.deepEqual(ping.result, {});
|
||||
} finally {
|
||||
proc.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it('validation locale sans API (q trop court -> isError)', async () => {
|
||||
const proc = spawn(process.execPath, ['./mcp/server.mjs'], { env: { ...process.env, NEWTUBE_API_URL: 'http://127.0.0.1:9/api' } });
|
||||
try {
|
||||
const call = await rpc(proc, {
|
||||
jsonrpc: '2.0', id: 10, method: 'tools/call',
|
||||
params: { name: 'search_videos', arguments: { q: 'x' } },
|
||||
});
|
||||
assert.equal(call.result.isError, true);
|
||||
assert.match(call.result.content[0].text, /min 2/);
|
||||
} finally {
|
||||
proc.kill();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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' });
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user