rivet-design 0.6.6 → 0.6.8
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/bin/rivet.js +2 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +564 -100
- 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/GatewayClient.d.ts.map +1 -1
- package/dist/services/GatewayClient.js +11 -2
- package/dist/services/GatewayClient.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/dist/services/TelemetryService.d.ts.map +1 -1
- package/dist/services/TelemetryService.js +15 -0
- package/dist/services/TelemetryService.js.map +1 -1
- package/dist/services/agent/AgentCore.js +2 -2
- package/dist/services/agent/AgentCore.js.map +1 -1
- package/dist/services/agent/AgentModService.d.ts +6 -0
- package/dist/services/agent/AgentModService.d.ts.map +1 -1
- package/dist/services/agent/AgentModService.js +12 -19
- package/dist/services/agent/AgentModService.js.map +1 -1
- package/dist/utils/logger.d.ts +1 -0
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +20 -2
- package/dist/utils/logger.js.map +1 -1
- package/package.json +12 -3
- package/src/ui/dist/assets/{main-jMwZV-Yc.js → main-DqoeOHsu.js} +101 -101
- package/src/ui/dist/assets/main-XaYyn4YU.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`.
|
|
@@ -146,8 +388,11 @@ const APPLY_GUIDE = 'Read each file in sourceFiles, then apply every change in t
|
|
|
146
388
|
'If a change has imageCount > 0, the corresponding reference images appear as image content items immediately following this text in the tool result — view them before making changes. ' +
|
|
147
389
|
'CRITICAL WORKFLOW: 1. Use report_feedback to share a brief plan. 2. Edit the files. 3. Use report_feedback with refresh_git: true to summarize what you changed. 4. ONLY THEN wait for the next round.';
|
|
148
390
|
const startMCPServer = async (mcpEditor) => {
|
|
149
|
-
// Redirect all logging to stderr so stdout is reserved for MCP protocol
|
|
391
|
+
// Redirect all logging to stderr so stdout is reserved for MCP protocol,
|
|
392
|
+
// then silence all output — users shouldn't see Rivet logs in their terminal.
|
|
393
|
+
// Error capture (_onError) still fires for PostHog since it bypasses loglevel.
|
|
150
394
|
(0, index_core_1.redirectLogsToStderr)();
|
|
395
|
+
(0, index_core_1.setLogLevel)('SILENT');
|
|
151
396
|
const sendButtonLabel = getSendButtonLabel(mcpEditor);
|
|
152
397
|
const bridge = new SessionBridgeService_1.SessionBridgeService();
|
|
153
398
|
const telemetry = new TelemetryService_1.TelemetryService();
|
|
@@ -179,8 +424,90 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
179
424
|
let sessionStartedAt = null;
|
|
180
425
|
// Track a dev server we auto-started so we can kill it on close
|
|
181
426
|
let devServerProcess = null;
|
|
182
|
-
|
|
427
|
+
let devServerOwnership = 'none';
|
|
428
|
+
let devServerHost = null;
|
|
429
|
+
let devServerPort = null;
|
|
430
|
+
let isCleanupRunning = false;
|
|
431
|
+
const updateBridgeHealth = (reason, isReachable) => {
|
|
432
|
+
bridge.updateDevServerHealth({
|
|
433
|
+
reason,
|
|
434
|
+
isReachable,
|
|
435
|
+
lastCheckedAt: Date.now(),
|
|
436
|
+
});
|
|
437
|
+
};
|
|
438
|
+
const cleanupSessionResources = async (options) => {
|
|
439
|
+
if (isCleanupRunning) {
|
|
440
|
+
return { totalRoundsCompleted: 0, sessionId: null };
|
|
441
|
+
}
|
|
442
|
+
isCleanupRunning = true;
|
|
443
|
+
try {
|
|
444
|
+
const activeSessionId = bridge.getSessionId();
|
|
445
|
+
const sessionIdToClose = options?.closeSessionId ??
|
|
446
|
+
(bridge.isActive() && activeSessionId ? activeSessionId : null);
|
|
447
|
+
const telemetryContext = bridge.getTelemetryContext();
|
|
448
|
+
const activeRunCorrelation = bridge.completeActiveRunCorrelation();
|
|
449
|
+
let totalRoundsCompleted = 0;
|
|
450
|
+
if (sessionIdToClose && bridge.isActive()) {
|
|
451
|
+
totalRoundsCompleted = bridge.endSession();
|
|
452
|
+
}
|
|
453
|
+
if (serverHandle) {
|
|
454
|
+
try {
|
|
455
|
+
await serverHandle.close();
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
log.warn('Error stopping Express server:', error);
|
|
459
|
+
}
|
|
460
|
+
serverHandle = null;
|
|
461
|
+
}
|
|
462
|
+
if (devServerOwnership === 'rivet_owned' && devServerProcess) {
|
|
463
|
+
try {
|
|
464
|
+
await stopChildProcess(devServerProcess);
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
log.warn('Error stopping dev server process:', error);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
devServerProcess = null;
|
|
471
|
+
devServerOwnership = 'none';
|
|
472
|
+
devServerHost = null;
|
|
473
|
+
devServerPort = null;
|
|
474
|
+
updateBridgeHealth('rivet_unreachable', false);
|
|
475
|
+
const durationMs = sessionStartedAt ? Date.now() - sessionStartedAt : 0;
|
|
476
|
+
sessionStartedAt = null;
|
|
477
|
+
if (options?.trackSessionEnd !== false && sessionIdToClose) {
|
|
478
|
+
telemetry.trackMCPSessionEnd({
|
|
479
|
+
sessionId: sessionIdToClose,
|
|
480
|
+
rounds: totalRoundsCompleted,
|
|
481
|
+
durationMs,
|
|
482
|
+
browserSessionId: telemetryContext?.browserSessionId,
|
|
483
|
+
browserDistinctId: telemetryContext?.browserDistinctId,
|
|
484
|
+
});
|
|
485
|
+
if (activeRunCorrelation) {
|
|
486
|
+
telemetry.trackAgentRunFailed({
|
|
487
|
+
traceId: activeRunCorrelation.traceId,
|
|
488
|
+
agentRunId: activeRunCorrelation.agentRunId,
|
|
489
|
+
sessionId: sessionIdToClose,
|
|
490
|
+
posthogSessionId: activeRunCorrelation.browserSessionId,
|
|
491
|
+
durationMs: Date.now() - activeRunCorrelation.startedAt,
|
|
492
|
+
errorType: 'mcp_session_closed',
|
|
493
|
+
errorMessage: 'MCP session closed before apply completion confirmation',
|
|
494
|
+
source: 'mcp',
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
await telemetry.flush();
|
|
499
|
+
return {
|
|
500
|
+
totalRoundsCompleted,
|
|
501
|
+
sessionId: sessionIdToClose,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
isCleanupRunning = false;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
// Flush telemetry + resources before process exits
|
|
183
509
|
const shutdown = async () => {
|
|
510
|
+
await cleanupSessionResources({ trackSessionEnd: true });
|
|
184
511
|
await telemetry.shutdown();
|
|
185
512
|
};
|
|
186
513
|
process.on('SIGTERM', async () => {
|
|
@@ -215,6 +542,18 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
215
542
|
.number()
|
|
216
543
|
.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
544
|
.optional(),
|
|
545
|
+
startupMode: v3_1.z
|
|
546
|
+
.enum(['manual_existing', 'ai_startup'])
|
|
547
|
+
.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.')
|
|
548
|
+
.optional(),
|
|
549
|
+
existingPort: v3_1.z
|
|
550
|
+
.number()
|
|
551
|
+
.describe('Port of an already-running dev server. Required for startupMode manual_existing, and reused for follow-up calls after nextStep responses.')
|
|
552
|
+
.optional(),
|
|
553
|
+
forceAttachExternal: v3_1.z
|
|
554
|
+
.boolean()
|
|
555
|
+
.describe('Confirmation flag for startupMode manual_existing when validation reports a project mismatch. Set true only after user confirms attaching anyway.')
|
|
556
|
+
.optional(),
|
|
218
557
|
};
|
|
219
558
|
const sessionIdArgs = {
|
|
220
559
|
sessionId: v3_1.z
|
|
@@ -232,7 +571,7 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
232
571
|
.optional()
|
|
233
572
|
.describe('How long to wait for the user to send changes (default: 600, max: 600)'),
|
|
234
573
|
};
|
|
235
|
-
mcp.tool('detect_project', 'Detect the project framework, package manager, dev command, and default port. Also
|
|
574
|
+
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
575
|
const projectPath = args.projectPath || process.cwd();
|
|
237
576
|
const detection = new index_core_1.ProjectDetectionService();
|
|
238
577
|
let framework;
|
|
@@ -260,6 +599,9 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
260
599
|
devCommand: 'dev',
|
|
261
600
|
defaultPort: 3000,
|
|
262
601
|
};
|
|
602
|
+
const runningServer = framework === 'static'
|
|
603
|
+
? null
|
|
604
|
+
: await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
|
|
263
605
|
return {
|
|
264
606
|
content: [
|
|
265
607
|
{
|
|
@@ -270,6 +612,9 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
270
612
|
devCommand: devConfig.devCommand,
|
|
271
613
|
defaultPort: devConfig.defaultPort,
|
|
272
614
|
projectPath,
|
|
615
|
+
devServerRunning: Boolean(runningServer),
|
|
616
|
+
detectedPort: runningServer?.port ?? null,
|
|
617
|
+
detectedHost: runningServer?.host ?? null,
|
|
273
618
|
startCommand: devConfig.devCommand
|
|
274
619
|
? `${packageManager} run ${devConfig.devCommand}`
|
|
275
620
|
: null,
|
|
@@ -278,7 +623,7 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
278
623
|
],
|
|
279
624
|
};
|
|
280
625
|
});
|
|
281
|
-
mcp.tool('open_visual_editor', "Start a Rivet visual editing session. Always call detect_project first to get the framework.
|
|
626
|
+
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
627
|
if (bridge.isActive()) {
|
|
283
628
|
return {
|
|
284
629
|
content: [
|
|
@@ -295,13 +640,18 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
295
640
|
}
|
|
296
641
|
const projectPath = args.projectPath || process.cwd();
|
|
297
642
|
const framework = args.framework ?? 'nextjs';
|
|
298
|
-
|
|
299
|
-
const
|
|
643
|
+
const requestedStartupMode = args.startupMode;
|
|
644
|
+
const startupMode = requestedStartupMode ?? 'ai_startup';
|
|
645
|
+
let nextDevServerPort = null;
|
|
646
|
+
const nextDevServerHost = 'localhost';
|
|
647
|
+
let nextDevServerOwnership = 'none';
|
|
648
|
+
const startupModeSelection = requestedStartupMode ? 'explicit' : 'auto';
|
|
300
649
|
if (framework !== 'static') {
|
|
301
650
|
const frameworkConfig = FRAMEWORK_DEV_CONFIG[framework] ?? {
|
|
302
651
|
devCommand: 'dev',
|
|
303
652
|
defaultPort: 3000,
|
|
304
653
|
};
|
|
654
|
+
const detection = new index_core_1.ProjectDetectionService();
|
|
305
655
|
if (!frameworkConfig.devCommand) {
|
|
306
656
|
return {
|
|
307
657
|
content: [
|
|
@@ -316,51 +666,180 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
316
666
|
isError: true,
|
|
317
667
|
};
|
|
318
668
|
}
|
|
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
|
-
|
|
669
|
+
if (startupMode === 'manual_existing') {
|
|
670
|
+
const existingPort = args.existingPort ?? args.startPort ?? null;
|
|
671
|
+
if (!existingPort) {
|
|
672
|
+
return {
|
|
673
|
+
content: [
|
|
674
|
+
{
|
|
675
|
+
type: 'text',
|
|
676
|
+
text: JSON.stringify({
|
|
677
|
+
error: 'startupMode manual_existing requires an existingPort value.',
|
|
678
|
+
suggestion: 'Pass existingPort for your running dev server.',
|
|
679
|
+
}),
|
|
680
|
+
},
|
|
681
|
+
],
|
|
682
|
+
isError: true,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const manualValidation = await validateProjectServer({
|
|
686
|
+
host: nextDevServerHost,
|
|
687
|
+
port: existingPort,
|
|
688
|
+
framework,
|
|
689
|
+
projectPath,
|
|
690
|
+
});
|
|
691
|
+
if (!manualValidation.valid) {
|
|
692
|
+
if (args.forceAttachExternal) {
|
|
693
|
+
const isReachable = await waitForPort(existingPort, nextDevServerHost, 3000);
|
|
694
|
+
if (!isReachable) {
|
|
695
|
+
return {
|
|
696
|
+
content: [
|
|
697
|
+
{
|
|
698
|
+
type: 'text',
|
|
699
|
+
text: JSON.stringify({
|
|
700
|
+
error: `Cannot attach to ${nextDevServerHost}:${existingPort} because it is not reachable.`,
|
|
701
|
+
validation: manualValidation,
|
|
702
|
+
}),
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
isError: true,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
nextDevServerPort = existingPort;
|
|
709
|
+
nextDevServerOwnership = 'external';
|
|
710
|
+
log.info(`Force-attached to external dev server on ${nextDevServerHost}:${nextDevServerPort}`);
|
|
711
|
+
updateBridgeHealth('healthy', true);
|
|
712
|
+
}
|
|
713
|
+
else if (manualValidation.selectedProjectPath ||
|
|
714
|
+
(manualValidation.observedProcessPaths?.length ?? 0) > 0) {
|
|
715
|
+
return {
|
|
716
|
+
content: [
|
|
717
|
+
{
|
|
718
|
+
type: 'text',
|
|
719
|
+
text: JSON.stringify({
|
|
720
|
+
opened: false,
|
|
721
|
+
nextStep: 'mismatch_confirmation_required',
|
|
722
|
+
error: manualValidation.reason,
|
|
723
|
+
existingServerPort: existingPort,
|
|
724
|
+
selectedProjectPath: manualValidation.selectedProjectPath,
|
|
725
|
+
observedProcessPaths: manualValidation.observedProcessPaths,
|
|
726
|
+
validation: manualValidation,
|
|
727
|
+
nextAction: 'If the user confirms, call open_visual_editor again with startupMode: "manual_existing", existingPort, and forceAttachExternal: true. Otherwise ask for another port.',
|
|
728
|
+
}),
|
|
729
|
+
},
|
|
730
|
+
],
|
|
731
|
+
isError: false,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
return {
|
|
736
|
+
content: [
|
|
737
|
+
{
|
|
738
|
+
type: 'text',
|
|
739
|
+
text: JSON.stringify({
|
|
740
|
+
error: manualValidation.reason,
|
|
741
|
+
validation: manualValidation,
|
|
742
|
+
}),
|
|
743
|
+
},
|
|
744
|
+
],
|
|
745
|
+
isError: true,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (!nextDevServerPort) {
|
|
750
|
+
nextDevServerPort = existingPort;
|
|
751
|
+
nextDevServerOwnership = 'external';
|
|
752
|
+
log.info(`Using existing dev server on ${nextDevServerHost}:${nextDevServerPort}`);
|
|
753
|
+
updateBridgeHealth('healthy', true);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
const packageManager = await detection.detectPackageManager(projectPath);
|
|
758
|
+
const preferredPort = args.startPort ??
|
|
759
|
+
detection.readConfiguredPort(projectPath) ??
|
|
760
|
+
frameworkConfig.defaultPort;
|
|
761
|
+
nextDevServerPort = await (0, portUtils_1.findAvailablePort)(preferredPort);
|
|
762
|
+
const { cmd, args: cmdArgs, env, } = buildDevServerCommand(framework, packageManager, frameworkConfig.devCommand, nextDevServerPort);
|
|
763
|
+
log.info(`Auto-starting dev server: ${cmd} ${cmdArgs.join(' ')} in ${projectPath}`);
|
|
764
|
+
devServerProcess = (0, child_process_1.spawn)(cmd, cmdArgs, {
|
|
765
|
+
cwd: projectPath,
|
|
766
|
+
env: { ...process.env, ...env },
|
|
767
|
+
stdio: 'pipe',
|
|
768
|
+
detached: false,
|
|
769
|
+
});
|
|
770
|
+
nextDevServerOwnership = 'rivet_owned';
|
|
771
|
+
devServerProcess.on('error', (err) => {
|
|
772
|
+
log.error('Dev server process error:', err);
|
|
773
|
+
});
|
|
774
|
+
devServerProcess.on('exit', (code) => {
|
|
775
|
+
log.info(`Dev server exited with code ${code}`);
|
|
776
|
+
devServerProcess = null;
|
|
777
|
+
if (bridge.isActive()) {
|
|
778
|
+
updateBridgeHealth('upstream_unreachable', false);
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
devServerProcess.stdout?.on('data', (data) => {
|
|
782
|
+
log.debug(`[dev-server] ${data.toString().trim()}`);
|
|
783
|
+
});
|
|
784
|
+
devServerProcess.stderr?.on('data', (data) => {
|
|
785
|
+
log.debug(`[dev-server] ${data.toString().trim()}`);
|
|
786
|
+
});
|
|
787
|
+
const ready = await waitForPort(nextDevServerPort, nextDevServerHost, 30_000);
|
|
788
|
+
if (!ready) {
|
|
789
|
+
if (devServerProcess) {
|
|
790
|
+
await stopChildProcess(devServerProcess);
|
|
791
|
+
}
|
|
792
|
+
devServerProcess = null;
|
|
793
|
+
const existingServer = await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
|
|
794
|
+
return {
|
|
795
|
+
content: [
|
|
796
|
+
{
|
|
797
|
+
type: 'text',
|
|
798
|
+
text: JSON.stringify({
|
|
799
|
+
opened: false,
|
|
800
|
+
nextStep: 'manual_port_required',
|
|
801
|
+
error: `Dev server did not become ready on port ${nextDevServerPort} within 30s.`,
|
|
802
|
+
existingServerPort: existingServer?.port ?? null,
|
|
803
|
+
validation: null,
|
|
804
|
+
nextAction: 'Call open_visual_editor again with startupMode: "manual_existing" and existingPort set to a running app server port.',
|
|
805
|
+
}),
|
|
806
|
+
},
|
|
807
|
+
],
|
|
808
|
+
isError: false,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
const startupValidation = await validateProjectServer({
|
|
812
|
+
host: nextDevServerHost,
|
|
813
|
+
port: nextDevServerPort,
|
|
814
|
+
framework,
|
|
815
|
+
projectPath,
|
|
816
|
+
});
|
|
817
|
+
if (!startupValidation.valid) {
|
|
818
|
+
if (devServerProcess) {
|
|
819
|
+
await stopChildProcess(devServerProcess);
|
|
820
|
+
}
|
|
821
|
+
devServerProcess = null;
|
|
822
|
+
const existingServer = await detection.findRunningDevServer(projectPath, [index_1.DEFAULT_PORT]);
|
|
823
|
+
return {
|
|
824
|
+
content: [
|
|
825
|
+
{
|
|
826
|
+
type: 'text',
|
|
827
|
+
text: JSON.stringify({
|
|
828
|
+
opened: false,
|
|
829
|
+
nextStep: 'manual_port_required',
|
|
830
|
+
error: startupValidation.reason,
|
|
831
|
+
existingServerPort: existingServer?.port ?? null,
|
|
832
|
+
validation: startupValidation,
|
|
833
|
+
nextAction: 'Call open_visual_editor again with startupMode: "manual_existing" and existingPort set to a running app server port.',
|
|
834
|
+
}),
|
|
835
|
+
},
|
|
836
|
+
],
|
|
837
|
+
isError: false,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
updateBridgeHealth('healthy', true);
|
|
841
|
+
log.info(`Dev server ready on ${nextDevServerHost}:${nextDevServerPort}`);
|
|
362
842
|
}
|
|
363
|
-
log.info(`Dev server ready on ${devServerHost}:${devServerPort}`);
|
|
364
843
|
}
|
|
365
844
|
// Find a free port for Rivet's Express server (avoids conflict with desktop or other Rivet instances)
|
|
366
845
|
let rivetPort;
|
|
@@ -381,8 +860,8 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
381
860
|
serverHandle = await (0, server_1.startServer)({
|
|
382
861
|
framework,
|
|
383
862
|
projectPath,
|
|
384
|
-
userPort:
|
|
385
|
-
userHost:
|
|
863
|
+
userPort: nextDevServerPort ?? 0,
|
|
864
|
+
userHost: nextDevServerHost,
|
|
386
865
|
sessionBridge: bridge,
|
|
387
866
|
telemetry,
|
|
388
867
|
mcpEditor,
|
|
@@ -399,7 +878,9 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
399
878
|
}
|
|
400
879
|
else {
|
|
401
880
|
const message = error instanceof Error ? error.message : 'Server start failed';
|
|
402
|
-
devServerProcess
|
|
881
|
+
if (devServerProcess) {
|
|
882
|
+
await stopChildProcess(devServerProcess);
|
|
883
|
+
}
|
|
403
884
|
devServerProcess = null;
|
|
404
885
|
return {
|
|
405
886
|
content: [
|
|
@@ -414,7 +895,9 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
414
895
|
}
|
|
415
896
|
}
|
|
416
897
|
if (!serverStarted) {
|
|
417
|
-
devServerProcess
|
|
898
|
+
if (devServerProcess) {
|
|
899
|
+
await stopChildProcess(devServerProcess);
|
|
900
|
+
}
|
|
418
901
|
devServerProcess = null;
|
|
419
902
|
return {
|
|
420
903
|
content: [
|
|
@@ -430,6 +913,21 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
430
913
|
}
|
|
431
914
|
const sessionId = bridge.startSession(projectPath);
|
|
432
915
|
sessionStartedAt = Date.now();
|
|
916
|
+
devServerOwnership = nextDevServerOwnership;
|
|
917
|
+
devServerHost = nextDevServerHost;
|
|
918
|
+
devServerPort = nextDevServerPort;
|
|
919
|
+
bridge.setDevServerContext({
|
|
920
|
+
ownership: devServerOwnership,
|
|
921
|
+
host: devServerHost,
|
|
922
|
+
port: devServerPort,
|
|
923
|
+
startedByRivet: devServerOwnership === 'rivet_owned',
|
|
924
|
+
});
|
|
925
|
+
if (devServerOwnership === 'none') {
|
|
926
|
+
updateBridgeHealth('unknown', null);
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
updateBridgeHealth('healthy', true);
|
|
930
|
+
}
|
|
433
931
|
// Write editor-specific guidance file on first successful open
|
|
434
932
|
if (mcpEditor === 'cursor') {
|
|
435
933
|
(0, skillWriter_1.writeCursorRulesIfNeeded)(projectPath);
|
|
@@ -440,7 +938,7 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
440
938
|
// Track session start
|
|
441
939
|
telemetry.trackMCPSessionStart({
|
|
442
940
|
framework,
|
|
443
|
-
devServerStarted:
|
|
941
|
+
devServerStarted: devServerOwnership === 'rivet_owned',
|
|
444
942
|
sessionId,
|
|
445
943
|
mcpEditor,
|
|
446
944
|
});
|
|
@@ -462,7 +960,12 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
462
960
|
projectPath,
|
|
463
961
|
framework,
|
|
464
962
|
rivetPort,
|
|
465
|
-
|
|
963
|
+
opened: true,
|
|
964
|
+
devServerPort: nextDevServerPort,
|
|
965
|
+
devServerOwnership: nextDevServerOwnership,
|
|
966
|
+
startupMode,
|
|
967
|
+
startupModeSelection,
|
|
968
|
+
requestedStartupMode,
|
|
466
969
|
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
970
|
}),
|
|
468
971
|
},
|
|
@@ -489,6 +992,7 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
489
992
|
intent = await bridge.waitForChanges(timeoutMs);
|
|
490
993
|
}
|
|
491
994
|
catch {
|
|
995
|
+
const health = bridge.getDevServerHealth();
|
|
492
996
|
return {
|
|
493
997
|
content: [
|
|
494
998
|
{
|
|
@@ -496,6 +1000,7 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
496
1000
|
text: JSON.stringify({
|
|
497
1001
|
hasChanges: false,
|
|
498
1002
|
message: 'Timed out waiting for changes. Call again to keep waiting.',
|
|
1003
|
+
devServerHealth: health,
|
|
499
1004
|
}),
|
|
500
1005
|
},
|
|
501
1006
|
],
|
|
@@ -570,51 +1075,10 @@ const startMCPServer = async (mcpEditor) => {
|
|
|
570
1075
|
isError: true,
|
|
571
1076
|
};
|
|
572
1077
|
}
|
|
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,
|
|
1078
|
+
const { totalRoundsCompleted } = await cleanupSessionResources({
|
|
1079
|
+
closeSessionId: args.sessionId,
|
|
1080
|
+
trackSessionEnd: true,
|
|
604
1081
|
});
|
|
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
1082
|
return {
|
|
619
1083
|
content: [
|
|
620
1084
|
{
|