@powerfm/libretime-mcp 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -16,7 +16,7 @@ Tools are organised into subdirectories under `src/tools/` — one file per tool
16
16
  - `get_stream_state` — current on-air state
17
17
 
18
18
  **Analytics (admin)**
19
- - `get_listener_counts`listener stats by mount point
19
+ - ~~`get_listener_counts`~~disabled (API returns full history with no filtering, ~120k records)
20
20
  - `get_playout_history` — recent playout history with track metadata
21
21
 
22
22
  **Media library (admin)**
@@ -113,6 +113,7 @@ LIBRETIME_USER=your_api_username
113
113
  LIBRETIME_PASS=your_api_password
114
114
  MCP_API_KEY=your_secret_api_key # clients must send this as a Bearer token
115
115
  MCP_PORT=3000 # optional, defaults to 3000 (admin) / 3001 (client)
116
+ CORS_ORIGIN=https://your-app.example.com # optional, lock CORS to a specific origin (default: reflect any)
116
117
  ```
117
118
 
118
119
  Generate a random API key:
@@ -3,10 +3,12 @@
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 (e.g. powerfm-agent). For Claude Desktop use stdio/admin.ts.
6
+ import '../env.js';
6
7
  import { createRequire } from 'module';
7
8
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
9
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
10
  import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
11
+ import cors from 'cors';
10
12
  const { version } = createRequire(import.meta.url)('../../package.json');
11
13
  import { register as registerShows } from '../tools/shows/index.js';
12
14
  import { register as registerAnalytics } from '../tools/analytics/index.js';
@@ -18,6 +20,7 @@ if (!API_KEY) {
18
20
  process.exit(1);
19
21
  }
20
22
  const app = createMcpExpressApp({ host: '0.0.0.0' });
23
+ app.use(cors({ origin: process.env.CORS_ORIGIN ?? true, credentials: true }));
21
24
  // Simple API key middleware — checks Authorization: Bearer <key>
22
25
  app.use('/mcp', (req, res, next) => {
23
26
  const auth = req.headers['authorization'];
@@ -29,7 +32,7 @@ app.use('/mcp', (req, res, next) => {
29
32
  });
30
33
  // Stateless transport — a fresh McpServer per request keeps things simple
31
34
  // and avoids session management complexity for now
32
- app.post('/mcp', async (req, res) => {
35
+ app.all('/mcp', async (req, res) => {
33
36
  const server = new McpServer({ name: 'libretime-mcp-admin', version });
34
37
  registerShows(server);
35
38
  registerAnalytics(server);
@@ -3,10 +3,12 @@
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 '../env.js';
6
7
  import { createRequire } from 'module';
7
8
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
9
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
10
  import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
11
+ import cors from 'cors';
10
12
  import { register as registerShows } from '../tools/shows/index.js';
11
13
  const { version } = createRequire(import.meta.url)('../../package.json');
12
14
  const PORT = parseInt(process.env.MCP_PORT ?? '3001', 10);
@@ -16,6 +18,7 @@ if (!API_KEY) {
16
18
  process.exit(1);
17
19
  }
18
20
  const app = createMcpExpressApp({ host: '0.0.0.0' });
21
+ app.use(cors({ origin: process.env.CORS_ORIGIN ?? true, credentials: true }));
19
22
  // Simple API key middleware — checks Authorization: Bearer <key>
20
23
  app.use('/mcp', (req, res, next) => {
21
24
  const auth = req.headers['authorization'];
@@ -27,7 +30,7 @@ app.use('/mcp', (req, res, next) => {
27
30
  });
28
31
  // Stateless transport — a fresh McpServer per request keeps things simple
29
32
  // and avoids session management complexity for now
30
- app.post('/mcp', async (req, res) => {
33
+ app.all('/mcp', async (req, res) => {
31
34
  const server = new McpServer({ name: 'libretime-mcp-client', version });
32
35
  registerShows(server);
33
36
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
@@ -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
  }
@@ -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,8 +1,9 @@
1
1
  {
2
2
  "name": "@powerfm/libretime-mcp",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
7
8
  "repository": {
8
9
  "type": "git",
@@ -20,13 +21,13 @@
20
21
  "README.md"
21
22
  ],
22
23
  "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",
24
+ "dev:client": "tsx watch --env-file=.env src/stdio/client.ts",
25
+ "dev:admin": "tsx watch --env-file=.env src/stdio/admin.ts",
26
+ "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",
27
28
  "clean": "rm -rf dist",
28
29
  "build": "npm run clean && tsc",
29
- "prepublishOnly": "npm run build",
30
+ "prepublishOnly": "npm run build && npm test",
30
31
  "publish:patch": "npm version patch && npm publish --access public",
31
32
  "publish:minor": "npm version minor && npm publish --access public",
32
33
  "start:client": "node --env-file=.env dist/stdio/client.js",
@@ -34,15 +35,21 @@
34
35
  "start:client-http": "node --env-file=.env dist/http/client.js",
35
36
  "start:admin-http": "node --env-file=.env dist/http/admin.js",
36
37
  "generate:key": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
38
+ "inspect:client": "npx @modelcontextprotocol/inspector tsx --env-file=.env src/stdio/client.ts",
39
+ "inspect:admin": "npx @modelcontextprotocol/inspector tsx --env-file=.env src/stdio/admin.ts",
40
+ "inspect:admin-http": "npx @modelcontextprotocol/inspector --transport http --server-url http://localhost:3000/mcp --header \"Authorization: Bearer $MCP_API_KEY\"",
41
+ "inspect:client-http": "npx @modelcontextprotocol/inspector --transport http --server-url http://localhost:3001/mcp --header \"Authorization: Bearer $MCP_API_KEY\"",
37
42
  "test": "vitest run",
38
43
  "test:watch": "vitest"
39
44
  },
40
45
  "dependencies": {
41
46
  "@modelcontextprotocol/sdk": "^1.0.0",
47
+ "cors": "~2.8.6",
42
48
  "express": "~5.2.1",
43
49
  "zod": "^3.23.8"
44
50
  },
45
51
  "devDependencies": {
52
+ "@types/cors": "~2.8.19",
46
53
  "@types/express": "~5.0.6",
47
54
  "@types/node": "^22.0.0",
48
55
  "tsx": "^4.19.0",