#!/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.1.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) avec filtres (type de contenu, durée, période de mise en ligne). 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' }, type: { type: 'string', enum: ['video', 'shorts', 'live', 'channel'], description: 'Type de contenu attendu' }, duration: { type: 'string', enum: ['short', 'medium', 'long'], description: 'Durée : court < 4 min, moyen 4-20 min, long > 20 min' }, period: { type: 'string', enum: ['hour', 'today', 'week', 'month', 'year'], description: 'Période de mise en ligne' }, }, 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: {} }, }, { name: 'list_playlists', description: "Playlists de l'utilisateur connecté (NEWTUBE_TOKEN requis). Équivalent GET /api/playlists.", inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 }, offset: { type: 'integer', minimum: 0, default: 0 }, q: { type: 'string', description: 'Recherche dans les titres' }, }, }, }, { name: 'list_likes', description: "Vidéos likées de l'utilisateur connecté (NEWTUBE_TOKEN requis). Équivalent GET /api/user/likes.", inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 500, default: 50 }, q: { type: 'string', description: 'Recherche serveur' }, }, }, }, { name: 'list_subscriptions', description: "Abonnements aux chaînes de l'utilisateur connecté (NEWTUBE_TOKEN requis). Équivalent GET /api/subscriptions.", inputSchema: { type: 'object', properties: {} }, }, { name: 'list_watch_history', description: "Historique de visionnage de l'utilisateur connecté (NEWTUBE_TOKEN requis). Équivalent GET /api/user/history/watch.", inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 } }, }, }, ]; function requireAuth() { if (!TOKEN) throw new Error('NEWTUBE_TOKEN manquant — connectez-vous (POST /api/auth/login) puis exportez NEWTUBE_TOKEN='); } 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'), // Filtres : ignorés côté API s'ils sont inconnus, donc sûrs à passer. ...(args.type ? { type: String(args.type) } : {}), ...(args.duration ? { duration: String(args.duration) } : {}), ...(args.period ? { period: String(args.period) } : {}), }); const data = await apiFetch(`/search?${qs}`); const counts = Object.fromEntries(Object.entries(data.groups || {}).map(([k, v]) => [k, v.length])); const filterHint = data.filters && Object.keys(data.filters).length ? ` [${JSON.stringify(data.filters)}]` : ''; return { summary: `${data.groups ? Object.values(data.groups).flat().length : 0} résultats pour "${q}" ${JSON.stringify(counts)}${filterHint}`, 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 }; } case 'list_playlists': { requireAuth(); const qs = new URLSearchParams({ limit: String(args.limit ?? 50), offset: String(args.offset ?? 0), ...(args.q ? { q: String(args.q) } : {}), }); const data = await apiFetch(`/playlists?${qs}`, { auth: true }); const n = Array.isArray(data) ? data.length : 0; return { summary: `${n} playlist(s)`, data }; } case 'list_likes': { requireAuth(); const qs = new URLSearchParams({ limit: String(args.limit ?? 50), ...(args.q ? { q: String(args.q) } : {}), }); const data = await apiFetch(`/user/likes?${qs}`, { auth: true }); const n = Array.isArray(data) ? data.length : 0; return { summary: `${n} vidéo(s) likée(s)`, data }; } case 'list_subscriptions': { requireAuth(); const data = await apiFetch('/subscriptions', { auth: true }); const n = Array.isArray(data?.items) ? data.items.length : 0; return { summary: `${n} abonnement(s)`, data }; } case 'list_watch_history': { requireAuth(); const qs = new URLSearchParams({ limit: String(args.limit ?? 50) }); const data = await apiFetch(`/user/history/watch?${qs}`, { auth: true }); const n = Array.isArray(data) ? data.length : 0; return { summary: `${n} entrée(s) d'historique`, 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)`);