@powerfm/libretime-mcp 0.1.8 → 0.2.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 +73 -0
- package/dist/tools/files/index.js +6 -6
- package/dist/tools/files/types.js +6 -0
- package/dist/tools/files/upload_file.js +73 -14
- 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,73 @@
|
|
|
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
|
+
const USER = process.env.LIBRETIME_USER ?? '';
|
|
34
|
+
const PASS = process.env.LIBRETIME_PASS ?? '';
|
|
35
|
+
const libreAuth = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
|
|
36
|
+
const libreUrl = new URL('/api/v2/files', BASE_URL).toString();
|
|
37
|
+
try {
|
|
38
|
+
const body = await new Promise((resolve, reject) => {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
41
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
42
|
+
req.on('error', reject);
|
|
43
|
+
});
|
|
44
|
+
const sizeMb = (body.length / 1024 / 1024).toFixed(2);
|
|
45
|
+
const libreStart = Date.now();
|
|
46
|
+
const response = await fetch(libreUrl, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: libreAuth,
|
|
50
|
+
Accept: 'application/json',
|
|
51
|
+
'Content-Type': req.headers['content-type'],
|
|
52
|
+
'Content-Length': String(body.length),
|
|
53
|
+
},
|
|
54
|
+
body: new Uint8Array(body),
|
|
55
|
+
});
|
|
56
|
+
const libreMs = Date.now() - libreStart;
|
|
57
|
+
const totalMs = Date.now() - totalStart;
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const detail = await response.text();
|
|
60
|
+
console.error(`[upload] size=${sizeMb}mb libretime_ms=${libreMs} total_ms=${totalMs} status=${response.status} error="${detail}"`);
|
|
61
|
+
res.status(502).json({ error: `LibreTime error: ${response.status}`, detail });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
console.error(`[upload] size=${sizeMb}mb libretime_ms=${libreMs} total_ms=${totalMs} status=${response.status}`);
|
|
65
|
+
res.json(await response.json());
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
console.error(`[upload] total_ms=${Date.now() - totalStart} status=500 error="${err instanceof Error ? err.message : String(err)}"`);
|
|
69
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return `${publicUrl}/upload`;
|
|
73
|
+
}
|
|
@@ -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.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,30 +1,67 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from '@modelcontextprotocol/ext-apps/server';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { libreGet, libreUpload } from '../../libretime.js';
|
|
3
6
|
import { toolText } from '../../tool-response.js';
|
|
4
|
-
import { LibreFileSchema } from './types.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
import { LibreFileSchema, LibrarySchema } from './types.js';
|
|
8
|
+
// When running via tsx (dev): __dirname is src/tools/files/ → walk up to project root, then into dist/
|
|
9
|
+
// When running compiled: __dirname is dist/tools/files/ → walk up two levels to dist/
|
|
10
|
+
const HTML_PATH = import.meta.filename.endsWith('.ts')
|
|
11
|
+
? path.join(import.meta.dirname, '../../../dist/apps/upload-file.html')
|
|
12
|
+
: path.join(import.meta.dirname, '../../apps/upload-file.html');
|
|
13
|
+
const resourceUri = 'ui://libretime/upload-file.html';
|
|
14
|
+
const metadataFields = {
|
|
15
|
+
track_title: z.string().optional().describe('Track title'),
|
|
16
|
+
artist_name: z.string().optional().describe('Artist name'),
|
|
17
|
+
album_title: z.string().optional().describe('Album title'),
|
|
18
|
+
genre: z.string().optional().describe('Genre'),
|
|
19
|
+
library: z.number().optional().describe('Track type / library ID'),
|
|
20
|
+
};
|
|
21
|
+
export function register(server, uploadUrl, uploadToken) {
|
|
22
|
+
registerAppTool(server, 'upload_file', {
|
|
23
|
+
description: 'Upload an audio file to the LibreTime media library. When no URL is provided, opens a file picker. Optionally pre-fill metadata such as track title, artist, album, and genre.',
|
|
8
24
|
inputSchema: {
|
|
9
25
|
url: z.string().optional().describe('Publicly accessible URL of the audio file to upload'),
|
|
10
|
-
|
|
11
|
-
artist_name: z.string().optional().describe('Artist name'),
|
|
12
|
-
album_title: z.string().optional().describe('Album title'),
|
|
13
|
-
genre: z.string().optional().describe('Genre'),
|
|
26
|
+
...metadataFields,
|
|
14
27
|
},
|
|
15
|
-
|
|
28
|
+
_meta: { ui: { resourceUri } },
|
|
29
|
+
}, async ({ url, track_title, artist_name, album_title, genre, library }) => {
|
|
16
30
|
if (!url) {
|
|
17
|
-
|
|
31
|
+
// Fetch track type options for the UI dropdown
|
|
32
|
+
let libraries = [];
|
|
33
|
+
try {
|
|
34
|
+
const raw = await libreGet('/api/v2/libraries');
|
|
35
|
+
libraries = LibrarySchema.array().parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Non-fatal — UI will just show no dropdown
|
|
39
|
+
}
|
|
40
|
+
// No URL — hand off to the UI.
|
|
41
|
+
// If this is the HTTP server, include an upload_url so the UI can POST directly.
|
|
42
|
+
// If this is stdio, upload_url is null and the UI will ask for a URL instead.
|
|
43
|
+
return toolText({
|
|
44
|
+
status: uploadUrl ? 'upload_ready' : 'upload_required',
|
|
45
|
+
upload_url: uploadUrl ?? null,
|
|
46
|
+
upload_token: uploadToken ?? null,
|
|
47
|
+
libraries,
|
|
48
|
+
});
|
|
18
49
|
}
|
|
19
50
|
let fileResponse;
|
|
20
51
|
try {
|
|
21
52
|
fileResponse = await fetch(url);
|
|
22
53
|
if (!fileResponse.ok) {
|
|
23
|
-
return toolText({
|
|
54
|
+
return toolText({
|
|
55
|
+
status: 'error',
|
|
56
|
+
reason: `Could not fetch file: ${fileResponse.status} ${fileResponse.statusText}`,
|
|
57
|
+
});
|
|
24
58
|
}
|
|
25
59
|
}
|
|
26
60
|
catch (err) {
|
|
27
|
-
return toolText({
|
|
61
|
+
return toolText({
|
|
62
|
+
status: 'error',
|
|
63
|
+
reason: `Failed to reach URL: ${err instanceof Error ? err.message : String(err)}`,
|
|
64
|
+
});
|
|
28
65
|
}
|
|
29
66
|
const fileName = url.split('/').pop()?.split('?')[0] || 'upload';
|
|
30
67
|
const blob = await fileResponse.blob();
|
|
@@ -38,13 +75,35 @@ export function register(server) {
|
|
|
38
75
|
formData.append('album_title', album_title);
|
|
39
76
|
if (genre)
|
|
40
77
|
formData.append('genre', genre);
|
|
78
|
+
if (library)
|
|
79
|
+
formData.append('library', String(library));
|
|
41
80
|
try {
|
|
42
81
|
const raw = await libreUpload('/api/v2/files', formData);
|
|
43
82
|
const file = LibreFileSchema.parse(raw);
|
|
44
83
|
return toolText({ status: 'success', file });
|
|
45
84
|
}
|
|
46
85
|
catch (err) {
|
|
47
|
-
return toolText({
|
|
86
|
+
return toolText({
|
|
87
|
+
status: 'error',
|
|
88
|
+
reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
89
|
+
});
|
|
48
90
|
}
|
|
49
91
|
});
|
|
92
|
+
// Serve the bundled React app HTML.
|
|
93
|
+
// When an upload URL is configured, allow the iframe to connect to it (CSP connect-src).
|
|
94
|
+
registerAppResource(server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async () => {
|
|
95
|
+
const html = await fs.readFile(HTML_PATH, 'utf-8');
|
|
96
|
+
return {
|
|
97
|
+
contents: [
|
|
98
|
+
{
|
|
99
|
+
uri: resourceUri,
|
|
100
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
101
|
+
text: html,
|
|
102
|
+
...(uploadUrl && {
|
|
103
|
+
_meta: { ui: { csp: { connectDomains: [uploadUrl] } } },
|
|
104
|
+
}),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
});
|
|
50
109
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@powerfm/libretime-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "GPL-3.0",
|
|
7
7
|
"description": "MCP server for LibreTime radio station — connect Claude to your broadcast schedule, media library, and analytics",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"mcp",
|
|
10
|
+
"modelcontextprotocol",
|
|
11
|
+
"libretime",
|
|
12
|
+
"radio",
|
|
13
|
+
"claude"
|
|
14
|
+
],
|
|
8
15
|
"repository": {
|
|
9
16
|
"type": "git",
|
|
10
17
|
"url": "git+https://github.com/nelsonra/libretime-mcp.git"
|
|
@@ -24,9 +31,11 @@
|
|
|
24
31
|
"dev:client": "tsx watch --env-file=.env src/stdio/client.ts",
|
|
25
32
|
"dev:admin": "tsx watch --env-file=.env src/stdio/admin.ts",
|
|
26
33
|
"dev:client-http": "tsx watch --env-file=.env src/http/client.ts",
|
|
27
|
-
"dev:admin-http": "tsx watch --env-file=.env src/http/admin.ts",
|
|
34
|
+
"dev:admin-http": "concurrently \"npm run watch:app\" \"tsx watch --env-file=.env src/http/admin.ts\"",
|
|
28
35
|
"clean": "rm -rf dist",
|
|
29
|
-
"build": "
|
|
36
|
+
"build:app": "cross-env INPUT=apps/upload-file.html vite build",
|
|
37
|
+
"watch:app": "cross-env NODE_ENV=development INPUT=apps/upload-file.html vite build --watch",
|
|
38
|
+
"build": "npm run clean && npm run build:app && tsc",
|
|
30
39
|
"prepublishOnly": "npm run build && npm test",
|
|
31
40
|
"publish:patch": "npm version patch && npm publish --access public",
|
|
32
41
|
"publish:minor": "npm version minor && npm publish --access public",
|
|
@@ -43,17 +52,31 @@
|
|
|
43
52
|
"test:watch": "vitest"
|
|
44
53
|
},
|
|
45
54
|
"dependencies": {
|
|
55
|
+
"@modelcontextprotocol/ext-apps": "~1.3.2",
|
|
46
56
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
47
57
|
"cors": "~2.8.6",
|
|
48
58
|
"express": "~5.2.1",
|
|
59
|
+
"express-rate-limit": "~8.3.2",
|
|
60
|
+
"helmet": "~8.1.0",
|
|
61
|
+
"morgan": "~1.10.1",
|
|
62
|
+
"react": "~19.2.4",
|
|
63
|
+
"react-dom": "~19.2.4",
|
|
49
64
|
"zod": "^3.23.8"
|
|
50
65
|
},
|
|
51
66
|
"devDependencies": {
|
|
52
67
|
"@types/cors": "~2.8.19",
|
|
53
68
|
"@types/express": "~5.0.6",
|
|
69
|
+
"@types/morgan": "~1.9.10",
|
|
54
70
|
"@types/node": "^22.0.0",
|
|
71
|
+
"@types/react": "~19.2.14",
|
|
72
|
+
"@types/react-dom": "~19.2.3",
|
|
73
|
+
"@vitejs/plugin-react": "~6.0.1",
|
|
74
|
+
"concurrently": "~9.2.1",
|
|
75
|
+
"cross-env": "~10.1.0",
|
|
55
76
|
"tsx": "^4.19.0",
|
|
56
77
|
"typescript": "^5.6.0",
|
|
78
|
+
"vite": "~8.0.3",
|
|
79
|
+
"vite-plugin-singlefile": "~2.3.2",
|
|
57
80
|
"vitest": "~4.1.1"
|
|
58
81
|
}
|
|
59
82
|
}
|