From fb31d111ef6a9ef893bb73710e5893d043766fed Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Tue, 28 Apr 2026 13:57:44 -0700 Subject: [PATCH] fix(path-b): dev_server tool dispatch + state-machine transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caught by the live end-to-end test: 1. Tool dispatch mismatch. Gemini tool name "dev_server_list" runs through executeMcpTool's _-to-. converter (toolName.replace(/_/g, '.')) and arrives as "dev.server.list". The dispatcher only had cases for "dev_server.list", so all four dev_server.* tools 404'd as "Unknown tool". The AI gracefully fell back to shell.exec + nohup, so Express still ran — but the dev_servers table never got populated and the preview URL machinery was bypassed. Add aliases for both underscore and fully-dotted forms. 2. State machine never transitioned. ensureDevContainer wrote state='provisioning'; nothing ever flipped it to 'running'. As a result the idle-sweep (which filters by state='running') never saw a candidate to suspend. Use the first successful exec as the authoritative liveness signal: touchActivity() now also flips provisioning|suspended → running and clears suspended_at. Surfaced by the live trace: AI tried dev_server_list, got 404, fell back to manually grepping the process table. Made-with: Cursor --- app/api/mcp/route.ts | 8 ++++++++ lib/dev-container.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index f6722260..5932c163 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -369,13 +369,21 @@ export async function POST(request: Request) { case 'fs.grep': return await toolFsGrep(principal, params); + // The Gemini tool-name "dev_server_list" maps to dotted action + // "dev.server.list" via executeMcpTool's underscore→dot replace. + // We accept BOTH the original underscore form and the converted + // dotted form so external MCP clients aren't surprised either way. case 'dev_server.start': + case 'dev.server.start': return await toolDevServerStart(principal, params); case 'dev_server.stop': + case 'dev.server.stop': return await toolDevServerStop(principal, params); case 'dev_server.list': + case 'dev.server.list': return await toolDevServerList(principal, params); case 'dev_server.logs': + case 'dev.server.logs': return await toolDevServerLogs(principal, params); case 'ship': return await toolShip(principal, params); diff --git a/lib/dev-container.ts b/lib/dev-container.ts index 2d4a8818..e9ff8e9d 100644 --- a/lib/dev-container.ts +++ b/lib/dev-container.ts @@ -275,8 +275,16 @@ export async function resumeDevContainer(projectId: string): Promise { } async function touchActivity(projectId: string): Promise { + // Also flips state 'provisioning' → 'running' on first successful exec. + // We can't rely on Coolify's deploy webhook alone (it fires before the + // container's actually accepting docker exec), so the first exec that + // returns is our authoritative liveness signal. await query( - `UPDATE fs_project_dev_containers SET last_active_at = now() WHERE project_id = $1`, + `UPDATE fs_project_dev_containers + SET last_active_at = now(), + state = CASE WHEN state IN ('provisioning','suspended') THEN 'running' ELSE state END, + suspended_at = NULL + WHERE project_id = $1`, [projectId], ); }