feat(mcp): add MCP server with whitelist, archive, and metadata tools

This commit is contained in:
localhost 2026-08-20 17:40:06 +02:00
parent 86e2dc41f3
commit eb535c3405
7 changed files with 496 additions and 4 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -5,7 +5,8 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "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": { "devDependencies": {
"@types/bun": "^1.3.1" "@types/bun": "^1.3.1"
@ -15,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@elysiajs/static": "^1.4.0", "@elysiajs/static": "^1.4.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/html-minifier-next": "^2.1.0", "@types/html-minifier-next": "^2.1.0",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",

View File

@ -7,6 +7,7 @@ import transparency from '@/router/transparency'
import video from '@/router/video' import video from '@/router/video'
import websocket from '@/router/websocket' import websocket from '@/router/websocket'
import html from '@/router/html' import html from '@/router/html'
import mcp from '@/router/mcp'
const app = new Elysia() const app = new Elysia()
app.use(latest) app.use(latest)
@ -15,8 +16,9 @@ app.use(transparency)
app.use(video) app.use(video)
app.use(websocket) app.use(websocket)
app.use(html) app.use(html)
app.onRequest(({ set, url }: any) => { app.use(mcp)
set.headers['Onion-Location'] = 'http://tubey5btlzxkcjpxpj2c7irrbhvgu3noouobndafuhbw4i5ndvn4v7qd.onion/' + url.split('/').at(-1) app.onRequest(({ set, request }: { set: { headers: Record<string, string> }, request: Request }) => {
set.headers['Onion-Location'] = 'http://tubey5btlzxkcjpxpj2c7irrbhvgu3noouobndafuhbw4i5ndvn4v7qd.onion/' + request.url.split('/').at(-1)
}) })
process.on('uncaughtException', err => { process.on('uncaughtException', err => {

115
src/mcp.ts Normal file
View File

@ -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<string, unknown>).videoId || (args as Record<string, unknown>).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)

177
src/router/mcp.ts Normal file
View File

@ -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<string, string | undefined>): 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<string, unknown>)?.videoId || (args as Record<string, unknown>)?.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<Response> {
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<string, string | undefined>)) {
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 }

196
src/utils/archive.ts Normal file
View File

@ -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 }

View File

@ -22,6 +22,6 @@ setInterval(async () => {
console.log(`deleted file ${f} because there is no active download of it`) console.log(`deleted file ${f} because there is no active download of it`)
} }
}) })
}, 5 * 60000) }, 5 * 60000).unref()
export default redis export default redis