feat(mcp): add MCP server with whitelist, archive, and metadata tools
This commit is contained in:
parent
86e2dc41f3
commit
eb535c3405
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, string> }, request: Request }) => {
|
||||
set.headers['Onion-Location'] = 'http://tubey5btlzxkcjpxpj2c7irrbhvgu3noouobndafuhbw4i5ndvn4v7qd.onion/' + request.url.split('/').at(-1)
|
||||
})
|
||||
|
||||
process.on('uncaughtException', err => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue