@powerfm/libretime-mcp 0.1.5 → 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)**
@@ -86,14 +86,17 @@ Clone the repo, create a `.env` file with your credentials (see [Development](#d
86
86
  "mcpServers": {
87
87
  "libretime": {
88
88
  "command": "node",
89
- "args": ["/absolute/path/to/libretime-mcp/dist/stdio/admin.js"]
89
+ "args": ["/absolute/path/to/libretime-mcp/dist/stdio/admin.js"],
90
+ "env": {
91
+ "LIBRETIME_URL": "https://your-instance.example.com",
92
+ "LIBRETIME_USER": "user",
93
+ "LIBRETIME_PASS": "pass"
94
+ }
90
95
  }
91
96
  }
92
97
  }
93
98
  ```
94
99
 
95
- No `env` block needed — credentials are loaded automatically from `.env`.
96
-
97
100
  ## Option 2 — Self-hosted HTTP server
98
101
 
99
102
  Best for advanced setups — connect any MCP-compatible client over the network.
@@ -110,6 +113,7 @@ LIBRETIME_USER=your_api_username
110
113
  LIBRETIME_PASS=your_api_password
111
114
  MCP_API_KEY=your_secret_api_key # clients must send this as a Bearer token
112
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)
113
117
  ```
114
118
 
115
119
  Generate a random API key:
@@ -3,11 +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
+ import '../env.js';
7
7
  import { createRequire } from 'module';
8
8
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
10
10
  import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
11
+ import cors from 'cors';
11
12
  const { version } = createRequire(import.meta.url)('../../package.json');
12
13
  import { register as registerShows } from '../tools/shows/index.js';
13
14
  import { register as registerAnalytics } from '../tools/analytics/index.js';
@@ -19,6 +20,7 @@ if (!API_KEY) {
19
20
  process.exit(1);
20
21
  }
21
22
  const app = createMcpExpressApp({ host: '0.0.0.0' });
23
+ app.use(cors({ origin: process.env.CORS_ORIGIN ?? true, credentials: true }));
22
24
  // Simple API key middleware — checks Authorization: Bearer <key>
23
25
  app.use('/mcp', (req, res, next) => {
24
26
  const auth = req.headers['authorization'];
@@ -30,7 +32,7 @@ app.use('/mcp', (req, res, next) => {
30
32
  });
31
33
  // Stateless transport — a fresh McpServer per request keeps things simple
32
34
  // and avoids session management complexity for now
33
- app.post('/mcp', async (req, res) => {
35
+ app.all('/mcp', async (req, res) => {
34
36
  const server = new McpServer({ name: 'libretime-mcp-admin', version });
35
37
  registerShows(server);
36
38
  registerAnalytics(server);
@@ -3,11 +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
+ import '../env.js';
7
7
  import { createRequire } from 'module';
8
8
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
10
10
  import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
11
+ import cors from 'cors';
11
12
  import { register as registerShows } from '../tools/shows/index.js';
12
13
  const { version } = createRequire(import.meta.url)('../../package.json');
13
14
  const PORT = parseInt(process.env.MCP_PORT ?? '3001', 10);
@@ -17,6 +18,7 @@ if (!API_KEY) {
17
18
  process.exit(1);
18
19
  }
19
20
  const app = createMcpExpressApp({ host: '0.0.0.0' });
21
+ app.use(cors({ origin: process.env.CORS_ORIGIN ?? true, credentials: true }));
20
22
  // Simple API key middleware — checks Authorization: Bearer <key>
21
23
  app.use('/mcp', (req, res, next) => {
22
24
  const auth = req.headers['authorization'];
@@ -28,7 +30,7 @@ app.use('/mcp', (req, res, next) => {
28
30
  });
29
31
  // Stateless transport — a fresh McpServer per request keeps things simple
30
32
  // and avoids session management complexity for now
31
- app.post('/mcp', async (req, res) => {
33
+ app.all('/mcp', async (req, res) => {
32
34
  const server = new McpServer({ name: 'libretime-mcp-client', version });
33
35
  registerShows(server);
34
36
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import './env.js';
3
2
  import { createRequire } from 'module';
4
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import './env.js';
3
2
  import { createRequire } from 'module';
4
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -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.5",
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",