@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.
- package/README.md +143 -0
- package/dist/env.js +9 -0
- package/dist/http/admin.js +43 -0
- package/dist/http/client.js +39 -0
- package/dist/index.js +48 -0
- package/dist/libretime.js +103 -0
- package/dist/stdio/admin.js +20 -0
- package/dist/stdio/client.js +13 -0
- package/dist/tool-response.js +7 -0
- package/dist/tools/admin/delete_file.js +14 -0
- package/dist/tools/admin/get_hosts.js +28 -0
- package/dist/tools/admin/get_users.js +16 -0
- package/dist/tools/admin/index.js +6 -0
- package/dist/tools/admin/search_files.js +22 -0
- package/dist/tools/admin/types.js +14 -0
- package/dist/tools/admin/update_file_metadata.js +29 -0
- package/dist/tools/admin/upload_file.js +50 -0
- package/dist/tools/admin.js +153 -0
- package/dist/tools/analytics/get_listener_counts.js +24 -0
- package/dist/tools/analytics/get_playout_history.js +41 -0
- package/dist/tools/analytics/index.js +6 -0
- package/dist/tools/analytics/types.js +26 -0
- package/dist/tools/analytics.js +53 -0
- package/dist/tools/files/delete_file.js +14 -0
- package/dist/tools/files/index.js +10 -0
- package/dist/tools/files/search_files.js +22 -0
- package/dist/tools/files/types.js +14 -0
- package/dist/tools/files/update_file_metadata.js +29 -0
- package/dist/tools/files/upload_file.js +50 -0
- package/dist/tools/shows/get_schedule.js +22 -0
- package/dist/tools/shows/get_shows.js +14 -0
- package/dist/tools/shows/get_stream_state.js +10 -0
- package/dist/tools/shows/index.js +8 -0
- package/dist/tools/shows/types.js +19 -0
- package/dist/tools/shows.js +30 -0
- 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,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,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
|
+
}
|