mcp-mpv-player 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +106 -0
  2. package/index.js +724 -0
  3. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # mpv-mcp
2
+
3
+ MCP server for controlling **mpv media player** on Windows via opencode (or any MCP client).
4
+
5
+ ## Features
6
+
7
+ | Tool | Description |
8
+ |------|-------------|
9
+ | `player_play` | Open file or URL (auto-starts mpv) |
10
+ | `player_pause` | Toggle pause/resume |
11
+ | `player_stop` | Stop playback |
12
+ | `player_next` | Next track |
13
+ | `player_prev` | Previous track |
14
+ | `player_seek` | Seek by seconds / absolute time / percent |
15
+ | `player_set_volume` | Set volume 0–130 |
16
+ | `player_set_speed` | Set speed (0.5x, 1x, 2x…) |
17
+ | `player_status` | Get current playback state |
18
+ | `playlist_create` | Create a new .m3u playlist |
19
+ | `playlist_add` | Append files to a playlist |
20
+ | `playlist_remove` | Remove an item by index |
21
+ | `playlist_load` | Load & play a saved playlist |
22
+ | `playlist_list` | List all playlists or inspect one |
23
+ | `playlist_delete` | Delete a playlist file |
24
+
25
+ Playlists are saved as `.m3u` files in `%USERPROFILE%\mpv-playlists\`.
26
+
27
+ ---
28
+
29
+ ## Requirements
30
+
31
+ - **Node.js** 18+
32
+ - **mpv** installed and in PATH
33
+ Download: https://mpv.io/installation/
34
+ Recommended: `winget install mpv` or `scoop install mpv`
35
+
36
+ ---
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ cd mpv-mcp
42
+ npm install
43
+ ```
44
+
45
+ ---
46
+
47
+ ## opencode Configuration
48
+
49
+ Add this to your opencode config (`~/.config/opencode/config.json` or similar):
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "mpv": {
55
+ "command": "node",
56
+ "args": ["C:/path/to/mpv-mcp/index.js"]
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ > **Tip:** If mpv is not in PATH, add an env var:
63
+ > ```json
64
+ > {
65
+ > "mcpServers": {
66
+ > "mpv": {
67
+ > "command": "node",
68
+ > "args": ["C:/path/to/mpv-mcp/index.js"],
69
+ > "env": {
70
+ > "MPV_PATH": "C:/tools/mpv/mpv.exe"
71
+ > }
72
+ > }
73
+ > }
74
+ > }
75
+ > ```
76
+
77
+ ---
78
+
79
+ ## How It Works
80
+
81
+ mpv exposes a **JSON IPC** interface via a Windows Named Pipe (`\\.\pipe\mpv-ipc`).
82
+
83
+ - If mpv is **not running** when you call `player_play`, the server auto-launches it with `--idle --keep-open` so it stays open between tracks.
84
+ - All other tools check the pipe first and return a helpful error if mpv isn't running.
85
+ - Playlist files are plain `.m3u` text files — you can edit them manually too.
86
+
87
+ ---
88
+
89
+ ## Usage Examples (via opencode)
90
+
91
+ ```
92
+ 播放一个文件:
93
+ "播放 D:/Music/song.mp3"
94
+
95
+ 快进30秒:
96
+ "快进30秒" → player_seek(30, relative)
97
+
98
+ 跳到第2分30秒:
99
+ "跳到2分30秒" → player_seek(150, absolute)
100
+
101
+ 创建播放列表:
102
+ "创建一个叫 jazz 的播放列表,包含 D:/Music/a.mp3 和 D:/Music/b.mp3"
103
+
104
+ 加载并播放播放列表:
105
+ "播放 jazz 播放列表"
106
+ ```
package/index.js ADDED
@@ -0,0 +1,724 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mpv-mcp: MCP server for controlling mpv media player on Windows
4
+ * Communication via Named Pipe IPC: \\.\pipe\mpv-ipc
5
+ */
6
+
7
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import {
10
+ CallToolRequestSchema,
11
+ ListToolsRequestSchema,
12
+ } from "@modelcontextprotocol/sdk/types.js";
13
+ import net from "net";
14
+ import { spawn, spawnSync } from "child_process";
15
+ import readline from "readline";
16
+ import fs from "fs";
17
+ import path from "path";
18
+ import os from "os";
19
+
20
+ // ── Config ────────────────────────────────────────────────────────────────────
21
+ const PIPE_PATH = "\\\\.\\pipe\\mpv-ipc";
22
+ const PLAYLIST_DIR = path.join(os.homedir(), "mpv-playlists");
23
+ const MPV_BINARY = process.env.MPV_PATH || "mpv"; // set MPV_PATH env if not in PATH
24
+
25
+ if (!fs.existsSync(PLAYLIST_DIR)) fs.mkdirSync(PLAYLIST_DIR, { recursive: true });
26
+
27
+
28
+ // ═════════════════════════════════════════════════════════════════════════════
29
+ // ── Install Wizard ────────────────────────────────────────────────────────────
30
+ // ═════════════════════════════════════════════════════════════════════════════
31
+ function detectMpvInPath() {
32
+ try {
33
+ const result = spawnSync("where.exe", ["mpv"], { encoding: "utf8" });
34
+ return result.status === 0 && result.stdout.trim().length > 0;
35
+ } catch { return false; }
36
+ }
37
+
38
+ function detectMpv() {
39
+ try {
40
+ const result = spawnSync("where.exe", ["mpv"], { encoding: "utf8" });
41
+ if (result.status === 0 && result.stdout.trim())
42
+ return result.stdout.trim().split("\n")[0].trim();
43
+ } catch {}
44
+ const candidates = [
45
+ "C:\\Program Files\\MPV Player\\mpv.exe",
46
+ "C:\\Program Files (x86)\\MPV Player\\mpv.exe",
47
+ path.join(os.homedir(), "scoop\\apps\\mpv\\current\\mpv.exe"),
48
+ ];
49
+ for (const p of candidates) {
50
+ if (fs.existsSync(p)) return p;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ async function runInstallWizard() {
56
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
57
+ const ask = (q) => new Promise((res) => rl.question(q, res));
58
+
59
+ console.log("\n╔══════════════════════════════════════════╗");
60
+ console.log("║ mcp-mpv-player 安装向导 ║");
61
+ console.log("╚══════════════════════════════════════════╝\n");
62
+
63
+ // Step 1: 检测 mpv
64
+ console.log("【第一步】检测 mpv 播放器...");
65
+ let mpvPath = detectMpv();
66
+
67
+ if (mpvPath) {
68
+ console.log(`✅ 已找到 mpv: ${mpvPath}\n`);
69
+ } else {
70
+ console.log("❌ 未检测到 mpv\n");
71
+ console.log("请选择安装方式:");
72
+ console.log(" 1. 自动安装(推荐): winget install shinchiro.mpv");
73
+ console.log(" 2. 手动下载: https://mpv.io/installation/");
74
+ console.log(" 3. 我已安装,手动输入 mpv.exe 路径");
75
+ const choice = (await ask("\n请输入选择 (1/2/3): ")).trim();
76
+
77
+ if (choice === "1") {
78
+ console.log("\n正在安装 mpv,请稍候...");
79
+ const result = spawnSync("winget", ["install", "shinchiro.mpv"], { stdio: "inherit", shell: true });
80
+ if (result.status === 0) {
81
+ console.log("\n✅ mpv 安装成功!");
82
+ console.log("⚠️ 请关闭此窗口,重新打开终端后再次运行 npx mcp-mpv-player 继续安装。");
83
+ } else {
84
+ console.log("\n❌ 自动安装失败,请手动下载: https://mpv.io/installation/");
85
+ }
86
+ rl.close(); process.exit(0);
87
+ } else if (choice === "2") {
88
+ console.log("\n请前往 https://mpv.io/installation/ 下载安装后,重新运行 npx mcp-mpv-player");
89
+ rl.close(); process.exit(0);
90
+ } else if (choice === "3") {
91
+ const inputPath = (await ask("请输入 mpv.exe 的完整路径: ")).trim().replace(/^"|"$/g, "");
92
+ if (!fs.existsSync(inputPath)) {
93
+ console.log("❌ 路径不存在,请检查后重新运行");
94
+ rl.close(); process.exit(1);
95
+ }
96
+ mpvPath = inputPath;
97
+ console.log(`✅ 已设置 mpv 路径: ${mpvPath}\n`);
98
+ } else {
99
+ console.log("❌ 无效选择,请重新运行");
100
+ rl.close(); process.exit(1);
101
+ }
102
+ }
103
+
104
+ // Step 2: 当前脚本路径
105
+ console.log("【第二步】确认安装路径...");
106
+ const scriptPath = path.resolve(process.argv[1]);
107
+ console.log(`✅ 工具路径: ${scriptPath}\n`);
108
+
109
+ // Step 3: 找 opencode 配置文件
110
+ console.log("【第三步】查找 opencode 配置文件...");
111
+ const defaultConfigPath = path.join(os.homedir(), ".config", "opencode", "opencode.json");
112
+ let configPath = null;
113
+
114
+ if (fs.existsSync(defaultConfigPath)) {
115
+ console.log(`✅ 已找到配置文件: ${defaultConfigPath}`);
116
+ const confirm = (await ask("使用此路径?(Y/n): ")).trim().toLowerCase();
117
+ if (confirm === "" || confirm === "y") configPath = defaultConfigPath;
118
+ }
119
+
120
+ if (!configPath) {
121
+ const inputPath = (await ask("请输入 opencode config.json 的完整路径: ")).trim().replace(/^"|"$/g, "");
122
+ if (!fs.existsSync(inputPath)) {
123
+ console.log("❌ 配置文件不存在,请检查后重新运行");
124
+ rl.close(); process.exit(1);
125
+ }
126
+ configPath = inputPath;
127
+ }
128
+ console.log();
129
+
130
+ // Step 4: 注入配置
131
+ console.log("【第四步】注册到 opencode...");
132
+ let config;
133
+ try {
134
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
135
+ } catch {
136
+ console.log("❌ 配置文件格式错误,无法解析 JSON");
137
+ rl.close(); process.exit(1);
138
+ }
139
+
140
+ if (!config.mcp) config.mcp = {};
141
+
142
+ if (config.mcp["mcp-mpv-player"]) {
143
+ console.log("⚠️ 检测到已有 mcp-mpv-player 配置");
144
+ const overwrite = (await ask("是否覆盖?(y/N): ")).trim().toLowerCase();
145
+ if (overwrite !== "y") {
146
+ console.log("已取消,保留原有配置。");
147
+ rl.close(); process.exit(0);
148
+ }
149
+ }
150
+
151
+ const entry = { type: "local", command: ["node", scriptPath], enabled: true };
152
+ if (!detectMpvInPath()) entry.environment = { MPV_PATH: mpvPath };
153
+ config.mcp["mcp-mpv-player"] = entry;
154
+
155
+ try {
156
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
157
+ } catch {
158
+ console.log("❌ 写入配置文件失败,请检查文件权限");
159
+ rl.close(); process.exit(1);
160
+ }
161
+
162
+ console.log("\n╔══════════════════════════════════════════╗");
163
+ console.log("║ ✅ 安装完成! ║");
164
+ console.log("╚══════════════════════════════════════════╝");
165
+ console.log(`\n配置文件: ${configPath}`);
166
+ console.log("\n请重启 opencode 使配置生效。");
167
+ console.log("之后你可以对 AI 说「播放 D:/Music/xxx.mp3」来控制播放器。\n");
168
+ rl.close();
169
+ }
170
+
171
+ // 入口:区分安装向导 vs MCP server 模式
172
+ if (process.stdin.isTTY) {
173
+ await runInstallWizard();
174
+ process.exit(0);
175
+ }
176
+
177
+ // ── IPC helpers ───────────────────────────────────────────────────────────────
178
+ function ipcCommand(cmd) {
179
+ return new Promise((resolve, reject) => {
180
+ const client = net.createConnection(PIPE_PATH);
181
+ let buffer = "";
182
+ const timer = setTimeout(() => {
183
+ client.destroy();
184
+ reject(new Error("IPC timeout"));
185
+ }, 3000);
186
+
187
+ client.on("connect", () => client.write(JSON.stringify(cmd) + "\n"));
188
+ client.on("data", (chunk) => {
189
+ buffer += chunk.toString();
190
+ const lines = buffer.split("\n");
191
+ buffer = lines.pop();
192
+ for (const line of lines) {
193
+ if (!line.trim()) continue;
194
+ try {
195
+ const msg = JSON.parse(line);
196
+ if (msg.request_id !== undefined || msg.error !== undefined) {
197
+ clearTimeout(timer);
198
+ client.destroy();
199
+ if (msg.error && msg.error !== "success") {
200
+ reject(new Error(msg.error));
201
+ } else {
202
+ resolve(msg.data);
203
+ }
204
+ return;
205
+ }
206
+ } catch (_) {}
207
+ }
208
+ });
209
+ client.on("error", (err) => { clearTimeout(timer); reject(err); });
210
+ });
211
+ }
212
+
213
+ let reqId = 1;
214
+ async function mpv(command, args = []) {
215
+ return ipcCommand({ command: [command, ...args], request_id: reqId++ });
216
+ }
217
+
218
+ async function getProperty(prop) {
219
+ return ipcCommand({ command: ["get_property", prop], request_id: reqId++ });
220
+ }
221
+
222
+ async function setProperty(prop, value) {
223
+ return ipcCommand({ command: ["set_property", prop, value], request_id: reqId++ });
224
+ }
225
+
226
+ // ── mpv process management ────────────────────────────────────────────────────
227
+ function isMpvRunning() {
228
+ return new Promise((resolve) => {
229
+ const client = net.createConnection(PIPE_PATH);
230
+ client.on("connect", () => { client.destroy(); resolve(true); });
231
+ client.on("error", () => resolve(false));
232
+ });
233
+ }
234
+
235
+ async function ensureMpv(filePath = null) {
236
+ const running = await isMpvRunning();
237
+ if (running) return { started: false };
238
+
239
+ const args = [
240
+ "--input-ipc-server=" + PIPE_PATH,
241
+ "--idle=yes",
242
+ "--keep-open=yes",
243
+ "--no-terminal",
244
+ ];
245
+ if (filePath) args.push(filePath);
246
+
247
+ const proc = spawn(MPV_BINARY, args, {
248
+ detached: true,
249
+ stdio: "ignore",
250
+ windowsHide: false,
251
+ });
252
+ proc.unref();
253
+
254
+ // Wait for pipe to be ready
255
+ for (let i = 0; i < 20; i++) {
256
+ await new Promise((r) => setTimeout(r, 250));
257
+ if (await isMpvRunning()) return { started: true };
258
+ }
259
+ throw new Error(
260
+ "mpv failed to start. Make sure mpv is installed and in PATH, or set MPV_PATH environment variable."
261
+ );
262
+ }
263
+
264
+ // ── Playlist helpers ──────────────────────────────────────────────────────────
265
+ function listPlaylists() {
266
+ return fs
267
+ .readdirSync(PLAYLIST_DIR)
268
+ .filter((f) => f.endsWith(".m3u"))
269
+ .map((f) => f.replace(".m3u", ""));
270
+ }
271
+
272
+ function playlistPath(name) {
273
+ return path.join(PLAYLIST_DIR, name.replace(/[/\\?%*:|"<>]/g, "_") + ".m3u");
274
+ }
275
+
276
+ function readPlaylist(name) {
277
+ const p = playlistPath(name);
278
+ if (!fs.existsSync(p)) throw new Error(`Playlist "${name}" not found`);
279
+ return fs
280
+ .readFileSync(p, "utf8")
281
+ .split("\n")
282
+ .map((l) => l.trim())
283
+ .filter((l) => l && !l.startsWith("#"));
284
+ }
285
+
286
+ function writePlaylist(name, files) {
287
+ const content = "#EXTM3U\n" + files.join("\n") + "\n";
288
+ fs.writeFileSync(playlistPath(name), content, "utf8");
289
+ }
290
+
291
+ // ── Formatting helpers ────────────────────────────────────────────────────────
292
+ function formatTime(secs) {
293
+ if (secs == null) return "N/A";
294
+ const h = Math.floor(secs / 3600);
295
+ const m = Math.floor((secs % 3600) / 60);
296
+ const s = Math.floor(secs % 60);
297
+ return h > 0
298
+ ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
299
+ : `${m}:${String(s).padStart(2, "0")}`;
300
+ }
301
+
302
+ function ok(msg) {
303
+ return { content: [{ type: "text", text: `✅ ${msg}` }] };
304
+ }
305
+ function info(msg) {
306
+ return { content: [{ type: "text", text: msg }] };
307
+ }
308
+ function fail(msg) {
309
+ return { content: [{ type: "text", text: `❌ ${msg}` }], isError: true };
310
+ }
311
+
312
+ // ── Tool definitions ──────────────────────────────────────────────────────────
313
+ const TOOLS = [
314
+ {
315
+ name: "player_play",
316
+ description:
317
+ "Open and play a media file or URL. If mpv is not running, it will be launched automatically.",
318
+ inputSchema: {
319
+ type: "object",
320
+ properties: {
321
+ path: {
322
+ type: "string",
323
+ description: "Absolute file path or URL (http/https/rtmp etc.)",
324
+ },
325
+ append: {
326
+ type: "boolean",
327
+ description: "Append to current playlist instead of replacing it",
328
+ default: false,
329
+ },
330
+ },
331
+ required: ["path"],
332
+ },
333
+ },
334
+ {
335
+ name: "player_pause",
336
+ description: "Toggle pause / resume playback.",
337
+ inputSchema: { type: "object", properties: {} },
338
+ },
339
+ {
340
+ name: "player_stop",
341
+ description: "Stop playback and clear the current file.",
342
+ inputSchema: { type: "object", properties: {} },
343
+ },
344
+ {
345
+ name: "player_next",
346
+ description: "Skip to the next item in the playlist.",
347
+ inputSchema: { type: "object", properties: {} },
348
+ },
349
+ {
350
+ name: "player_prev",
351
+ description: "Go back to the previous item in the playlist.",
352
+ inputSchema: { type: "object", properties: {} },
353
+ },
354
+ {
355
+ name: "player_seek",
356
+ description: "Seek within the current media.",
357
+ inputSchema: {
358
+ type: "object",
359
+ properties: {
360
+ value: {
361
+ type: "number",
362
+ description:
363
+ "Seconds to seek (positive = forward, negative = backward) when mode=relative. Absolute second when mode=absolute. 0-100 when mode=percent.",
364
+ },
365
+ mode: {
366
+ type: "string",
367
+ enum: ["relative", "absolute", "percent"],
368
+ default: "relative",
369
+ description: "Seek mode",
370
+ },
371
+ },
372
+ required: ["value"],
373
+ },
374
+ },
375
+ {
376
+ name: "player_set_volume",
377
+ description: "Set playback volume (0–130). 100 is default.",
378
+ inputSchema: {
379
+ type: "object",
380
+ properties: {
381
+ volume: { type: "number", description: "Volume level 0–130" },
382
+ },
383
+ required: ["volume"],
384
+ },
385
+ },
386
+ {
387
+ name: "player_set_speed",
388
+ description: "Set playback speed multiplier. 1.0 = normal speed.",
389
+ inputSchema: {
390
+ type: "object",
391
+ properties: {
392
+ speed: {
393
+ type: "number",
394
+ description: "Speed multiplier e.g. 0.5, 1.0, 1.5, 2.0",
395
+ },
396
+ },
397
+ required: ["speed"],
398
+ },
399
+ },
400
+ {
401
+ name: "player_status",
402
+ description:
403
+ "Get current playback status: file name, position, duration, volume, speed, pause state.",
404
+ inputSchema: { type: "object", properties: {} },
405
+ },
406
+ {
407
+ name: "player_shuffle",
408
+ description: "Randomly shuffle the current playlist and start playing from the first track.",
409
+ inputSchema: { type: "object", properties: {} },
410
+ },
411
+ {
412
+ name: "playlist_load",
413
+ description: "Load a saved playlist by name and start playing it.",
414
+ inputSchema: {
415
+ type: "object",
416
+ properties: {
417
+ name: { type: "string", description: "Playlist name (without .m3u)" },
418
+ },
419
+ required: ["name"],
420
+ },
421
+ },
422
+ {
423
+ name: "playlist_create",
424
+ description: "Create a new playlist with a list of file paths.",
425
+ inputSchema: {
426
+ type: "object",
427
+ properties: {
428
+ name: { type: "string", description: "Playlist name" },
429
+ files: {
430
+ type: "array",
431
+ items: { type: "string" },
432
+ description: "Array of absolute file paths or URLs",
433
+ },
434
+ },
435
+ required: ["name", "files"],
436
+ },
437
+ },
438
+ {
439
+ name: "playlist_add",
440
+ description: "Add files to an existing playlist.",
441
+ inputSchema: {
442
+ type: "object",
443
+ properties: {
444
+ name: { type: "string", description: "Playlist name" },
445
+ files: {
446
+ type: "array",
447
+ items: { type: "string" },
448
+ description: "Files to append",
449
+ },
450
+ },
451
+ required: ["name", "files"],
452
+ },
453
+ },
454
+ {
455
+ name: "playlist_remove",
456
+ description: "Remove a file from a saved playlist by index (0-based).",
457
+ inputSchema: {
458
+ type: "object",
459
+ properties: {
460
+ name: { type: "string", description: "Playlist name" },
461
+ index: { type: "number", description: "0-based index to remove" },
462
+ },
463
+ required: ["name", "index"],
464
+ },
465
+ },
466
+ {
467
+ name: "playlist_list",
468
+ description: "List all saved playlists or show contents of a specific playlist.",
469
+ inputSchema: {
470
+ type: "object",
471
+ properties: {
472
+ name: {
473
+ type: "string",
474
+ description: "Playlist name to inspect (omit to list all playlists)",
475
+ },
476
+ },
477
+ },
478
+ },
479
+ {
480
+ name: "playlist_delete",
481
+ description: "Delete a saved playlist file.",
482
+ inputSchema: {
483
+ type: "object",
484
+ properties: {
485
+ name: { type: "string", description: "Playlist name to delete" },
486
+ },
487
+ required: ["name"],
488
+ },
489
+ },
490
+ ];
491
+
492
+ // ── Tool handlers ─────────────────────────────────────────────────────────────
493
+ async function handleTool(name, args) {
494
+ try {
495
+ switch (name) {
496
+ // ── Playback controls ──────────────────────────────────────────────────
497
+ case "player_play": {
498
+ await ensureMpv();
499
+ const flag = args.append ? "append-play" : "replace";
500
+ await mpv("loadfile", [args.path, flag]);
501
+
502
+ // If it's a video file, bring mpv window to foreground
503
+ const VIDEO_EXTS = new Set([
504
+ "mp4","mkv","avi","mov","wmv","flv","webm","m4v",
505
+ "mpg","mpeg","ts","rmvb","3gp","ogv","hevc"
506
+ ]);
507
+ const ext = args.path.split(".").pop().toLowerCase().split("?")[0];
508
+ if (VIDEO_EXTS.has(ext)) {
509
+ // Wait for mpv to open the video window, then restore + focus
510
+ await new Promise((r) => setTimeout(r, 800));
511
+ await mpv("focus").catch(() => null);
512
+ spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command",
513
+ "(New-Object -ComObject Shell.Application).Windows() | ForEach-Object { if ($_.FullName -like '*mpv*') { $_.Visible = $true } };" +
514
+ "$wshell = New-Object -ComObject wscript.shell;" +
515
+ "$wshell.AppActivate('mpv')"
516
+ ], { detached: true, stdio: "ignore" }).unref();
517
+ }
518
+
519
+ await setProperty("pause", false);
520
+ return ok(`Playing: ${args.path}`);
521
+ }
522
+
523
+ case "player_pause": {
524
+ await ensureMpv();
525
+ await mpv("cycle", ["pause"]);
526
+ const paused = await getProperty("pause");
527
+ return ok(paused ? "Paused" : "Resumed");
528
+ }
529
+
530
+ case "player_stop": {
531
+ await ensureMpv();
532
+ await mpv("stop");
533
+ return ok("Stopped");
534
+ }
535
+
536
+ case "player_next": {
537
+ await ensureMpv();
538
+ const plCount = await getProperty("playlist-count").catch(() => 0);
539
+ const plPos = await getProperty("playlist-pos").catch(() => 0);
540
+ if (plCount <= 1 || plPos >= plCount - 1) {
541
+ return fail("已经是最后一首,没有下一曲");
542
+ }
543
+ await mpv("playlist-next", ["weak"]);
544
+ await setProperty("pause", false);
545
+ const nextTitle = await getProperty("media-title").catch(() => null);
546
+ return ok(`Playing next: ${nextTitle || "unknown"}`);
547
+ }
548
+
549
+ case "player_prev": {
550
+ await ensureMpv();
551
+ const plPosPrev = await getProperty("playlist-pos").catch(() => 0);
552
+ if (plPosPrev <= 0) {
553
+ return fail("已经是第一首,没有上一曲");
554
+ }
555
+ await mpv("playlist-prev", ["weak"]);
556
+ await setProperty("pause", false);
557
+ const prevTitle = await getProperty("media-title").catch(() => null);
558
+ return ok(`Playing previous: ${prevTitle || "unknown"}`);
559
+ }
560
+
561
+ case "player_seek": {
562
+ await ensureMpv();
563
+ const mode = args.mode || "relative";
564
+ await mpv("seek", [args.value, mode]);
565
+ const pos = await getProperty("time-pos");
566
+ return ok(`Seeked → ${formatTime(pos)}`);
567
+ }
568
+
569
+ case "player_set_volume": {
570
+ await ensureMpv();
571
+ await setProperty("volume", Math.max(0, Math.min(130, args.volume)));
572
+ return ok(`Volume set to ${args.volume}`);
573
+ }
574
+
575
+ case "player_set_speed": {
576
+ await ensureMpv();
577
+ await setProperty("speed", args.speed);
578
+ return ok(`Speed set to ${args.speed}x`);
579
+ }
580
+
581
+ case "player_status": {
582
+ const running = await isMpvRunning();
583
+ if (!running) return info("mpv is not running.");
584
+
585
+ const [filename, pos, dur, paused, vol, speed, plPos, plCount] =
586
+ await Promise.all([
587
+ getProperty("media-title").catch(() => null),
588
+ getProperty("time-pos").catch(() => null),
589
+ getProperty("duration").catch(() => null),
590
+ getProperty("pause").catch(() => null),
591
+ getProperty("volume").catch(() => null),
592
+ getProperty("speed").catch(() => null),
593
+ getProperty("playlist-pos").catch(() => null),
594
+ getProperty("playlist-count").catch(() => null),
595
+ ]);
596
+
597
+ const lines = [
598
+ `🎵 **Now playing:** ${filename || "N/A"}`,
599
+ `⏱ **Position:** ${formatTime(pos)} / ${formatTime(dur)}`,
600
+ `${paused ? "⏸" : "▶️"} **State:** ${paused ? "Paused" : "Playing"}`,
601
+ `🔊 **Volume:** ${vol != null ? Math.round(vol) : "N/A"}`,
602
+ `⚡ **Speed:** ${speed != null ? speed + "x" : "N/A"}`,
603
+ `📋 **Playlist:** ${plPos != null ? plPos + 1 : "N/A"} / ${plCount ?? "N/A"}`,
604
+ ];
605
+ return info(lines.join("\n"));
606
+ }
607
+
608
+ case "player_shuffle": {
609
+ await ensureMpv();
610
+ const count = await getProperty("playlist-count").catch(() => 0);
611
+ if (!count || count < 2) return fail("Need at least 2 tracks in the playlist to shuffle");
612
+ await mpv("playlist-shuffle");
613
+ await mpv("playlist-play-index", [0]);
614
+ await setProperty("pause", false);
615
+ const title = await getProperty("media-title").catch(() => null);
616
+ return ok(`Playlist shuffled (${count} tracks). Now playing: ${title || "unknown"}`);
617
+ }
618
+
619
+ // ── Playlist management ────────────────────────────────────────────────
620
+ case "playlist_load": {
621
+ const p = playlistPath(args.name);
622
+ if (!fs.existsSync(p))
623
+ return fail(`Playlist "${args.name}" not found. Use playlist_list to see available playlists.`);
624
+ await ensureMpv();
625
+ await mpv("loadlist", [p, "replace"]);
626
+ return ok(`Loaded playlist "${args.name}"`);
627
+ }
628
+
629
+ case "playlist_create": {
630
+ if (!args.files || args.files.length === 0)
631
+ return fail("files array cannot be empty");
632
+ writePlaylist(args.name, args.files);
633
+ return ok(
634
+ `Created playlist "${args.name}" with ${args.files.length} item(s)\nSaved to: ${playlistPath(args.name)}`
635
+ );
636
+ }
637
+
638
+ case "playlist_add": {
639
+ const existing = readPlaylist(args.name);
640
+ writePlaylist(args.name, [...existing, ...args.files]);
641
+ return ok(
642
+ `Added ${args.files.length} item(s) to "${args.name}" (total: ${existing.length + args.files.length})`
643
+ );
644
+ }
645
+
646
+ case "playlist_remove": {
647
+ const files = readPlaylist(args.name);
648
+ if (args.index < 0 || args.index >= files.length)
649
+ return fail(`Index ${args.index} out of range (0–${files.length - 1})`);
650
+ const removed = files.splice(args.index, 1);
651
+ writePlaylist(args.name, files);
652
+
653
+ // Also remove from live mpv queue if running
654
+ if (await isMpvRunning()) {
655
+ const currentPos = await getProperty("playlist-pos").catch(() => null);
656
+ const plCount = await getProperty("playlist-count").catch(() => 0);
657
+ // Find matching entry in mpv's live playlist
658
+ let liveIndex = null;
659
+ for (let i = 0; i < plCount; i++) {
660
+ const entry = await getProperty(`playlist/${i}/filename`).catch(() => null);
661
+ if (entry && (entry === removed[0] || entry.replace(/\\/g, "/") === removed[0].replace(/\\/g, "/"))) {
662
+ liveIndex = i;
663
+ break;
664
+ }
665
+ }
666
+ if (liveIndex !== null) {
667
+ await mpv("playlist-remove", [liveIndex]);
668
+ // If we removed the currently playing track, mpv auto-advances;
669
+ // make sure it's playing (not paused)
670
+ if (liveIndex === currentPos) {
671
+ await setProperty("pause", false);
672
+ }
673
+ }
674
+ }
675
+
676
+ return ok(`Removed "${removed[0]}" from "${args.name}" (live queue updated)`);
677
+ }
678
+
679
+ case "playlist_list": {
680
+ if (args.name) {
681
+ const files = readPlaylist(args.name);
682
+ const lines = files.map((f, i) => ` ${i}. ${f}`);
683
+ return info(`📋 Playlist "${args.name}" (${files.length} items):\n${lines.join("\n")}`);
684
+ }
685
+ const playlists = listPlaylists();
686
+ if (playlists.length === 0) return info("No playlists found. Use playlist_create to make one.");
687
+ return info(`📁 Saved playlists (${PLAYLIST_DIR}):\n${playlists.map((p) => ` • ${p}`).join("\n")}`);
688
+ }
689
+
690
+ case "playlist_delete": {
691
+ const p = playlistPath(args.name);
692
+ if (!fs.existsSync(p)) return fail(`Playlist "${args.name}" not found`);
693
+ fs.unlinkSync(p);
694
+ return ok(`Deleted playlist "${args.name}"`);
695
+ }
696
+
697
+ default:
698
+ return fail(`Unknown tool: ${name}`);
699
+ }
700
+ } catch (err) {
701
+ if (err.message?.includes("ENOENT") || err.message?.includes("IPC timeout")) {
702
+ return fail(
703
+ `Cannot connect to mpv. Make sure mpv is running with:\n mpv --input-ipc-server=\\\\.\\pipe\\mpv-ipc --idle\n\nOr use player_play to start it automatically.\n\nError: ${err.message}`
704
+ );
705
+ }
706
+ return fail(err.message);
707
+ }
708
+ }
709
+
710
+ // ── MCP Server ────────────────────────────────────────────────────────────────
711
+ const server = new Server(
712
+ { name: "mcp-mpv-player", version: "1.2.0" },
713
+ { capabilities: { tools: {} } }
714
+ );
715
+
716
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
717
+
718
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
719
+ const { name, arguments: args } = request.params;
720
+ return handleTool(name, args || {});
721
+ });
722
+
723
+ const transport = new StdioServerTransport();
724
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "mcp-mpv-player",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for controlling mpv media player on Windows",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "start": "node index.js"
9
+ },
10
+ "dependencies": {
11
+ "@modelcontextprotocol/sdk": "^1.0.0"
12
+ },
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
16
+ "bin": {
17
+ "mcp-mpv-player": "index.js"
18
+ },
19
+ "keywords": [
20
+ "mcp",
21
+ "mpv",
22
+ "media-player",
23
+ "opencode"
24
+ ],
25
+ "license": "MIT"
26
+ }