@powerfm/libretime-mcp 0.1.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.
Files changed (36) hide show
  1. package/README.md +143 -0
  2. package/dist/env.js +9 -0
  3. package/dist/http/admin.js +43 -0
  4. package/dist/http/client.js +39 -0
  5. package/dist/index.js +48 -0
  6. package/dist/libretime.js +103 -0
  7. package/dist/stdio/admin.js +20 -0
  8. package/dist/stdio/client.js +13 -0
  9. package/dist/tool-response.js +7 -0
  10. package/dist/tools/admin/delete_file.js +14 -0
  11. package/dist/tools/admin/get_hosts.js +28 -0
  12. package/dist/tools/admin/get_users.js +16 -0
  13. package/dist/tools/admin/index.js +6 -0
  14. package/dist/tools/admin/search_files.js +22 -0
  15. package/dist/tools/admin/types.js +14 -0
  16. package/dist/tools/admin/update_file_metadata.js +29 -0
  17. package/dist/tools/admin/upload_file.js +50 -0
  18. package/dist/tools/admin.js +153 -0
  19. package/dist/tools/analytics/get_listener_counts.js +24 -0
  20. package/dist/tools/analytics/get_playout_history.js +41 -0
  21. package/dist/tools/analytics/index.js +6 -0
  22. package/dist/tools/analytics/types.js +26 -0
  23. package/dist/tools/analytics.js +53 -0
  24. package/dist/tools/files/delete_file.js +14 -0
  25. package/dist/tools/files/index.js +10 -0
  26. package/dist/tools/files/search_files.js +22 -0
  27. package/dist/tools/files/types.js +14 -0
  28. package/dist/tools/files/update_file_metadata.js +29 -0
  29. package/dist/tools/files/upload_file.js +50 -0
  30. package/dist/tools/shows/get_schedule.js +22 -0
  31. package/dist/tools/shows/get_shows.js +14 -0
  32. package/dist/tools/shows/get_stream_state.js +10 -0
  33. package/dist/tools/shows/index.js +8 -0
  34. package/dist/tools/shows/types.js +19 -0
  35. package/dist/tools/shows.js +30 -0
  36. package/package.json +44 -0
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # libretime-mcp
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that connects Claude (or any MCP-compatible AI client) to a [LibreTime](https://libretime.org) radio station via its REST API. Ask Claude to check your schedule, manage files, pull listener stats, and more — directly from your station.
4
+
5
+ ## Servers
6
+
7
+ Two access levels, each available in stdio and HTTP flavors:
8
+
9
+ | Entry point | Transport | Tools |
10
+ |---|---|---|
11
+ | `src/stdio/client.ts` | stdio | Shows, schedule, stream state (read-only) |
12
+ | `src/stdio/admin.ts` | stdio | All client tools + analytics + file/user management |
13
+ | `src/http/client.ts` | HTTP :3001 | Same as client, over the network |
14
+ | `src/http/admin.ts` | HTTP :3000 | Same as admin, over the network |
15
+
16
+ Use **stdio** for Claude Desktop. Use **HTTP** for server-to-server integrations (e.g. an AI agent or backend service calling over the network).
17
+
18
+ ## Tools
19
+
20
+ Tools are organised into subdirectories under `src/tools/` — one file per tool.
21
+
22
+ | Tool | Server | Description |
23
+ |---|---|---|
24
+ | `get_shows` | both | List shows |
25
+ | `get_schedule` | both | Fetch the broadcast schedule |
26
+ | `get_stream_state` | both | Current stream/on-air state |
27
+ | `get_listener_counts` | admin | Listener stats by mount point |
28
+ | `get_playout_history` | admin | Recent playout history with file metadata |
29
+ | `search_files` | admin | Search the media library |
30
+ | `upload_file` | admin | Upload an audio file |
31
+ | `update_file_metadata` | admin | Edit metadata for a file |
32
+ | `delete_file` | admin | Delete a file from the library |
33
+ | `get_users` | admin | List station users |
34
+ | `get_hosts` | admin | List show hosts with enriched user details |
35
+
36
+ ## Setup
37
+
38
+ ```bash
39
+ npm install @powerfm/libretime-mcp
40
+ ```
41
+
42
+ Or run without installing:
43
+
44
+ ```bash
45
+ npx @powerfm/libretime-mcp
46
+ ```
47
+
48
+ If you're working from source:
49
+
50
+ ```bash
51
+ npm install
52
+ ```
53
+
54
+ Copy and fill in your environment variables:
55
+
56
+ ```bash
57
+ # LibreTime instance (required for all servers)
58
+ LIBRETIME_URL=https://your-libretime-instance.example.com
59
+ LIBRETIME_USER=your_api_username
60
+ LIBRETIME_PASS=your_api_password
61
+
62
+ # Required for HTTP servers only
63
+ MCP_API_KEY=your_secret_api_key
64
+ MCP_PORT=3000 # optional, defaults to 3000 (admin) / 3001 (client)
65
+ ```
66
+
67
+ Generate a random API key:
68
+
69
+ ```bash
70
+ npm run generate:key
71
+ ```
72
+
73
+ ## Commands
74
+
75
+ ```bash
76
+ # Development (tsx watch — restarts on save)
77
+ npm run dev:client # read-only stdio server
78
+ npm run dev:admin # full-access stdio server
79
+ npm run dev:client-http # read-only HTTP server (port 3001)
80
+ npm run dev:admin-http # full-access HTTP server (port 3000)
81
+
82
+ # Build TypeScript → dist/
83
+ npm run build
84
+
85
+ # Run built output
86
+ npm run start:client
87
+ npm run start:admin
88
+ npm run start:client-http
89
+ npm run start:admin-http
90
+
91
+ # Tests
92
+ npm test
93
+ ```
94
+
95
+ ## Using with Claude Desktop (stdio)
96
+
97
+ Claude Desktop spawns the server as a subprocess and manages its lifecycle — nothing extra to run.
98
+
99
+ Add to your `~/Library/Application Support/Claude/claude_desktop_config.json`:
100
+
101
+ ```json
102
+ {
103
+ "mcpServers": {
104
+ "libretime": {
105
+ "command": "npx",
106
+ "args": ["tsx", "/absolute/path/to/libretime-mcp/src/stdio/admin.ts"],
107
+ "env": {
108
+ "LIBRETIME_URL": "https://your-instance.example.com",
109
+ "LIBRETIME_USER": "user",
110
+ "LIBRETIME_PASS": "pass"
111
+ }
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ `npx tsx` resolves from the project's local `node_modules` — no global install needed as long as you've run `npm install` first.
118
+
119
+ Or point at the built output (`npm run build` first):
120
+
121
+ ```json
122
+ "command": "node",
123
+ "args": ["/absolute/path/to/libretime-mcp/dist/stdio/admin.js"]
124
+ ```
125
+
126
+ ## Using the HTTP Server (server-to-server)
127
+
128
+ The HTTP servers are designed for network clients — e.g. an AI agent or backend service calling LibreTime tools over the network. Claude Desktop does not support HTTP MCP servers directly; use stdio above for Desktop.
129
+
130
+ Start the server:
131
+
132
+ ```bash
133
+ npm run start:admin-http # full access, port 3000
134
+ npm run start:client-http # read-only, port 3001
135
+ ```
136
+
137
+ All requests must go to `POST /mcp` with the API key header:
138
+
139
+ ```
140
+ Authorization: Bearer <MCP_API_KEY>
141
+ ```
142
+
143
+ Requests without a valid key receive `401 Unauthorized`.
package/dist/env.js ADDED
@@ -0,0 +1,9 @@
1
+ // This module must be the first import in any entry point that needs .env loaded.
2
+ // ES module imports are hoisted and evaluated before any code runs, so we can't
3
+ // call process.loadEnvFile() inline and have it take effect before other imports.
4
+ // Importing this file first ensures .env is loaded before any other module reads env vars.
5
+ try {
6
+ process.loadEnvFile();
7
+ }
8
+ catch { }
9
+ export {};
@@ -0,0 +1,43 @@
1
+ // HTTP MCP server — full admin access (shows + analytics + file/user management).
2
+ // Exposes POST /mcp using MCP Streamable HTTP transport.
3
+ // Requires Authorization: Bearer <MCP_API_KEY> on every request.
4
+ // Intended for network clients (e.g. powerfm-agent). For Claude Desktop use stdio/admin.ts.
5
+ import { createRequire } from 'module';
6
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
+ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
9
+ const { version } = createRequire(import.meta.url)('../../package.json');
10
+ import { register as registerShows } from '../tools/shows/index.js';
11
+ import { register as registerAnalytics } from '../tools/analytics/index.js';
12
+ import { register as registerAdmin } from '../tools/admin/index.js';
13
+ const PORT = parseInt(process.env.MCP_PORT ?? '3000', 10);
14
+ const API_KEY = process.env.MCP_API_KEY;
15
+ if (!API_KEY) {
16
+ console.error('ERROR: MCP_API_KEY environment variable is required');
17
+ process.exit(1);
18
+ }
19
+ const app = createMcpExpressApp({ host: '0.0.0.0' });
20
+ // Simple API key middleware — checks Authorization: Bearer <key>
21
+ app.use('/mcp', (req, res, next) => {
22
+ const auth = req.headers['authorization'];
23
+ if (!auth || auth !== `Bearer ${API_KEY}`) {
24
+ res.status(401).json({ error: 'Unauthorized' });
25
+ return;
26
+ }
27
+ next();
28
+ });
29
+ // Stateless transport — a fresh McpServer per request keeps things simple
30
+ // and avoids session management complexity for now
31
+ app.post('/mcp', async (req, res) => {
32
+ const server = new McpServer({ name: 'libretime-mcp-admin', version });
33
+ registerShows(server);
34
+ registerAnalytics(server);
35
+ registerAdmin(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 admin HTTP server listening on port ${PORT}`);
42
+ console.error('Endpoint: POST /mcp (Authorization: Bearer <MCP_API_KEY>)');
43
+ });
@@ -0,0 +1,39 @@
1
+ // HTTP MCP server — read-only access (shows, schedule, stream state only).
2
+ // Exposes POST /mcp using MCP Streamable HTTP transport.
3
+ // Requires Authorization: Bearer <MCP_API_KEY> on every request.
4
+ // Intended for network clients. For Claude Desktop use stdio/client.ts.
5
+ import { createRequire } from 'module';
6
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
+ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
9
+ import { register as registerShows } from '../tools/shows/index.js';
10
+ const { version } = createRequire(import.meta.url)('../../package.json');
11
+ const PORT = parseInt(process.env.MCP_PORT ?? '3001', 10);
12
+ const API_KEY = process.env.MCP_API_KEY;
13
+ if (!API_KEY) {
14
+ console.error('ERROR: MCP_API_KEY environment variable is required');
15
+ process.exit(1);
16
+ }
17
+ const app = createMcpExpressApp({ host: '0.0.0.0' });
18
+ // Simple API key middleware — checks Authorization: Bearer <key>
19
+ app.use('/mcp', (req, res, next) => {
20
+ const auth = req.headers['authorization'];
21
+ if (!auth || auth !== `Bearer ${API_KEY}`) {
22
+ res.status(401).json({ error: 'Unauthorized' });
23
+ return;
24
+ }
25
+ next();
26
+ });
27
+ // Stateless transport — a fresh McpServer per request keeps things simple
28
+ // and avoids session management complexity for now
29
+ app.post('/mcp', async (req, res) => {
30
+ const server = new McpServer({ name: 'libretime-mcp-client', version });
31
+ registerShows(server);
32
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
33
+ await server.connect(transport);
34
+ await transport.handleRequest(req, res, req.body);
35
+ });
36
+ app.listen(PORT, '0.0.0.0', () => {
37
+ console.error(`LibreTime MCP client HTTP server listening on port ${PORT}`);
38
+ console.error('Endpoint: POST /mcp (Authorization: Bearer <MCP_API_KEY>)');
39
+ });
package/dist/index.js ADDED
@@ -0,0 +1,48 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { z } from 'zod';
4
+ import { libreGet } from './libretime.js';
5
+ // ---- MCP Server ----------------------------------------------------------
6
+ const server = new McpServer({
7
+ name: 'libretime-mcp',
8
+ version: '0.1.0',
9
+ });
10
+ // Tool: get_shows
11
+ server.registerTool('get_shows', {
12
+ description: 'List all shows registered in LibreTime. Returns each show\'s id, name, description, and genre.'
13
+ }, async () => {
14
+ const shows = await libreGet('/api/v2/shows');
15
+ const trimmed = shows.map(({ id, name, description, genre, url }) => ({ id, name, description, genre, url }));
16
+ return {
17
+ content: [{ type: 'text', text: JSON.stringify(trimmed) }],
18
+ };
19
+ });
20
+ // Tool: get_schedule
21
+ server.registerTool('get_schedule', {
22
+ description: 'Get the broadcast schedule for a date range. Use starts_after and starts_before to filter by date (ISO 8601 format, e.g. 2024-06-01T00:00:00Z).',
23
+ inputSchema: {
24
+ starts_after: z.string().describe('Return items starting after this datetime (ISO 8601)'),
25
+ starts_before: z.string().describe('Return items starting before this datetime (ISO 8601)'),
26
+ }
27
+ }, async ({ starts_after, starts_before }) => {
28
+ const params = {};
29
+ if (starts_after)
30
+ params.starts_after = starts_after;
31
+ if (starts_before)
32
+ params.starts_before = starts_before;
33
+ const schedule = await libreGet('/api/v2/schedule', params);
34
+ return {
35
+ content: [{ type: 'text', text: JSON.stringify(schedule) }],
36
+ };
37
+ });
38
+ // Tool: get_stream_state
39
+ server.registerTool('get_stream_state', { description: 'Check whether the PowerFM station is currently broadcasting live.' }, async () => {
40
+ const state = await libreGet('/api/v2/stream/state');
41
+ return {
42
+ content: [{ type: 'text', text: JSON.stringify(state) }],
43
+ };
44
+ });
45
+ // ---- Start ---------------------------------------------------------------
46
+ const transport = new StdioServerTransport();
47
+ await server.connect(transport);
48
+ console.error('LibreTime MCP server running');
@@ -0,0 +1,103 @@
1
+ const BASE_URL = process.env.LIBRETIME_URL ?? '';
2
+ const USER = process.env.LIBRETIME_USER ?? '';
3
+ const PASS = process.env.LIBRETIME_PASS ?? '';
4
+ // Basic Auth header value, base64-encoded as the HTTP spec requires
5
+ const authHeader = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
6
+ /**
7
+ * Make an authenticated GET request to the LibreTime API.
8
+ * Returns the parsed JSON response as unknown — callers are responsible for validation.
9
+ */
10
+ export async function libreGet(path, params) {
11
+ const url = new URL(path, BASE_URL);
12
+ if (params) {
13
+ for (const [key, value] of Object.entries(params)) {
14
+ url.searchParams.set(key, value);
15
+ }
16
+ }
17
+ const response = await fetch(url.toString(), {
18
+ headers: {
19
+ Authorization: authHeader,
20
+ Accept: 'application/json',
21
+ },
22
+ });
23
+ if (!response.ok) {
24
+ throw new Error(`LibreTime API error: ${response.status} ${response.statusText} — ${url.toString()}`);
25
+ }
26
+ return response.json();
27
+ }
28
+ /**
29
+ * Make an authenticated POST request to the LibreTime API with a JSON body.
30
+ * Returns the parsed JSON response as unknown — callers are responsible for validation.
31
+ */
32
+ export async function librePost(path, body) {
33
+ const url = new URL(path, BASE_URL);
34
+ const response = await fetch(url.toString(), {
35
+ method: 'POST',
36
+ headers: {
37
+ Authorization: authHeader,
38
+ 'Content-Type': 'application/json',
39
+ Accept: 'application/json',
40
+ },
41
+ body: JSON.stringify(body),
42
+ });
43
+ if (!response.ok) {
44
+ throw new Error(`LibreTime API error: ${response.status} ${response.statusText} — ${url.toString()}`);
45
+ }
46
+ return response.json();
47
+ }
48
+ /**
49
+ * Make an authenticated multipart POST request to the LibreTime API.
50
+ * Used for file uploads. Returns the parsed JSON response as unknown.
51
+ */
52
+ export async function libreUpload(path, formData) {
53
+ const url = new URL(path, BASE_URL);
54
+ const response = await fetch(url.toString(), {
55
+ method: 'POST',
56
+ headers: {
57
+ Authorization: authHeader,
58
+ 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
+ },
62
+ body: formData,
63
+ });
64
+ if (!response.ok) {
65
+ throw new Error(`LibreTime API error: ${response.status} ${response.statusText} — ${url.toString()}`);
66
+ }
67
+ return response.json();
68
+ }
69
+ /**
70
+ * Make an authenticated PATCH request to the LibreTime API with a JSON body.
71
+ * Returns the parsed JSON response as unknown — callers are responsible for validation.
72
+ */
73
+ export async function librePatch(path, body) {
74
+ const url = new URL(path, BASE_URL);
75
+ const response = await fetch(url.toString(), {
76
+ method: 'PATCH',
77
+ headers: {
78
+ Authorization: authHeader,
79
+ 'Content-Type': 'application/json',
80
+ Accept: 'application/json',
81
+ },
82
+ body: JSON.stringify(body),
83
+ });
84
+ if (!response.ok) {
85
+ throw new Error(`LibreTime API error: ${response.status} ${response.statusText} — ${url.toString()}`);
86
+ }
87
+ return response.json();
88
+ }
89
+ /**
90
+ * Make an authenticated DELETE request to the LibreTime API.
91
+ */
92
+ export async function libreDelete(path) {
93
+ const url = new URL(path, BASE_URL);
94
+ const response = await fetch(url.toString(), {
95
+ method: 'DELETE',
96
+ headers: {
97
+ Authorization: authHeader,
98
+ },
99
+ });
100
+ if (!response.ok) {
101
+ throw new Error(`LibreTime API error: ${response.status} ${response.statusText} — ${url.toString()}`);
102
+ }
103
+ }
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'module';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { register as registerShows } from '../tools/shows/index.js';
6
+ import { register as registerAnalytics } from '../tools/analytics/index.js';
7
+ import { register as registerAdmin } from '../tools/admin/index.js';
8
+ import { register as registerFiles } from '../tools/files/index.js';
9
+ const { version } = createRequire(import.meta.url)('../../package.json');
10
+ const server = new McpServer({
11
+ name: 'libretime-mcp-admin',
12
+ version,
13
+ });
14
+ registerShows(server);
15
+ registerAnalytics(server);
16
+ registerAdmin(server);
17
+ registerFiles(server);
18
+ const transport = new StdioServerTransport();
19
+ await server.connect(transport);
20
+ console.error('LibreTime MCP admin server running (shows + analytics + admin)');
@@ -0,0 +1,13 @@
1
+ import { createRequire } from 'module';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { register as registerShows } from '../tools/shows/index.js';
5
+ const { version } = createRequire(import.meta.url)('../../package.json');
6
+ const server = new McpServer({
7
+ name: 'libretime-mcp-client',
8
+ version,
9
+ });
10
+ registerShows(server);
11
+ const transport = new StdioServerTransport();
12
+ await server.connect(transport);
13
+ console.error('LibreTime MCP client server running (read-only: shows)');
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Wraps any JSON-serialisable value in the MCP text content response shape.
3
+ * Use this in every tool handler instead of writing the structure inline.
4
+ */
5
+ export const toolText = (data) => ({
6
+ content: [{ type: 'text', text: JSON.stringify(data) }],
7
+ });
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ import { libreDelete } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ export function register(server) {
5
+ server.registerTool('delete_file', {
6
+ description: 'Delete a file from the LibreTime media library by its ID.',
7
+ inputSchema: {
8
+ file_id: z.number().describe('ID of the file to delete'),
9
+ },
10
+ }, async ({ file_id }) => {
11
+ await libreDelete(`/api/v2/files/${file_id}`);
12
+ return toolText({ success: true, deleted_id: file_id });
13
+ });
14
+ }
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { ShowHostSchema, UserSchema } from './types.js';
5
+ export function register(server) {
6
+ server.registerTool('get_hosts', {
7
+ description: 'Get all show-to-host assignments. Enriches each entry with user details so you can see which presenter hosts which show by name.',
8
+ }, async () => {
9
+ const [rawHosts, rawUsers] = await Promise.all([
10
+ libreGet('/api/v2/show-hosts'),
11
+ libreGet('/api/v2/users'),
12
+ ]);
13
+ const hosts = z.array(ShowHostSchema).parse(rawHosts);
14
+ const users = z.array(UserSchema).parse(rawUsers);
15
+ const userMap = new Map(users.map((u) => [u.id, u]));
16
+ const enriched = hosts.map(({ id, show, user }) => {
17
+ const u = userMap.get(user);
18
+ return {
19
+ id,
20
+ show_id: show,
21
+ user_id: user,
22
+ username: u?.username ?? null,
23
+ name: u ? `${u.first_name} ${u.last_name}`.trim() : null,
24
+ };
25
+ });
26
+ return toolText(enriched);
27
+ });
28
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { UserSchema } from './types.js';
5
+ export function register(server) {
6
+ server.registerTool('get_users', {
7
+ description: 'List all LibreTime users (presenters and admins). Returns id, username, name, email, and role. Roles: G = Guest, H = Host, P = Manager, A = Admin.',
8
+ }, async () => {
9
+ const raw = await libreGet('/api/v2/users');
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,
13
+ }));
14
+ return toolText(trimmed);
15
+ });
16
+ }
@@ -0,0 +1,6 @@
1
+ import { register as registerGetUsers } from './get_users.js';
2
+ import { register as registerGetHosts } from './get_hosts.js';
3
+ export function register(server) {
4
+ registerGetUsers(server);
5
+ registerGetHosts(server);
6
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { LibreFileSchema } from './types.js';
5
+ export function register(server) {
6
+ server.registerTool('search_files', {
7
+ description: 'Search the LibreTime media library. Optionally filter by genre. Returns file id, name, track title, artist, album, length, and mime type.',
8
+ inputSchema: {
9
+ genre: z.string().optional().describe('Filter files by genre'),
10
+ },
11
+ }, async ({ genre }) => {
12
+ const params = {};
13
+ if (genre)
14
+ params.genre = genre;
15
+ const raw = await libreGet('/api/v2/files', params);
16
+ const files = z.array(LibreFileSchema).parse(raw);
17
+ const trimmed = files.map(({ id, name, track_title, artist_name, album_title, genre, length, mime, import_status, created_at, last_played_at }) => ({
18
+ id, name, track_title, artist_name, album_title, genre, length, mime, import_status, created_at, last_played_at,
19
+ }));
20
+ return toolText(trimmed);
21
+ });
22
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export const UserSchema = z.object({
3
+ id: z.number(),
4
+ username: z.string(),
5
+ first_name: z.string(),
6
+ last_name: z.string(),
7
+ email: z.string(),
8
+ type: z.string(),
9
+ });
10
+ export const ShowHostSchema = z.object({
11
+ id: z.number(),
12
+ show: z.number(),
13
+ user: z.number(),
14
+ });
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { librePatch } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { LibreFileSchema } from './types.js';
5
+ export function register(server) {
6
+ server.registerTool('update_file_metadata', {
7
+ description: 'Update metadata for a file already in the LibreTime library (track title, artist, album, genre).',
8
+ inputSchema: {
9
+ file_id: z.number().describe('ID of the file to update'),
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'),
14
+ },
15
+ }, async ({ file_id, track_title, artist_name, album_title, genre }) => {
16
+ const body = {};
17
+ if (track_title !== undefined)
18
+ body.track_title = track_title;
19
+ if (artist_name !== undefined)
20
+ body.artist_name = artist_name;
21
+ if (album_title !== undefined)
22
+ body.album_title = album_title;
23
+ if (genre !== undefined)
24
+ body.genre = genre;
25
+ const raw = await librePatch(`/api/v2/files/${file_id}`, body);
26
+ const result = LibreFileSchema.parse(raw);
27
+ return toolText(result);
28
+ });
29
+ }
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+ import { libreUpload } from '../../libretime.js';
3
+ 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.',
8
+ inputSchema: {
9
+ 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'),
14
+ },
15
+ }, async ({ url, track_title, artist_name, album_title, genre }) => {
16
+ if (!url) {
17
+ return toolText({ status: 'upload_required', action: 'file_upload' });
18
+ }
19
+ let fileResponse;
20
+ try {
21
+ fileResponse = await fetch(url);
22
+ if (!fileResponse.ok) {
23
+ return toolText({ status: 'error', reason: `Could not fetch file: ${fileResponse.status} ${fileResponse.statusText}` });
24
+ }
25
+ }
26
+ catch (err) {
27
+ return toolText({ status: 'error', reason: `Failed to reach URL: ${err instanceof Error ? err.message : String(err)}` });
28
+ }
29
+ const fileName = url.split('/').pop()?.split('?')[0] || 'upload';
30
+ const blob = await fileResponse.blob();
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);
41
+ try {
42
+ const raw = await libreUpload('/api/v2/files', formData);
43
+ const file = LibreFileSchema.parse(raw);
44
+ return toolText({ status: 'success', file });
45
+ }
46
+ catch (err) {
47
+ return toolText({ status: 'error', reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}` });
48
+ }
49
+ });
50
+ }
@@ -0,0 +1,153 @@
1
+ import { z } from 'zod';
2
+ import { libreGet, librePatch, libreDelete, libreUpload } from '../libretime.js';
3
+ export function register(server) {
4
+ // ---- Files ---------------------------------------------------------------
5
+ server.registerTool('search_files', {
6
+ description: 'Search the LibreTime media library. Optionally filter by genre. Returns file id, name, track title, artist, album, length, and mime type.',
7
+ inputSchema: {
8
+ genre: z.string().optional().describe('Filter files by genre'),
9
+ },
10
+ }, async ({ genre }) => {
11
+ const params = {};
12
+ if (genre)
13
+ params.genre = genre;
14
+ const files = await libreGet('/api/v2/files', params);
15
+ const trimmed = files.map(({ id, name, track_title, artist_name, album_title, genre, length, mime, import_status, created_at, last_played_at }) => ({
16
+ id, name, track_title, artist_name, album_title, genre, length, mime, import_status, created_at, last_played_at,
17
+ }));
18
+ return { content: [{ type: 'text', text: JSON.stringify(trimmed) }] };
19
+ });
20
+ server.registerTool('upload_file', {
21
+ 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.',
22
+ inputSchema: {
23
+ url: z.string().optional().describe('Publicly accessible URL of the audio file to upload'),
24
+ track_title: z.string().optional().describe('Track title'),
25
+ artist_name: z.string().optional().describe('Artist name'),
26
+ album_title: z.string().optional().describe('Album title'),
27
+ genre: z.string().optional().describe('Genre'),
28
+ },
29
+ }, async ({ url, track_title, artist_name, album_title, genre }) => {
30
+ // No URL — signal the client to trigger the local file upload workflow
31
+ if (!url) {
32
+ return {
33
+ content: [{
34
+ type: 'text',
35
+ text: JSON.stringify({ status: 'upload_required', action: 'file_upload' }),
36
+ }],
37
+ };
38
+ }
39
+ // Fetch the file from the provided URL
40
+ let fileResponse;
41
+ try {
42
+ fileResponse = await fetch(url);
43
+ if (!fileResponse.ok) {
44
+ return {
45
+ content: [{
46
+ type: 'text',
47
+ text: JSON.stringify({ status: 'error', reason: `Could not fetch file: ${fileResponse.status} ${fileResponse.statusText}` }),
48
+ }],
49
+ };
50
+ }
51
+ }
52
+ catch (err) {
53
+ return {
54
+ content: [{
55
+ type: 'text',
56
+ text: JSON.stringify({ status: 'error', reason: `Failed to reach URL: ${err instanceof Error ? err.message : String(err)}` }),
57
+ }],
58
+ };
59
+ }
60
+ // Derive filename from URL, fall back to 'upload'
61
+ const fileName = url.split('/').pop()?.split('?')[0] || 'upload';
62
+ const blob = await fileResponse.blob();
63
+ const formData = new FormData();
64
+ formData.append('file', blob, fileName);
65
+ if (track_title)
66
+ formData.append('track_title', track_title);
67
+ if (artist_name)
68
+ formData.append('artist_name', artist_name);
69
+ if (album_title)
70
+ formData.append('album_title', album_title);
71
+ if (genre)
72
+ formData.append('genre', genre);
73
+ try {
74
+ const result = await libreUpload('/api/v2/files', formData);
75
+ return {
76
+ content: [{
77
+ type: 'text',
78
+ text: JSON.stringify({ status: 'success', file: result }),
79
+ }],
80
+ };
81
+ }
82
+ catch (err) {
83
+ return {
84
+ content: [{
85
+ type: 'text',
86
+ text: JSON.stringify({ status: 'error', reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}` }),
87
+ }],
88
+ };
89
+ }
90
+ });
91
+ server.registerTool('update_file_metadata', {
92
+ description: 'Update metadata for a file already in the LibreTime library (track title, artist, album, genre).',
93
+ inputSchema: {
94
+ file_id: z.number().describe('ID of the file to update'),
95
+ track_title: z.string().optional().describe('Track title'),
96
+ artist_name: z.string().optional().describe('Artist name'),
97
+ album_title: z.string().optional().describe('Album title'),
98
+ genre: z.string().optional().describe('Genre'),
99
+ },
100
+ }, async ({ file_id, track_title, artist_name, album_title, genre }) => {
101
+ const body = {};
102
+ if (track_title !== undefined)
103
+ body.track_title = track_title;
104
+ if (artist_name !== undefined)
105
+ body.artist_name = artist_name;
106
+ if (album_title !== undefined)
107
+ body.album_title = album_title;
108
+ if (genre !== undefined)
109
+ body.genre = genre;
110
+ const result = await librePatch(`/api/v2/files/${file_id}`, body);
111
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
112
+ });
113
+ server.registerTool('delete_file', {
114
+ description: 'Delete a file from the LibreTime media library by its ID.',
115
+ inputSchema: {
116
+ file_id: z.number().describe('ID of the file to delete'),
117
+ },
118
+ }, async ({ file_id }) => {
119
+ await libreDelete(`/api/v2/files/${file_id}`);
120
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, deleted_id: file_id }) }] };
121
+ });
122
+ // ---- Users ---------------------------------------------------------------
123
+ server.registerTool('get_users', {
124
+ description: 'List all LibreTime users (presenters and admins). Returns id, username, name, email, and role. Roles: G = Guest, H = Host, P = Manager, A = Admin.',
125
+ }, async () => {
126
+ const users = await libreGet('/api/v2/users');
127
+ const trimmed = users.map(({ id, username, first_name, last_name, email, type }) => ({
128
+ id, username, first_name, last_name, email, role: type,
129
+ }));
130
+ return { content: [{ type: 'text', text: JSON.stringify(trimmed) }] };
131
+ });
132
+ // ---- Show Hosts ----------------------------------------------------------
133
+ server.registerTool('get_hosts', {
134
+ description: 'Get all show-to-host assignments. Enriches each entry with user details so you can see which presenter hosts which show by name.',
135
+ }, async () => {
136
+ const [hosts, users] = await Promise.all([
137
+ libreGet('/api/v2/show-hosts'),
138
+ libreGet('/api/v2/users'),
139
+ ]);
140
+ const userMap = new Map(users.map((u) => [u.id, u]));
141
+ const enriched = hosts.map(({ id, show, user }) => {
142
+ const u = userMap.get(user);
143
+ return {
144
+ id,
145
+ show_id: show,
146
+ user_id: user,
147
+ username: u?.username ?? null,
148
+ name: u ? `${u.first_name} ${u.last_name}`.trim() : null,
149
+ };
150
+ });
151
+ return { content: [{ type: 'text', text: JSON.stringify(enriched) }] };
152
+ });
153
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { ListenerCountSchema, MountNameSchema } from './types.js';
5
+ export function register(server) {
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 () => {
9
+ const [rawCounts, rawMounts] = await Promise.all([
10
+ libreGet('/api/v2/listener-counts'),
11
+ libreGet('/api/v2/mount-names'),
12
+ ]);
13
+ const counts = z.array(ListenerCountSchema).parse(rawCounts);
14
+ const mounts = z.array(MountNameSchema).parse(rawMounts);
15
+ const mountMap = new Map(mounts.map((m) => [m.id, m.mount_name]));
16
+ const enriched = counts.map(({ id, listener_count, timestamp, mount_name }) => ({
17
+ id,
18
+ listener_count,
19
+ timestamp,
20
+ mount: mountMap.get(mount_name) ?? `mount_${mount_name}`,
21
+ }));
22
+ return toolText(enriched);
23
+ });
24
+ }
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { PlayoutHistorySchema, FileMetadataSchema } from './types.js';
5
+ export function register(server) {
6
+ server.registerTool('get_playout_history', {
7
+ description: 'Get the history of tracks that have played on PowerFM. Enriches each entry with track title and artist from the file library. Use starts and ends to filter by time range (ISO 8601).',
8
+ inputSchema: {
9
+ starts: z.string().optional().describe('Return history entries starting after this datetime (ISO 8601)'),
10
+ ends: z.string().optional().describe('Return history entries ending before this datetime (ISO 8601)'),
11
+ },
12
+ }, async ({ starts, ends }) => {
13
+ const params = {};
14
+ if (starts)
15
+ params.starts = starts;
16
+ if (ends)
17
+ params.ends = ends;
18
+ const rawHistory = await libreGet('/api/v2/playout-history', params);
19
+ const history = z.array(PlayoutHistorySchema).parse(rawHistory);
20
+ // Collect unique file IDs then fetch metadata in parallel
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));
24
+ const fileMap = new Map(files.map((f) => [f.id, f]));
25
+ const enriched = history.map(({ id, starts, ends, file, instance }) => {
26
+ const meta = file !== null ? fileMap.get(file) : undefined;
27
+ return {
28
+ id,
29
+ starts,
30
+ ends,
31
+ instance,
32
+ track_title: meta?.track_title ?? null,
33
+ artist_name: meta?.artist_name ?? null,
34
+ album_title: meta?.album_title ?? null,
35
+ genre: meta?.genre ?? null,
36
+ length: meta?.length ?? null,
37
+ };
38
+ });
39
+ return toolText(enriched);
40
+ });
41
+ }
@@ -0,0 +1,6 @@
1
+ import { register as registerGetListenerCounts } from './get_listener_counts.js';
2
+ import { register as registerGetPlayoutHistory } from './get_playout_history.js';
3
+ export function register(server) {
4
+ registerGetListenerCounts(server);
5
+ registerGetPlayoutHistory(server);
6
+ }
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ export const ListenerCountSchema = z.object({
3
+ id: z.number(),
4
+ listener_count: z.number(),
5
+ timestamp: z.number(),
6
+ mount_name: z.number(),
7
+ });
8
+ export const MountNameSchema = z.object({
9
+ id: z.number(),
10
+ mount_name: z.string(),
11
+ });
12
+ export const PlayoutHistorySchema = z.object({
13
+ id: z.number(),
14
+ starts: z.string(),
15
+ ends: z.string().nullable(),
16
+ file: z.number().nullable(),
17
+ instance: z.number().nullable(),
18
+ });
19
+ export const FileMetadataSchema = z.object({
20
+ id: z.number(),
21
+ track_title: z.string().nullable(),
22
+ artist_name: z.string().nullable(),
23
+ album_title: z.string().nullable(),
24
+ genre: z.string().nullable(),
25
+ length: z.string().nullable(),
26
+ });
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../libretime.js';
3
+ export function register(server) {
4
+ server.registerTool('get_listener_counts', {
5
+ 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.',
6
+ }, async () => {
7
+ const [counts, mounts] = await Promise.all([
8
+ libreGet('/api/v2/listener-counts'),
9
+ libreGet('/api/v2/mount-names'),
10
+ ]);
11
+ const mountMap = new Map(mounts.map((m) => [m.id, m.mount_name]));
12
+ const enriched = counts.map(({ id, listener_count, timestamp, mount_name }) => ({
13
+ id,
14
+ listener_count,
15
+ timestamp,
16
+ mount: mountMap.get(mount_name) ?? `mount_${mount_name}`,
17
+ }));
18
+ return { content: [{ type: 'text', text: JSON.stringify(enriched) }] };
19
+ });
20
+ server.registerTool('get_playout_history', {
21
+ description: 'Get the history of tracks that have played on PowerFM. Enriches each entry with track title and artist from the file library. Use starts and ends to filter by time range (ISO 8601).',
22
+ inputSchema: {
23
+ starts: z.string().optional().describe('Return history entries starting after this datetime (ISO 8601)'),
24
+ ends: z.string().optional().describe('Return history entries ending before this datetime (ISO 8601)'),
25
+ },
26
+ }, async ({ starts, ends }) => {
27
+ const params = {};
28
+ if (starts)
29
+ params.starts = starts;
30
+ if (ends)
31
+ params.ends = ends;
32
+ const history = await libreGet('/api/v2/playout-history', params);
33
+ // Collect unique file IDs to enrich in parallel
34
+ const fileIds = [...new Set(history.map((h) => h.file).filter((id) => id !== null))];
35
+ const files = await Promise.all(fileIds.map((id) => libreGet(`/api/v2/files/${id}`)));
36
+ const fileMap = new Map(files.map((f) => [f.id, f]));
37
+ const enriched = history.map(({ id, starts, ends, file, instance }) => {
38
+ const meta = file !== null ? fileMap.get(file) : undefined;
39
+ return {
40
+ id,
41
+ starts,
42
+ ends,
43
+ instance,
44
+ track_title: meta?.track_title ?? null,
45
+ artist_name: meta?.artist_name ?? null,
46
+ album_title: meta?.album_title ?? null,
47
+ genre: meta?.genre ?? null,
48
+ length: meta?.length ?? null,
49
+ };
50
+ });
51
+ return { content: [{ type: 'text', text: JSON.stringify(enriched) }] };
52
+ });
53
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ import { libreDelete } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ export function register(server) {
5
+ server.registerTool('delete_file', {
6
+ description: 'Delete a file from the LibreTime media library by its ID.',
7
+ inputSchema: {
8
+ file_id: z.number().describe('ID of the file to delete'),
9
+ },
10
+ }, async ({ file_id }) => {
11
+ await libreDelete(`/api/v2/files/${file_id}`);
12
+ return toolText({ success: true, deleted_id: file_id });
13
+ });
14
+ }
@@ -0,0 +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) {
6
+ registerSearchFiles(server);
7
+ registerUploadFile(server);
8
+ registerUpdateFileMetadata(server);
9
+ registerDeleteFile(server);
10
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { LibreFileSchema } from './types.js';
5
+ export function register(server) {
6
+ server.registerTool('search_files', {
7
+ description: 'Search the LibreTime media library. Optionally filter by genre. Returns file id, name, track title, artist, album, length, and mime type.',
8
+ inputSchema: {
9
+ genre: z.string().optional().describe('Filter files by genre'),
10
+ },
11
+ }, async ({ genre }) => {
12
+ const params = {};
13
+ if (genre)
14
+ params.genre = genre;
15
+ const raw = await libreGet('/api/v2/files', params);
16
+ const files = z.array(LibreFileSchema).parse(raw);
17
+ const trimmed = files.map(({ id, name, track_title, artist_name, album_title, genre, length, mime, import_status, created_at, last_played_at }) => ({
18
+ id, name, track_title, artist_name, album_title, genre, length, mime, import_status, created_at, last_played_at,
19
+ }));
20
+ return toolText(trimmed);
21
+ });
22
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export const LibreFileSchema = z.object({
3
+ id: z.number(),
4
+ name: z.string(),
5
+ track_title: z.string().nullable(),
6
+ artist_name: z.string().nullable(),
7
+ album_title: z.string().nullable(),
8
+ genre: z.string().nullable(),
9
+ length: z.string().nullable(),
10
+ mime: z.string(),
11
+ import_status: z.number(),
12
+ created_at: z.string().nullable(),
13
+ last_played_at: z.string().nullable(),
14
+ });
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { librePatch } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { LibreFileSchema } from './types.js';
5
+ export function register(server) {
6
+ server.registerTool('update_file_metadata', {
7
+ description: 'Update metadata for a file already in the LibreTime library (track title, artist, album, genre).',
8
+ inputSchema: {
9
+ file_id: z.number().describe('ID of the file to update'),
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'),
14
+ },
15
+ }, async ({ file_id, track_title, artist_name, album_title, genre }) => {
16
+ const body = {};
17
+ if (track_title !== undefined)
18
+ body.track_title = track_title;
19
+ if (artist_name !== undefined)
20
+ body.artist_name = artist_name;
21
+ if (album_title !== undefined)
22
+ body.album_title = album_title;
23
+ if (genre !== undefined)
24
+ body.genre = genre;
25
+ const raw = await librePatch(`/api/v2/files/${file_id}`, body);
26
+ const result = LibreFileSchema.parse(raw);
27
+ return toolText(result);
28
+ });
29
+ }
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+ import { libreUpload } from '../../libretime.js';
3
+ 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.',
8
+ inputSchema: {
9
+ 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'),
14
+ },
15
+ }, async ({ url, track_title, artist_name, album_title, genre }) => {
16
+ if (!url) {
17
+ return toolText({ status: 'upload_required', action: 'file_upload' });
18
+ }
19
+ let fileResponse;
20
+ try {
21
+ fileResponse = await fetch(url);
22
+ if (!fileResponse.ok) {
23
+ return toolText({ status: 'error', reason: `Could not fetch file: ${fileResponse.status} ${fileResponse.statusText}` });
24
+ }
25
+ }
26
+ catch (err) {
27
+ return toolText({ status: 'error', reason: `Failed to reach URL: ${err instanceof Error ? err.message : String(err)}` });
28
+ }
29
+ const fileName = url.split('/').pop()?.split('?')[0] || 'upload';
30
+ const blob = await fileResponse.blob();
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);
41
+ try {
42
+ const raw = await libreUpload('/api/v2/files', formData);
43
+ const file = LibreFileSchema.parse(raw);
44
+ return toolText({ status: 'success', file });
45
+ }
46
+ catch (err) {
47
+ return toolText({ status: 'error', reason: `LibreTime upload failed: ${err instanceof Error ? err.message : String(err)}` });
48
+ }
49
+ });
50
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { ScheduleItemSchema } from './types.js';
5
+ export function register(server) {
6
+ server.registerTool('get_schedule', {
7
+ description: 'Get the broadcast schedule for a date range. Use starts_after and starts_before to filter by date (ISO 8601 format, e.g. 2024-06-01T00:00:00Z).',
8
+ inputSchema: {
9
+ starts_after: z.string().describe('Return items starting after this datetime (ISO 8601)'),
10
+ starts_before: z.string().describe('Return items starting before this datetime (ISO 8601)'),
11
+ },
12
+ }, async ({ starts_after, starts_before }) => {
13
+ const params = {};
14
+ if (starts_after)
15
+ params.starts_after = starts_after;
16
+ if (starts_before)
17
+ params.starts_before = starts_before;
18
+ const raw = await libreGet('/api/v2/schedule', params);
19
+ const schedule = z.array(ScheduleItemSchema).parse(raw);
20
+ return toolText(schedule);
21
+ });
22
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../../libretime.js';
3
+ import { toolText } from '../../tool-response.js';
4
+ import { ShowSchema } from './types.js';
5
+ export function register(server) {
6
+ server.registerTool('get_shows', {
7
+ description: "List all shows registered in LibreTime. Returns each show's id, name, description, and genre.",
8
+ }, async () => {
9
+ const raw = await libreGet('/api/v2/shows');
10
+ const shows = z.array(ShowSchema).parse(raw);
11
+ const trimmed = shows.map(({ id, name, description, genre, url }) => ({ id, name, description, genre, url }));
12
+ return toolText(trimmed);
13
+ });
14
+ }
@@ -0,0 +1,10 @@
1
+ import { libreGet } from '../../libretime.js';
2
+ import { toolText } from '../../tool-response.js';
3
+ import { StreamStateSchema } from './types.js';
4
+ export function register(server) {
5
+ server.registerTool('get_stream_state', { description: 'Check whether the PowerFM station is currently broadcasting live.' }, async () => {
6
+ const raw = await libreGet('/api/v2/stream/state');
7
+ const state = StreamStateSchema.parse(raw);
8
+ return toolText(state);
9
+ });
10
+ }
@@ -0,0 +1,8 @@
1
+ import { register as registerGetShows } from './get_shows.js';
2
+ import { register as registerGetSchedule } from './get_schedule.js';
3
+ import { register as registerGetStreamState } from './get_stream_state.js';
4
+ export function register(server) {
5
+ registerGetShows(server);
6
+ registerGetSchedule(server);
7
+ registerGetStreamState(server);
8
+ }
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+ export const ShowSchema = z.object({
3
+ id: z.number(),
4
+ name: z.string(),
5
+ description: z.string(),
6
+ genre: z.string(),
7
+ url: z.string(),
8
+ });
9
+ export const ScheduleItemSchema = z.object({
10
+ id: z.number(),
11
+ starts: z.string(),
12
+ ends: z.string(),
13
+ show_id: z.number(),
14
+ show_name: z.string(),
15
+ broadcasted: z.number(),
16
+ });
17
+ export const StreamStateSchema = z.object({
18
+ source_enabled: z.boolean(),
19
+ }).passthrough();
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ import { libreGet } from '../libretime.js';
3
+ export function register(server) {
4
+ server.registerTool('get_shows', {
5
+ description: "List all shows registered in LibreTime. Returns each show's id, name, description, and genre.",
6
+ }, async () => {
7
+ const shows = await libreGet('/api/v2/shows');
8
+ const trimmed = shows.map(({ id, name, description, genre, url }) => ({ id, name, description, genre, url }));
9
+ return { content: [{ type: 'text', text: JSON.stringify(trimmed) }] };
10
+ });
11
+ server.registerTool('get_schedule', {
12
+ description: 'Get the broadcast schedule for a date range. Use starts_after and starts_before to filter by date (ISO 8601 format, e.g. 2024-06-01T00:00:00Z).',
13
+ inputSchema: {
14
+ starts_after: z.string().describe('Return items starting after this datetime (ISO 8601)'),
15
+ starts_before: z.string().describe('Return items starting before this datetime (ISO 8601)'),
16
+ },
17
+ }, async ({ starts_after, starts_before }) => {
18
+ const params = {};
19
+ if (starts_after)
20
+ params.starts_after = starts_after;
21
+ if (starts_before)
22
+ params.starts_before = starts_before;
23
+ const schedule = await libreGet('/api/v2/schedule', params);
24
+ return { content: [{ type: 'text', text: JSON.stringify(schedule) }] };
25
+ });
26
+ server.registerTool('get_stream_state', { description: 'Check whether the PowerFM station is currently broadcasting live.' }, async () => {
27
+ const state = await libreGet('/api/v2/stream/state');
28
+ return { content: [{ type: 'text', text: JSON.stringify(state) }] };
29
+ });
30
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@powerfm/libretime-mcp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "MCP server for LibreTime radio station — connect Claude to your broadcast schedule, media library, and analytics",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/nelsonra/libretime-mcp.git"
10
+ },
11
+ "bin": {
12
+ "libretime-mcp": "dist/stdio/admin.js"
13
+ },
14
+ "files": [
15
+ "dist/",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "dev:client": "tsx --env-file=.env watch src/stdio/client.ts",
20
+ "dev:admin": "tsx --env-file=.env watch src/stdio/admin.ts",
21
+ "dev:client-http": "tsx --env-file=.env watch src/http/client.ts",
22
+ "dev:admin-http": "tsx --env-file=.env watch src/http/admin.ts",
23
+ "build": "tsc",
24
+ "start:client": "node --env-file=.env dist/stdio/client.js",
25
+ "start:admin": "node --env-file=.env dist/stdio/admin.js",
26
+ "start:client-http": "node --env-file=.env dist/http/client.js",
27
+ "start:admin-http": "node --env-file=.env dist/http/admin.js",
28
+ "generate:key": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest"
31
+ },
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.0.0",
34
+ "express": "~5.2.1",
35
+ "zod": "^3.23.8"
36
+ },
37
+ "devDependencies": {
38
+ "@types/express": "~5.0.6",
39
+ "@types/node": "^22.0.0",
40
+ "tsx": "^4.19.0",
41
+ "typescript": "^5.6.0",
42
+ "vitest": "~4.1.1"
43
+ }
44
+ }