@sriinnu/harmon-flow 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/LICENSE +661 -0
- package/README.md +79 -0
- package/SKILL.md +44 -0
- package/dist/graph/index.d.ts +116 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +484 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/app-server.d.ts +66 -0
- package/dist/mcp/app-server.d.ts.map +1 -0
- package/dist/mcp/app-server.js +682 -0
- package/dist/mcp/app-server.js.map +1 -0
- package/dist/mcp/auth.d.ts +37 -0
- package/dist/mcp/auth.d.ts.map +1 -0
- package/dist/mcp/auth.js +156 -0
- package/dist/mcp/auth.js.map +1 -0
- package/dist/mcp/cli.d.ts +8 -0
- package/dist/mcp/cli.d.ts.map +1 -0
- package/dist/mcp/cli.js +84 -0
- package/dist/mcp/cli.js.map +1 -0
- package/dist/mcp/daemon-client.d.ts +89 -0
- package/dist/mcp/daemon-client.d.ts.map +1 -0
- package/dist/mcp/daemon-client.js +400 -0
- package/dist/mcp/daemon-client.js.map +1 -0
- package/dist/mcp/http-utils.d.ts +13 -0
- package/dist/mcp/http-utils.d.ts.map +1 -0
- package/dist/mcp/http-utils.js +23 -0
- package/dist/mcp/http-utils.js.map +1 -0
- package/dist/mcp/index.d.ts +90 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +602 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/journal-search.d.ts +31 -0
- package/dist/mcp/journal-search.d.ts.map +1 -0
- package/dist/mcp/journal-search.js +90 -0
- package/dist/mcp/journal-search.js.map +1 -0
- package/dist/mcp/tool-auth.d.ts +14 -0
- package/dist/mcp/tool-auth.d.ts.map +1 -0
- package/dist/mcp/tool-auth.js +25 -0
- package/dist/mcp/tool-auth.js.map +1 -0
- package/dist/parser/frontmatter.d.ts +11 -0
- package/dist/parser/frontmatter.d.ts.map +1 -0
- package/dist/parser/frontmatter.js +28 -0
- package/dist/parser/frontmatter.js.map +1 -0
- package/dist/parser/index.d.ts +53 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +178 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/types.d.ts +197 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +38 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +8 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +21 -0
- package/dist/version.js.map +1 -0
- package/logo.svg +13 -0
- package/package.json +65 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote MCP server for ChatGPT/OpenAI app integration.
|
|
3
|
+
*/
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
|
|
6
|
+
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
7
|
+
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
8
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
10
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { SessionPolicy } from '@sriinnu/harmon-protocol';
|
|
13
|
+
import { createFlowParser } from '../parser/index.js';
|
|
14
|
+
import { getFlowServerVersion } from '../version.js';
|
|
15
|
+
import { createAppAuthContext } from './auth.js';
|
|
16
|
+
import { createDaemonAppClient } from './daemon-client.js';
|
|
17
|
+
import { getHeaderValue } from './http-utils.js';
|
|
18
|
+
import { fetchJournalEntry, searchJournalEntries } from './journal-search.js';
|
|
19
|
+
import { assertToolScopesFromExtra } from './tool-auth.js';
|
|
20
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
21
|
+
const DEFAULT_PATH = '/mcp';
|
|
22
|
+
const DEFAULT_PORT = 17400;
|
|
23
|
+
/**
|
|
24
|
+
* I expose a narrow tool surface that is honest enough for ChatGPT app import.
|
|
25
|
+
*/
|
|
26
|
+
export class HarmonAppMCPServer {
|
|
27
|
+
config;
|
|
28
|
+
app;
|
|
29
|
+
auth;
|
|
30
|
+
bearerToken;
|
|
31
|
+
daemonClient;
|
|
32
|
+
flowParser;
|
|
33
|
+
host;
|
|
34
|
+
mcpPath;
|
|
35
|
+
port;
|
|
36
|
+
server;
|
|
37
|
+
httpServer = null;
|
|
38
|
+
transports = new Map();
|
|
39
|
+
constructor(config = {}) {
|
|
40
|
+
this.config = config;
|
|
41
|
+
this.bearerToken = config.bearerToken ?? process.env.HARMON_MCP_BEARER_TOKEN;
|
|
42
|
+
this.daemonClient = config.daemonClient ?? createDaemonAppClient(config);
|
|
43
|
+
this.flowParser = createFlowParser(config.flowDir);
|
|
44
|
+
this.host = config.host ?? DEFAULT_HOST;
|
|
45
|
+
this.mcpPath = config.path ?? DEFAULT_PATH;
|
|
46
|
+
this.port = config.port ?? DEFAULT_PORT;
|
|
47
|
+
this.auth = createAppAuthContext({
|
|
48
|
+
auth: {
|
|
49
|
+
bearerToken: this.bearerToken,
|
|
50
|
+
...config.auth,
|
|
51
|
+
},
|
|
52
|
+
defaultResourceServerUrl: `http://${this.host}:${this.port}${this.mcpPath}`,
|
|
53
|
+
});
|
|
54
|
+
this.server = new McpServer({
|
|
55
|
+
name: config.name ?? 'harmon-app',
|
|
56
|
+
version: config.version ?? getFlowServerVersion(),
|
|
57
|
+
});
|
|
58
|
+
this.app = createMcpExpressApp({
|
|
59
|
+
allowedHosts: config.allowedHosts ?? splitList(process.env.HARMON_MCP_ALLOWED_HOSTS),
|
|
60
|
+
host: this.host,
|
|
61
|
+
});
|
|
62
|
+
if (this.shouldAllowUnauthenticatedWrites() && !isLoopbackHost(this.host)) {
|
|
63
|
+
throw new Error('Unauthenticated MCP write tools are only allowed on loopback hosts.');
|
|
64
|
+
}
|
|
65
|
+
this.app.disable('x-powered-by');
|
|
66
|
+
this.setupHttpRoutes();
|
|
67
|
+
this.registerTools();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* I expose the underlying MCP server for tests and advanced callers.
|
|
71
|
+
*/
|
|
72
|
+
getMcpServer() {
|
|
73
|
+
return this.server;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* I return the remote MCP URL after the HTTP server is listening.
|
|
77
|
+
*/
|
|
78
|
+
getMcpUrl() {
|
|
79
|
+
const address = this.httpServer?.address();
|
|
80
|
+
const port = typeof address === 'object' && address ? address.port : this.port;
|
|
81
|
+
return `http://${this.host}:${port}${this.mcpPath}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* I start a remote streamable HTTP MCP endpoint and a small health route.
|
|
85
|
+
*/
|
|
86
|
+
async start() {
|
|
87
|
+
if (this.httpServer) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await new Promise((resolve, reject) => {
|
|
91
|
+
this.httpServer = this.app.listen(this.port, this.host, () => resolve());
|
|
92
|
+
this.httpServer.once('error', reject);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* I close the HTTP listener and every in-flight MCP session.
|
|
97
|
+
*/
|
|
98
|
+
async close() {
|
|
99
|
+
for (const transport of this.transports.values()) {
|
|
100
|
+
await transport.close();
|
|
101
|
+
}
|
|
102
|
+
this.transports.clear();
|
|
103
|
+
await this.server.close();
|
|
104
|
+
if (this.httpServer) {
|
|
105
|
+
await new Promise((resolve, reject) => {
|
|
106
|
+
this.httpServer?.close((error) => error ? reject(error) : resolve());
|
|
107
|
+
});
|
|
108
|
+
this.httpServer = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
registerTools() {
|
|
112
|
+
const authEnabled = this.auth.mode !== 'none';
|
|
113
|
+
const canExposeWriteTools = this.auth.canExposeWriteTools || (this.auth.mode === 'none' &&
|
|
114
|
+
this.shouldAllowUnauthenticatedWrites());
|
|
115
|
+
this.server.registerTool('search', {
|
|
116
|
+
annotations: { openWorldHint: false, readOnlyHint: true, title: 'Search Journal' },
|
|
117
|
+
description: 'Search Harmon journal entries for relevant context.',
|
|
118
|
+
inputSchema: {
|
|
119
|
+
limit: z.number().int().min(1).max(10).optional(),
|
|
120
|
+
query: z.string().min(1),
|
|
121
|
+
},
|
|
122
|
+
}, async ({ limit, query }, extra) => {
|
|
123
|
+
assertToolScopesFromExtra(extra, this.auth.readScopes, authEnabled);
|
|
124
|
+
return this.jsonResult({
|
|
125
|
+
results: searchJournalEntries(this.readEntries(), query, limit),
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
this.server.registerTool('fetch', {
|
|
129
|
+
annotations: { openWorldHint: false, readOnlyHint: true, title: 'Fetch Journal Entry' },
|
|
130
|
+
description: 'Fetch a full Harmon journal entry by ID.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
id: z.string().min(1),
|
|
133
|
+
},
|
|
134
|
+
}, async ({ id }, extra) => {
|
|
135
|
+
assertToolScopesFromExtra(extra, this.auth.readScopes, authEnabled);
|
|
136
|
+
const entry = fetchJournalEntry(this.readEntries(), id);
|
|
137
|
+
if (!entry) {
|
|
138
|
+
throw new Error(`Journal entry ${id} was not found.`);
|
|
139
|
+
}
|
|
140
|
+
return this.jsonResult(entry);
|
|
141
|
+
});
|
|
142
|
+
this.server.registerTool('get_status', {
|
|
143
|
+
annotations: { openWorldHint: false, readOnlyHint: true, title: 'Get Harmon Status' },
|
|
144
|
+
description: 'Get provider readiness and current session state from the Harmon daemon.',
|
|
145
|
+
}, async (extra) => {
|
|
146
|
+
assertToolScopesFromExtra(extra, this.auth.readScopes, authEnabled);
|
|
147
|
+
return this.jsonResult(await this.getPublicStatus());
|
|
148
|
+
});
|
|
149
|
+
this.server.registerTool('search_music', {
|
|
150
|
+
annotations: { openWorldHint: false, readOnlyHint: true, title: 'Search Music' },
|
|
151
|
+
description: 'Search Spotify, Apple Music, or YouTube Music catalogs.',
|
|
152
|
+
inputSchema: {
|
|
153
|
+
kind: z.enum(['track', 'song', 'album', 'artist', 'playlist']).default('song'),
|
|
154
|
+
limit: z.number().int().min(1).max(10).optional(),
|
|
155
|
+
provider: z.enum(['spotify', 'apple', 'youtube']),
|
|
156
|
+
query: z.string().min(1),
|
|
157
|
+
},
|
|
158
|
+
}, async ({ kind, limit, provider, query }, extra) => {
|
|
159
|
+
assertToolScopesFromExtra(extra, this.auth.readScopes, authEnabled);
|
|
160
|
+
return this.jsonResult({
|
|
161
|
+
provider,
|
|
162
|
+
results: await this.daemonClient.searchMusic(provider, query, kind, limit),
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
this.server.registerTool('get_library_tracks', {
|
|
166
|
+
annotations: { openWorldHint: false, readOnlyHint: true, title: 'Get Library Tracks' },
|
|
167
|
+
description: 'List saved or liked tracks for a provider.',
|
|
168
|
+
inputSchema: {
|
|
169
|
+
limit: z.number().int().min(1).max(25).optional(),
|
|
170
|
+
provider: z.enum(['spotify', 'apple', 'youtube']),
|
|
171
|
+
},
|
|
172
|
+
}, async ({ limit, provider }, extra) => {
|
|
173
|
+
assertToolScopesFromExtra(extra, this.auth.readScopes, authEnabled);
|
|
174
|
+
return this.jsonResult({
|
|
175
|
+
provider,
|
|
176
|
+
tracks: await this.daemonClient.getLibraryTracks(provider, limit),
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
this.server.registerTool('list_playlists', {
|
|
180
|
+
annotations: { openWorldHint: false, readOnlyHint: true, title: 'List Playlists' },
|
|
181
|
+
description: 'List playlists for a provider.',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
limit: z.number().int().min(1).max(25).optional(),
|
|
184
|
+
provider: z.enum(['spotify', 'apple', 'youtube']),
|
|
185
|
+
},
|
|
186
|
+
}, async ({ limit, provider }, extra) => {
|
|
187
|
+
assertToolScopesFromExtra(extra, this.auth.readScopes, authEnabled);
|
|
188
|
+
return this.jsonResult({
|
|
189
|
+
playlists: await this.daemonClient.listPlaylists(provider, limit),
|
|
190
|
+
provider,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
this.server.registerTool('get_playlist_tracks', {
|
|
194
|
+
annotations: { openWorldHint: false, readOnlyHint: true, title: 'Get Playlist Tracks' },
|
|
195
|
+
description: 'Fetch tracks from a playlist for a provider.',
|
|
196
|
+
inputSchema: {
|
|
197
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
198
|
+
playlistId: z.string().min(1),
|
|
199
|
+
provider: z.enum(['spotify', 'apple', 'youtube']),
|
|
200
|
+
},
|
|
201
|
+
}, async ({ limit, playlistId, provider }, extra) => {
|
|
202
|
+
assertToolScopesFromExtra(extra, this.auth.readScopes, authEnabled);
|
|
203
|
+
return this.jsonResult({
|
|
204
|
+
playlistId,
|
|
205
|
+
provider,
|
|
206
|
+
tracks: await this.daemonClient.getPlaylistTracks(provider, playlistId, limit),
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
this.server.registerTool('get_now_playing', {
|
|
210
|
+
annotations: { openWorldHint: false, readOnlyHint: true, title: 'Get Now Playing' },
|
|
211
|
+
description: 'Get the current track for a specific provider runtime.',
|
|
212
|
+
inputSchema: {
|
|
213
|
+
provider: z.enum(['spotify', 'apple', 'youtube']),
|
|
214
|
+
},
|
|
215
|
+
}, async ({ provider }, extra) => {
|
|
216
|
+
assertToolScopesFromExtra(extra, this.auth.readScopes, authEnabled);
|
|
217
|
+
return this.jsonResult({
|
|
218
|
+
provider,
|
|
219
|
+
track: await this.daemonClient.getNowPlaying(provider),
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
this.server.registerTool('auth_status', {
|
|
223
|
+
annotations: { openWorldHint: false, readOnlyHint: true, title: 'Auth Status' },
|
|
224
|
+
description: 'Get authentication status for all music providers (Spotify, Apple Music, YouTube Music). Shows which providers are connected, their auth mode, and capabilities.',
|
|
225
|
+
}, async (extra) => {
|
|
226
|
+
assertToolScopesFromExtra(extra, this.auth.readScopes, authEnabled);
|
|
227
|
+
const status = await this.daemonClient.getStatus();
|
|
228
|
+
return this.jsonResult({
|
|
229
|
+
providers: status.providers ?? {},
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
this.server.registerTool('smart_search', {
|
|
233
|
+
annotations: { openWorldHint: false, readOnlyHint: true, title: 'Smart Search' },
|
|
234
|
+
description: 'Search for a song, artist, or album across ALL connected music providers (Spotify, Apple Music, YouTube Music) simultaneously. Returns results from each provider so you can compare availability. Use this when the user says "find this song" or "who has this track".',
|
|
235
|
+
inputSchema: {
|
|
236
|
+
query: z.string().min(1).describe('The song, artist, or album to search for'),
|
|
237
|
+
limit: z.number().int().min(1).max(25).optional().describe('Max results per provider (default: 5)'),
|
|
238
|
+
},
|
|
239
|
+
}, async ({ query, limit }, extra) => {
|
|
240
|
+
assertToolScopesFromExtra(extra, this.auth.readScopes, authEnabled);
|
|
241
|
+
return this.jsonResult(await this.daemonClient.smartSearch(query, limit));
|
|
242
|
+
});
|
|
243
|
+
if (!canExposeWriteTools) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.server.registerTool('recognize_song', {
|
|
247
|
+
annotations: {
|
|
248
|
+
destructiveHint: false,
|
|
249
|
+
openWorldHint: true,
|
|
250
|
+
readOnlyHint: false,
|
|
251
|
+
title: 'Recognize Song',
|
|
252
|
+
},
|
|
253
|
+
description: 'Identify a song from audio data. Send base64-encoded WAV audio (3-10 seconds). Returns song title, artist, album, and links to Spotify/Apple Music. Requires AUDD_API_TOKEN to be configured on the daemon.',
|
|
254
|
+
inputSchema: {
|
|
255
|
+
audio: z.string().min(1).describe('Base64-encoded WAV audio data (3-10 seconds, 16kHz mono)'),
|
|
256
|
+
},
|
|
257
|
+
}, async ({ audio }, extra) => {
|
|
258
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
259
|
+
return this.jsonResult(await this.daemonClient.recognizeSong(audio));
|
|
260
|
+
});
|
|
261
|
+
this.server.registerTool('play_music', {
|
|
262
|
+
annotations: {
|
|
263
|
+
destructiveHint: false,
|
|
264
|
+
openWorldHint: false,
|
|
265
|
+
readOnlyHint: false,
|
|
266
|
+
title: 'Play Music',
|
|
267
|
+
},
|
|
268
|
+
description: 'Play a track on the active provider. Accepts a Spotify URI, Apple Music URL, or YouTube URL. For Apple Music, a direct URL is required — query-based playback is not supported.',
|
|
269
|
+
inputSchema: {
|
|
270
|
+
kind: z.enum(['track', 'song']).default('song'),
|
|
271
|
+
provider: z.enum(['spotify', 'apple', 'youtube']),
|
|
272
|
+
query: z.string().min(1).optional(),
|
|
273
|
+
target: z.string().min(1).optional(),
|
|
274
|
+
},
|
|
275
|
+
}, async ({ kind, provider, query, target }, extra) => {
|
|
276
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
277
|
+
if (provider === 'apple' && !target && query) {
|
|
278
|
+
return {
|
|
279
|
+
content: [{ type: 'text', text: 'Apple Music requires a direct URL for playback. Search for the track first using search_music, then use the returned URL.' }],
|
|
280
|
+
isError: true,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const resolvedTarget = target ?? await this.resolvePlayTarget(provider, kind, query);
|
|
284
|
+
if (!resolvedTarget) {
|
|
285
|
+
throw new Error('play_music requires a target or a query that resolves to a playable track.');
|
|
286
|
+
}
|
|
287
|
+
await this.daemonClient.playMusic(provider, resolvedTarget);
|
|
288
|
+
return this.jsonResult({
|
|
289
|
+
provider,
|
|
290
|
+
success: true,
|
|
291
|
+
target: resolvedTarget,
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
this.server.registerTool('smart_play', {
|
|
295
|
+
annotations: {
|
|
296
|
+
destructiveHint: false,
|
|
297
|
+
openWorldHint: false,
|
|
298
|
+
readOnlyHint: false,
|
|
299
|
+
title: 'Smart Play',
|
|
300
|
+
},
|
|
301
|
+
description: 'Play a song by searching all connected providers and playing on the first match. If a specific provider is requested but needs authentication, returns an auth URL. Use this when the user says "play this song" without specifying a provider, or "play X on YouTube".',
|
|
302
|
+
inputSchema: {
|
|
303
|
+
query: z.string().min(1).optional().describe('Song name or search query to find and play'),
|
|
304
|
+
uri: z.string().min(1).optional().describe('Direct track URI (spotify:track:..., youtube URL, apple URL)'),
|
|
305
|
+
provider: z.enum(['spotify', 'apple', 'youtube']).optional().describe('Preferred provider (optional — if omitted, searches all)'),
|
|
306
|
+
},
|
|
307
|
+
}, async ({ query, uri, provider }, extra) => {
|
|
308
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
309
|
+
const result = await this.daemonClient.smartPlay({
|
|
310
|
+
query,
|
|
311
|
+
uri,
|
|
312
|
+
provider,
|
|
313
|
+
});
|
|
314
|
+
if (result.needsAuth) {
|
|
315
|
+
return {
|
|
316
|
+
content: [{
|
|
317
|
+
type: 'text',
|
|
318
|
+
text: `${result.provider} needs authentication. ${result.authUrl ? `The user should open this URL: ${result.authUrl}` : `Use the auth_${result.provider}_login tool to start authentication.`}`,
|
|
319
|
+
}],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (!result.success) {
|
|
323
|
+
return {
|
|
324
|
+
content: [{ type: 'text', text: result.error || 'Playback failed.' }],
|
|
325
|
+
isError: true,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const track = result.track;
|
|
329
|
+
let text = `Now playing on ${result.provider}`;
|
|
330
|
+
if (track) {
|
|
331
|
+
text += `: ${track.artist} - ${track.name}`;
|
|
332
|
+
if (track.album)
|
|
333
|
+
text += ` (${track.album})`;
|
|
334
|
+
}
|
|
335
|
+
if (result.alternateProviders?.length > 0) {
|
|
336
|
+
text += `\n\nAlso available on: ${result.alternateProviders.map((a) => a.provider).join(', ')}`;
|
|
337
|
+
}
|
|
338
|
+
return { content: [{ type: 'text', text }] };
|
|
339
|
+
});
|
|
340
|
+
this.server.registerTool('pause_music', {
|
|
341
|
+
annotations: {
|
|
342
|
+
destructiveHint: false,
|
|
343
|
+
openWorldHint: false,
|
|
344
|
+
readOnlyHint: false,
|
|
345
|
+
title: 'Pause Music',
|
|
346
|
+
},
|
|
347
|
+
description: 'Pause playback for a provider runtime. YouTube browser-handoff does not support pause.',
|
|
348
|
+
inputSchema: {
|
|
349
|
+
provider: z.enum(['spotify', 'apple', 'youtube']),
|
|
350
|
+
},
|
|
351
|
+
}, async ({ provider }, extra) => {
|
|
352
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
353
|
+
if (provider === 'youtube') {
|
|
354
|
+
throw new Error('YouTube Music pause is not supported in browser-handoff mode.');
|
|
355
|
+
}
|
|
356
|
+
return this.jsonResult(await this.daemonClient.pauseMusic(provider));
|
|
357
|
+
});
|
|
358
|
+
this.server.registerTool('next_track', {
|
|
359
|
+
annotations: {
|
|
360
|
+
destructiveHint: false,
|
|
361
|
+
openWorldHint: false,
|
|
362
|
+
readOnlyHint: false,
|
|
363
|
+
title: 'Next Track',
|
|
364
|
+
},
|
|
365
|
+
description: 'Skip to the next track for a provider runtime.',
|
|
366
|
+
inputSchema: {
|
|
367
|
+
provider: z.enum(['spotify', 'apple', 'youtube']),
|
|
368
|
+
},
|
|
369
|
+
}, async ({ provider }, extra) => {
|
|
370
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
371
|
+
return this.jsonResult(await this.daemonClient.nextTrack(provider));
|
|
372
|
+
});
|
|
373
|
+
this.server.registerTool('previous_track', {
|
|
374
|
+
annotations: {
|
|
375
|
+
destructiveHint: false,
|
|
376
|
+
openWorldHint: false,
|
|
377
|
+
readOnlyHint: false,
|
|
378
|
+
title: 'Previous Track',
|
|
379
|
+
},
|
|
380
|
+
description: 'Return to the previous track for a provider runtime.',
|
|
381
|
+
inputSchema: {
|
|
382
|
+
provider: z.enum(['spotify', 'apple', 'youtube']),
|
|
383
|
+
},
|
|
384
|
+
}, async ({ provider }, extra) => {
|
|
385
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
386
|
+
return this.jsonResult(await this.daemonClient.previousTrack(provider));
|
|
387
|
+
});
|
|
388
|
+
this.server.registerTool('start_session', {
|
|
389
|
+
annotations: {
|
|
390
|
+
destructiveHint: false,
|
|
391
|
+
openWorldHint: false,
|
|
392
|
+
readOnlyHint: false,
|
|
393
|
+
title: 'Start Session',
|
|
394
|
+
},
|
|
395
|
+
description: 'Start a Harmon session using the shared policy contract.',
|
|
396
|
+
inputSchema: {
|
|
397
|
+
policy: SessionPolicy,
|
|
398
|
+
},
|
|
399
|
+
}, async ({ policy }, extra) => {
|
|
400
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
401
|
+
const result = await this.daemonClient.startSession(policy);
|
|
402
|
+
return this.jsonResult({
|
|
403
|
+
provider: policy.provider ?? 'spotify',
|
|
404
|
+
success: result.success,
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
this.server.registerTool('nudge_session', {
|
|
408
|
+
annotations: {
|
|
409
|
+
destructiveHint: false,
|
|
410
|
+
openWorldHint: false,
|
|
411
|
+
readOnlyHint: false,
|
|
412
|
+
title: 'Nudge Session',
|
|
413
|
+
},
|
|
414
|
+
description: 'Nudge the active session calmer or sharper.',
|
|
415
|
+
inputSchema: {
|
|
416
|
+
amount: z.number().min(0).max(1).optional(),
|
|
417
|
+
direction: z.enum(['calmer', 'sharper']),
|
|
418
|
+
reason: z.string().max(280).optional(),
|
|
419
|
+
},
|
|
420
|
+
}, async ({ amount, direction, reason }, extra) => {
|
|
421
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
422
|
+
return this.jsonResult(await this.daemonClient.nudgeSession(direction, amount, reason));
|
|
423
|
+
});
|
|
424
|
+
this.server.registerTool('stop_session', {
|
|
425
|
+
annotations: {
|
|
426
|
+
destructiveHint: false,
|
|
427
|
+
idempotentHint: true,
|
|
428
|
+
openWorldHint: false,
|
|
429
|
+
readOnlyHint: false,
|
|
430
|
+
title: 'Stop Session',
|
|
431
|
+
},
|
|
432
|
+
description: 'Stop the active Harmon session.',
|
|
433
|
+
}, async (extra) => {
|
|
434
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
435
|
+
return this.jsonResult(await this.daemonClient.stopSession());
|
|
436
|
+
});
|
|
437
|
+
// ---- Auth write tools ----
|
|
438
|
+
this.server.registerTool('auth_youtube_login', {
|
|
439
|
+
annotations: {
|
|
440
|
+
destructiveHint: false,
|
|
441
|
+
openWorldHint: true,
|
|
442
|
+
readOnlyHint: false,
|
|
443
|
+
title: 'YouTube Login',
|
|
444
|
+
},
|
|
445
|
+
description: 'Start YouTube Music OAuth login. Returns a URL the user must open in their browser to authorize Harmon.',
|
|
446
|
+
}, async (extra) => {
|
|
447
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
448
|
+
return this.jsonResult(await this.daemonClient.youtubeAuthLogin());
|
|
449
|
+
});
|
|
450
|
+
this.server.registerTool('auth_youtube_refresh', {
|
|
451
|
+
annotations: {
|
|
452
|
+
destructiveHint: false,
|
|
453
|
+
openWorldHint: false,
|
|
454
|
+
readOnlyHint: false,
|
|
455
|
+
title: 'YouTube Refresh Token',
|
|
456
|
+
},
|
|
457
|
+
description: 'Refresh the YouTube Music access token using the stored refresh token.',
|
|
458
|
+
}, async (extra) => {
|
|
459
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
460
|
+
return this.jsonResult(await this.daemonClient.youtubeAuthRefresh());
|
|
461
|
+
});
|
|
462
|
+
this.server.registerTool('auth_youtube_logout', {
|
|
463
|
+
annotations: {
|
|
464
|
+
destructiveHint: true,
|
|
465
|
+
idempotentHint: true,
|
|
466
|
+
openWorldHint: false,
|
|
467
|
+
readOnlyHint: false,
|
|
468
|
+
title: 'YouTube Logout',
|
|
469
|
+
},
|
|
470
|
+
description: 'Clear YouTube Music authentication tokens.',
|
|
471
|
+
}, async (extra) => {
|
|
472
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
473
|
+
return this.jsonResult(await this.daemonClient.youtubeAuthLogout());
|
|
474
|
+
});
|
|
475
|
+
this.server.registerTool('auth_spotify_login', {
|
|
476
|
+
annotations: {
|
|
477
|
+
destructiveHint: false,
|
|
478
|
+
openWorldHint: true,
|
|
479
|
+
readOnlyHint: false,
|
|
480
|
+
title: 'Spotify Login',
|
|
481
|
+
},
|
|
482
|
+
description: 'Start Spotify OAuth login. Returns a URL the user must open in their browser to authorize Harmon.',
|
|
483
|
+
}, async (extra) => {
|
|
484
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
485
|
+
return this.jsonResult(await this.daemonClient.spotifyAuthLogin());
|
|
486
|
+
});
|
|
487
|
+
this.server.registerTool('auth_spotify_logout', {
|
|
488
|
+
annotations: {
|
|
489
|
+
destructiveHint: true,
|
|
490
|
+
idempotentHint: true,
|
|
491
|
+
openWorldHint: false,
|
|
492
|
+
readOnlyHint: false,
|
|
493
|
+
title: 'Spotify Logout',
|
|
494
|
+
},
|
|
495
|
+
description: 'Clear Spotify authentication tokens and cookies.',
|
|
496
|
+
}, async (extra) => {
|
|
497
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
498
|
+
return this.jsonResult(await this.daemonClient.spotifyAuthLogout());
|
|
499
|
+
});
|
|
500
|
+
this.server.registerTool('auth_apple_set_token', {
|
|
501
|
+
annotations: {
|
|
502
|
+
destructiveHint: false,
|
|
503
|
+
openWorldHint: false,
|
|
504
|
+
readOnlyHint: false,
|
|
505
|
+
title: 'Apple Set User Token',
|
|
506
|
+
},
|
|
507
|
+
description: "Set the Apple Music user token (obtained via MusicKit JS in a browser). Required for accessing the user's Apple Music library.",
|
|
508
|
+
inputSchema: {
|
|
509
|
+
token: z.string().min(1),
|
|
510
|
+
},
|
|
511
|
+
}, async ({ token }, extra) => {
|
|
512
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
513
|
+
return this.jsonResult(await this.daemonClient.appleAuthSetUserToken(token));
|
|
514
|
+
});
|
|
515
|
+
this.server.registerTool('auth_apple_refresh', {
|
|
516
|
+
annotations: {
|
|
517
|
+
destructiveHint: false,
|
|
518
|
+
openWorldHint: false,
|
|
519
|
+
readOnlyHint: false,
|
|
520
|
+
title: 'Apple Refresh Token',
|
|
521
|
+
},
|
|
522
|
+
description: 'Refresh the Apple Music developer token. Requires key material (APPLE_MUSIC_TEAM_ID, APPLE_MUSIC_KEY_ID, APPLE_MUSIC_PRIVATE_KEY) to be configured.',
|
|
523
|
+
}, async (extra) => {
|
|
524
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
525
|
+
return this.jsonResult(await this.daemonClient.appleAuthRefresh());
|
|
526
|
+
});
|
|
527
|
+
this.server.registerTool('auth_apple_logout', {
|
|
528
|
+
annotations: {
|
|
529
|
+
destructiveHint: true,
|
|
530
|
+
idempotentHint: true,
|
|
531
|
+
openWorldHint: false,
|
|
532
|
+
readOnlyHint: false,
|
|
533
|
+
title: 'Apple Logout',
|
|
534
|
+
},
|
|
535
|
+
description: 'Clear Apple Music authentication tokens.',
|
|
536
|
+
}, async (extra) => {
|
|
537
|
+
assertToolScopesFromExtra(extra, this.auth.writeScopes, authEnabled);
|
|
538
|
+
return this.jsonResult(await this.daemonClient.appleAuthLogout());
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
shouldAllowUnauthenticatedWrites() {
|
|
542
|
+
return (this.config.allowUnauthenticatedWrites
|
|
543
|
+
?? process.env.HARMON_MCP_ALLOW_UNAUTHENTICATED_WRITES === '1') === true;
|
|
544
|
+
}
|
|
545
|
+
async handleMcpRequest(req, res, parsedBody) {
|
|
546
|
+
const sessionId = getHeaderValue(req.headers['mcp-session-id']);
|
|
547
|
+
let transport = sessionId ? this.transports.get(sessionId) : undefined;
|
|
548
|
+
if (!transport) {
|
|
549
|
+
if (sessionId || !parsedBody || !isInitializeRequest(parsedBody)) {
|
|
550
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
551
|
+
res.end(JSON.stringify({ error: 'Bad Request: valid MCP session required.' }));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
transport = new StreamableHTTPServerTransport({
|
|
555
|
+
onsessioninitialized: (initializedSessionId) => {
|
|
556
|
+
this.transports.set(initializedSessionId, transport);
|
|
557
|
+
},
|
|
558
|
+
sessionIdGenerator: () => randomUUID(),
|
|
559
|
+
});
|
|
560
|
+
transport.onclose = () => {
|
|
561
|
+
if (transport?.sessionId) {
|
|
562
|
+
this.transports.delete(transport.sessionId);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
await this.server.connect(transport);
|
|
566
|
+
}
|
|
567
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
568
|
+
}
|
|
569
|
+
async getPublicStatus() {
|
|
570
|
+
const status = await this.daemonClient.getStatus();
|
|
571
|
+
return {
|
|
572
|
+
features: status.features ?? {},
|
|
573
|
+
isRunning: status.isRunning,
|
|
574
|
+
providers: status.providers ?? {},
|
|
575
|
+
session: status.session
|
|
576
|
+
? {
|
|
577
|
+
currentTrack: status.session.currentTrack ?? null,
|
|
578
|
+
isActive: status.session.isActive,
|
|
579
|
+
policy: summarizePolicy(status.session.policy),
|
|
580
|
+
provider: status.session.provider ?? null,
|
|
581
|
+
queueDepth: status.session.queueDepth,
|
|
582
|
+
}
|
|
583
|
+
: null,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
readEntries() {
|
|
587
|
+
return this.flowParser.scanDirectory();
|
|
588
|
+
}
|
|
589
|
+
async resolvePlayTarget(provider, kind, query) {
|
|
590
|
+
if (!query) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
const results = await this.daemonClient.searchMusic(provider, query, kind, 1);
|
|
594
|
+
const first = results[0];
|
|
595
|
+
if (!first) {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
if (first.uri) {
|
|
599
|
+
return first.uri;
|
|
600
|
+
}
|
|
601
|
+
if (first.url) {
|
|
602
|
+
return first.url;
|
|
603
|
+
}
|
|
604
|
+
if (provider === 'spotify') {
|
|
605
|
+
return `spotify:track:${first.id}`;
|
|
606
|
+
}
|
|
607
|
+
if (provider === 'apple') {
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
return `youtube:video:${first.id}`;
|
|
611
|
+
}
|
|
612
|
+
jsonResult(value) {
|
|
613
|
+
return {
|
|
614
|
+
content: [{ text: JSON.stringify(value, null, 2), type: 'text' }],
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
setupHttpRoutes() {
|
|
618
|
+
this.app.get('/healthz', (_req, res) => {
|
|
619
|
+
res.status(200).json({
|
|
620
|
+
authMode: this.auth.mode,
|
|
621
|
+
ok: true,
|
|
622
|
+
transport: 'streamable-http',
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
if (this.auth.metadata) {
|
|
626
|
+
this.app.use(mcpAuthMetadataRouter(this.auth.metadata));
|
|
627
|
+
}
|
|
628
|
+
if (this.auth.verifier) {
|
|
629
|
+
this.app.use(this.mcpPath, requireBearerAuth({
|
|
630
|
+
resourceMetadataUrl: this.auth.metadata
|
|
631
|
+
? getOAuthProtectedResourceMetadataUrl(this.auth.metadata.resourceServerUrl)
|
|
632
|
+
: undefined,
|
|
633
|
+
verifier: this.auth.verifier,
|
|
634
|
+
}));
|
|
635
|
+
}
|
|
636
|
+
this.app.get(this.mcpPath, (req, res) => {
|
|
637
|
+
void this.handleMcpRequest(req, res);
|
|
638
|
+
});
|
|
639
|
+
this.app.post(this.mcpPath, (req, res) => {
|
|
640
|
+
void this.handleMcpRequest(req, res, req.body);
|
|
641
|
+
});
|
|
642
|
+
this.app.delete(this.mcpPath, (req, res) => {
|
|
643
|
+
void this.handleMcpRequest(req, res);
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* I create and start the remote MCP app server.
|
|
649
|
+
*/
|
|
650
|
+
export async function createAppMCPServer(config) {
|
|
651
|
+
const server = new HarmonAppMCPServer(config);
|
|
652
|
+
await server.start();
|
|
653
|
+
return server;
|
|
654
|
+
}
|
|
655
|
+
function splitList(value) {
|
|
656
|
+
if (!value) {
|
|
657
|
+
return undefined;
|
|
658
|
+
}
|
|
659
|
+
const entries = value.split(/[,\s]+/).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
660
|
+
return entries.length > 0 ? entries : undefined;
|
|
661
|
+
}
|
|
662
|
+
function isLoopbackHost(host) {
|
|
663
|
+
return host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
|
664
|
+
}
|
|
665
|
+
function summarizePolicy(policy) {
|
|
666
|
+
if (!policy) {
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
return {
|
|
670
|
+
durationMs: policy.durationMs ?? null,
|
|
671
|
+
mode: policy.mode ?? null,
|
|
672
|
+
provider: policy.provider ?? 'spotify',
|
|
673
|
+
queue: policy.queue
|
|
674
|
+
? {
|
|
675
|
+
refillWhenBelow: policy.queue.refillWhenBelow ?? null,
|
|
676
|
+
target: policy.queue.target ?? null,
|
|
677
|
+
}
|
|
678
|
+
: null,
|
|
679
|
+
sources: policy.sources ?? null,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
//# sourceMappingURL=app-server.js.map
|