@powerfm/libretime-mcp 0.1.6 → 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.
@@ -1,44 +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
- import { createRequire } from 'module';
7
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
- import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
10
- const { version } = createRequire(import.meta.url)('../../package.json');
6
+ import '../env.js';
7
+ import crypto from 'node:crypto';
11
8
  import { register as registerShows } from '../tools/shows/index.js';
12
9
  import { register as registerAnalytics } from '../tools/analytics/index.js';
13
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';
14
14
  const PORT = parseInt(process.env.MCP_PORT ?? '3000', 10);
15
- const API_KEY = process.env.MCP_API_KEY;
16
- if (!API_KEY) {
17
- console.error('ERROR: MCP_API_KEY environment variable is required');
18
- process.exit(1);
19
- }
20
- const app = createMcpExpressApp({ host: '0.0.0.0' });
21
- // Simple API key middleware — checks Authorization: Bearer <key>
22
- app.use('/mcp', (req, res, next) => {
23
- const auth = req.headers['authorization'];
24
- if (!auth || auth !== `Bearer ${API_KEY}`) {
25
- res.status(401).json({ error: 'Unauthorized' });
26
- return;
27
- }
28
- next();
29
- });
30
- // Stateless transport — a fresh McpServer per request keeps things simple
31
- // and avoids session management complexity for now
32
- app.post('/mcp', async (req, res) => {
33
- const server = new McpServer({ name: 'libretime-mcp-admin', version });
34
- registerShows(server);
35
- registerAnalytics(server);
36
- registerAdmin(server);
37
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
38
- await server.connect(transport);
39
- await transport.handleRequest(req, res, req.body);
40
- });
41
- app.listen(PORT, '0.0.0.0', () => {
42
- console.error(`LibreTime MCP admin HTTP server listening on port ${PORT}`);
43
- 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
+ },
44
34
  });
@@ -3,38 +3,13 @@
3
3
  // Exposes POST /mcp using MCP Streamable HTTP transport.
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
- import { createRequire } from 'module';
7
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
- import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
6
+ import '../env.js';
10
7
  import { register as registerShows } from '../tools/shows/index.js';
