@powerfm/libretime-mcp 0.1.0 → 0.1.3

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,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { createRequire } from 'module';
2
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "@powerfm/libretime-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "MCP server for LibreTime radio station — connect Claude to your broadcast schedule, media library, and analytics",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/nelsonra/libretime-mcp.git"
9
+ "url": "git+https://github.com/nelsonra/libretime-mcp.git"
10
10
  },
11
11
  "bin": {
12
- "libretime-mcp": "dist/stdio/admin.js"
12
+ "libretime-mcp": "dist/stdio/admin.js",
13
+ "libretime-mcp-client": "dist/stdio/client.js"
13
14
  },
14
15
  "files": [
15
16
  "dist/",
@@ -20,7 +21,11 @@
20
21
  "dev:admin": "tsx --env-file=.env watch src/stdio/admin.ts",
21
22
  "dev:client-http": "tsx --env-file=.env watch src/http/client.ts",
22
23
  "dev:admin-http": "tsx --env-file=.env watch src/http/admin.ts",
23
- "build": "tsc",
24
+ "clean": "rm -rf dist",
25
+ "build": "npm run clean && tsc",
26
+ "prepublishOnly": "npm run build",
27
+ "publish:patch": "npm version patch && npm publish --access public",
28
+ "publish:minor": "npm version minor && npm publish --access public",
24
29
  "start:client": "node --env-file=.env dist/stdio/client.js",
25
30
  "start:admin": "node --env-file=.env dist/stdio/admin.js",
26
31
  "start:client-http": "node --env-file=.env dist/http/client.js",
package/dist/index.js DELETED
@@ -1,48 +0,0 @@
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');
@@ -1,14 +0,0 @@
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
- }
@@ -1,22 +0,0 @@
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
- }
@@ -1,29 +0,0 @@
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
- }
@@ -1,50 +0,0 @@
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
- }
@@ -1,153 +0,0 @@
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
- }
@@ -1,53 +0,0 @@
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
- }
@@ -1,30 +0,0 @@
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
- }