@powerfm/libretime-mcp 0.1.8 → 0.3.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.
- package/dist/apps/upload-file.html +94 -0
- package/dist/http/admin.js +24 -37
- package/dist/http/client.js +7 -35
- package/dist/http/server.js +72 -0
- package/dist/http/upload.js +75 -0
- package/dist/libretime.js +32 -4
- package/dist/stdio/admin.js +15 -1
- package/dist/tools/files/index.js +6 -6
- package/dist/tools/files/types.js +6 -0
- package/dist/tools/files/upload_file.js +162 -37
- package/dist/tools/files/upload_file_legacy.js +160 -0
- package/package.json +26 -3
package/dist/http/admin.js
CHANGED
|
@@ -1,47 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// HTTP MCP server — full admin access (shows + analytics + file/user management).
|
|
3
3
|
// Exposes POST /mcp using MCP Streamable HTTP transport.
|
|
4
|
-
// Requires Authorization: Bearer <MCP_API_KEY> on every request.
|
|
4
|
+
// Requires Authorization: Bearer <MCP_API_KEY> on every MCP request.
|
|
5
5
|
// Intended for network clients (e.g. powerfm-agent). For Claude Desktop use stdio/admin.ts.
|
|
6
6
|
import '../env.js';
|
|
7
|
-
import
|
|
8
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
10
|
-
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
|
|
11
|
-
import cors from 'cors';
|
|
12
|
-
const { version } = createRequire(import.meta.url)('../../package.json');
|
|
7
|
+
import crypto from 'node:crypto';
|
|
13
8
|
import { register as registerShows } from '../tools/shows/index.js';
|
|
14
9
|
import { register as registerAnalytics } from '../tools/analytics/index.js';
|
|
15
10
|
import { register as registerAdmin } from '../tools/admin/index.js';
|
|
11
|
+
import { register as registerFiles } from '../tools/files/index.js';
|
|
12
|
+
import { registerUploadEndpoint } from './upload.js';
|
|
13
|
+
import { createHttpServer } from './server.js';
|
|
16
14
|
const PORT = parseInt(process.env.MCP_PORT ?? '3000', 10);
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const server = new McpServer({ name: 'libretime-mcp-admin', version });
|
|
37
|
-
registerShows(server);
|
|
38
|
-
registerAnalytics(server);
|
|
39
|
-
registerAdmin(server);
|
|
40
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
41
|
-
await server.connect(transport);
|
|
42
|
-
await transport.handleRequest(req, res, req.body);
|
|
43
|
-
});
|
|
44
|
-
app.listen(PORT, '0.0.0.0', () => {
|
|
45
|
-
console.error(`LibreTime MCP admin HTTP server listening on port ${PORT}`);
|
|
46
|
-
console.error('Endpoint: POST /mcp (Authorization: Bearer <MCP_API_KEY>)');
|
|
15
|
+
const PUBLIC_URL = process.env.MCP_PUBLIC_URL ?? `http://localhost:${PORT}`;
|
|
16
|
+
// Scoped upload token — generated once at startup, rotates on restart.
|
|
17
|
+
const UPLOAD_TOKEN = crypto.randomBytes(32).toString('hex');
|
|
18
|
+
// uploadUrl is set by setupRoutes (which runs at startup) and read by register
|
|
19
|
+
// (which runs per-request). setupRoutes always runs first, so this is safe.
|
|
20
|
+
let uploadUrl;
|
|
21
|
+
createHttpServer({
|
|
22
|
+
name: 'libretime-mcp-admin',
|
|
23
|
+
defaultPort: 3000,
|
|
24
|
+
setupRoutes: (app) => {
|
|
25
|
+
uploadUrl = registerUploadEndpoint(app, PUBLIC_URL, UPLOAD_TOKEN);
|
|
26
|
+
console.error(`Upload: POST /upload (X-Upload-Token: <per-startup token>)`);
|
|
27
|
+
},
|
|
28
|
+
register: (server) => {
|
|
29
|
+
registerShows(server);
|
|
30
|
+
registerAnalytics(server);
|
|
31
|
+
registerAdmin(server);
|
|
32
|
+
registerFiles(server, uploadUrl, UPLOAD_TOKEN);
|
|
33
|
+
},
|
|
47
34
|
});
|
package/dist/http/client.js
CHANGED
|
@@ -4,40 +4,12 @@
|
|
|
4
4
|
// Requires Authorization: Bearer <MCP_API_KEY> on every request.
|
|
5
5
|
// Intended for network clients. For Claude Desktop use stdio/client.ts.
|
|
6
6
|
import '../env.js';
|
|
7
|
-
import { createRequire } from 'module';
|
|
8
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
10
|
-
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
|
|
11
|
-
import cors from 'cors';
|
|
12
7
|
import { register as registerShows } from '../tools/shows/index.js';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
const app = createMcpExpressApp({ host: '0.0.0.0' });
|
|
21
|
-
app.use(cors({ origin: process.env.CORS_ORIGIN ?? true, credentials: true }));
|
|
22
|
-
// Simple API key middleware — checks Authorization: Bearer <key>
|
|
23
|
-
app.use('/mcp', (req, res, next) => {
|
|
24
|
-
const auth = req.headers['authorization'];
|
|
25
|
-
if (!auth || auth !== `Bearer ${API_KEY}`) {
|
|
26
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
next();
|
|
30
|
-
});
|
|
31
|
-
// Stateless transport — a fresh McpServer per request keeps things simple
|
|
32
|
-
// and avoids session management complexity for now
|
|
33
|
-
app.all('/mcp', async (req, res) => {
|
|
34
|
-
const server = new McpServer({ name: 'libretime-mcp-client', version });
|
|
35
|
-
registerShows(server);
|
|
36
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
37
|
-
await server.connect(transport);
|
|
38
|
-
await transport.handleRequest(req, res, req.body);
|
|
39
|
-
});
|
|
40
|
-
app.listen(PORT, '0.0.0.0', () => {
|
|
41
|
-
console.error(`LibreTime MCP client HTTP server listening on port ${PORT}`);
|
|
42
|
-
console.error('Endpoint: POST /mcp (Authorization: Bearer <MCP_API_KEY>)');
|
|
8
|
+
import { createHttpServer } from './server.js';
|
|
9
|
+
createHttpServer({
|
|
10
|
+
name: 'libretime-mcp-client',
|
|
11
|
+
defaultPort: 3001,
|
|
12
|
+
register: (server) => {
|
|
13
|
+
registerShows(server);
|
|
14
|
+
},
|
|
43
15
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
|
+
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
|
|
5
|
+
import cors from 'cors';
|
|
6
|
+
import helmet from 'helmet';
|
|
7
|
+
import rateLimit from 'express-rate-limit';
|
|
8
|
+
import morgan from 'morgan';
|
|
9
|
+
const { version } = createRequire(import.meta.url)('../../package.json');
|
|
10
|
+
export function createHttpServer({ name, defaultPort, register, setupRoutes }) {
|
|
11
|
+
const PORT = parseInt(process.env.MCP_PORT ?? String(defaultPort), 10);
|
|
12
|
+
const API_KEY = process.env.MCP_API_KEY;
|
|
13
|
+
const AUTH_DISABLED = process.env.DISABLE_AUTH === 'true';
|
|
14
|
+
if (!AUTH_DISABLED && !API_KEY) {
|
|
15
|
+
console.error('ERROR: MCP_API_KEY environment variable is required');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const app = createMcpExpressApp({ host: '0.0.0.0' });
|
|
19
|
+
// Request logging — 'dev' in development (coloured, concise),
|
|
20
|
+
// 'combined' in production (Apache format: IP, method, path, status, size, user-agent).
|
|
21
|
+
// Runs first so every request is logged regardless of auth outcome.
|
|
22
|
+
const logFormat = process.env.NODE_ENV === 'production' ? 'combined' : 'dev';
|
|
23
|
+
app.use(morgan(logFormat));
|
|
24
|
+
// Security headers — sets ~14 protective HTTP headers automatically.
|
|
25
|
+
// contentSecurityPolicy disabled: the MCP SDK sets its own and they'd conflict.
|
|
26
|
+
app.use(helmet({ contentSecurityPolicy: false }));
|
|
27
|
+
app.use(cors({ origin: process.env.CORS_ORIGIN ?? true, credentials: true }));
|
|
28
|
+
// Rate limiting — max 120 requests per IP per minute.
|
|
29
|
+
// Protects against runaway clients and accidental hammering.
|
|
30
|
+
// /ping is excluded so uptime monitors never get blocked.
|
|
31
|
+
app.use(/^\/(mcp|upload)/, rateLimit({ windowMs: 60_000, max: 120 }));
|
|
32
|
+
// Rate limiting — max 120 requests per IP per minute.
|
|
33
|
+
// Protects against runaway clients and accidental hammering.
|
|
34
|
+
// /ping is excluded so uptime monitors never get blocked.
|
|
35
|
+
app.use(/^\/(mcp|upload)/, rateLimit({ windowMs: 60_000, max: 120 }));
|
|
36
|
+
// Health check — no auth required, used by uptime monitors and load balancers
|
|
37
|
+
app.get('/ping', (_req, res) => {
|
|
38
|
+
res.json({ status: 'ok' });
|
|
39
|
+
});
|
|
40
|
+
// Extra routes (e.g. /upload) with their own auth — must come before the MCP auth guard
|
|
41
|
+
setupRoutes?.(app);
|
|
42
|
+
// API key guard — protects all /mcp requests
|
|
43
|
+
app.use('/mcp', (req, res, next) => {
|
|
44
|
+
if (AUTH_DISABLED)
|
|
45
|
+
return next();
|
|
46
|
+
const auth = req.headers['authorization'];
|
|
47
|
+
if (!auth || auth !== `Bearer ${API_KEY}`) {
|
|
48
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
next();
|
|
52
|
+
});
|
|
53
|
+
// Stateless transport — a fresh McpServer per request keeps things simple
|
|
54
|
+
// and avoids session management complexity for now.
|
|
55
|
+
app.all('/mcp', async (req, res) => {
|
|
56
|
+
// Log which MCP method and tool was called — morgan only sees POST /mcp
|
|
57
|
+
const method = req.body?.method;
|
|
58
|
+
const toolName = req.body?.params?.name;
|
|
59
|
+
if (method)
|
|
60
|
+
console.error(`[mcp] ${method}${toolName ? ` tool=${toolName}` : ''}`);
|
|
61
|
+
const server = new McpServer({ name, version });
|
|
62
|
+
register(server);
|
|
63
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
64
|
+
await server.connect(transport);
|
|
65
|
+
await transport.handleRequest(req, res, req.body);
|
|
66
|
+
});
|
|
67
|
+
app.listen(PORT, '0.0.0.0', () => {
|
|
68
|
+
console.error(`LibreTime MCP ${name} HTTP server listening on port ${PORT}`);
|
|
69
|
+
console.error(`Health: GET /ping`);
|
|
70
|
+
console.error(`Endpoint: POST /mcp (Authorization: Bearer <MCP_API_KEY>)`);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import cors from 'cors';
|
|
2
|
+
/**
|
|
3
|
+
* Registers POST /upload on the given Express app.
|
|
4
|
+
*
|
|
5
|
+
* The React UI posts audio files here directly (bypassing the MCP protocol).
|
|
6
|
+
* The body is buffered in memory before forwarding to LibreTime.
|
|
7
|
+
*
|
|
8
|
+
* TODO: switch back to streaming (Readable.toWeb) for large show files once
|
|
9
|
+
* the basic flow is confirmed working end-to-end.
|
|
10
|
+
*
|
|
11
|
+
* Auth: X-Upload-Token header must match the provided uploadToken.
|
|
12
|
+
*
|
|
13
|
+
* Returns the full upload URL so callers can pass it to registerFiles().
|
|
14
|
+
*/
|
|
15
|
+
export function registerUploadEndpoint(app, publicUrl, uploadToken) {
|
|
16
|
+
// Explicit CORS for the upload route — must allow X-Upload-Token so the
|
|
17
|
+
// browser's preflight passes before sending the actual file.
|
|
18
|
+
const uploadCors = cors({
|
|
19
|
+
origin: process.env.CORS_ORIGIN ?? true,
|
|
20
|
+
credentials: true,
|
|
21
|
+
allowedHeaders: ['Content-Type', 'X-Upload-Token'],
|
|
22
|
+
});
|
|
23
|
+
app.options('/upload', uploadCors);
|
|
24
|
+
app.post('/upload', uploadCors, async (req, res) => {
|
|
25
|
+
const totalStart = Date.now();
|
|
26
|
+
const token = req.headers['x-upload-token'];
|
|
27
|
+
if (!token || token !== uploadToken) {
|
|
28
|
+
console.error(`[upload] status=401 total_ms=${Date.now() - totalStart}`);
|
|
29
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const BASE_URL = process.env.LIBRETIME_URL ?? '';
|
|
33
|
+
// /rest/media is the legacy PHP upload endpoint — the only path that writes
|
|
34
|
+
// files to disk and queues the analyzer. Auth uses the LibreTime API key
|
|
35
|
+
// (from config.yml general.api_key) as the Basic Auth username with no password.
|
|
36
|
+
const API_KEY = process.env.LIBRETIME_API_KEY ?? '';
|
|
37
|
+
const libreAuth = 'Basic ' + Buffer.from(`${API_KEY}:`).toString('base64');
|
|
38
|
+
const libreUrl = new URL('/rest/media', BASE_URL).toString();
|
|
39
|
+
try {
|
|
40
|
+
const body = await new Promise((resolve, reject) => {
|
|
41
|
+
const chunks = [];
|
|
42
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
43
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
44
|
+
req.on('error', reject);
|
|
45
|
+
});
|
|
46
|
+
const sizeMb = (body.length / 1024 / 1024).toFixed(2);
|
|
47
|
+
const libreStart = Date.now();
|
|
48
|
+
const response = await fetch(libreUrl, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: {
|
|
51
|
+
Authorization: libreAuth,
|
|
52
|
+
Accept: 'application/json',
|
|
53
|
+
'Content-Type': req.headers['content-type'],
|
|
54
|
+
'Content-Length': String(body.length),
|
|
55
|
+
},
|
|
56
|
+
body: new Uint8Array(body),
|
|
57
|
+
});
|
|
58
|
+
const libreMs = Date.now() - libreStart;
|
|
59
|
+
const totalMs = Date.now() - totalStart;
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const detail = await response.text();
|
|
62
|
+
console.error(`[upload] size=${sizeMb}mb libretime_ms=${libreMs} total_ms=${totalMs} status=${response.status} error="${detail}"`);
|
|
63
|
+
res.status(502).json({ error: `LibreTime error: ${response.status}`, detail });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
console.error(`[upload] size=${sizeMb}mb libretime_ms=${libreMs} total_ms=${totalMs} status=${response.status}`);
|
|
67
|
+
res.json(await response.json());
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.error(`[upload] total_ms=${Date.now() - totalStart} status=500 error="${err instanceof Error ? err.message : String(err)}"`);
|
|
71
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return `${publicUrl}/upload`;
|
|
75
|
+
}
|
package/dist/libretime.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const BASE_URL = process.env.LIBRETIME_URL ?? '';
|
|
2
2
|
const USER = process.env.LIBRETIME_USER ?? '';
|
|
3
3
|
const PASS = process.env.LIBRETIME_PASS ?? '';
|
|
4
|
+
const API_KEY = process.env.LIBRETIME_API_KEY ?? '';
|
|
4
5
|
// Basic Auth header value, base64-encoded as the HTTP spec requires
|
|
5
6
|
const authHeader = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
|
|
6
7
|
/**
|
|
@@ -46,8 +47,11 @@ export async function librePost(path, body) {
|
|
|
46
47
|
return response.json();
|
|
47
48
|
}
|
|
48
49
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
50
|
+
* BENCHED: multipart POST to /api/v2/files (DRF).
|
|
51
|
+
* Creates a DB record but never writes to disk or queues the analyzer —
|
|
52
|
+
* filepath stays null and import_status stays 1 forever.
|
|
53
|
+
* Kept here for when the DRF endpoint gets the analyzer wiring fixed upstream.
|
|
54
|
+
* See: PLAN.md → Open source contributions → LibreTime file upload
|
|
51
55
|
*/
|
|
52
56
|
export async function libreUpload(path, formData) {
|
|
53
57
|
const url = new URL(path, BASE_URL);
|
|
@@ -56,8 +60,6 @@ export async function libreUpload(path, formData) {
|
|
|
56
60
|
headers: {
|
|
57
61
|
Authorization: authHeader,
|
|
58
62
|
Accept: 'application/json',
|
|
59
|
-
// Note: do NOT set Content-Type here — fetch sets it automatically
|
|
60
|
-
// with the correct multipart boundary when given a FormData body
|
|
61
63
|
},
|
|
62
64
|
body: formData,
|
|
63
65
|
});
|
|
@@ -66,6 +68,32 @@ export async function libreUpload(path, formData) {
|
|
|
66
68
|
}
|
|
67
69
|
return response.json();
|
|
68
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Upload a file to /rest/media — the legacy PHP endpoint that triggers the full
|
|
73
|
+
* import workflow (writes to disk, queues the analyzer). The DRF /api/v2/files
|
|
74
|
+
* endpoint creates a DB record but never writes to disk, so this is the only
|
|
75
|
+
* working upload path.
|
|
76
|
+
*
|
|
77
|
+
* Auth uses LIBRETIME_API_KEY (general.api_key from LibreTime config.yml) as
|
|
78
|
+
* Basic Auth username with no password, which is what /rest/media expects.
|
|
79
|
+
*/
|
|
80
|
+
export async function libreRestMedia(formData) {
|
|
81
|
+
const url = new URL('/rest/media', BASE_URL);
|
|
82
|
+
const apiKeyAuth = 'Basic ' + Buffer.from(`${API_KEY}:`).toString('base64');
|
|
83
|
+
const response = await fetch(url.toString(), {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: {
|
|
86
|
+
Authorization: apiKeyAuth,
|
|
87
|
+
Accept: 'application/json',
|
|
88
|
+
// Do NOT set Content-Type — fetch sets it with the correct multipart boundary
|
|
89
|
+
},
|
|
90
|
+
body: formData,
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new Error(`LibreTime upload error: ${response.status} ${response.statusText} — ${url.toString()}`);
|
|
94
|
+
}
|
|
95
|
+
return response.json();
|
|
96
|
+
}
|
|
69
97
|
/**
|
|
70
98
|
* Make an authenticated PATCH request to the LibreTime API with a JSON body.
|
|
71
99
|
* Returns the parsed JSON response as unknown — callers are responsible for validation.
|
package/dist/stdio/admin.js
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from 'module';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import express from 'express';
|
|
3
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
7
|
import { register as registerShows } from '../tools/shows/index.js';
|
|
6
8
|
import { register as registerAnalytics } from '../tools/analytics/index.js';
|
|
7
9
|
import { register as registerAdmin } from '../tools/admin/index.js';
|
|
8
10
|
import { register as registerFiles } from '../tools/files/index.js';
|
|
11
|
+
import { registerUploadEndpoint } from '../http/upload.js';
|
|
9
12
|
const { version } = createRequire(import.meta.url)('../../package.json');
|
|
13
|
+
// Spin up a local HTTP server solely for the file upload endpoint.
|
|
14
|
+
// The MCP protocol runs over stdio — this is a side-channel so the
|
|
15
|
+
// iframe UI can POST files without going through the MCP protocol.
|
|
16
|
+
const UPLOAD_PORT = parseInt(process.env.UPLOAD_PORT ?? '4000', 10);
|
|
17
|
+
const PUBLIC_URL = `http://localhost:${UPLOAD_PORT}`;
|
|
18
|
+
const UPLOAD_TOKEN = crypto.randomBytes(32).toString('hex');
|
|
19
|
+
const app = express();
|
|
20
|
+
const uploadUrl = registerUploadEndpoint(app, PUBLIC_URL, UPLOAD_TOKEN);
|
|
21
|
+
app.listen(UPLOAD_PORT, () => {
|
|
22
|
+
console.error(`Upload: POST /upload on port ${UPLOAD_PORT}`);
|
|
23
|
+
});
|
|
10
24
|
const server = new McpServer({
|
|
11
25
|
name: 'libretime-mcp-admin',
|
|
12
26
|
version,
|
|
@@ -14,7 +28,7 @@ const server = new McpServer({
|
|
|
14
28
|
registerShows(server);
|
|
15
29
|
registerAnalytics(server);
|
|
16
30
|
registerAdmin(server);
|
|
17
|
-
registerFiles(server);
|
|
31
|
+
registerFiles(server, uploadUrl, UPLOAD_TOKEN);
|
|
18
32
|
const transport = new StdioServerTransport();
|
|
19
33
|
await server.connect(transport);
|
|
20
34
|
console.error('LibreTime MCP admin server running (shows + analytics + admin)');
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { register as registerSearchFiles } from '
|
|
2
|
-
import { register as registerUploadFile } from '
|
|
3
|
-
import { register as registerUpdateFileMetadata } from '
|
|
4
|
-
import { register as registerDeleteFile } from '
|
|
5
|
-
export function register(server) {
|
|
1
|
+
import { register as registerSearchFiles } from './search_files.js';
|
|
2
|
+
import { register as registerUploadFile } from './upload_file_legacy.js';
|
|
3
|
+
import { register as registerUpdateFileMetadata } from './update_file_metadata.js';
|
|
4
|
+
import { register as registerDeleteFile } from './delete_file.js';
|
|
5
|
+
export function register(server, uploadUrl, uploadToken) {
|
|
6
6
|
registerSearchFiles(server);
|
|
7
|
-
registerUploadFile(server);
|
|
7
|
+
registerUploadFile(server, uploadUrl, uploadToken);
|
|
8
8
|
registerUpdateFileMetadata(server);
|
|
9
9
|
registerDeleteFile(server);
|
|
10
10
|
}
|
|
@@ -1,50 +1,175 @@
|
|
|
1
|
+
// ON HOLD — waiting on LibreTime to wire the analyzer in the DRF /api/v2/files endpoint.
|
|
2
|
+
//
|
|
3
|
+
// Current behaviour of /api/v2/files (POST):
|
|
4
|
+
// - Creates a DB record (import_status = 1)
|
|
5
|
+
// - Does NOT write the file to disk
|
|
6
|
+
// - Does NOT queue the RabbitMQ analyzer job
|
|
7
|
+
// - filepath stays null; import_status never transitions to 0
|
|
8
|
+
//
|
|
9
|
+
// The working upload path is in upload_file_legacy.ts (/rest/media, legacy PHP endpoint).
|
|
10
|
+
// index.ts imports from upload_file_legacy.ts until this is resolved upstream.
|
|
11
|
+
//
|
|
12
|
+
// To switch back once the DRF endpoint is fixed:
|
|
13
|
+
// 1. Update index.ts to import from './upload_file.js' instead of './upload_file_legacy.js'
|
|
14
|
+
// 2. Delete upload_file_legacy.ts
|
|
15
|
+
//
|
|
16
|
+
// Upstream context: PLAN.md → Open source contributions → LibreTime file upload
|
|
1
17
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
18
|
+
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from '@modelcontextprotocol/ext-apps/server';
|
|
19
|
+
import fs from 'node:fs/promises';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { libreGet, libreUpload } from '../../libretime.js';
|
|
3
22
|
import { toolText } from '../../tool-response.js';
|
|
4
|
-
import { LibreFileSchema } from './types.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
23
|
+
import { LibreFileSchema, LibrarySchema } from './types.js';
|
|
24
|
+
const HTML_PATH = import.meta.filename.endsWith('.ts')
|
|
25
|
+
? path.join(import.meta.dirname, '../../../dist/apps/upload-file.html')
|
|
26
|
+
: path.join(import.meta.dirname, '../../apps/upload-file.html');
|
|
27
|
+
const resourceUri = 'ui://libretime/upload-file.html';
|
|
28
|
+
const metadataFields = {
|
|
29
|
+
track_title: z.string().optional().describe('Track title'),
|
|
30
|
+
artist_name: z.string().optional().describe('Artist name'),
|
|
31
|
+
album_title: z.string().optional().describe('Album title'),
|
|
32
|
+
genre: z.string().optional().describe('Genre'),
|
|
33
|
+
library: z.number().optional().describe('Track type / library ID'),
|
|
34
|
+
};
|
|
35
|
+
const MIME_MAP = {
|
|
36
|
+
mp3: 'audio/mpeg',
|
|
37
|
+
flac: 'audio/flac',
|
|
38
|
+
wav: 'audio/wav',
|
|
39
|
+
ogg: 'audio/ogg',
|
|
40
|
+
aac: 'audio/aac',
|
|
41
|
+
m4a: 'audio/mp4',
|
|
42
|
+
opus: 'audio/opus',
|
|
43
|
+
};
|
|
44
|
+
function inferMime(filePath) {
|
|
45
|
+
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
|
46
|
+
return MIME_MAP[ext] ?? 'audio/mpeg';
|
|
47
|
+
}
|
|
48
|
+
function appendMetadata(formData, fields) {
|
|
49
|
+
if (fields.track_title)
|
|
50
|
+
formData.append('track_title', fields.track_title);
|
|
51
|
+
if (fields.artist_name)
|
|
52
|
+
formData.append('artist_name', fields.artist_name);
|
|
53
|
+
if (fields.album_title)
|
|
54
|
+
formData.append('album_title', fields.album_title);
|
|
55
|
+
if (fields.genre)
|
|
56
|
+
formData.append('genre', fields.genre);
|
|
57
|
+
if (fields.library)
|
|
58
|
+
formData.append('library', String(fields.library));
|
|
59
|
+
}
|
|
60
|
+
export function register(server, uploadUrl, uploadToken) {
|
|
61
|
+
registerAppTool(server, 'upload_file', {
|
|
62
|
+
description: 'Upload an audio file to the LibreTime media library. Provide a URL to fetch the file from, or a local file path when running in stdio mode. When neither is given, opens a file picker. Optionally pre-fill metadata such as track title, artist, album, and genre.',
|
|
8
63
|
inputSchema: {
|
|
9
64
|
url: z.string().optional().describe('Publicly accessible URL of the audio file to upload'),
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
album_title: z.string().optional().describe('Album title'),
|
|
13
|
-
genre: z.string().optional().describe('Genre'),
|
|
65
|
+
file_path: z.string().optional().describe('Absolute path to a local audio file — use when running stdio mode'),
|
|
66
|
+
...metadataFields,
|
|
14
67
|
},
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
68
|
+
_meta: { ui: { resourceUri } },
|
|
69
|
+
}, async ({ url, file_path, track_title, artist_name, album_title, genre, library }) => {
|
|
70
|
+
const meta = { track_title, artist_name, album_title, genre, library };
|
|
71
|
+
// Priority 1: local file path (stdio mode — server runs on the user's machine)
|
|
72
|
+
if (file_path) {
|
|
73
|
+
let fileBuffer;
|
|
74
|
+
try {
|
|
75
|
+
fileBuffer = await fs.readFile(file_path);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return toolText({
|
|
79
|
+
status: 'error',
|
|
80
|
+
reason: `Could not read file: ${err instanceof Error ? err.message : String(err)}`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const fileName = path.basename(file_path);
|
|
84
|
+
const mime = inferMime(file_path);
|
|
85
|
+
const formData = new FormData();
|
|
86
|
+
formData.append('file', new Blob([new Uint8Array(fileBuffer)], { type: mime }), fileName);
|
|
87
|
+
formData.append('name', fileName);
|
|
88
|
+
formData.append('size', String(fileBuffer.byteLength));
|
|
89
|
+
formData.append('mime', mime);
|
|
90
|
+
formData.append('accessed', String(Math.floor(Date.now() / 1000)));
|
|
91
|
+
appendMetadata(formData, meta);
|
|
92
|
+
try {
|
|
93
|
+
const raw = await libreUpload('/api/v2/files', formData);
|
|
94
|
+
const file = LibreFileSchema.parse(raw);
|
|
95
|
+
return toolText({ status: 'success', file });
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return toolText({
|
|
99
|
+
status: 'error',
|
|
100
|
+
reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
101
|
+
});
|
|
24
102
|
}
|
|
25
103
|
}
|
|
26
|
-
|
|
27
|
-
|
|
104
|
+
// Priority 2: remote URL — fetch then forward to LibreTime
|
|
105
|
+
if (url) {
|
|
106
|
+
let fileResponse;
|
|
107
|
+
try {
|
|
108
|
+
fileResponse = await fetch(url);
|
|
109
|
+
if (!fileResponse.ok) {
|
|
110
|
+
return toolText({
|
|
111
|
+
status: 'error',
|
|
112
|
+
reason: `Could not fetch file: ${fileResponse.status} ${fileResponse.statusText}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
return toolText({
|
|
118
|
+
status: 'error',
|
|
119
|
+
reason: `Failed to reach URL: ${err instanceof Error ? err.message : String(err)}`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const fileName = url.split('/').pop()?.split('?')[0] || 'upload';
|
|
123
|
+
const blob = await fileResponse.blob();
|
|
124
|
+
const mime = blob.type || inferMime(fileName);
|
|
125
|
+
const formData = new FormData();
|
|
126
|
+
formData.append('file', blob, fileName);
|
|
127
|
+
formData.append('name', fileName);
|
|
128
|
+
formData.append('size', String(blob.size));
|
|
129
|
+
formData.append('mime', mime);
|
|
130
|
+
formData.append('accessed', String(Math.floor(Date.now() / 1000)));
|
|
131
|
+
appendMetadata(formData, meta);
|
|
132
|
+
try {
|
|
133
|
+
const raw = await libreUpload('/api/v2/files', formData);
|
|
134
|
+
const file = LibreFileSchema.parse(raw);
|
|
135
|
+
return toolText({ status: 'success', file });
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
return toolText({
|
|
139
|
+
status: 'error',
|
|
140
|
+
reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
28
143
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const formData = new FormData();
|
|
32
|
-
formData.append('file', blob, fileName);
|
|
33
|
-
if (track_title)
|
|
34
|
-
formData.append('track_title', track_title);
|
|
35
|
-
if (artist_name)
|
|
36
|
-
formData.append('artist_name', artist_name);
|
|
37
|
-
if (album_title)
|
|
38
|
-
formData.append('album_title', album_title);
|
|
39
|
-
if (genre)
|
|
40
|
-
formData.append('genre', genre);
|
|
144
|
+
// Priority 3: no input — hand off to the file picker UI
|
|
145
|
+
let libraries = [];
|
|
41
146
|
try {
|
|
42
|
-
const raw = await
|
|
43
|
-
|
|
44
|
-
return toolText({ status: 'success', file });
|
|
147
|
+
const raw = await libreGet('/api/v2/libraries');
|
|
148
|
+
libraries = LibrarySchema.array().parse(raw);
|
|
45
149
|
}
|
|
46
|
-
catch
|
|
47
|
-
|
|
150
|
+
catch {
|
|
151
|
+
// Non-fatal — UI will just show no dropdown
|
|
48
152
|
}
|
|
153
|
+
return toolText({
|
|
154
|
+
status: uploadUrl ? 'upload_ready' : 'upload_required',
|
|
155
|
+
upload_url: uploadUrl ?? null,
|
|
156
|
+
upload_token: uploadToken ?? null,
|
|
157
|
+
libraries,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
registerAppResource(server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async () => {
|
|
161
|
+
const html = await fs.readFile(HTML_PATH, 'utf-8');
|
|
162
|
+
return {
|
|
163
|
+
contents: [
|
|
164
|
+
{
|
|
165
|
+
uri: resourceUri,
|
|
166
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
167
|
+
text: html,
|
|
168
|
+
...(uploadUrl && {
|
|
169
|
+
_meta: { ui: { csp: { connectDomains: [uploadUrl] } } },
|
|
170
|
+
}),
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
49
174
|
});
|
|
50
175
|
}
|