11
- const { version } = createRequire(import.meta.url)('../../package.json');
12
- const PORT = parseInt(process.env.MCP_PORT ?? '3001', 10);
13
- const API_KEY = process.env.MCP_API_KEY;
14
- if (!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
- // Simple API key middleware — checks Authorization: Bearer <key>
20
- app.use('/mcp', (req, res, next) => {
21
- const auth = req.headers['authorization'];
22
- if (!auth || auth !== `Bearer ${API_KEY}`) {
23
- res.status(401).json({ error: 'Unauthorized' });
24
- return;
25
- }
26
- next();
27
- });
28
- // Stateless transport — a fresh McpServer per request keeps things simple
29
- // and avoids session management complexity for now
30
- app.post('/mcp', async (req, res) => {
31
- const server = new McpServer({ name: 'libretime-mcp-client', version });
32
- registerShows(server);
33
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
34
- await server.connect(transport);
35
- await transport.handleRequest(req, res, req.body);
36
- });
37
- app.listen(PORT, '0.0.0.0', () => {
38
- console.error(`LibreTime MCP client HTTP server listening on port ${PORT}`);
39
- 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
+ },
40
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
+ }
@@ -8,8 +8,8 @@ export function register(server) {
8
8
  }, async () => {
9
9
  const raw = await libreGet('/api/v2/users');
10
10
  const users = z.array(UserSchema).parse(raw);
11
- const trimmed = users.map(({ id, username, first_name, last_name, email, type }) => ({
12
- id, username, first_name, last_name, email, role: type,
11
+ const trimmed = users.map(({ id, username, first_name, last_name, email, role }) => ({
12
+ id, username, first_name, last_name, email, role,
13
13
  }));
14
14
  return toolText(trimmed);
15
15
  });
@@ -5,7 +5,7 @@ export const UserSchema = z.object({
5
5
  first_name: z.string(),
6
6
  last_name: z.string(),
7
7
  email: z.string(),
8
- type: z.string(),
8
+ role: z.string(),
9
9
  });
10
10
  export const ShowHostSchema = z.object({
11
11
  id: z.number(),
@@ -4,8 +4,11 @@ import { toolText } from '../../tool-response.js';
4
4
  import { ListenerCountSchema, MountNameSchema } from './types.js';
5
5
  export function register(server) {
6
6
  server.registerTool('get_listener_counts', {
7
- description: 'Get listener count history for PowerFM streams. Returns counts per mount point (stream URL) with timestamps. Useful for understanding peak listening times and audience size.',
8
- }, async () => {
7
+ description: 'Get listener count history for PowerFM streams. Returns the most recent entries per mount point with timestamps. Useful for understanding peak listening times and audience size.',
8
+ inputSchema: {
9
+ limit: z.number().int().min(1).max(500).default(100).describe('Number of most recent entries to return (default 100, max 500)'),
10
+ },
11
+ }, async ({ limit }) => {
9
12
  const [rawCounts, rawMounts] = await Promise.all([
10
13
  libreGet('/api/v2/listener-counts'),
11
14
  libreGet('/api/v2/mount-names'),
@@ -13,7 +16,9 @@ export function register(server) {
13
16
  const counts = z.array(ListenerCountSchema).parse(rawCounts);
14
17
  const mounts = z.array(MountNameSchema).parse(rawMounts);
15
18
  const mountMap = new Map(mounts.map((m) => [m.id, m.mount_name]));
16
- const enriched = counts.map(({ id, listener_count, timestamp, mount_name }) => ({
19
+ const enriched = counts
20
+ .slice(-limit)
21
+ .map(({ id, listener_count, timestamp, mount_name }) => ({
17
22
  id,
18
23
  listener_count,
19
24
  timestamp,
@@ -19,8 +19,10 @@ export function register(server) {
19
19
  const history = z.array(PlayoutHistorySchema).parse(rawHistory);
20
20
  // Collect unique file IDs then fetch metadata in parallel
21
21
  const fileIds = [...new Set(history.map((h) => h.file).filter((id) => id !== null))];
22
- const rawFiles = await Promise.all(fileIds.map((id) => libreGet(`/api/v2/files/${id}`)));
23
- const files = rawFiles.map((f) => FileMetadataSchema.parse(f));
22
+ const rawFiles = await Promise.allSettled(fileIds.map((id) => libreGet(`/api/v2/files/${id}`)));
23
+ const files = rawFiles
24
+ .map((r) => r.status === 'fulfilled' ? FileMetadataSchema.safeParse(r.value) : null)
25
+ .flatMap((r) => (r?.success ? [r.data] : []));
24
26
  const fileMap = new Map(files.map((f) => [f.id, f]));
25
27
  const enriched = history.map(({ id, starts, ends, file, instance }) => {
26
28
  const meta = file !== null ? fileMap.get(file) : undefined;
@@ -1,6 +1,4 @@
1
- import { register as registerGetListenerCounts } from './get_listener_counts.js';
2
1
  import { register as registerGetPlayoutHistory } from './get_playout_history.js';
3
2
  export function register(server) {
4
- registerGetListenerCounts(server);
5
3
  registerGetPlayoutHistory(server);
6
4
  }
@@ -1,10 +1,10 @@
1
- import { register as registerSearchFiles } from '../files/search_files.js';
2
- import { register as registerUploadFile } from '../files/upload_file.js';
3
- import { register as registerUpdateFileMetadata } from '../files/update_file_metadata.js';
4
- import { register as registerDeleteFile } from '../files/delete_file.js';
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,4 +1,10 @@
1
1
  import { z } from 'zod';
2
+ export const LibrarySchema = z.object({
3
+ id: z.number(),
4
+ name: z.string().nullable(),
5
+ code: z.string(),
6
+ enabled: z.boolean(),
7
+ });
2
8
  export const LibreFileSchema = z.object({
3
9
  id: z.number(),
4
10
  name: z.string(),
@@ -1,30 +1,67 @@
1
1
  import { z } from 'zod';
2
- import { libreUpload } from '../../libretime.js';
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
- export function register(server) {
6
- server.registerTool('upload_file', {
7
- description: 'Upload an audio file to the LibreTime media library from a URL. If no URL is provided, returns an action signal so the client can trigger a local file upload workflow. Optionally provide metadata such as track title, artist, album, and genre.',
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
- track_title: z.string().optional().describe('Track title'),
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
- }, async ({ url, track_title, artist_name, album_title, genre }) => {
28
+ _meta: { ui: { resourceUri } },
29
+ }, async ({ url, track_title, artist_name, album_title, genre, library }) => {
16
30
  if (!url) {
17
- return toolText({ status: 'upload_required', action: 'file_upload' });
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({ status: 'error', reason: `Could not fetch file: ${fileResponse.status} ${fileResponse.statusText}` });
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({ status: 'error', reason: `Failed to reach URL: ${err instanceof Error ? err.message : String(err)}` });
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({ status: 'error', reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}` });
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
  }
@@ -8,12 +8,17 @@ export const ShowSchema = z.object({
8
8
  });
9
9
  export const ScheduleItemSchema = z.object({
10
10
  id: z.number(),
11
- starts: z.string(),
12
- ends: z.string(),
13
- show_id: z.number(),
14
- show_name: z.string(),
11
+ starts_at: z.string(),
12
+ ends_at: z.string(),
13
+ instance: z.number(),
14
+ file: z.number().nullable(),
15
15
  broadcasted: z.number(),
16
- });
17
- export const StreamStateSchema = z.object({
18
- source_enabled: z.boolean(),
16
+ played: z.boolean(),
19
17
  }).passthrough();
18
+ export const StreamStateSchema = z.object({
19
+ input_main_connected: z.boolean(),
20
+ input_main_streaming: z.boolean(),
21
+ input_show_connected: z.boolean(),
22
+ input_show_streaming: z.boolean(),
23
+ schedule_streaming: z.boolean(),
24
+ });
package/package.json CHANGED
@@ -1,9 +1,17 @@
1
1
  {
2
2
  "name": "@powerfm/libretime-mcp",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
+ "license": "GPL-3.0",
6
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
+ ],
7
15
  "repository": {
8
16
  "type": "git",
9
17
  "url": "git+https://github.com/nelsonra/libretime-mcp.git"
@@ -20,13 +28,15 @@
20
28
  "README.md"
21
29
  ],
22
30
  "scripts": {
23
- "dev:client": "tsx --env-file=.env watch src/stdio/client.ts",
24
- "dev:admin": "tsx --env-file=.env watch src/stdio/admin.ts",
25
- "dev:client-http": "tsx --env-file=.env watch src/http/client.ts",
26
- "dev:admin-http": "tsx --env-file=.env watch src/http/admin.ts",
31
+ "dev:client": "tsx watch --env-file=.env src/stdio/client.ts",
32
+ "dev:admin": "tsx watch --env-file=.env src/stdio/admin.ts",
33
+ "dev:client-http": "tsx watch --env-file=.env src/http/client.ts",
34
+ "dev:admin-http": "concurrently \"npm run watch:app\" \"tsx watch --env-file=.env src/http/admin.ts\"",
27
35
  "clean": "rm -rf dist",
28
- "build": "npm run clean && tsc",
29
- "prepublishOnly": "npm run 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",
39
+ "prepublishOnly": "npm run build && npm test",
30
40
  "publish:patch": "npm version patch && npm publish --access public",
31
41
  "publish:minor": "npm version minor && npm publish --access public",
32
42
  "start:client": "node --env-file=.env dist/stdio/client.js",
@@ -34,19 +44,39 @@
34
44
  "start:client-http": "node --env-file=.env dist/http/client.js",
35
45
  "start:admin-http": "node --env-file=.env dist/http/admin.js",
36
46
  "generate:key": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
47
+ "inspect:client": "npx @modelcontextprotocol/inspector tsx --env-file=.env src/stdio/client.ts",
48
+ "inspect:admin": "npx @modelcontextprotocol/inspector tsx --env-file=.env src/stdio/admin.ts",
49
+ "inspect:admin-http": "npx @modelcontextprotocol/inspector --transport http --server-url http://localhost:3000/mcp --header \"Authorization: Bearer $MCP_API_KEY\"",
50
+ "inspect:client-http": "npx @modelcontextprotocol/inspector --transport http --server-url http://localhost:3001/mcp --header \"Authorization: Bearer $MCP_API_KEY\"",
37
51
  "test": "vitest run",
38
52
  "test:watch": "vitest"
39
53
  },
40
54
  "dependencies": {
55
+ "@modelcontextprotocol/ext-apps": "~1.3.2",
41
56
  "@modelcontextprotocol/sdk": "^1.0.0",
57
+ "cors": "~2.8.6",
42
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",
43
64
  "zod": "^3.23.8"
44
65
  },
45
66
  "devDependencies": {
67
+ "@types/cors": "~2.8.19",
46
68
  "@types/express": "~5.0.6",
69
+ "@types/morgan": "~1.9.10",
47
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",
48
76
  "tsx": "^4.19.0",
49
77
  "typescript": "^5.6.0",
78
+ "vite": "~8.0.3",
79
+ "vite-plugin-singlefile": "~2.3.2",
50
80
  "vitest": "~4.1.1"
51
81
  }
52
82
  }