@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.1
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 +202 -705
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +384 -76
- package/dist/server/tools/ai.js +124 -91
- package/dist/server/tools/analysis.js +168 -59
- package/dist/server/tools/capture.js +135 -24
- package/dist/server/tools/compose.js +26 -26
- package/dist/server/tools/diagnostics.js +20 -3
- package/dist/server/tools/editor.js +60 -6
- package/dist/server/tools/generate.js +38 -15
- package/dist/server/tools/history.js +30 -8
- package/dist/server/tools/playback.js +2 -1
- package/dist/server/tools/session.js +38 -6
- package/dist/server/tools/storage.js +179 -8
- package/dist/server/tools/transform.js +127 -43
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +27 -16
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
package/dist/server/server.js
CHANGED
|
@@ -8,11 +8,13 @@ import { PatternGenerator } from '../services/PatternGenerator.js';
|
|
|
8
8
|
import { GeminiService } from '../services/GeminiService.js';
|
|
9
9
|
import { AudioCaptureService } from '../services/AudioCaptureService.js';
|
|
10
10
|
import { MIDIExportService } from '../services/MIDIExportService.js';
|
|
11
|
+
import { AudioExportService } from '../services/AudioExportService.js';
|
|
12
|
+
import { MIDIImportService } from '../services/MIDIImportService.js';
|
|
11
13
|
import { SessionManager } from '../services/SessionManager.js';
|
|
12
14
|
import { readFileSync, existsSync } from 'fs';
|
|
13
15
|
import { Logger } from '../utils/Logger.js';
|
|
14
16
|
import { PerformanceMonitor } from '../utils/PerformanceMonitor.js';
|
|
15
|
-
import {
|
|
17
|
+
import { IsolatedStrudelEngine } from '../services/IsolatedStrudelEngine.js';
|
|
16
18
|
import { diagnosticsModule } from './tools/diagnostics.js';
|
|
17
19
|
import { playbackModule } from './tools/playback.js';
|
|
18
20
|
import { storageModule } from './tools/storage.js';
|
|
@@ -25,13 +27,47 @@ import { sessionModule } from './tools/session.js';
|
|
|
25
27
|
import { captureModule } from './tools/capture.js';
|
|
26
28
|
import { aiModule } from './tools/ai.js';
|
|
27
29
|
import { composeModule } from './tools/compose.js';
|
|
28
|
-
import { categorizeError, err, isEnvelope, ok } from './tools/types.js';
|
|
30
|
+
import { categorizeError, err, isEnvelope, ok, isFailureShaped, PATTERN_STASHED_PREFIX } from './tools/types.js';
|
|
29
31
|
import { readResource, resources as mcpResources } from './resources.js';
|
|
30
|
-
import { join } from 'node:path';
|
|
32
|
+
import { join, resolve } from 'node:path';
|
|
33
|
+
import { parseServerConfig } from '../utils/ServerConfig.js';
|
|
34
|
+
import { BusinessError } from '../utils/CategorisedError.js';
|
|
31
35
|
const configPath = './config.json';
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Parsed once at module load. parseServerConfig never throws — a bad
|
|
38
|
+
* value falls back to its default and records a warning — so a typo in
|
|
39
|
+
* config.json cannot stop the server starting. It also warns about keys
|
|
40
|
+
* nothing reads, which is what stops the next `strudel_url` from being
|
|
41
|
+
* silently ignored for two releases (#227).
|
|
42
|
+
*/
|
|
43
|
+
let configFound = false;
|
|
44
|
+
function loadConfig() {
|
|
45
|
+
configFound = existsSync(configPath);
|
|
46
|
+
// No warning for an absent file — running without config.json is the
|
|
47
|
+
// normal case and warning about it every start would be crying wolf.
|
|
48
|
+
// The path checked is reported through `diagnostics` instead, so a
|
|
49
|
+
// user whose client launches the server from another cwd can SEE that
|
|
50
|
+
// their file was not the one read (#442).
|
|
51
|
+
if (!configFound)
|
|
52
|
+
return parseServerConfig(undefined);
|
|
53
|
+
try {
|
|
54
|
+
return parseServerConfig(JSON.parse(readFileSync(configPath, 'utf-8')));
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
const parsed = parseServerConfig(undefined);
|
|
59
|
+
parsed.warnings.push(`config.json could not be parsed (${message}); using defaults`);
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* How long shutdown may take before the process exits anyway. Browser
|
|
65
|
+
* cleanup talks to a wedged Chromium over CDP often enough that "wait
|
|
66
|
+
* forever" is not an option (#307).
|
|
67
|
+
*/
|
|
68
|
+
const SHUTDOWN_GRACE_MS = 5000;
|
|
69
|
+
const config = loadConfig();
|
|
70
|
+
const audioAnalysisConfig = config.audioAnalysis;
|
|
35
71
|
export class StrudelMCPServer {
|
|
36
72
|
server;
|
|
37
73
|
controller;
|
|
@@ -46,12 +82,15 @@ export class StrudelMCPServer {
|
|
|
46
82
|
* sessions no longer conflict.
|
|
47
83
|
*/
|
|
48
84
|
audioCaptureServices = new Map();
|
|
85
|
+
/** In-flight recorder injections, so concurrent calls share one (#423). */
|
|
86
|
+
captureServiceBuilds = new Map();
|
|
49
87
|
midiExportService;
|
|
88
|
+
audioExportService;
|
|
89
|
+
midiImportService;
|
|
50
90
|
sessionManager;
|
|
51
91
|
logger;
|
|
52
92
|
perfMonitor;
|
|
53
93
|
strudelEngine;
|
|
54
|
-
sessionHistory = [];
|
|
55
94
|
/**
|
|
56
95
|
* Per-session undo/redo/history bundles (#179). Keyed by session id;
|
|
57
96
|
* 'default' for the legacy/single-session path. Bundles are lazily
|
|
@@ -63,7 +102,18 @@ export class StrudelMCPServer {
|
|
|
63
102
|
/** Maximum history entries per session to prevent memory leaks */
|
|
64
103
|
MAX_HISTORY = 100;
|
|
65
104
|
isInitialized = false;
|
|
66
|
-
|
|
105
|
+
/**
|
|
106
|
+
* A pattern produced before a browser existed, held for the next init.
|
|
107
|
+
*
|
|
108
|
+
* One slot, not a map. Both readers only ever took the last value, so
|
|
109
|
+
* the map's other entries were pure growth — nothing deleted from it,
|
|
110
|
+
* up to 10KB each, for the life of the process. Its keys were also
|
|
111
|
+
* `pattern_${Date.now()}`, so two stashes in the same millisecond
|
|
112
|
+
* collided and one was silently dropped (#262).
|
|
113
|
+
*/
|
|
114
|
+
pendingPattern = null;
|
|
115
|
+
/** In-flight auto-init, so concurrent callers share one browser launch. */
|
|
116
|
+
initPromise = null;
|
|
67
117
|
constructor() {
|
|
68
118
|
this.server = new Server({
|
|
69
119
|
name: 'live-coding-music-mcp',
|
|
@@ -74,16 +124,38 @@ export class StrudelMCPServer {
|
|
|
74
124
|
resources: {},
|
|
75
125
|
},
|
|
76
126
|
});
|
|
77
|
-
this.controller = new StrudelController(config.headless);
|
|
78
|
-
this.store = new PatternStore(
|
|
127
|
+
this.controller = new StrudelController(config.headless, audioAnalysisConfig, config.strudelUrl, undefined, { viewport: config.viewport, blockResources: config.blockResources });
|
|
128
|
+
this.store = new PatternStore(config.patternsDir);
|
|
79
129
|
this.theory = new MusicTheory();
|
|
80
130
|
this.generator = new PatternGenerator();
|
|
81
131
|
this.geminiService = new GeminiService();
|
|
82
|
-
this
|
|
83
|
-
|
|
132
|
+
// Exports are confined to this directory (#224); `exports_dir` in
|
|
133
|
+
// config.json relocates it.
|
|
134
|
+
this.midiExportService = new MIDIExportService(config.exportsDir);
|
|
135
|
+
// Shares the export directory and sanitizer with MIDI export (#223, #224).
|
|
136
|
+
this.audioExportService = new AudioExportService(config.exportsDir);
|
|
137
|
+
this.midiImportService = new MIDIImportService();
|
|
138
|
+
this.sessionManager = new SessionManager(config.headless, audioAnalysisConfig, config.strudelUrl, { viewport: config.viewport, blockResources: config.blockResources });
|
|
139
|
+
// Covers every teardown path — the destroy tool, idle eviction, and
|
|
140
|
+
// destroyAll — rather than only the one the tool handler knew about.
|
|
141
|
+
// destroyAll fired none of these until #423; the claim above this
|
|
142
|
+
// line predated the code by some months.
|
|
143
|
+
this.sessionManager.onSessionDestroyed = (id) => {
|
|
144
|
+
this.historyBundles.delete(id);
|
|
145
|
+
this.audioCaptureServices.delete(id);
|
|
146
|
+
};
|
|
147
|
+
// The sweep cannot see a capture in progress: `audio_capture start`
|
|
148
|
+
// returns immediately and the recording runs on in the page, so the
|
|
149
|
+
// session looks idle while it is very much working (#423).
|
|
150
|
+
this.sessionManager.isSessionBusy = (id) => this.audioCaptureServices.get(id)?.isCapturing() === true;
|
|
84
151
|
this.logger = new Logger();
|
|
152
|
+
// A config problem the user never sees is how #227 survived: two
|
|
153
|
+
// documented keys were read by nothing, with no warning anywhere.
|
|
154
|
+
for (const warning of config.warnings) {
|
|
155
|
+
this.logger.warn(warning);
|
|
156
|
+
}
|
|
85
157
|
this.perfMonitor = new PerformanceMonitor();
|
|
86
|
-
this.strudelEngine = new
|
|
158
|
+
this.strudelEngine = new IsolatedStrudelEngine();
|
|
87
159
|
this.setupHandlers();
|
|
88
160
|
}
|
|
89
161
|
getTools() {
|
|
@@ -128,7 +200,11 @@ export class StrudelMCPServer {
|
|
|
128
200
|
try {
|
|
129
201
|
const content = await readResource(uri, {
|
|
130
202
|
store: this.store,
|
|
131
|
-
|
|
203
|
+
// Follows `patterns_dir`, like the store does. Hardcoding
|
|
204
|
+
// `process.cwd()` meant a server launched from anywhere but
|
|
205
|
+
// the repo root advertised `strudel://examples` and then
|
|
206
|
+
// failed to read it (#442).
|
|
207
|
+
examplesDir: join(config.patternsDir, 'examples'),
|
|
132
208
|
});
|
|
133
209
|
return { contents: [content] };
|
|
134
210
|
}
|
|
@@ -173,6 +249,38 @@ export class StrudelMCPServer {
|
|
|
173
249
|
}
|
|
174
250
|
return ok(result);
|
|
175
251
|
}
|
|
252
|
+
// A tool returning `{ success: false, ... }` is reporting a
|
|
253
|
+
// FAILURE. Wrapping it in ok() told MCP clients the call
|
|
254
|
+
// succeeded, with the real outcome buried one level down in a
|
|
255
|
+
// field the envelope contract does not mention — so an agent
|
|
256
|
+
// checking `envelope.ok` proceeded as though it had worked.
|
|
257
|
+
//
|
|
258
|
+
// 28 sites across the tool modules return this shape (capture,
|
|
259
|
+
// storage, ai). Rather than migrate them all at once, honour the
|
|
260
|
+
// shape here: it is unambiguous, and the alternative is a silent
|
|
261
|
+
// wrong answer.
|
|
262
|
+
if (isFailureShaped(result)) {
|
|
263
|
+
// Prefer whichever field actually carries the diagnosis, and
|
|
264
|
+
// combine them when both say something. transpile_pattern sets
|
|
265
|
+
// message:'Transpilation failed' with error:'Unexpected token
|
|
266
|
+
// (1:6)' — taking `message` alone would put the content-free half
|
|
267
|
+
// in the field an agent reads and bury the useful half in
|
|
268
|
+
// partialResult.
|
|
269
|
+
const detail = typeof result.error === 'string' && result.error.length > 0
|
|
270
|
+
? result.error
|
|
271
|
+
: undefined;
|
|
272
|
+
const summary = typeof result.message === 'string' && result.message.length > 0
|
|
273
|
+
? result.message
|
|
274
|
+
: undefined;
|
|
275
|
+
const message = summary !== undefined && detail !== undefined && !summary.includes(detail)
|
|
276
|
+
? `${summary}: ${detail}`
|
|
277
|
+
: detail ?? summary ?? 'Tool reported failure without a message.';
|
|
278
|
+
return err(categorizeError(new Error(message)), message, {
|
|
279
|
+
// Keep what the tool produced: some of these carry useful
|
|
280
|
+
// context alongside the failure.
|
|
281
|
+
partialResult: result,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
176
284
|
return ok(result);
|
|
177
285
|
}
|
|
178
286
|
catch (error) {
|
|
@@ -185,7 +293,35 @@ export class StrudelMCPServer {
|
|
|
185
293
|
// execute() does its own session-aware init check. The hardcoded
|
|
186
294
|
// tool-name lists had drifted and were silently wrong for explicit
|
|
187
295
|
// session_id.
|
|
188
|
-
|
|
296
|
+
/**
|
|
297
|
+
* The session a call belongs to when it names none.
|
|
298
|
+
*
|
|
299
|
+
* Returns the default session's id when SessionManager actually holds
|
|
300
|
+
* that session, and `undefined` when it does not — which means the
|
|
301
|
+
* legacy single controller, the only thing a server that has run
|
|
302
|
+
* nothing but `init` has.
|
|
303
|
+
*
|
|
304
|
+
* There used to be two answers to this question.
|
|
305
|
+
* `getControllerForSession` resolved through
|
|
306
|
+
* `sessionManager.getDefaultSession()`, while `getCurrentPatternSafe`,
|
|
307
|
+
* `writePatternSafe` and `getAudioCaptureService` went straight to the
|
|
308
|
+
* legacy controller. So after `session({action:'switch'})`, `playback`
|
|
309
|
+
* followed the switch and `edit_pattern` did not — reproduced end to
|
|
310
|
+
* end in #421: a write with no `session_id` landed in the legacy
|
|
311
|
+
* browser while the session switched to still held strudel.cc's
|
|
312
|
+
* boilerplate.
|
|
313
|
+
*
|
|
314
|
+
* `getSession` stamps `lastActivity`, which is correct here: resolving
|
|
315
|
+
* a session is the first step of using it.
|
|
316
|
+
*/
|
|
317
|
+
effectiveSessionId(sessionId) {
|
|
318
|
+
if (sessionId !== undefined)
|
|
319
|
+
return sessionId;
|
|
320
|
+
const defaultId = this.sessionManager.getDefaultSessionId();
|
|
321
|
+
return this.sessionManager.getSession(defaultId) !== undefined ? defaultId : undefined;
|
|
322
|
+
}
|
|
323
|
+
async getCurrentPatternSafe(rawSessionId) {
|
|
324
|
+
const sessionId = this.effectiveSessionId(rawSessionId);
|
|
189
325
|
if (sessionId) {
|
|
190
326
|
// Explicit session — strict lookup, no pre-init stash. Named sessions
|
|
191
327
|
// must be created via `session({ action: "create" })` before they're useful.
|
|
@@ -193,26 +329,22 @@ export class StrudelMCPServer {
|
|
|
193
329
|
if (!sessionController) {
|
|
194
330
|
throw new Error(`Session '${sessionId}' not found. Create it first with session({ action: "create" }).`);
|
|
195
331
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
332
|
+
// Do NOT collapse a read failure into ''. An empty string already
|
|
333
|
+
// means "the editor is empty", and every read-modify-write caller
|
|
334
|
+
// treats it as a blank canvas — so a transient CDP failure made
|
|
335
|
+
// edit_pattern append OVERWRITE the live pattern with just the
|
|
336
|
+
// appended line, and report success (#277). Let it propagate; the
|
|
337
|
+
// dispatcher turns it into a failure envelope.
|
|
338
|
+
return await sessionController.getCurrentPattern();
|
|
202
339
|
}
|
|
203
340
|
if (!this.isInitialized) {
|
|
204
|
-
// Default session: return the
|
|
205
|
-
|
|
206
|
-
return lastPattern || '';
|
|
207
|
-
}
|
|
208
|
-
try {
|
|
209
|
-
return await this.controller.getCurrentPattern();
|
|
210
|
-
}
|
|
211
|
-
catch {
|
|
212
|
-
return '';
|
|
341
|
+
// Default session: return the pre-init generated pattern if any
|
|
342
|
+
return this.pendingPattern ?? '';
|
|
213
343
|
}
|
|
344
|
+
return await this.controller.getCurrentPattern();
|
|
214
345
|
}
|
|
215
|
-
async writePatternSafe(pattern,
|
|
346
|
+
async writePatternSafe(pattern, rawSessionId) {
|
|
347
|
+
const sessionId = this.effectiveSessionId(rawSessionId);
|
|
216
348
|
if (sessionId) {
|
|
217
349
|
const sessionController = this.sessionManager.getSession(sessionId);
|
|
218
350
|
if (!sessionController) {
|
|
@@ -222,9 +354,8 @@ export class StrudelMCPServer {
|
|
|
222
354
|
}
|
|
223
355
|
if (!this.isInitialized) {
|
|
224
356
|
// Default session: stash the pattern for the next init/auto-init
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return `Pattern generated (initialize Strudel to use it): ${pattern.substring(0, 50)}...`;
|
|
357
|
+
this.pendingPattern = pattern;
|
|
358
|
+
return `${PATTERN_STASHED_PREFIX} ${pattern.substring(0, 50)}...`;
|
|
228
359
|
}
|
|
229
360
|
return await this.controller.writePattern(pattern);
|
|
230
361
|
}
|
|
@@ -275,29 +406,57 @@ export class StrudelMCPServer {
|
|
|
275
406
|
// (#179): route to the session-specific bundle. Default session is
|
|
276
407
|
// 'default'; named sessions get their own isolated stacks.
|
|
277
408
|
if (name === 'edit_pattern') {
|
|
278
|
-
|
|
409
|
+
// The session this edit belongs to, resolved the same way the
|
|
410
|
+
// read and write below resolve it. `sid ?? 'default'` sent every
|
|
411
|
+
// unnamed edit's undo snapshot to a bundle keyed 'default', which
|
|
412
|
+
// then survived destroying the session it actually belonged to —
|
|
413
|
+
// so the next default session inherited the previous one's undo
|
|
414
|
+
// stack (#421).
|
|
415
|
+
const sid = this.effectiveSessionId(args?.session_id);
|
|
416
|
+
// A default session is enough to snapshot against; `init` is not
|
|
417
|
+
// required when SessionManager is the one holding the page.
|
|
279
418
|
const canRead = sid !== undefined || this.isInitialized;
|
|
280
419
|
if (canRead) {
|
|
420
|
+
// Two different failures used to share this catch. A missing
|
|
421
|
+
// controller or destroyed session legitimately means there is
|
|
422
|
+
// nothing to save — but a READ failure means we are about to
|
|
423
|
+
// overwrite a pattern we could not capture, leaving nothing for
|
|
424
|
+
// undo to restore. The fault that destroys the work also disabled
|
|
425
|
+
// the recovery (#277), so they are separated here.
|
|
426
|
+
let controller;
|
|
281
427
|
try {
|
|
282
|
-
|
|
283
|
-
const current = await controller.getCurrentPattern();
|
|
284
|
-
const bundle = this.getHistoryBundle(sid ?? 'default');
|
|
285
|
-
bundle.undoStack.push(current);
|
|
286
|
-
this.historyIdCounter++;
|
|
287
|
-
bundle.historyStack.push({
|
|
288
|
-
id: this.historyIdCounter,
|
|
289
|
-
pattern: current,
|
|
290
|
-
timestamp: new Date(),
|
|
291
|
-
action: args?.mode ?? 'write',
|
|
292
|
-
});
|
|
293
|
-
if (bundle.undoStack.length > this.MAX_HISTORY)
|
|
294
|
-
bundle.undoStack.shift();
|
|
295
|
-
if (bundle.historyStack.length > this.MAX_HISTORY)
|
|
296
|
-
bundle.historyStack.shift();
|
|
297
|
-
bundle.redoStack.length = 0;
|
|
428
|
+
controller = this.getControllerForSession(sid);
|
|
298
429
|
}
|
|
299
430
|
catch {
|
|
300
|
-
//
|
|
431
|
+
// No session to snapshot; the edit itself will report that.
|
|
432
|
+
controller = undefined;
|
|
433
|
+
}
|
|
434
|
+
if (controller !== undefined) {
|
|
435
|
+
const current = await controller.getCurrentPattern();
|
|
436
|
+
// A destroy can land during that await. `getHistoryBundle`
|
|
437
|
+
// creates lazily, so it would then build a fresh bundle for a
|
|
438
|
+
// session that no longer exists — a permanent map entry the
|
|
439
|
+
// destroy callback has already run past (#423).
|
|
440
|
+
const destroyedMidRead = sid !== undefined && this.sessionManager.getSession(sid) === undefined;
|
|
441
|
+
if (destroyedMidRead) {
|
|
442
|
+
this.logger.warn(`Session '${sid}' was destroyed while snapshotting; skipping history.`);
|
|
443
|
+
}
|
|
444
|
+
const bundle = destroyedMidRead ? null : this.getHistoryBundle(sid ?? 'default');
|
|
445
|
+
bundle?.undoStack.push(current);
|
|
446
|
+
if (bundle !== null) {
|
|
447
|
+
this.historyIdCounter++;
|
|
448
|
+
bundle.historyStack.push({
|
|
449
|
+
id: this.historyIdCounter,
|
|
450
|
+
pattern: current,
|
|
451
|
+
timestamp: new Date(),
|
|
452
|
+
action: args?.mode ?? 'write',
|
|
453
|
+
});
|
|
454
|
+
if (bundle.undoStack.length > this.MAX_HISTORY)
|
|
455
|
+
bundle.undoStack.shift();
|
|
456
|
+
if (bundle.historyStack.length > this.MAX_HISTORY)
|
|
457
|
+
bundle.historyStack.shift();
|
|
458
|
+
bundle.redoStack.length = 0;
|
|
459
|
+
}
|
|
301
460
|
}
|
|
302
461
|
}
|
|
303
462
|
}
|
|
@@ -305,7 +464,12 @@ export class StrudelMCPServer {
|
|
|
305
464
|
// Part of the #104 file split — each module owns its own definitions
|
|
306
465
|
// and handlers. server.ts keeps the protocol + state-tracking shell.
|
|
307
466
|
const ctx = {
|
|
308
|
-
|
|
467
|
+
// Surfaced by `diagnostics level=status`; see ConfigReport (#442).
|
|
468
|
+
configReport: {
|
|
469
|
+
path: resolve(configPath),
|
|
470
|
+
found: configFound,
|
|
471
|
+
warnings: config.warnings,
|
|
472
|
+
},
|
|
309
473
|
perfMonitor: this.perfMonitor,
|
|
310
474
|
store: this.store,
|
|
311
475
|
generator: this.generator,
|
|
@@ -314,6 +478,8 @@ export class StrudelMCPServer {
|
|
|
314
478
|
geminiService: this.geminiService,
|
|
315
479
|
strudelEngine: this.strudelEngine,
|
|
316
480
|
midiExportService: this.midiExportService,
|
|
481
|
+
audioExportService: this.audioExportService,
|
|
482
|
+
midiImportService: this.midiImportService,
|
|
317
483
|
getAudioCaptureService: (sessionId) => this.getAudioCaptureService(sessionId),
|
|
318
484
|
dropAudioCaptureService: (sessionId) => { this.audioCaptureServices.delete(sessionId); },
|
|
319
485
|
getHistory: (sessionId) => {
|
|
@@ -325,7 +491,6 @@ export class StrudelMCPServer {
|
|
|
325
491
|
maxHistory: this.MAX_HISTORY,
|
|
326
492
|
};
|
|
327
493
|
},
|
|
328
|
-
historyEntryId: () => ++this.historyIdCounter,
|
|
329
494
|
dropHistory: (sessionId) => { this.historyBundles.delete(sessionId); },
|
|
330
495
|
logger: this.logger,
|
|
331
496
|
isInitialized: () => this.isInitialized,
|
|
@@ -373,13 +538,34 @@ export class StrudelMCPServer {
|
|
|
373
538
|
if (name === 'init') {
|
|
374
539
|
const initResult = await this.controller.initialize();
|
|
375
540
|
this.isInitialized = true;
|
|
376
|
-
//
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
541
|
+
// Install the recorder hook before any pattern playback connects the
|
|
542
|
+
// Strudel audio graph. Otherwise audio_capture can miss the first
|
|
543
|
+
// GainNode.connect call and remain disconnected even while diagnostics
|
|
544
|
+
// reports healthy WebAudio playback.
|
|
545
|
+
if (this._page) {
|
|
546
|
+
await this.getAudioCaptureService();
|
|
547
|
+
}
|
|
548
|
+
// Replay any pattern generated before init landed, then forget it.
|
|
549
|
+
//
|
|
550
|
+
// Clearing is the whole point: the stash exists to bridge the gap
|
|
551
|
+
// before a browser exists, and that gap closes the first time init
|
|
552
|
+
// succeeds. Without the clear, a second init — which agents call
|
|
553
|
+
// routinely, since it reports "Already initialized" and reads as a
|
|
554
|
+
// no-op — silently overwrote the live editor with a pattern from
|
|
555
|
+
// before the first init. Undo could not recover it either, because
|
|
556
|
+
// this writes through the controller and only edit_pattern pushes
|
|
557
|
+
// history (#262).
|
|
558
|
+
// Read without clearing, and clear only once the write lands.
|
|
559
|
+
//
|
|
560
|
+
// `takePendingPattern()` cleared first, so a write that threw —
|
|
561
|
+
// a page that died between init and this line — destroyed the
|
|
562
|
+
// pattern unrecoverably. The comment above justifies CLEARING;
|
|
563
|
+
// it does not justify clearing early (#453).
|
|
564
|
+
const pending = this.pendingPattern;
|
|
565
|
+
if (pending !== null) {
|
|
566
|
+
await this.controller.writePattern(pending);
|
|
567
|
+
this.pendingPattern = null;
|
|
568
|
+
return `${initResult}. Loaded generated pattern.`;
|
|
383
569
|
}
|
|
384
570
|
return initResult;
|
|
385
571
|
}
|
|
@@ -398,11 +584,45 @@ export class StrudelMCPServer {
|
|
|
398
584
|
return bundle;
|
|
399
585
|
}
|
|
400
586
|
/** Idempotent browser bring-up. Used by tools that promise auto-init (e.g. `compose`). */
|
|
587
|
+
/**
|
|
588
|
+
* Initializes the default browser if it is not already up.
|
|
589
|
+
*
|
|
590
|
+
* Single-flight. The MCP SDK does not serialize tool calls — each
|
|
591
|
+
* request is dispatched through a promise chain and returns immediately
|
|
592
|
+
* — so two `compose` calls arriving together both used to pass the
|
|
593
|
+
* `isInitialized` check, both call `initialize()`, and both assign
|
|
594
|
+
* `this.browser`. The second assignment orphaned the first Chromium
|
|
595
|
+
* process, which `cleanup()` could no longer reach. With the default
|
|
596
|
+
* `headless: false` the user also got two windows, half their tool
|
|
597
|
+
* calls addressing the one they were not watching (#265).
|
|
598
|
+
*
|
|
599
|
+
* The in-flight promise is the same shape `checkADC()` already uses.
|
|
600
|
+
*/
|
|
401
601
|
async ensureInitialized() {
|
|
602
|
+
// `isInitialized` was only ever set true, so once the browser died —
|
|
603
|
+
// user closed the window, or cleanup() ran — this returned early and
|
|
604
|
+
// never reached the self-healing initialize(). compose, the tool that
|
|
605
|
+
// advertises auto-init, then failed permanently with 'Target page,
|
|
606
|
+
// context or browser has been closed' (#265).
|
|
607
|
+
// Strict false, and an optional call: a controller that cannot answer
|
|
608
|
+
// (or does not implement the check) should mean 'assume alive' rather
|
|
609
|
+
// than tearing down a working browser. Recovery is the optimization
|
|
610
|
+
// here; a false negative just preserves the old behaviour.
|
|
611
|
+
if (this.isInitialized && this.controller.isAlive?.() === false) {
|
|
612
|
+
this.isInitialized = false;
|
|
613
|
+
}
|
|
402
614
|
if (this.isInitialized)
|
|
403
615
|
return;
|
|
404
|
-
|
|
405
|
-
|
|
616
|
+
// Promise.resolve wrap: initialize() is not guaranteed to return a
|
|
617
|
+
// thenable (a mocked controller returns undefined), and chaining
|
|
618
|
+
// directly off it would throw before ever launching a browser.
|
|
619
|
+
this.initPromise ??= Promise.resolve(this.controller.initialize())
|
|
620
|
+
.then(() => { this.isInitialized = true; })
|
|
621
|
+
.finally(() => { this.initPromise = null; });
|
|
622
|
+
await this.initPromise;
|
|
623
|
+
if (this._page) {
|
|
624
|
+
await this.getAudioCaptureService();
|
|
625
|
+
}
|
|
406
626
|
}
|
|
407
627
|
/** Getter for page access in audio capture. */
|
|
408
628
|
get _page() {
|
|
@@ -413,9 +633,16 @@ export class StrudelMCPServer {
|
|
|
413
633
|
// the class. Post-#180: per-session — each session's recorder injects
|
|
414
634
|
// into its own page; concurrent captures across sessions no longer
|
|
415
635
|
// share a singleton stream.
|
|
416
|
-
async getAudioCaptureService(
|
|
417
|
-
// Resolve the right page:
|
|
418
|
-
// legacy
|
|
636
|
+
async getAudioCaptureService(rawSessionId) {
|
|
637
|
+
// Resolve the right page: the named or default session via
|
|
638
|
+
// SessionManager, or the legacy controller when neither exists.
|
|
639
|
+
//
|
|
640
|
+
// This used to treat "no session_id" as "legacy, always", so a
|
|
641
|
+
// recorder was injected into the legacy page while `startCapture`
|
|
642
|
+
// ran against the default session's page — two different browser
|
|
643
|
+
// contexts (#421). It also refused with "Browser not initialized"
|
|
644
|
+
// when a live default session was sitting right there.
|
|
645
|
+
const sessionId = this.effectiveSessionId(rawSessionId);
|
|
419
646
|
let page;
|
|
420
647
|
let key;
|
|
421
648
|
if (sessionId) {
|
|
@@ -424,7 +651,7 @@ export class StrudelMCPServer {
|
|
|
424
651
|
throw new Error(`Session '${sessionId}' not found. Create it first with session({ action: "create" }).`);
|
|
425
652
|
}
|
|
426
653
|
if (!sessionController.page) {
|
|
427
|
-
throw new
|
|
654
|
+
throw new BusinessError(`Session '${sessionId}' has no active page yet.`);
|
|
428
655
|
}
|
|
429
656
|
page = sessionController.page;
|
|
430
657
|
key = sessionId;
|
|
@@ -436,23 +663,104 @@ export class StrudelMCPServer {
|
|
|
436
663
|
page = this._page;
|
|
437
664
|
key = 'default';
|
|
438
665
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
666
|
+
// Re-inject when the cached service is bound to a different page.
|
|
667
|
+
//
|
|
668
|
+
// A cached service used to be returned unconditionally, which broke
|
|
669
|
+
// in two ways (#264). A session recreated under a previously-evicted
|
|
670
|
+
// id got the old session's recorder, pointing at a closed page. And
|
|
671
|
+
// after `init` recovered a dead browser (#206), the 'default' service
|
|
672
|
+
// still referenced the old page — so audio capture was permanently
|
|
673
|
+
// broken by the very mechanism meant to recover from a crash, with
|
|
674
|
+
// "Audio capture not initialized" until restart.
|
|
675
|
+
//
|
|
676
|
+
// Strict false, like the liveness check in ensureInitialized: a
|
|
677
|
+
// service that cannot answer keeps the old reuse-the-cache
|
|
678
|
+
// behaviour, so this can only ever add a re-injection that was
|
|
679
|
+
// needed, never drop one that was working.
|
|
680
|
+
const service = this.audioCaptureServices.get(key);
|
|
681
|
+
// Awaited: the check asks the page whether the recorder is still
|
|
682
|
+
// there, because a Playwright `Page` outlives the JS realm it points
|
|
683
|
+
// at and a reload wipes the recorder while identity still matches
|
|
684
|
+
// (#437).
|
|
685
|
+
if (service && (await service.isInjectedInto?.(page)) !== false) {
|
|
686
|
+
return service;
|
|
687
|
+
}
|
|
688
|
+
// Single-flight, like `SessionManager.ensureBrowser` (#263).
|
|
689
|
+
//
|
|
690
|
+
// This was check-then-act: two concurrent `audio_capture` calls on
|
|
691
|
+
// one uncached session both missed, both constructed, both awaited
|
|
692
|
+
// `injectRecorder`, and the last `set` won. `injectRecorder` assigns
|
|
693
|
+
// `window.strudelAudioCapture` unconditionally, so the second
|
|
694
|
+
// injection WIPED a capture the first had started, and the orphaned
|
|
695
|
+
// service still reported itself injected (#423).
|
|
696
|
+
const existing = this.captureServiceBuilds.get(key);
|
|
697
|
+
if (existing !== undefined)
|
|
698
|
+
return await existing;
|
|
699
|
+
const build = (async () => {
|
|
700
|
+
const fresh = new AudioCaptureService();
|
|
701
|
+
await fresh.injectRecorder(page);
|
|
702
|
+
// A destroy landing during the await deleted a key that was not
|
|
703
|
+
// there yet; re-inserting here would resurrect a service for a
|
|
704
|
+
// dead session. The caller still gets its recorder — the page it
|
|
705
|
+
// asked about is the page it got — but nothing is cached for an
|
|
706
|
+
// id that no longer exists.
|
|
707
|
+
if (key === 'default' || this.sessionManager.getSession(key) !== undefined) {
|
|
708
|
+
this.audioCaptureServices.set(key, fresh);
|
|
709
|
+
}
|
|
710
|
+
return fresh;
|
|
711
|
+
})();
|
|
712
|
+
this.captureServiceBuilds.set(key, build);
|
|
713
|
+
try {
|
|
714
|
+
return await build;
|
|
715
|
+
}
|
|
716
|
+
finally {
|
|
717
|
+
this.captureServiceBuilds.delete(key);
|
|
444
718
|
}
|
|
445
|
-
return service;
|
|
446
719
|
}
|
|
447
720
|
async run() {
|
|
448
721
|
const transport = new StdioServerTransport();
|
|
449
722
|
await this.server.connect(transport);
|
|
450
723
|
this.logger.info('Enhanced Strudel MCP server v2.0.1 running (fixed)');
|
|
451
|
-
|
|
452
|
-
|
|
724
|
+
// SIGTERM as well as SIGINT. The isolated engine child is a real
|
|
725
|
+
// process, and `docker stop`, systemd and most supervisors send
|
|
726
|
+
// SIGTERM — handling only SIGINT left the child orphaned in exactly
|
|
727
|
+
// the deployments where nobody is watching a terminal (#307).
|
|
728
|
+
let shuttingDown = false;
|
|
729
|
+
const shutdown = async (signal) => {
|
|
730
|
+
if (shuttingDown) {
|
|
731
|
+
// A second signal means the operator is not willing to wait any
|
|
732
|
+
// longer, and they are usually right. Swallowing it left Ctrl+C
|
|
733
|
+
// doing nothing at all while a wedged browser held the first
|
|
734
|
+
// shutdown open forever.
|
|
735
|
+
this.logger.warn(`${signal} during shutdown — exiting now.`);
|
|
736
|
+
process.exit(1);
|
|
737
|
+
}
|
|
738
|
+
shuttingDown = true;
|
|
739
|
+
this.logger.info(`Shutting down (${signal})...`);
|
|
740
|
+
// cleanup() and destroyAll() both talk to a browser over CDP, and a
|
|
741
|
+
// wedged one never answers. Without this the process hangs in
|
|
742
|
+
// shutdown indefinitely and the supervisor eventually SIGKILLs it,
|
|
743
|
+
// which is the one path that CAN orphan the engine child. unref'd
|
|
744
|
+
// so it never keeps a healthy process alive.
|
|
745
|
+
const forceExit = setTimeout(() => {
|
|
746
|
+
this.logger.error(`Shutdown exceeded ${String(SHUTDOWN_GRACE_MS)}ms — forcing exit.`);
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}, SHUTDOWN_GRACE_MS);
|
|
749
|
+
forceExit.unref();
|
|
750
|
+
// Disposed FIRST and synchronously. The two awaits below can hang
|
|
751
|
+
// on a wedged browser, and the child must not outlive us because
|
|
752
|
+
// cleanup took too long.
|
|
753
|
+
this.strudelEngine.dispose();
|
|
453
754
|
await this.controller.cleanup();
|
|
454
755
|
await this.sessionManager.destroyAll();
|
|
756
|
+
clearTimeout(forceExit);
|
|
455
757
|
process.exit(0);
|
|
456
|
-
}
|
|
758
|
+
};
|
|
759
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
760
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
761
|
+
// Last resort: a path that reaches exit without a signal — an
|
|
762
|
+
// uncaught throw, or the transport closing — still must not leak a
|
|
763
|
+
// process. Synchronous only; this handler cannot await.
|
|
764
|
+
process.on('exit', () => { this.strudelEngine.dispose(); });
|
|
457
765
|
}
|
|
458
766
|
}
|