From eb535c340567b19ebacef249e186ac577be89d13 Mon Sep 17 00:00:00 2001 From: localhost Date: Thu, 20 Aug 2026 17:40:06 +0200 Subject: [PATCH] feat(mcp): add MCP server with whitelist, archive, and metadata tools --- bun.lockb | Bin 54676 -> 89927 bytes package.json | 4 +- src/index.ts | 6 +- src/mcp.ts | 115 +++++++++++++++++++++++++ src/router/mcp.ts | 177 ++++++++++++++++++++++++++++++++++++++ src/utils/archive.ts | 196 +++++++++++++++++++++++++++++++++++++++++++ src/utils/redis.ts | 2 +- 7 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 src/mcp.ts create mode 100644 src/router/mcp.ts create mode 100644 src/utils/archive.ts diff --git a/bun.lockb b/bun.lockb index 2853a9b6a6f58b6a8c0a5b6950e31efa2df92f08..f04e7de55428941f93071d1eb86b356d39d74bf7 100755 GIT binary patch delta 28278 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 delta 6965 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 diff --git a/package.json b/package.json index a514ea5..a2285ec 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "license": "AGPL-3.0", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "bun run --watch src/index.ts" + "dev": "bun run --watch src/index.ts", + "mcp": "bun run src/mcp.ts" }, "devDependencies": { "@types/bun": "^1.3.1" @@ -15,6 +16,7 @@ }, "dependencies": { "@elysiajs/static": "^1.4.0", + "@modelcontextprotocol/sdk": "^1.30.0", "@types/crypto-js": "^4.2.2", "@types/html-minifier-next": "^2.1.0", "@types/js-yaml": "^4.0.9", diff --git a/src/index.ts b/src/index.ts index 3f05452..1075c8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import transparency from '@/router/transparency' import video from '@/router/video' import websocket from '@/router/websocket' import html from '@/router/html' +import mcp from '@/router/mcp' const app = new Elysia() app.use(latest) @@ -15,8 +16,9 @@ app.use(transparency) app.use(video) app.use(websocket) app.use(html) -app.onRequest(({ set, url }: any) => { - set.headers['Onion-Location'] = 'http://tubey5btlzxkcjpxpj2c7irrbhvgu3noouobndafuhbw4i5ndvn4v7qd.onion/' + url.split('/').at(-1) +app.use(mcp) +app.onRequest(({ set, request }: { set: { headers: Record }, request: Request }) => { + set.headers['Onion-Location'] = 'http://tubey5btlzxkcjpxpj2c7irrbhvgu3noouobndafuhbw4i5ndvn4v7qd.onion/' + request.url.split('/').at(-1) }) process.on('uncaughtException', err => { diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..33d26cc --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,115 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { addToSizeWhitelist, archiveVideo, getVideoMetadata } from '@/utils/archive' + +const server = new Server( + { + name: 'preservetube-mcp', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } +) + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'add_to_size_whitelist', + description: 'Add a YouTube video ID to the size whitelist in preservetube-metadata config.json to bypass max video size limits.', + inputSchema: { + type: 'object', + properties: { + videoId: { + type: 'string', + description: 'YouTube video ID (11 characters) or full YouTube URL' + } + }, + required: ['videoId'] + } + }, + { + name: 'archive_video', + description: 'Archive a YouTube video by ID or URL on PreserveTube.', + inputSchema: { + type: 'object', + properties: { + videoId: { + type: 'string', + description: 'YouTube video ID (11 characters) or full YouTube URL' + } + }, + required: ['videoId'] + } + }, + { + name: 'get_video_metadata', + description: 'Fetch video metadata including title, duration/length, channel, publish date, and preserved database record if archived.', + inputSchema: { + type: 'object', + properties: { + videoId: { + type: 'string', + description: 'YouTube video ID (11 characters) or full YouTube URL' + } + }, + required: ['videoId'] + } + } + ] +})) + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + const inputArg = args && typeof args === 'object' && ('videoId' in args || 'url' in args) + ? String((args as Record).videoId || (args as Record).url || '') + : '' + + if (name === 'add_to_size_whitelist') { + const result = await addToSizeWhitelist(inputArg) + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ] + } + } + + if (name === 'archive_video') { + const result = await archiveVideo(inputArg) + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ] + } + } + + if (name === 'get_video_metadata') { + const result = await getVideoMetadata(inputArg) + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ] + } + } + + throw new Error(`Unknown tool: ${name}`) +}) + +async function run() { + const transport = new StdioServerTransport() + await server.connect(transport) +} + +run().catch(console.error) diff --git a/src/router/mcp.ts b/src/router/mcp.ts new file mode 100644 index 0000000..7b8b9b1 --- /dev/null +++ b/src/router/mcp.ts @@ -0,0 +1,177 @@ +import { Elysia, t } from 'elysia' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { addToSizeWhitelist, archiveVideo, getVideoMetadata } from '@/utils/archive' + +function isAuthorized(headers: Record): boolean { + const authHeader = headers['authorization'] || headers['Authorization'] + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : authHeader + const secret = process.env.MCP_SECRET || process.env.MCP_KEY + + if (!secret || !token) return false + return token === secret +} + +function createMcpServer() { + const server = new Server( + { name: 'preservetube-mcp', version: '1.0.0' }, + { capabilities: { tools: {} } } + ) + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'add_to_size_whitelist', + description: 'Add a YouTube video ID to the size whitelist in preservetube-metadata config.json', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + }, + { + name: 'archive_video', + description: 'Archive a YouTube video on PreserveTube by video ID or URL', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + }, + { + name: 'get_video_metadata', + description: 'Fetch video metadata including title, duration/length, channel, publish date, and preserved database record if archived.', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + } + ] + })) + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params + const inputArg = String((args as Record)?.videoId || (args as Record)?.url || '') + + if (name === 'add_to_size_whitelist') { + const result = await addToSizeWhitelist(inputArg) + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } + } + + if (name === 'archive_video') { + const result = await archiveVideo(inputArg) + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } + } + + if (name === 'get_video_metadata') { + const result = await getVideoMetadata(inputArg) + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } + } + + throw new Error(`Unknown tool: ${name}`) + }) + + return server +} + +async function handleMcpStreamRequest(request: Request): Promise { + const transport = new WebStandardStreamableHTTPServerTransport() + const mcpServer = createMcpServer() + await mcpServer.connect(transport) + return await transport.handleRequest(request) +} + +const app = new Elysia({ prefix: '/api/mcp' }) + +app.onBeforeHandle(({ headers, set }) => { + if (!isAuthorized(headers as Record)) { + set.status = 401 + return { success: false, message: 'Unauthorized: Invalid or missing Bearer authorization token.' } + } +}) + +app.get('/tools', () => { + return { + tools: [ + { + name: 'add_to_size_whitelist', + description: 'Add a YouTube video ID to the size whitelist in preservetube-metadata config.json', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + }, + { + name: 'archive_video', + description: 'Archive a YouTube video on PreserveTube by video ID or URL', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + }, + { + name: 'get_video_metadata', + description: 'Fetch video metadata including title, duration/length, channel, publish date, and preserved database record if archived.', + inputSchema: { + type: 'object', + properties: { + videoId: { type: 'string', description: 'YouTube video ID or URL' } + }, + required: ['videoId'] + } + } + ] + } +}) + +app.post('/call', async ({ body }: { body: { name: string, arguments?: { videoId?: string, url?: string } } }) => { + const { name, arguments: args } = body + const inputArg = String(args?.videoId || args?.url || '') + + if (name === 'add_to_size_whitelist') { + return await addToSizeWhitelist(inputArg) + } + + if (name === 'archive_video') { + return await archiveVideo(inputArg) + } + + if (name === 'get_video_metadata') { + return await getVideoMetadata(inputArg) + } + + return { success: false, message: `Unknown tool: ${name}` } +}, { + body: t.Object({ + name: t.String(), + arguments: t.Optional(t.Object({ + videoId: t.Optional(t.String()), + url: t.Optional(t.String()) + })) + }) +}) + +app.all('/', async ({ request }) => { + return await handleMcpStreamRequest(request) +}) + +app.all('/*', async ({ request }) => { + return await handleMcpStreamRequest(request) +}) + +export default app +export { isAuthorized } diff --git a/src/utils/archive.ts b/src/utils/archive.ts new file mode 100644 index 0000000..b94f041 --- /dev/null +++ b/src/utils/archive.ts @@ -0,0 +1,196 @@ +import * as fs from 'node:fs' +import { db } from '@/utils/database' +import { validateVideo } from '@/utils/regex' +import { createDatabaseVideo } from '@/utils/common' +import { downloadVideo } from '@/utils/download' +import { uploadVideo } from '@/utils/upload' +import { getChannel, getVideo } from '@/utils/metadata' +import redis from '@/utils/redis' + +function extractVideoId(input: string): string | null { + if (!input) return null + const trimmed = input.trim() + if (/^[\w\-_]{11}$/.test(trimmed)) return trimmed + const validated = validateVideo(trimmed) + if (validated && /^[\w\-_]{11}$/.test(validated)) return validated + const match = trimmed.match(/[\w\-_]{11}/) + return match ? match[0] : null +} + +async function archiveVideo(input: string) { + const videoId = extractVideoId(input) + if (!videoId) { + return { success: false, message: 'Invalid video URL or ID.' } + } + + const existing = await db.selectFrom('videos') + .select(['id', 'deletion_stage', 'title']) + .where('id', '=', videoId) + .executeTakeFirst() + + if (existing) { + if (existing.deletion_stage !== null) { + await db.updateTable('videos') + .set({ deletion_stage: null }) + .where('id', '=', videoId) + .execute() + + await redis.del(`watch:${videoId}:html`) + await redis.del('deletion:html') + + return { + success: true, + message: `Video '${existing.title || videoId}' (${videoId}) restored from deletion stage '${existing.deletion_stage}'.`, + videoId + } + } + + return { + success: true, + message: `Video '${existing.title || videoId}' (${videoId}) is already archived.`, + videoId + } + } + + if (await redis.get(`blacklist:${videoId}`)) { + return { success: false, message: 'This video is blacklisted.' } + } + + if (await redis.get(`save:${videoId}`)) { + return { success: false, message: 'Someone is currently archiving or downloading this video.' } + } + + await redis.set(`save:${videoId}`, 'downloading', 'EX', 300) + + try { + const data = await getVideo(videoId) + if (data.error) { + return { success: false, message: `Unable to retrieve video info from YouTube: ${data.error}` } + } + + const channelData = await getChannel(data.videoDetails.channelId) + if (channelData.error) { + return { success: false, message: `Unable to retrieve channel info from YouTube: ${channelData.error}` } + } + + const wsMock = { + send: (msg: string) => console.log(`[Archive ${videoId}] ${msg}`) + } + + const downloadResult = await downloadVideo(wsMock, videoId) + if (downloadResult.fail) { + return { success: false, message: `Download failed: ${downloadResult.message}` } + } + + let filePath = fs.readdirSync('./videos/').find(f => f.includes(`${videoId}.`)) + if (!filePath) { + return { success: false, message: `Downloaded video file for ${videoId} not found.` } + } + + filePath = './videos/' + filePath + const videoUrl = await uploadVideo(filePath) + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) + + const uploaded = await createDatabaseVideo(videoId, videoUrl, { data, channelData }) + if (uploaded !== 'success') { + return { success: false, message: `Failed to create database record: ${JSON.stringify(uploaded)}` } + } + + return { + success: true, + message: `Successfully archived video '${data.videoDetails.title}' (${videoId}).`, + videoId, + title: data.videoDetails.title, + watchUrl: `https://preservetube.com/watch?v=${videoId}` + } + } catch (error: unknown) { + const err = error as Error + return { success: false, message: `Archiving failed: ${err.message}` } + } finally { + await redis.del(`save:${videoId}`) + } +} + +async function addToSizeWhitelist(input: string) { + const videoId = extractVideoId(input) + if (!videoId) { + return { success: false, message: 'Invalid video URL or ID.' } + } + + const servers = [process.env.METADATA, process.env.ALTERNATIVE_METADATA].filter(Boolean) as string[] + + const results = await Promise.all(servers.map(async (serverHost) => { + try { + const res = await fetch(`${serverHost}/whitelist`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: videoId }) + }) + + if (!res.ok) { + return { server: serverHost, success: false, message: `Metadata service returned status ${res.status}: ${await res.text()}` } + } + + const data = await res.json() as { success: boolean, message: string, whitelist?: string[] } + return { server: serverHost, ...data } + } catch (error: unknown) { + const err = error as Error + return { server: serverHost, success: false, message: `Failed to connect: ${err.message}` } + } + })) + + const allSuccessful = results.length > 0 && results.every(r => r.success) + return { + success: allSuccessful, + message: allSuccessful + ? `Successfully added video ${videoId} to size whitelist on all metadata servers (${results.map(r => r.server).join(', ')}).` + : `Whitelisting video ${videoId} completed with issues on some servers: ${JSON.stringify(results)}`, + results + } +} + +async function getVideoMetadata(input: string) { + const videoId = extractVideoId(input) + if (!videoId) { + return { success: false, message: 'Invalid video URL or ID.' } + } + + const dbVideo = await db.selectFrom('videos') + .selectAll() + .where('id', '=', videoId) + .executeTakeFirst() + + let fileInfo = null + if (dbVideo && dbVideo.deletion_stage === 'cold_storage') { + fileInfo = await db.selectFrom('files') + .selectAll() + .where('videoId', '=', videoId) + .executeTakeFirst() || null + } + + const metadata = await getVideo(videoId) + const ytDetails = metadata && !metadata.error ? { + title: metadata.videoDetails?.title, + lengthSeconds: metadata.videoDetails?.lengthSeconds ? parseInt(metadata.videoDetails.lengthSeconds, 10) : null, + channel: metadata.videoDetails?.author, + channelId: metadata.videoDetails?.channelId, + viewCount: metadata.videoDetails?.viewCount, + isLive: metadata.videoDetails?.isLive || false, + published: metadata.microformat?.playerMicroformatRenderer?.publishDate?.slice(0, 10) || null, + description: metadata.microformat?.playerMicroformatRenderer?.description?.simpleText || null + } : null + + return { + success: true, + videoId, + isArchived: Boolean(dbVideo), + watchUrl: `https://preservetube.com/watch?v=${videoId}`, + databaseRecord: dbVideo ? { + ...dbVideo, + coldStorageFile: fileInfo + } : null, + youtubeMetadata: ytDetails + } +} + +export { archiveVideo, addToSizeWhitelist, getVideoMetadata, extractVideoId } diff --git a/src/utils/redis.ts b/src/utils/redis.ts index deb6fcd..191daf1 100644 --- a/src/utils/redis.ts +++ b/src/utils/redis.ts @@ -22,6 +22,6 @@ setInterval(async () => { console.log(`deleted file ${f} because there is no active download of it`) } }) -}, 5 * 60000) +}, 5 * 60000).unref() export default redis \ No newline at end of file