Files
media-center/ref/netdisk-mcp-server/src/index.ts
T
sutong 750f981c7e feat: init media-center skill
资源中心——从多渠道获取资源链接,转存到夸克网盘并整理归档。
- sources/tencent-doc: 腾讯文档读取
- sources/search: 网盘搜索
- storage/quark: 夸克网盘操作
- ref/: 来源 skill 参考归档

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-16 18:28:23 +08:00

300 lines
11 KiB
JavaScript

#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { loadConfig } from './config.js';
import { NetdiskClient } from './client.js';
import { PansouClient } from './pansou.js';
import { OfflineDownloader } from './offline.js';
const config = loadConfig();
const client = new NetdiskClient(config);
const pansou = config.pansouUrl ? new PansouClient(config.pansouUrl) : null;
const downloader = new OfflineDownloader(config);
const server = new McpServer({
name: 'netdisk-mcp-server',
version: '3.0.0',
});
// ── Tool: list ──
server.tool(
'list',
[
'List files and folders in your Quark or 115 cloud drive.',
'Returns numbered entries with [dir]/[file] type, name, size and ID.',
'',
'Examples:',
' list(cloud="quark", path="/") → list Quark root',
' list(cloud="115", path="/媒体库") → list 115 媒体库 folder',
'',
'The path is resolved internally — you never need to know folder IDs.',
].join('\n'),
{
cloud: z.enum(['quark', '115']).describe('"quark" for 夸克网盘, "115" for 115网盘'),
path: z.string().default('/').describe(
'Directory path. Use "/" for root. Sub-folders like "/3670" or "/媒体库/电视剧".'
),
},
async ({ cloud, path }) => {
try {
const lines = cloud === 'quark'
? await client.listQuark(path)
: await client.list115(path);
if (lines.length === 0) return { content: [{ type: 'text', text: 'Directory is empty or not found' }] };
return {
content: [{ type: 'text', text: `Listing ${cloud} drive: ${path}\n\n${lines.join('\n')}` }],
};
} catch (err: any) {
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
}
}
);
// ── Tool: view ──
server.tool(
'view',
[
'View the file listing of a Quark or 115 share link.',
'Returns file name, size, and the folder each file lives in.',
'',
'Supported link formats:',
' Quark: https://pan.quark.cn/s/<id> (optionally with ?pwd=<code>)',
' 115: https://115.com/s/<code> (optionally with ?password=<code>)',
' 115: https://115cdn.com/s/<code> (optionally with ?password=<code>)',
'',
'file_pattern uses glob-style matching:',
' * all files (default)',
' *.mp4 all MP4 files',
' *.mkv all MKV files',
' S01E01* files starting with "S01E01"',
' *2160p* files containing "2160p"',
' exact.mp4 match exact filename',
].join('\n'),
{
share_link: z.string().describe('Full share link URL from Quark or 115'),
file_pattern: z.string().default('*').describe(
'Glob pattern to filter by filename. Use "*" for all, "*.mp4" for videos, "S01E01*" for a specific episode, etc.'
),
},
async ({ share_link, file_pattern }) => {
try {
const lines = await client.viewShare(share_link, file_pattern);
return { content: [{ type: 'text', text: lines.join('\n') }] };
} catch (err: any) {
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
}
}
);
// ── Tool: transfer ──
server.tool(
'transfer',
[
'Transfer files from a Quark or 115 share link into your own cloud drive.',
'Uses CP-like path patterns: the last segment of source_pattern can contain wildcards.',
'',
'source_pattern rules:',
' / → all files in root of the share',
' /Season 1 → all files in "Season 1" folder',
' /Season 1/*.mp4 → only .mp4 files in "Season 1"',
' /Season 1/S01E01* → files starting with "S01E01" in "Season 1"',
' /folder/subfolder/*.mkv → .mkv files in a nested folder',
'',
'target_path is a path in YOUR drive (not the share). Examples: "/3670", "/媒体库/电视剧"',
'',
'Workflow: search → view → transfer',
' 1. search("流浪地球", cloud_types=["quark"]) to find share links',
' 2. view(share_link, "*.mp4") to see what files are available',
' 3. transfer(share_link, "/Season 1/*.mp4", "/3670") to save them',
'',
'Note: 115 transfers may have a delay before files appear in the target folder.',
].join('\n'),
{
share_link: z.string().describe('Full share link URL from Quark or 115'),
source_pattern: z.string().describe(
'Path pattern inside the share. "/" = all files. The last segment supports wildcards: "/Season 1/*.mp4"'
),
target_path: z.string().describe(
'Destination path in YOUR cloud drive, e.g. "/3670", "/媒体库/电视剧". Path is resolved internally.'
),
},
async ({ share_link, source_pattern, target_path }) => {
try {
const result = await client.transferRecursive(share_link, source_pattern, target_path);
return { content: [{ type: 'text', text: result }] };
} catch (err: any) {
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
}
}
);
// ── Tool: offline_download ──
server.tool(
'offline_download',
[
'Add magnet link download tasks to 115 cloud drive.',
'115 will download the files server-side — no local bandwidth needed.',
'',
'After adding the task, check progress in the 115 app "云下载" page.',
'Downloaded files appear in the target_path directory.',
'',
'Typical workflow:',
' 1. search("电影名", cloud_types=["magnet"]) to find magnet links',
' 2. offline_download(magnet_links=[...], target_path="/媒体库/云下载电影")',
'',
'Note: 115 has offline download quota limits. Check 115 app for current limits.',
].join('\n'),
{
magnet_links: z.array(z.string()).describe(
'Array of magnet links, e.g. ["magnet:?xt=urn:btih:abc123...", ...]'
),
target_path: z.string().default('/').describe(
'Target directory path in your 115 drive, e.g. "/媒体库/云下载电影"'
),
},
async ({ magnet_links, target_path }) => {
try {
const result = await downloader.download(magnet_links, target_path);
return { content: [{ type: 'text', text: result }] };
} catch (err: any) {
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
}
}
);
// ── Tool: search ──
server.tool(
'search',
[
'Search for movies, TV shows and resources across 12+ cloud storage platforms.',
'Returns share links (and optionally magnet links) grouped by cloud type.',
'',
'Results include: title, share URL, password (if any), date, and source.',
'',
'cloud_types filter:',
' quark 夸克网盘 baidu 百度网盘 aliyun 阿里云盘',
' 115 115网盘 pikpak PikPak xunlei 迅雷网盘',
' tianyi 天翼云盘 uc UC网盘 123 123网盘',
' magnet 磁力链接 ed2k eD2K链接 mobile 移动云盘',
'',
'Examples:',
' search(query="肖申克的救赎")',
' search(query="权力的游戏", cloud_types=["quark", "115"])',
' search(query="电影", cloud_types=["magnet"])',
' search(query="电视剧", include=["合集"], exclude=["预告", "花絮"])',
].join('\n'),
{
query: z.string().describe('Search keyword — movie name, TV show name, or resource title'),
cloud_types: z.array(z.string()).optional().describe(
'Filter results to specific cloud platforms, e.g. ["quark", "magnet"]. Omit to search all.'
),
source: z.enum(['all', 'tg', 'plugin']).default('all').describe(
'"all" = all sources, "tg" = Telegram channels only, "plugin" = search plugins only'
),
include: z.array(z.string()).optional().describe(
'Only show results whose title contains ALL of these keywords, e.g. ["合集", "全集"]'
),
exclude: z.array(z.string()).optional().describe(
'Hide results whose title contains any of these keywords, e.g. ["预告", "花絮"]'
),
refresh: z.boolean().default(false).describe('Set true to bypass cache and fetch fresh results'),
},
async ({ query, cloud_types, source, include, exclude, refresh }) => {
if (!pansou) {
return { content: [{ type: 'text', text: 'Error: PANSOU_URL environment variable is not set' }], isError: true };
}
try {
const result = await pansou.search({ query, cloudTypes: cloud_types, source, include, exclude, refresh });
if (result.total === 0) {
return { content: [{ type: 'text', text: `No results found for "${query}"` }] };
}
const lines: string[] = [`Found ${result.total} results for "${query}":`, ''];
for (const [type, items] of Object.entries(result.merged_by_type)) {
lines.push(`=== ${type} (${items.length}) ===`);
for (const item of items) {
lines.push(` ${item.note}`);
lines.push(` Link: ${item.url}`);
if (item.password) lines.push(` Password: ${item.password}`);
lines.push(` Date: ${item.datetime?.split('T')[0] || 'N/A'} | Source: ${item.source}`);
}
lines.push('');
}
return { content: [{ type: 'text', text: lines.join('\n') }] };
} catch (err: any) {
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
}
}
);
// ── Tool: health ──
server.tool(
'health',
[
'Check connectivity and validity of all configured services:',
' - Quark cookie: attempts a lightweight API call to list Quark root',
' - 115 cookie: attempts a lightweight API call to list 115 root',
' - PanSou API: checks /api/health and lists available search plugins',
'',
'Use this to diagnose which services are working and which need attention.',
'Each check runs independently — partial failures are reported, not fatal.',
].join('\n'),
{},
async () => {
const lines: string[] = ['=== Health Check ===', ''];
// Check Quark
if (config.quarkCookie) {
const quark = await client.checkQuarkCookie();
lines.push(quark.ok ? `✅ Quark: ${quark.message}` : `❌ Quark: ${quark.message}`);
} else {
lines.push('⏭️ Quark: not configured (NETDISK_QUARK_COOKIE not set)');
}
// Check 115
if (config.cookie115) {
const c115 = await client.check115Cookie();
lines.push(c115.ok ? `✅ 115: ${c115.message}` : `❌ 115: ${c115.message}`);
} else {
lines.push('⏭️ 115: not configured (NETDISK_115_COOKIE not set)');
}
// Check PanSou
if (pansou) {
try {
const data = await pansou.health();
lines.push(`✅ PanSou: status ${data.status}`);
if (data.plugins?.length) {
lines.push(` Plugins (${data.plugins.length}): ${data.plugins.join(', ')}`);
}
} catch (err: any) {
lines.push(`❌ PanSou: ${err.message}`);
}
} else {
lines.push('⏭️ PanSou: not configured (PANSOU_URL not set)');
}
const hasError = lines.some(l => l.startsWith('❌'));
return { content: [{ type: 'text', text: lines.join('\n') }], isError: hasError };
}
);
// ── Start ──
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('netdisk-mcp-server started');
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});