rivet-design 0.6.6 → 0.6.7
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/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +560 -99
- package/dist/mcp/server.js.map +1 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +40 -1
- package/dist/routes/mcp.js.map +1 -1
- package/dist/services/ProjectDetectionService.d.ts +4 -0
- package/dist/services/ProjectDetectionService.d.ts.map +1 -1
- package/dist/services/ProjectDetectionService.js +85 -7
- package/dist/services/ProjectDetectionService.js.map +1 -1
- package/dist/services/SessionBridgeService.d.ts +27 -0
- package/dist/services/SessionBridgeService.d.ts.map +1 -1
- package/dist/services/SessionBridgeService.js +54 -0
- package/dist/services/SessionBridgeService.js.map +1 -1
- package/package.json +1 -1
- package/src/ui/dist/assets/{main-jMwZV-Yc.js → main-B0BsoUuj.js} +101 -101
- package/src/ui/dist/assets/main-BrSIbJEX.css +1 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/dist/assets/main-Bui5ryn7.css +0 -1
package/dist/mcp/server.js
CHANGED
|
@@ -16,7 +16,11 @@ const index_1 = require("../index");
|
|
|
16
16
|
const skillWriter_1 = require("../utils/skillWriter");
|
|
17
17
|
const portUtils_1 = require("../utils/portUtils");
|
|
18
18
|
const child_process_1 = require("child_process");
|
|
19
|
+
const fs_1 = __importDefault(require("fs"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const util_1 = require("util");
|
|
19
22
|
const log = (0, index_core_1.createLogger)('MCPServer');
|
|
23
|
+
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
20
24
|
// Dev server command + default port per framework
|
|
21
25
|
const FRAMEWORK_DEV_CONFIG = {
|
|
22
26
|
nextjs: { devCommand: 'dev', defaultPort: 3000 },
|
|
@@ -62,6 +66,244 @@ async function waitForPort(port, host, timeoutMs = 60_000) {
|
|
|
62
66
|
}
|
|
63
67
|
return false;
|
|
64
68
|
}
|
|
69
|
+
/** Stop a spawned process with graceful SIGTERM and SIGKILL fallback. */
|
|
70
|
+
async function stopChildProcess(child, timeoutMs = DEV_SERVER_STOP_TIMEOUT_MS) {
|
|
71
|
+
if (child.killed || child.exitCode !== null || child.pid == null) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const waitForExit = new Promise((resolve) => {
|
|
75
|
+
const onExit = () => {
|
|
76
|
+
child.off('exit', onExit);
|
|
77
|
+
child.off('error', onExit);
|
|
78
|
+
resolve();
|
|
79
|
+
};
|
|
80
|
+
child.once('exit', onExit);
|
|
81
|
+
child.once('error', onExit);
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
child.kill('SIGTERM');
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// no-op; fallback still runs below if process persists
|
|
88
|
+
}
|
|
89
|
+
const timedOut = await Promise.race([
|
|
90
|
+
waitForExit.then(() => false),
|
|
91
|
+
new Promise((resolve) => setTimeout(() => resolve(true), timeoutMs)),
|
|
92
|
+
]);
|
|
93
|
+
if (!timedOut) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
child.kill('SIGKILL');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// best effort
|
|
101
|
+
}
|
|
102
|
+
await waitForExit;
|
|
103
|
+
}
|
|
104
|
+
const DEV_SERVER_STOP_TIMEOUT_MS = 5000;
|
|
105
|
+
function normalizeFilesystemPath(inputPath) {
|
|
106
|
+
if (!inputPath) {
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
const resolvedPath = path_1.default.resolve(inputPath);
|
|
110
|
+
try {
|
|
111
|
+
return fs_1.default.realpathSync(resolvedPath);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return resolvedPath;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function pathsMatchExactly(pathA, pathB) {
|
|
118
|
+
if (!pathA || !pathB) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
return pathA === pathB;
|
|
122
|
+
}
|
|
123
|
+
async function getListeningPortPids(port) {
|
|
124
|
+
try {
|
|
125
|
+
const { stdout } = await execAsync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -Fp`);
|
|
126
|
+
const pids = stdout
|
|
127
|
+
.split('\n')
|
|
128
|
+
.map((line) => line.trim())
|
|
129
|
+
.filter((line) => line.startsWith('p'))
|
|
130
|
+
.map((line) => parseInt(line.slice(1), 10))
|
|
131
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
132
|
+
return [...new Set(pids)];
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function getProcessCwd(pid) {
|
|
139
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const { stdout } = await execAsync(`lsof -a -p ${pid} -d cwd -Fn`);
|
|
144
|
+
const cwdLine = stdout
|
|
145
|
+
.split('\n')
|
|
146
|
+
.map((line) => line.trim())
|
|
147
|
+
.find((line) => line.startsWith('n'));
|
|
148
|
+
if (!cwdLine) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return cwdLine.slice(1) || null;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function isPortOwnedByProject(port, projectPath) {
|
|
158
|
+
if (!Number.isInteger(port) || port <= 0 || port >= 65536) {
|
|
159
|
+
return {
|
|
160
|
+
matches: false,
|
|
161
|
+
reason: `Invalid port ${port} for project validation.`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const normalizedProjectPath = normalizeFilesystemPath(projectPath);
|
|
165
|
+
if (!normalizedProjectPath) {
|
|
166
|
+
return {
|
|
167
|
+
matches: false,
|
|
168
|
+
reason: 'Project path is invalid for strict server validation.',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const candidatePids = await getListeningPortPids(port);
|
|
172
|
+
if (candidatePids.length === 0) {
|
|
173
|
+
return {
|
|
174
|
+
matches: false,
|
|
175
|
+
reason: `No listening process found on localhost:${port}.`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const observedProcessPaths = [];
|
|
179
|
+
for (const pid of candidatePids) {
|
|
180
|
+
const cwd = await getProcessCwd(pid);
|
|
181
|
+
const normalizedCwd = normalizeFilesystemPath(cwd);
|
|
182
|
+
if (!normalizedCwd) {
|
|
183
|
+
observedProcessPaths.push(`${pid}:<unknown_cwd>`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
observedProcessPaths.push(`${pid}:${normalizedCwd}`);
|
|
187
|
+
if (pathsMatchExactly(normalizedProjectPath, normalizedCwd)) {
|
|
188
|
+
return {
|
|
189
|
+
matches: true,
|
|
190
|
+
reason: `Process ${pid} is running from ${normalizedCwd}.`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
matches: false,
|
|
196
|
+
reason: `A server is listening on localhost:${port}, but process paths do not match the selected project path. selected=${normalizedProjectPath}; observed=${observedProcessPaths.join(', ') || '<none>'}`,
|
|
197
|
+
selectedProjectPath: normalizedProjectPath,
|
|
198
|
+
observedProcessPaths,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function detectFrameworkFromHtml(html) {
|
|
202
|
+
const lower = html.toLowerCase();
|
|
203
|
+
const hasViteClient = lower.includes('/@vite/client');
|
|
204
|
+
const hasViteMarkers = lower.includes('vite') &&
|
|
205
|
+
(lower.includes('hmr') || lower.includes('/src/main.'));
|
|
206
|
+
if (hasViteClient || hasViteMarkers) {
|
|
207
|
+
return 'vite';
|
|
208
|
+
}
|
|
209
|
+
const hasNextAssets = lower.includes('/_next/');
|
|
210
|
+
const hasNextMarkers = lower.includes('__next_data__') || lower.includes('next-route-announcer');
|
|
211
|
+
if (hasNextAssets || hasNextMarkers) {
|
|
212
|
+
return 'nextjs';
|
|
213
|
+
}
|
|
214
|
+
return 'unknown';
|
|
215
|
+
}
|
|
216
|
+
function normalizeExpectedFramework(framework) {
|
|
217
|
+
if (!framework)
|
|
218
|
+
return 'unknown';
|
|
219
|
+
const lower = framework.toLowerCase();
|
|
220
|
+
if (lower.includes('vite'))
|
|
221
|
+
return 'vite';
|
|
222
|
+
if (lower.includes('next'))
|
|
223
|
+
return 'nextjs';
|
|
224
|
+
return 'unknown';
|
|
225
|
+
}
|
|
226
|
+
function isFrameworkCompatible(expected, detected) {
|
|
227
|
+
if (expected === 'unknown')
|
|
228
|
+
return true;
|
|
229
|
+
return expected === detected;
|
|
230
|
+
}
|
|
231
|
+
async function validateProjectServer(options) {
|
|
232
|
+
const { host, port, framework, projectPath } = options;
|
|
233
|
+
const signals = [];
|
|
234
|
+
const isReachable = await waitForPort(port, host, 3000);
|
|
235
|
+
if (!isReachable) {
|
|
236
|
+
return {
|
|
237
|
+
valid: false,
|
|
238
|
+
reason: `No server is responding on ${host}:${port}.`,
|
|
239
|
+
detectedFramework: 'unknown',
|
|
240
|
+
signals,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
signals.push('tcp_reachable');
|
|
244
|
+
if (projectPath) {
|
|
245
|
+
const projectMatch = await isPortOwnedByProject(port, projectPath);
|
|
246
|
+
if (!projectMatch.matches) {
|
|
247
|
+
return {
|
|
248
|
+
valid: false,
|
|
249
|
+
reason: projectMatch.reason,
|
|
250
|
+
detectedFramework: 'unknown',
|
|
251
|
+
signals,
|
|
252
|
+
selectedProjectPath: projectMatch.selectedProjectPath,
|
|
253
|
+
observedProcessPaths: projectMatch.observedProcessPaths,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
signals.push('path_match');
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const response = await fetch(`http://${host}:${port}`, {
|
|
260
|
+
signal: AbortSignal.timeout(3000),
|
|
261
|
+
});
|
|
262
|
+
const html = await response.text();
|
|
263
|
+
const detectedFramework = detectFrameworkFromHtml(html);
|
|
264
|
+
if (detectedFramework === 'vite') {
|
|
265
|
+
signals.push('vite_marker');
|
|
266
|
+
}
|
|
267
|
+
if (detectedFramework === 'nextjs') {
|
|
268
|
+
signals.push('next_marker');
|
|
269
|
+
}
|
|
270
|
+
const expected = normalizeExpectedFramework(framework);
|
|
271
|
+
if (!isFrameworkCompatible(expected, detectedFramework)) {
|
|
272
|
+
return {
|
|
273
|
+
valid: false,
|
|
274
|
+
reason: `Expected ${expected}, but detected ${detectedFramework} on ${host}:${port}.`,
|
|
275
|
+
detectedFramework,
|
|
276
|
+
signals,
|
|
277
|
+
statusCode: response.status,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (detectedFramework === 'unknown' && expected !== 'unknown') {
|
|
281
|
+
return {
|
|
282
|
+
valid: false,
|
|
283
|
+
reason: 'Server is reachable but did not expose framework markers needed for validation.',
|
|
284
|
+
detectedFramework,
|
|
285
|
+
signals,
|
|
286
|
+
statusCode: response.status,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
valid: true,
|
|
291
|
+
reason: `Validated ${host}:${port} as a project server.`,
|
|
292
|
+
detectedFramework,
|
|
293
|
+
signals,
|
|
294
|
+
statusCode: response.status,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
299
|
+
return {
|
|
300
|
+
valid: false,
|
|
301
|
+
reason: `HTTP validation failed: ${message}`,
|
|
302
|
+
detectedFramework: 'unknown',
|
|
303
|
+
signals,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
65
307
|
/**
|
|
66
308
|
* Starts the Rivet MCP server on stdio transport.
|
|
67
309
|
* Called when the user runs `rivet mcp`.
|
|
@@ -179,8 +421,90 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
179
421
|
let sessionStartedAt = null;
|
|
180
422
|
// Track a dev server we auto-started so we can kill it on close
|
|
181
423
|
let devServerProcess = null;
|
|
182
|
-
|
|
424
|
+
let devServerOwnership = 'none';
|
|
425
|
+
let devServerHost = null;
|
|
426
|
+
let devServerPort = null;
|
|
427
|
+
let isCleanupRunning = false;
|
|
428
|
+
const updateBridgeHealth = (reason, isReachable) => {
|
|
429
|
+
bridge.updateDevServerHealth({
|
|
430
|
+
reason,
|
|
431
|
+
isReachable,
|
|
432
|
+
lastCheckedAt: Date.now(),
|
|
433
|
+
});
|
|
434
|
+
};
|
|
435
|
+
const cleanupSessionResources = async (options) => {
|
|
436
|
+
if (isCleanupRunning) {
|
|
437
|
+
return { totalRoundsCompleted: 0, sessionId: null };
|
|
438
|
+
}
|
|
439
|
+
isCleanupRunning = true;
|
|
440
|
+
try {
|
|
441
|
+
const activeSessionId = bridge.getSessionId();
|
|
442
|
+
const sessionIdToClose = options?.closeSessionId ??
|
|
443
|
+
(bridge.isActive() && activeSessionId ? activeSessionId : null);
|
|
444
|
+
const telemetryContext = bridge.getTelemetryContext();
|
|
445
|
+
const activeRunCorrelation = bridge.completeActiveRunCorrelation();
|
|
446
|
+
let totalRoundsCompleted = 0;
|
|
447
|
+
if (sessionIdToClose && bridge.isActive()) {
|
|
448
|
+
totalRoundsCompleted = bridge.endSession();
|
|
449
|
+
}
|
|
450
|
+
if (serverHandle) {
|
|
451
|
+
try {
|
|
452
|
+
await serverHandle.close();
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
log.warn('Error stopping Express server:', error);
|
|
456
|
+
}
|
|
457
|
+
serverHandle = null;
|
|
458
|
+
}
|
|
459
|
+
if (devServerOwnership === 'rivet_owned' && devServerProcess) {
|
|
460
|
+
try {
|
|
461
|
+
await stopChildProcess(devServerProcess);
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
log.warn('Error stopping dev server process:', error);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
devServerProcess = null;
|
|
468
|
+
devServerOwnership = 'none';
|
|
469
|
+
devServerHost = null;
|
|
470
|
+
devServerPort = null;
|
|
471
|
+
updateBridgeHealth('rivet_unreachable', false);
|
|
472
|
+
const durationMs = sessionStartedAt ? Date.now() - sessionStartedAt : 0;
|
|
473
|
+
sessionStartedAt = null;
|
|
474
|
+
if (options?.trackSessionEnd !== false && sessionIdToClose) {
|
|
475
|
+
telemetry.trackMCPSessionEnd({
|
|
476
|
+
sessionId: sessionIdToClose,
|
|
477
|
+
rounds: totalRoundsCompleted,
|
|
478
|
+
durationMs,
|
|
479
|
+
browserSessionId: telemetryContext?.browserSessionId,
|
|
480
|
+
browserDistinctId: telemetryContext?.browserDistinctId,
|
|
481
|
+
});
|
|
482
|
+
if (activeRunCorrelation) {
|
|
483
|
+
telemetry.trackAgentRunFailed({
|
|
484
|
+
traceId: activeRunCorrelation.traceId,
|
|
485
|
+
agentRunId: activeRunCorrelation.agentRunId,
|
|
486
|
+
sessionId: sessionIdToClose,
|
|
487
|
+
posthogSessionId: activeRunCorrelation.browserSessionId,
|
|
488
|
+
durationMs: Date.now() - activeRunCorrelation.startedAt,
|
|
489
|
+
errorType: 'mcp_session_closed',
|
|
490
|
+
errorMessage: 'MCP session closed before apply completion confirmation',
|
|
491
|
+
source: 'mcp',
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
await telemetry.flush();
|
|
496
|
+
return {
|
|
497
|
+
totalRoundsCompleted,
|
|
498
|
+
sessionId: sessionIdToClose,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
finally {
|
|
502
|
+
isCleanupRunning = false;
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
// Flush telemetry + resources before process exits
|
|
183
506
|
const shutdown = async () => {
|
|
507
|
+
await cleanupSessionResources({ trackSessionEnd: true });
|
|
184
508
|
await telemetry.shutdown();
|
|
185
509
|
};
|
|
186
510
|
process.on('SIGTERM', async () => {
|
|
@@ -215,6 +539,18 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
215
539
|
.number()
|
|
216
540
|
.describe('Preferred starting port for the dev server. Rivet will find the next free port at or above this value. Falls back to the framework default if omitted.')
|
|
217
541
|
.optional(),
|
|
542
|
+
startupMode: v3_1.z
|
|
543
|
+
.enum(['manual_existing', 'ai_startup'])
|
|
544
|
+
.describe('How to connect the dev server: manual_existing uses an already-running server, ai_startup auto-starts one. Optional; if omitted, Rivet defaults to ai_startup.')
|
|
545
|
+
.optional(),
|
|
546
|
+
existingPort: v3_1.z
|
|
547
|
+
.number()
|
|
548
|
+
.describe('Port of an already-running dev server. Required for startupMode manual_existing, and reused for follow-up calls after nextStep responses.')
|
|
549
|
+
.optional(),
|
|
550
|
+
forceAttachExternal: v3_1.z
|
|
551
|
+
.boolean()
|
|
552
|
+
.describe('Confirmation flag for startupMode manual_existing when validation reports a project mismatch. Set true only after user confirms attaching anyway.')
|
|
553
|
+
.optional(),
|
|
218
554
|
};
|
|
219
555
|
const sessionIdArgs = {
|
|
220
556
|
sessionId: v3_1.z
|
|
@@ -232,7 +568,7 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
232
568
|
.optional()
|
|
233
569
|
.describe('How long to wait for the user to send changes (default: 600, max: 600)'),
|
|
234
570
|
};
|
|
235
|
-
mcp.tool('detect_project', 'Detect the project framework, package manager, dev command, and default port. Also
|
|
571
|
+
mcp.tool('detect_project', 'Detect the project framework, package manager, dev command, and default port. Also checks whether a project dev server is currently reachable and reports its endpoint when found. Call this before open_visual_editor to decide startupMode.', detectProjectArgs, async (args) => {
|
|
236
572
|
const projectPath = args.projectPath || process.cwd();
|
|
237
573
|
const detection = new index_core_1.ProjectDetectionService();
|
|
238
574
|
let framework;
|
|
@@ -260,6 +596,9 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
260
596
|
devCommand: 'dev',
|
|
261
597
|
defaultPort: 3000,
|
|
262
598
|
};
|
|
599
|
+
const runningServer = framework === 'static'
|
|
600
|
+
? null
|
|
601
|
+
: await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
|
|
263
602
|
return {
|
|
264
603
|
content: [
|
|
265
604
|
{
|
|
@@ -270,6 +609,9 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
270
609
|
devCommand: devConfig.devCommand,
|
|
271
610
|
defaultPort: devConfig.defaultPort,
|
|
272
611
|
projectPath,
|
|
612
|
+
devServerRunning: Boolean(runningServer),
|
|
613
|
+
detectedPort: runningServer?.port ?? null,
|
|
614
|
+
detectedHost: runningServer?.host ?? null,
|
|
273
615
|
startCommand: devConfig.devCommand
|
|
274
616
|
? `${packageManager} run ${devConfig.devCommand}`
|
|
275
617
|
: null,
|
|
@@ -278,7 +620,7 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
278
620
|
],
|
|
279
621
|
};
|
|
280
622
|
});
|
|
281
|
-
mcp.tool('open_visual_editor', "Start a Rivet visual editing session. Always call detect_project first to get the framework.
|
|
623
|
+
mcp.tool('open_visual_editor', "Start a Rivet visual editing session. Always call detect_project first to get the framework. Supports startupMode manual_existing (validate an existing dev server) or ai_startup (auto-start one on a free port). If startupMode is omitted, Rivet defaults to ai_startup. Some recoverable startup outcomes return a non-error follow-up payload (opened: false, nextStep) rather than failing: nextStep manual_port_required means retry with startupMode manual_existing + existingPort, and nextStep mismatch_confirmation_required means confirm with forceAttachExternal: true before attaching a mismatched server. Opens the browser with Rivet's visual editor when opened is true. After calling this, use watch_for_changes in a loop to receive changes as the user works.", openEditorArgs, async (args) => {
|
|
282
624
|
if (bridge.isActive()) {
|
|
283
625
|
return {
|
|
284
626
|
content: [
|
|
@@ -295,13 +637,18 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
295
637
|
}
|
|
296
638
|
const projectPath = args.projectPath || process.cwd();
|
|
297
639
|
const framework = args.framework ?? 'nextjs';
|
|
298
|
-
|
|
299
|
-
const
|
|
640
|
+
const requestedStartupMode = args.startupMode;
|
|
641
|
+
const startupMode = requestedStartupMode ?? 'ai_startup';
|
|
642
|
+
let nextDevServerPort = null;
|
|
643
|
+
const nextDevServerHost = 'localhost';
|
|
644
|
+
let nextDevServerOwnership = 'none';
|
|
645
|
+
const startupModeSelection = requestedStartupMode ? 'explicit' : 'auto';
|
|
300
646
|
if (framework !== 'static') {
|
|
301
647
|
const frameworkConfig = FRAMEWORK_DEV_CONFIG[framework] ?? {
|
|
302
648
|
devCommand: 'dev',
|
|
303
649
|
defaultPort: 3000,
|
|
304
650
|
};
|
|
651
|
+
const detection = new index_core_1.ProjectDetectionService();
|
|
305
652
|
if (!frameworkConfig.devCommand) {
|
|
306
653
|
return {
|
|
307
654
|
content: [
|
|
@@ -316,51 +663,180 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
316
663
|
isError: true,
|
|
317
664
|
};
|
|
318
665
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
666
|
+
if (startupMode === 'manual_existing') {
|
|
667
|
+
const existingPort = args.existingPort ?? args.startPort ?? null;
|
|
668
|
+
if (!existingPort) {
|
|
669
|
+
return {
|
|
670
|
+
content: [
|
|
671
|
+
{
|
|
672
|
+
type: 'text',
|
|
673
|
+
text: JSON.stringify({
|
|
674
|
+
error: 'startupMode manual_existing requires an existingPort value.',
|
|
675
|
+
suggestion: 'Pass existingPort for your running dev server.',
|
|
676
|
+
}),
|
|
677
|
+
},
|
|
678
|
+
],
|
|
679
|
+
isError: true,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const manualValidation = await validateProjectServer({
|
|
683
|
+
host: nextDevServerHost,
|
|
684
|
+
port: existingPort,
|
|
685
|
+
framework,
|
|
686
|
+
projectPath,
|
|
687
|
+
});
|
|
688
|
+
if (!manualValidation.valid) {
|
|
689
|
+
if (args.forceAttachExternal) {
|
|
690
|
+
const isReachable = await waitForPort(existingPort, nextDevServerHost, 3000);
|
|
691
|
+
if (!isReachable) {
|
|
692
|
+
return {
|
|
693
|
+
content: [
|
|
694
|
+
{
|
|
695
|
+
type: 'text',
|
|
696
|
+
text: JSON.stringify({
|
|
697
|
+
error: `Cannot attach to ${nextDevServerHost}:${existingPort} because it is not reachable.`,
|
|
698
|
+
validation: manualValidation,
|
|
699
|
+
}),
|
|
700
|
+
},
|
|
701
|
+
],
|
|
702
|
+
isError: true,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
nextDevServerPort = existingPort;
|
|
706
|
+
nextDevServerOwnership = 'external';
|
|
707
|
+
log.info(`Force-attached to external dev server on ${nextDevServerHost}:${nextDevServerPort}`);
|
|
708
|
+
updateBridgeHealth('healthy', true);
|
|
709
|
+
}
|
|
710
|
+
else if (manualValidation.selectedProjectPath ||
|
|
711
|
+
(manualValidation.observedProcessPaths?.length ?? 0) > 0) {
|
|
712
|
+
return {
|
|
713
|
+
content: [
|
|
714
|
+
{
|
|
715
|
+
type: 'text',
|
|
716
|
+
text: JSON.stringify({
|
|
717
|
+
opened: false,
|
|
718
|
+
nextStep: 'mismatch_confirmation_required',
|
|
719
|
+
error: manualValidation.reason,
|
|
720
|
+
existingServerPort: existingPort,
|
|
721
|
+
selectedProjectPath: manualValidation.selectedProjectPath,
|
|
722
|
+
observedProcessPaths: manualValidation.observedProcessPaths,
|
|
723
|
+
validation: manualValidation,
|
|
724
|
+
nextAction: 'If the user confirms, call open_visual_editor again with startupMode: "manual_existing", existingPort, and forceAttachExternal: true. Otherwise ask for another port.',
|
|
725
|
+
}),
|
|
726
|
+
},
|
|
727
|
+
],
|
|
728
|
+
isError: false,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
return {
|
|
733
|
+
content: [
|
|
734
|
+
{
|
|
735
|
+
type: 'text',
|
|
736
|
+
text: JSON.stringify({
|
|
737
|
+
error: manualValidation.reason,
|
|
738
|
+
validation: manualValidation,
|
|
739
|
+
}),
|
|
740
|
+
},
|
|
741
|
+
],
|
|
742
|
+
isError: true,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (!nextDevServerPort) {
|
|
747
|
+
nextDevServerPort = existingPort;
|
|
748
|
+
nextDevServerOwnership = 'external';
|
|
749
|
+
log.info(`Using existing dev server on ${nextDevServerHost}:${nextDevServerPort}`);
|
|
750
|
+
updateBridgeHealth('healthy', true);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
const packageManager = await detection.detectPackageManager(projectPath);
|
|
755
|
+
const preferredPort = args.startPort ??
|
|
756
|
+
detection.readConfiguredPort(projectPath) ??
|
|
757
|
+
frameworkConfig.defaultPort;
|
|
758
|
+
nextDevServerPort = await (0, portUtils_1.findAvailablePort)(preferredPort);
|
|
759
|
+
const { cmd, args: cmdArgs, env, } = buildDevServerCommand(framework, packageManager, frameworkConfig.devCommand, nextDevServerPort);
|
|
760
|
+
log.info(`Auto-starting dev server: ${cmd} ${cmdArgs.join(' ')} in ${projectPath}`);
|
|
761
|
+
devServerProcess = (0, child_process_1.spawn)(cmd, cmdArgs, {
|
|
762
|
+
cwd: projectPath,
|
|
763
|
+
env: { ...process.env, ...env },
|
|
764
|
+
stdio: 'pipe',
|
|
765
|
+
detached: false,
|
|
766
|
+
});
|
|
767
|
+
nextDevServerOwnership = 'rivet_owned';
|
|
768
|
+
devServerProcess.on('error', (err) => {
|
|
769
|
+
log.error('Dev server process error:', err);
|
|
770
|
+
});
|
|
771
|
+
devServerProcess.on('exit', (code) => {
|
|
772
|
+
log.info(`Dev server exited with code ${code}`);
|
|
773
|
+
devServerProcess = null;
|
|
774
|
+
if (bridge.isActive()) {
|
|
775
|
+
updateBridgeHealth('upstream_unreachable', false);
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
devServerProcess.stdout?.on('data', (data) => {
|
|
779
|
+
log.debug(`[dev-server] ${data.toString().trim()}`);
|
|
780
|
+
});
|
|
781
|
+
devServerProcess.stderr?.on('data', (data) => {
|
|
782
|
+
log.debug(`[dev-server] ${data.toString().trim()}`);
|
|
783
|
+
});
|
|
784
|
+
const ready = await waitForPort(nextDevServerPort, nextDevServerHost, 30_000);
|
|
785
|
+
if (!ready) {
|
|
786
|
+
if (devServerProcess) {
|
|
787
|
+
await stopChildProcess(devServerProcess);
|
|
788
|
+
}
|
|
789
|
+
devServerProcess = null;
|
|
790
|
+
const existingServer = await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
|
|
791
|
+
return {
|
|
792
|
+
content: [
|
|
793
|
+
{
|
|
794
|
+
type: 'text',
|
|
795
|
+
text: JSON.stringify({
|
|
796
|
+
opened: false,
|
|
797
|
+
nextStep: 'manual_port_required',
|
|
798
|
+
error: `Dev server did not become ready on port ${nextDevServerPort} within 30s.`,
|
|
799
|
+
existingServerPort: existingServer?.port ?? null,
|
|
800
|
+
validation: null,
|
|
801
|
+
nextAction: 'Call open_visual_editor again with startupMode: "manual_existing" and existingPort set to a running app server port.',
|
|
802
|
+
}),
|
|
803
|
+
},
|
|
804
|
+
],
|
|
805
|
+
isError: false,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
const startupValidation = await validateProjectServer({
|
|
809
|
+
host: nextDevServerHost,
|
|
810
|
+
port: nextDevServerPort,
|
|
811
|
+
framework,
|
|
812
|
+
projectPath,
|
|
813
|
+
});
|
|
814
|
+
if (!startupValidation.valid) {
|
|
815
|
+
if (devServerProcess) {
|
|
816
|
+
await stopChildProcess(devServerProcess);
|
|
817
|
+
}
|
|
818
|
+
devServerProcess = null;
|
|
819
|
+
const existingServer = await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
|
|
820
|
+
return {
|
|
821
|
+
content: [
|
|
822
|
+
{
|
|
823
|
+
type: 'text',
|
|
824
|
+
text: JSON.stringify({
|
|
825
|
+
opened: false,
|
|
826
|
+
nextStep: 'manual_port_required',
|
|
827
|
+
error: startupValidation.reason,
|
|
828
|
+
existingServerPort: existingServer?.port ?? null,
|
|
829
|
+
validation: startupValidation,
|
|
830
|
+
nextAction: 'Call open_visual_editor again with startupMode: "manual_existing" and existingPort set to a running app server port.',
|
|
831
|
+
}),
|
|
832
|
+
},
|
|
833
|
+
],
|
|
834
|
+
isError: false,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
updateBridgeHealth('healthy', true);
|
|
838
|
+
log.info(`Dev server ready on ${nextDevServerHost}:${nextDevServerPort}`);
|
|
362
839
|
}
|
|
363
|
-
log.info(`Dev server ready on ${devServerHost}:${devServerPort}`);
|
|
364
840
|
}
|
|
365
841
|
// Find a free port for Rivet's Express server (avoids conflict with desktop or other Rivet instances)
|
|
366
842
|
let rivetPort;
|
|
@@ -381,8 +857,8 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
381
857
|
serverHandle = await (0, server_1.startServer)({
|
|
382
858
|
framework,
|
|
383
859
|
projectPath,
|
|
384
|
-
userPort:
|
|
385
|
-
userHost:
|
|
860
|
+
userPort: nextDevServerPort ?? 0,
|
|
861
|
+
userHost: nextDevServerHost,
|
|
386
862
|
sessionBridge: bridge,
|
|
387
863
|
telemetry,
|
|
388
864
|
mcpEditor,
|
|
@@ -399,7 +875,9 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
399
875
|
}
|
|
400
876
|
else {
|
|
401
877
|
const message = error instanceof Error ? error.message : 'Server start failed';
|
|
402
|
-
devServerProcess
|
|
878
|
+
if (devServerProcess) {
|
|
879
|
+
await stopChildProcess(devServerProcess);
|
|
880
|
+
}
|
|
403
881
|
devServerProcess = null;
|
|
404
882
|
return {
|
|
405
883
|
content: [
|
|
@@ -414,7 +892,9 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
414
892
|
}
|
|
415
893
|
}
|
|
416
894
|
if (!serverStarted) {
|
|
417
|
-
devServerProcess
|
|
895
|
+
if (devServerProcess) {
|
|
896
|
+
await stopChildProcess(devServerProcess);
|
|
897
|
+
}
|
|
418
898
|
devServerProcess = null;
|
|
419
899
|
return {
|
|
420
900
|
content: [
|
|
@@ -430,6 +910,21 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
430
910
|
}
|
|
431
911
|
const sessionId = bridge.startSession(projectPath);
|
|
432
912
|
sessionStartedAt = Date.now();
|
|
913
|
+
devServerOwnership = nextDevServerOwnership;
|
|
914
|
+
devServerHost = nextDevServerHost;
|
|
915
|
+
devServerPort = nextDevServerPort;
|
|
916
|
+
bridge.setDevServerContext({
|
|
917
|
+
ownership: devServerOwnership,
|
|
918
|
+
host: devServerHost,
|
|
919
|
+
port: devServerPort,
|
|
920
|
+
startedByRivet: devServerOwnership === 'rivet_owned',
|
|
921
|
+
});
|
|
922
|
+
if (devServerOwnership === 'none') {
|
|
923
|
+
updateBridgeHealth('unknown', null);
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
updateBridgeHealth('healthy', true);
|
|
927
|
+
}
|
|
433
928
|
// Write editor-specific guidance file on first successful open
|
|
434
929
|
if (mcpEditor === 'cursor') {
|
|
435
930
|
(0, skillWriter_1.writeCursorRulesIfNeeded)(projectPath);
|
|
@@ -440,7 +935,7 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
440
935
|
// Track session start
|
|
441
936
|
telemetry.trackMCPSessionStart({
|
|
442
937
|
framework,
|
|
443
|
-
devServerStarted:
|
|
938
|
+
devServerStarted: devServerOwnership === 'rivet_owned',
|
|
444
939
|
sessionId,
|
|
445
940
|
mcpEditor,
|
|
446
941
|
});
|
|
@@ -462,7 +957,12 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
462
957
|
projectPath,
|
|
463
958
|
framework,
|
|
464
959
|
rivetPort,
|
|
465
|
-
|
|
960
|
+
opened: true,
|
|
961
|
+
devServerPort: nextDevServerPort,
|
|
962
|
+
devServerOwnership: nextDevServerOwnership,
|
|
963
|
+
startupMode,
|
|
964
|
+
startupModeSelection,
|
|
965
|
+
requestedStartupMode,
|
|
466
966
|
next_step: `IMPORTANT: You must call watch_for_changes({ sessionId: "${sessionId}" }) RIGHT NOW to wait for the user's changes. Do not wait for the user to ask — call it immediately. It will block until they click "${sendButtonLabel}" in the browser.`,
|
|
467
967
|
}),
|
|
468
968
|
},
|
|
@@ -489,6 +989,7 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
489
989
|
intent = await bridge.waitForChanges(timeoutMs);
|
|
490
990
|
}
|
|
491
991
|
catch {
|
|
992
|
+
const health = bridge.getDevServerHealth();
|
|
492
993
|
return {
|
|
493
994
|
content: [
|
|
494
995
|
{
|
|
@@ -496,6 +997,7 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
496
997
|
text: JSON.stringify({
|
|
497
998
|
hasChanges: false,
|
|
498
999
|
message: 'Timed out waiting for changes. Call again to keep waiting.',
|
|
1000
|
+
devServerHealth: health,
|
|
499
1001
|
}),
|
|
500
1002
|
},
|
|
501
1003
|
],
|
|
@@ -570,51 +1072,10 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
570
1072
|
isError: true,
|
|
571
1073
|
};
|
|
572
1074
|
}
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const durationMs = sessionStartedAt ? Date.now() - sessionStartedAt : 0;
|
|
577
|
-
sessionStartedAt = null;
|
|
578
|
-
// Stop the Express server
|
|
579
|
-
if (serverHandle) {
|
|
580
|
-
try {
|
|
581
|
-
await serverHandle.close();
|
|
582
|
-
}
|
|
583
|
-
catch (error) {
|
|
584
|
-
log.warn('Error stopping Express server:', error);
|
|
585
|
-
}
|
|
586
|
-
serverHandle = null;
|
|
587
|
-
}
|
|
588
|
-
// Kill the dev server if we auto-started it
|
|
589
|
-
if (devServerProcess) {
|
|
590
|
-
try {
|
|
591
|
-
devServerProcess.kill();
|
|
592
|
-
}
|
|
593
|
-
catch (error) {
|
|
594
|
-
log.warn('Error stopping dev server process:', error);
|
|
595
|
-
}
|
|
596
|
-
devServerProcess = null;
|
|
597
|
-
}
|
|
598
|
-
telemetry.trackMCPSessionEnd({
|
|
599
|
-
sessionId: args.sessionId,
|
|
600
|
-
rounds: totalRoundsCompleted,
|
|
601
|
-
durationMs,
|
|
602
|
-
browserSessionId: telemetryContext?.browserSessionId,
|
|
603
|
-
browserDistinctId: telemetryContext?.browserDistinctId,
|
|
1075
|
+
const { totalRoundsCompleted } = await cleanupSessionResources({
|
|
1076
|
+
closeSessionId: args.sessionId,
|
|
1077
|
+
trackSessionEnd: true,
|
|
604
1078
|
});
|
|
605
|
-
if (activeRunCorrelation) {
|
|
606
|
-
telemetry.trackAgentRunFailed({
|
|
607
|
-
traceId: activeRunCorrelation.traceId,
|
|
608
|
-
agentRunId: activeRunCorrelation.agentRunId,
|
|
609
|
-
sessionId: args.sessionId,
|
|
610
|
-
posthogSessionId: activeRunCorrelation.browserSessionId,
|
|
611
|
-
durationMs: Date.now() - activeRunCorrelation.startedAt,
|
|
612
|
-
errorType: 'mcp_session_closed',
|
|
613
|
-
errorMessage: 'MCP session closed before apply completion confirmation',
|
|
614
|
-
source: 'mcp',
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
await telemetry.flush();
|
|
618
1079
|
return {
|
|
619
1080
|
content: [
|
|
620
1081
|
{
|