- tests: server/tests/api_coverage2.test.mjs (23 cas offline-safe) : oauth (status/url/connections/preview), channels (validations), subscriptions batch + groupes CRUD, transcripts history, likes/history DELETE, playlists public/view/reorder, telemetry summary, downloads 401/403/400, ai 503, twitch-token, config.js, rumble 400 - mcp v1.1.0 : 4 outils authentifies (list_playlists/likes/ subscriptions/watch_history, NEWTUBE_TOKEN) + tests stub - openapi.json v1.1 : 15 chemins documentes (sante, recherche, details, trending, transcript, auth, prefs, likes, subs, downloads, oauth, playlists) ; guide maj (11 outils)
This commit is contained in:
+74
-1
@@ -27,7 +27,7 @@
|
||||
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 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'];
|
||||
@@ -187,8 +187,48 @@ const TOOLS = [
|
||||
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=<accessToken>');
|
||||
}
|
||||
|
||||
async function callTool(name, args = {}) {
|
||||
switch (name) {
|
||||
case 'search_videos': {
|
||||
@@ -265,6 +305,39 @@ async function callTool(name, args = {}) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ function rpc(proc, obj) {
|
||||
}
|
||||
|
||||
describe('NewTube MCP server (stdio)', () => {
|
||||
it('initialize + tools/list expose 7 outils dont transcript', async () => {
|
||||
it('initialize + tools/list expose 11 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: {} });
|
||||
@@ -32,7 +32,7 @@ describe('NewTube MCP server (stdio)', () => {
|
||||
|
||||
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']) {
|
||||
for (const expected of ['search_videos', 'suggest_queries', 'get_video_details', 'get_trending', 'get_transcript', 'get_transcript_text', 'health_check', 'list_playlists', 'list_likes', 'list_subscriptions', 'list_watch_history']) {
|
||||
assert.ok(names.includes(expected), `outil manquant: ${expected}`);
|
||||
}
|
||||
const tr = list.result.tools.find((t) => t.name === 'get_transcript');
|
||||
|
||||
@@ -23,6 +23,10 @@ const stub = http.createServer((req, res) => {
|
||||
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));
|
||||
@@ -98,4 +102,27 @@ describe('MCP tools (stub API)', () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user