@launchchair/agent 0.4.12 → 0.5.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.
@@ -19,9 +19,12 @@ import process from "node:process";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { resolveProjectWorkspace } from "./project-workspace.mjs";
21
21
  import { buildRunnerCommand, parseRunnerCommand } from "./runner-command.mjs";
22
+ import { createMcpCapabilities, EXTRA_MCP_TOOLS, MCP_WORKFLOW_INSTRUCTIONS, VALIDATION_GATE_SCHEMA } from "./mcp-capabilities.mjs";
23
+ import { MCP_WIDGET_TOOLS, MCP_WIDGET_INSTRUCTIONS, listWidgetResources, readWidgetResource, callWidgetTool } from "./mcp-widgets.mjs";
22
24
 
23
- const VERSION = "0.4.12";
25
+ const VERSION = "0.5.1";
24
26
  const SERVER_NAME = "launchchair-agent-mcp";
27
+ const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]);
25
28
  const AGENT_SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
26
29
  const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
27
30
  ? path.resolve(process.env.LAUNCHCHAIR_CONFIG_DIR.trim())
@@ -108,7 +111,7 @@ function builtInRunnerCommand(target) {
108
111
  }
109
112
 
110
113
  const config = {
111
- baseUrl: env("LAUNCHCHAIR_BASE_URL", savedConfigValue("baseUrl", "http://localhost:3000")).replace(/\/+$/, ""),
114
+ baseUrl: env("LAUNCHCHAIR_BASE_URL", savedConfigValue("baseUrl", "https://www.launchchair.io")).replace(/\/+$/, ""),
112
115
  token: env("LAUNCHCHAIR_AGENT_TOKEN", env("LAUNCHCHAIR_API_KEY", savedConfigValue("agentToken"))),
113
116
  projectId: env("LAUNCHCHAIR_PROJECT_ID", savedConfigValue("projectId")) || null,
114
117
  clientName: env("LC_AGENT_CLIENT_NAME", "MCP Bridge"),
@@ -135,70 +138,52 @@ const config = {
135
138
  },
136
139
  };
137
140
 
138
- if (!config.token) {
139
- // eslint-disable-next-line no-console
140
- console.error(
141
- `Missing LaunchChair Agent API token. Run "launchchair login" or set LAUNCHCHAIR_AGENT_TOKEN. Checked ${CONFIG_PATH}.`,
142
- );
143
- process.exit(1);
144
- }
145
-
146
- if (
147
- !config.mockResponse &&
148
- !config.runnerCommands.generic &&
149
- !config.runnerCommands.gpt &&
150
- !config.runnerCommands.codex &&
151
- !config.runnerCommands.chatgpt &&
152
- !config.runnerCommands.claude &&
153
- !config.runnerCommands.codexTarget &&
154
- !config.runnerCommands.claudeCode
155
- ) {
156
- // eslint-disable-next-line no-console
157
- console.error(
158
- "No runner command configured for MCP bridge. Set LC_AGENT_RUNNER_CMD* or use the built-in Codex/Claude Code runners; ChatGPT/Claude API fallbacks require provider API keys.",
159
- );
160
- process.exit(1);
161
- }
141
+ let statusPromise = null;
142
+ let initialized = false;
143
+ const capabilities = createMcpCapabilities({ config, configPath: CONFIG_PATH, api, resolveProjectId, onAuthChange: () => { statusPromise = null; if (initialized) writeMessage({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }); } });
162
144
 
163
145
  async function api(path, { method = "GET", body } = {}) {
146
+ const token = capabilities.currentToken();
147
+ if (!token) {
148
+ const error = new Error("Login required. Call launchchair_login and complete browser approval.");
149
+ error.status = 401;
150
+ error.payload = { code: "login_required", nextTool: "launchchair_login" };
151
+ throw error;
152
+ }
164
153
  const response = await fetch(`${config.baseUrl}${path}`, {
165
154
  method,
166
155
  headers: {
167
- Authorization: `Bearer ${config.token}`,
156
+ Authorization: `Bearer ${token}`,
168
157
  "Content-Type": "application/json",
169
158
  },
170
159
  body: body === undefined ? undefined : JSON.stringify(body),
160
+ signal: AbortSignal.timeout(30_000),
171
161
  });
172
162
 
173
- let payload = null;
174
- try {
175
- payload = await response.json();
176
- } catch (_error) {
177
- payload = null;
178
- }
163
+ const raw = await response.text();
164
+ let payload;
165
+ try { payload = raw ? JSON.parse(raw) : null; } catch { payload = { content: raw, contentType: response.headers.get("content-type") }; }
179
166
 
180
167
  if (!response.ok) {
181
168
  const err = new Error(`HTTP ${response.status}: ${String(payload?.error ?? response.statusText ?? "Request failed")}`);
182
169
  err.status = response.status;
183
- err.payload = payload;
170
+ err.payload = response.status === 401 ? { ...payload, code: "login_required", nextTool: "launchchair_login" } : payload;
184
171
  err.retryAfterSeconds = Number.parseInt(response.headers.get("retry-after") ?? "", 10) || null;
185
172
  throw err;
186
173
  }
187
174
  return payload;
188
175
  }
189
176
 
190
- let statusPromise = null;
191
-
192
177
  function loadStatus() {
193
178
  if (!statusPromise) {
194
- statusPromise = api("/api/agent/v1").catch(() => null);
179
+ statusPromise = api("/api/agent/v1").catch(() => { statusPromise = null; return null; });
195
180
  }
196
181
  return statusPromise;
197
182
  }
198
183
 
199
184
  function serverInstructions(status) {
200
185
  const featureInstructions = String(status?.capabilities?.mcpServerInstructions ?? "").trim();
201
- return featureInstructions ? `${BASE_SERVER_INSTRUCTIONS} ${featureInstructions}` : BASE_SERVER_INSTRUCTIONS;
186
+ return `${BASE_SERVER_INSTRUCTIONS} ${featureInstructions} ${MCP_WORKFLOW_INSTRUCTIONS} ${MCP_WIDGET_INSTRUCTIONS}`;
202
187
  }
203
188
 
204
189
  function targetForRun(run) {
@@ -291,6 +276,7 @@ async function executeRunner(run) {
291
276
  LC_AGENT_PROMPT_MODE: String(run.promptMode ?? ""),
292
277
  LC_AGENT_RUN_TYPE: String(run.runType ?? ""),
293
278
  [workspaceEnvName]: workspace.path,
279
+ ...(workspace.source === "isolated" && run?.runnerWorkspace?.requiresRepository !== true ? { LC_AGENT_CODEX_SKIP_GIT_CHECK: "1" } : {}),
294
280
  },
295
281
  });
296
282
  let stdout = "";
@@ -347,6 +333,7 @@ async function claimNextRun(args = {}) {
347
333
  method: "POST",
348
334
  body: {
349
335
  projectId,
336
+ ...(args.runId ? { runId: String(args.runId) } : {}),
350
337
  clientName: String(args.clientName ?? "").trim() || config.clientName,
351
338
  clientType: normalizeTarget(args.clientType ?? config.clientType, "other"),
352
339
  clientVersion: String(args.clientVersion ?? "").trim() || config.clientVersion,
@@ -600,15 +587,19 @@ async function continueProjectLoop(args = {}) {
600
587
  break;
601
588
  }
602
589
 
603
- const run = loop.run
604
- ? { ...loop.run, runnerWorkspace: loop.runnerWorkspace ?? runnerWorkspaceContext }
605
- : null;
606
- if (!run?.id || !run?.prompt) {
607
- final = loop;
590
+ const claimed = loop.run?.id ? await claimNextRun({ ...args, projectId, runId: loop.run.id }) : null;
591
+ if (!claimed) {
592
+ final = { ...loop, run: loop.run?.id ? { id: loop.run.id } : null, message: "Run already claimed; do not execute it again." };
593
+ break;
594
+ }
595
+ const run = { ...claimed, runnerWorkspace: claimed.runnerWorkspace ?? loop.runnerWorkspace ?? runnerWorkspaceContext };
596
+ let execution;
597
+ try { execution = await executeRunner(run); }
598
+ catch (error) {
599
+ await completeRun({ runId: run.id, status: "failed", error: error.message, resultRaw: error.stdout || error.stderr || "" });
600
+ final = { status: "stopped_after_failed_step", projectId, runId: run.id, message: error.message };
608
601
  break;
609
602
  }
610
-
611
- const execution = await executeRunner(run);
612
603
  const stdout = String(execution.stdout ?? "").trim();
613
604
  const stderr = String(execution.stderr ?? "").trim();
614
605
  const output = stdout || stderr;
@@ -695,6 +686,7 @@ async function createProject(args = {}) {
695
686
  delete createArgs.maxSteps;
696
687
  delete createArgs.fullLoop;
697
688
  delete createArgs.buildMaxSteps;
689
+ delete createArgs.executionMode;
698
690
  const body = {
699
691
  ...createArgs,
700
692
  name: String(args.name ?? args.projectName ?? "").trim(),
@@ -721,6 +713,8 @@ async function createProject(args = {}) {
721
713
  return payload;
722
714
  }
723
715
 
716
+ if (args.executionMode !== "local_runner") return { ...payload, next: { ...payload.next, tool: "launchchair_next_step", arguments: { projectId, stopPhaseKey: "build", completionTarget: fullLoop ? "phase_complete" : "build_board_ready" }, execution: "host", instructions: MCP_WORKFLOW_INSTRUCTIONS } };
717
+
724
718
  const planning = await continueProjectLoop({
725
719
  projectId,
726
720
  stopPhaseKey: "build",
@@ -997,10 +991,11 @@ const READ_ONLY_TOOLS = new Set(["launchchair_status", "launchchair_list_project
997
991
  function annotateTool(tool) {
998
992
  return {
999
993
  ...tool,
994
+ ...(tool.name === "launchchair_select_choice" ? { _meta: { ...tool._meta, ui: { visibility: ["model", "app"] }, "openai/widgetAccessible": true } } : {}),
1000
995
  annotations: {
1001
996
  ...(tool.annotations ?? {}),
1002
- readOnlyHint: READ_ONLY_TOOLS.has(tool.name),
1003
- destructiveHint: false,
997
+ readOnlyHint: tool.annotations?.readOnlyHint ?? READ_ONLY_TOOLS.has(tool.name),
998
+ destructiveHint: tool.annotations?.destructiveHint ?? false,
1004
999
  openWorldHint: true,
1005
1000
  },
1006
1001
  };
@@ -1016,7 +1011,12 @@ function mergeToolOverrides(baseTools, overrides) {
1016
1011
  merged.push(override);
1017
1012
  }
1018
1013
  }
1019
- return merged.map(annotateTool);
1014
+ return merged.map((tool) => {
1015
+ if (["launchchair_create_project", "launchchair_create_feature_project"].includes(tool.name)) tool = { ...tool, inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties, idempotencyKey: { type: "string" }, ideaIntent: { type: "string", enum: ["preserve", "explore"], default: "preserve" }, validationGate: VALIDATION_GATE_SCHEMA, executionMode: { type: "string", enum: ["host", "local_runner"], default: "host" } } } };
1016
+ if (tool.name === "launchchair_claim_next_run") tool = { ...tool, inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties, runId: { type: "string" } } } };
1017
+ if (tool.name === "launchchair_continue_project") tool = { ...tool, description: "Advance one step in the current host agent by default. Only use executionMode=local_runner to spawn a separate local CLI.", inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties, executionMode: { type: "string", enum: ["host", "local_runner"], default: "host" } } } };
1018
+ return annotateTool(tool);
1019
+ });
1020
1020
  }
1021
1021
 
1022
1022
  function writeMessage(obj) {
@@ -1037,6 +1037,7 @@ function writeError(id, code, message, data = undefined) {
1037
1037
 
1038
1038
  function textResult(payload) {
1039
1039
  return {
1040
+ ...(payload && typeof payload === "object" && !Array.isArray(payload) ? { structuredContent: payload } : {}),
1040
1041
  content: [
1041
1042
  {
1042
1043
  type: "text",
@@ -1057,9 +1058,10 @@ async function handleRpc(request) {
1057
1058
  if (method === "initialize") {
1058
1059
  const status = await loadStatus();
1059
1060
  writeResult(id, {
1060
- protocolVersion: "2024-11-05",
1061
+ protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.has(request?.params?.protocolVersion) ? request.params.protocolVersion : "2025-11-25",
1061
1062
  capabilities: {
1062
- tools: {},
1063
+ tools: { listChanged: true },
1064
+ resources: {},
1063
1065
  },
1064
1066
  serverInfo: {
1065
1067
  name: SERVER_NAME,
@@ -1071,6 +1073,23 @@ async function handleRpc(request) {
1071
1073
  }
1072
1074
 
1073
1075
  if (method === "notifications/initialized") {
1076
+ initialized = true;
1077
+ return;
1078
+ }
1079
+
1080
+ if (method === "resources/list") {
1081
+ writeResult(id, { resources: listWidgetResources() });
1082
+ return;
1083
+ }
1084
+
1085
+ if (method === "resources/read") {
1086
+ try { writeResult(id, readWidgetResource(request?.params?.uri)); }
1087
+ catch (error) { writeError(id, -32002, error.message); }
1088
+ return;
1089
+ }
1090
+
1091
+ if (method === "ping") {
1092
+ writeResult(id, {});
1074
1093
  return;
1075
1094
  }
1076
1095
 
@@ -1079,7 +1098,7 @@ async function handleRpc(request) {
1079
1098
  const overrides = Array.isArray(status?.capabilities?.mcpToolOverrides)
1080
1099
  ? status.capabilities.mcpToolOverrides
1081
1100
  : [];
1082
- const tools = mergeToolOverrides(MCP_TOOLS, overrides);
1101
+ const tools = [...mergeToolOverrides([...MCP_TOOLS, ...EXTRA_MCP_TOOLS], overrides), ...MCP_WIDGET_TOOLS];
1083
1102
  writeResult(id, { tools });
1084
1103
  return;
1085
1104
  }
@@ -1089,9 +1108,17 @@ async function handleRpc(request) {
1089
1108
  const toolName = String(params.name ?? "");
1090
1109
  const args = typeof params.arguments === "object" && params.arguments ? params.arguments : {};
1091
1110
  try {
1111
+ if (MCP_WIDGET_TOOLS.some((tool) => tool.name === toolName)) {
1112
+ writeResult(id, await callWidgetTool(toolName, args, { api, resolveProjectId, baseUrl: config.baseUrl }));
1113
+ return;
1114
+ }
1115
+ if (EXTRA_MCP_TOOLS.some((tool) => tool.name === toolName)) {
1116
+ writeResult(id, textResult(await capabilities.dispatch(toolName, args)));
1117
+ return;
1118
+ }
1092
1119
  if (toolName === "launchchair_status") {
1093
1120
  const status = await api("/api/agent/v1");
1094
- writeResult(id, textResult(status));
1121
+ writeResult(id, textResult({ ...status, authenticated: true }));
1095
1122
  return;
1096
1123
  }
1097
1124
  if (toolName === "launchchair_create_project") {
@@ -1140,7 +1167,7 @@ async function handleRpc(request) {
1140
1167
  return;
1141
1168
  }
1142
1169
  if (toolName === "launchchair_continue_project") {
1143
- const payload = await continueProjectLoop(args);
1170
+ const payload = args.executionMode === "local_runner" ? await continueProjectLoop(args) : await capabilities.dispatch("launchchair_next_step", { ...args, executionMode: undefined, maxSteps: undefined });
1144
1171
  writeResult(id, textResult(payload));
1145
1172
  return;
1146
1173
  }
@@ -0,0 +1,227 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { bindProjectWorkspace } from "./project-workspace.mjs";
5
+
6
+ const string = { type: "string" };
7
+ const object = { type: "object", additionalProperties: true };
8
+ const patch = { type: "array", items: { type: "object", properties: { op: { type: "string", enum: ["add", "replace", "remove"] }, path: string, value: {} }, required: ["op", "path"], additionalProperties: false } };
9
+ const project = { projectId: string, projectName: string };
10
+ const bucket = { ...project, phaseKey: string, bucketKey: string };
11
+ const card = { ...project, cardId: string };
12
+ export const VALIDATION_GATE_SCHEMA = { anyOf: [{ type: "string", enum: ["very_high"] }, { type: "object", properties: { minPain: { type: "number", minimum: 0, maximum: 100 }, minOpportunity: { type: "number", minimum: 0, maximum: 100 }, maxSaturation: { type: "number", minimum: 0, maximum: 100 } }, additionalProperties: false }] };
13
+ export const MCP_WORKFLOW_INSTRUCTIONS = "Prefer launchchair_next_step: execute the returned claimed prompt in THIS host agent using its existing research, repository, GitHub, Vercel and Supabase tools, then launchchair_complete_run and repeat. Do not start a second model/CLI unless the user requests local-runner execution. The server cannot inherit or call the host's other plugins itself. Preserve a supplied idea by default, including when scores are low; report risks without rewriting the source problem, audience, solution or specified requirements. Only use ideaIntent=explore when the user requests discovery/pivots. Validation gates are opt-in: do not impose a gate on an established idea unless the user requested one. For 'VERY high' scores use validationGate=very_high (pain>=85, opportunity>=85, saturation<=30). Research real evidence, create separate candidates, finish validation and compare LaunchChair scorecards, never inflate evidence or use direction-option scores as validation. Stop at a failed gate. If no candidate qualifies, report that honestly. Use connected infrastructure plugins only after the gate passes, save their verified results with update_bucket plus executionEvidence, bind the repository, and continue. Present actual authorization/production SQL checkpoints; reuse existing plugin authorization. Do not claim external actions were done without evidence. Complete runnable QA yourself and record results; ask for human checks only when truly necessary. Never disclose bearer tokens or private login device codes.";
14
+
15
+ function tool(name, description, properties = {}, required = [], readOnly = false) {
16
+ return { name: `launchchair_${name}`, description, inputSchema: { type: "object", properties, required, additionalProperties: false }, annotations: { readOnlyHint: readOnly, destructiveHint: ["revoke_token", "cancel_run", "write_competitor_note", "upload_logo"].includes(name), openWorldHint: true } };
17
+ }
18
+
19
+ const definitions = [
20
+ ["list_competitor_notes", "Read competitor research notes in the current project.", project, [], "GET", "/competitor-notes"],
21
+ ["write_competitor_note", "Create, update, or delete a competitor note. Updating/deleting requires authorship or active owner access.", { ...project, operation: { type: "string", enum: ["create", "update", "delete"] }, noteId: string, competitorKey: string, competitorName: string, content: string }, ["operation"], "POST", "/competitor-notes"],
22
+ ["upload_logo", "Upload a PNG/WebP logo (base64, up to 1 MiB) or remove the existing logo with remove=true. Use the host's image tools to create the logo first.", { ...project, contentBase64: string, mimeType: { type: "string", enum: ["image/png", "image/webp"] }, generationPrompt: string, remove: { type: "boolean" } }, [], "POST", "/logo"],
23
+ ["research_sources", "Fetch LaunchChair research sources: Hacker News search, Product Hunt ranked posts, or a public HTTPS RSS feed (including Reddit/Indie Hackers). Returns source material without changing the idea. Inspect evidence before saving findings via bucket tools.", { ...project, source: { type: "string", enum: ["hn", "product_hunt", "rss"] }, query: string, url: string, limit: { type: "integer", minimum: 1, maximum: 40 } }, ["source"], "POST", "/research"],
24
+ ["search_media", "Search LaunchChair's Pexels integration for photos/videos. Retain source and creator attribution; save selected assets through the appropriate bucket.", { ...project, query: string, kind: { type: "string", enum: ["photo", "video"] }, limit: { type: "integer", minimum: 1, maximum: 40 } }, ["query"], "POST", "/media"],
25
+ ["get_starter", "Read the private LaunchChair starter manifest, then download batches of files (base64) using paths and treeSha. Paid, scoped, and validation-gated. Create/commit/deploy the repository with the host GitHub/Vercel/Supabase tools; no provider credentials are transferred to LaunchChair.", { ...project, paths: { type: "array", items: string, minItems: 1, maxItems: 20 }, treeSha: string }, [], "POST", "/starter"],
26
+ ["get_spec", "Read the normalized project spec, validation scores and saved idea intent.", project, [], "GET", "/spec"],
27
+ ["update_spec", "Advanced guarded spec patch. Requires spec.write; default tokens use bucket-specific tools. Cannot change idea intent or validation policy.", { ...project, patch }, ["patch"], "PATCH", "/spec"],
28
+ ["list_buckets", "List all phases/buckets and progress.", project, [], "GET", "/buckets"],
29
+ ["get_bucket", "Read a bucket's data, allowed patch paths and evidence requirements before editing.", bucket, ["phaseKey", "bucketKey"], "GET", "/buckets/:phaseKey/:bucketKey"],
30
+ ["update_bucket", "Save form/checklist data using allowed JSON Patch paths, or update status. For setup and checklists supply factual executionEvidence {source,checkedAt,summary} from the host's connected plugins/tests. Never store credentials. Read get_bucket first. Does not provision infrastructure itself.", { ...bucket, patch, status: { type: "string", enum: ["not_started", "in_progress", "done"] }, executionEvidence: object }, ["phaseKey", "bucketKey"], "PATCH", "/buckets/:phaseKey/:bucketKey"],
31
+ ["get_bucket_prompt", "Read a bucket's compiled prompt for execution by the current host agent. Brand supports variant=tagline or logo; campaign_builder requires campaignId. Save generated form output with update_bucket.", { ...bucket, variant: { type: "string", enum: ["tagline", "logo"] }, campaignId: string }, ["phaseKey", "bucketKey"], "GET", "/buckets/:phaseKey/:bucketKey/prompt"],
32
+ ["apply_bucket_update", "Apply validated SPEC_UPDATE JSON Patch for a prompt bucket. Prefer complete_run for claimed runs so validation/remediation executes.", { ...bucket, patch, compiledPrompt: string, templateKey: string, pastedResponse: string }, ["phaseKey", "bucketKey", "patch"], "POST", "/buckets/:phaseKey/:bucketKey/apply-spec-update"],
33
+ ["get_build_cards", "Inspect build cards, dependencies, SQL actions and QA checkpoints.", project, [], "GET", "/build-cards"],
34
+ ["update_build_card", "Record build-card status, acceptance, SQL completion and QA results only after verification. Supply executionEvidence {source,checkedAt,summary} when completing a card or checking results. This records results, it does not execute SQL or tests.", { ...card, status: { type: "string", enum: ["To Do", "In Progress", "Needs Review", "Done"] }, notes: string, acceptanceStatus: object, testingNotesStatus: object, sqlActionsStatus: object, executionEvidence: object }, ["cardId", "status"], "PATCH", "/build-cards/:cardId"],
35
+ ["get_build_card_prompt", "Read the compiled build-card prompt. Requires a passing validation gate when configured.", card, ["cardId"], "GET", "/build-cards/:cardId/prompt"],
36
+ ["apply_build_card_run", "Apply CHANGELOG_JSON/REQUIRES from an executed build card. Use the API payload contract returned by the catalog.", { ...card, payload: object }, ["cardId", "payload"], "POST", "/build-cards/:cardId/apply-run"],
37
+ ["export_mvp_spec", "Export the AI-ready MVP markdown specification.", project, [], "GET", "/exports/mvp-ai-spec"],
38
+ ["export_phase", "Export a phase synthesis as markdown.", { ...project, phaseKey: string }, ["phaseKey"], "GET", "/exports/phase/:phaseKey"],
39
+ ["get_runner_workspace", "Read expected repository and workspace requirements.", project, [], "GET", "/runner-workspace"],
40
+ ["get_validation_gate", "Evaluate the saved validation gate using current calculated LaunchChair scores.", project, [], "GET", "/validation-gate"],
41
+ ["set_validation_gate", "Set or tighten an explicitly user-requested gate. Does not authorize changing the user's idea. An existing gate cannot be weakened by this tool.", { ...project, validationGate: VALIDATION_GATE_SCHEMA }, ["validationGate"], "POST", "/validation-gate"],
42
+ ["list_runs", "List recent runs belonging to this token and project.", project, [], "GET", "/runs"],
43
+ ["queue_run", "Queue a specific prompt bucket or build-card run, including research reruns. Then claim it and execute in this host agent; complete_run applies and validates the result.", { ...bucket, cardId: string }, [], "POST", "/runs"],
44
+ ];
45
+
46
+ export const EXTRA_MCP_TOOLS = [
47
+ ...definitions.map(([name, description, properties, required, method]) => tool(name, description, properties, required, method === "GET")),
48
+ tool("login", "Start browser-assisted LaunchChair login/token creation without an existing token. Return the existing LaunchChair approval URL for the user; then poll login_status. No password or token is requested in chat.", { tokenName: string, restart: { type: "boolean" } }),
49
+ tool("login_status", "Poll the pending browser approval once. Respect retryAfterSeconds. On approval save the token privately and authenticate this MCP session immediately.", {}),
50
+ tool("logout", "Disconnect this MCP and remove its saved token. Optionally revoke the current token on the server first.", { revoke: { type: "boolean", default: false } }),
51
+ tool("list_tokens", "List redacted tokens. Requires fresh owner authorization with tokens.manage and workspace-wide access.", {}, [], true),
52
+ tool("create_token", "Create a token with no more privileges/lifetime than the current token. Store its secret in a private local file and return only metadata/path. Optionally activate it for this MCP. Requires tokens.manage from owner-approved login.", { name: string, projectIds: { type: "array", items: string }, scopes: { type: "array", items: string }, expiresAt: string, activate: { type: "boolean", default: false } }, ["name"]),
53
+ tool("revoke_token", "Revoke a token in the authorized workspace. Revoking the current token disconnects this MCP.", { tokenId: string }, ["tokenId"]),
54
+ tool("get_run", "Read a run's status and output. Do not execute a run already claimed by another worker.", { runId: string }, ["runId"], true),
55
+ tool("cancel_run", "Cancel a queued/running run so further results cannot be applied. Stop any host process separately.", { runId: string }, ["runId"]),
56
+ tool("next_step", "Queue and atomically claim ONE next step for THIS host agent, without spawning a separate runner. Execute its prompt with your own tools/plugins, complete_run, then call again. Returns forms/setup/gate checkpoints when applicable. Set stopPhaseKey=launch_sales for the entire Product workflow, or market_validation for research only.", { ...project, stopPhaseKey: string, completionTarget: { type: "string", enum: ["phase_complete", "build_board_ready"] }, ideaSnapshot: object, choiceSelections: { type: "array", items: object }, agentSetup: object }, []),
57
+ tool("bind_repository", "Bind a local clone after checking its Git origin against the repository saved in LaunchChair. No cloning or infrastructure provisioning occurs.", { ...project, cwd: string }, ["cwd"]),
58
+ tool("compare_projects", "Compare up to 20 candidate ideas using calculated validation scores and requested thresholds. Never mutates ideas or inflates scores; returns qualifying candidates separately.", { projectIds: { type: "array", items: string, minItems: 1, maxItems: 20 }, validationGate: VALIDATION_GATE_SCHEMA }, ["projectIds"], true),
59
+ tool("research_opportunities", "Get a bounded research workflow for an explicitly exploratory user request. Execute the research using this host's search tools, then create and validate separate LaunchChair candidates. This tool itself does not research or create projects.", { audience: string, maxCandidates: { type: "integer", minimum: 1, maximum: 20, default: 5 }, validationGate: VALIDATION_GATE_SCHEMA }, ["audience"], true),
60
+ ];
61
+
62
+ export function createMcpCapabilities({ config, configPath, api, resolveProjectId, onAuthChange }) {
63
+ let sessionToken;
64
+ let pollInFlight = false;
65
+ let authEpoch = 0;
66
+ const projectSteps = new Set();
67
+ const configDir = path.dirname(configPath);
68
+ function readConfig() { try { const value = JSON.parse(readFileSync(configPath, "utf8")); return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } catch { return {}; } }
69
+ function saveConfig(changes) {
70
+ const next = { ...readConfig(), ...changes };
71
+ for (const key of Object.keys(next)) if (next[key] === undefined) delete next[key];
72
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
73
+ const temporary = `${configPath}.${randomUUID()}.tmp`;
74
+ writeFileSync(temporary, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
75
+ renameSync(temporary, configPath);
76
+ chmodSync(configPath, 0o600);
77
+ }
78
+ function currentToken() {
79
+ if (sessionToken !== undefined) return sessionToken;
80
+ const saved = readConfig();
81
+ if (saved.mcpLoggedOut === true) return "";
82
+ if (saved.mcpPreferSavedToken === true && saved.agentToken) return saved.agentToken;
83
+ return process.env.LAUNCHCHAIR_AGENT_TOKEN?.trim() || process.env.LAUNCHCHAIR_API_KEY?.trim() || saved.agentToken || "";
84
+ }
85
+ function activate(token) {
86
+ saveConfig({ agentToken: token, baseUrl: config.baseUrl, mcpLoggedOut: false, mcpPreferSavedToken: true, pendingMcpLogin: undefined });
87
+ sessionToken = token;
88
+ onAuthChange();
89
+ }
90
+ function disconnect() {
91
+ authEpoch += 1;
92
+ saveConfig({ agentToken: undefined, pendingMcpLogin: undefined, mcpLoggedOut: true });
93
+ sessionToken = "";
94
+ onAuthChange();
95
+ }
96
+ async function publicRequest(endpoint, body) {
97
+ const response = await fetch(`${config.baseUrl}${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
98
+ const payload = await response.json();
99
+ if (response.status === 429) return { status: "pending", retryAfterSeconds: Math.max(5, Number(response.headers.get("retry-after")) || 10) };
100
+ if (!response.ok) {
101
+ const error = new Error(String(payload.error || payload.status || `HTTP ${response.status}`));
102
+ error.status = response.status;
103
+ throw error;
104
+ }
105
+ return payload;
106
+ }
107
+ async function loginStatus() {
108
+ const saved = readConfig().pendingMcpLogin;
109
+ if (!saved) return { status: currentToken() ? "authenticated" : "not_started", nextTool: currentToken() ? "launchchair_status" : "launchchair_login" };
110
+ if (Date.now() >= saved.expiresAt) { saveConfig({ pendingMcpLogin: undefined }); return { status: "expired", nextTool: "launchchair_login" }; }
111
+ if (pollInFlight || Date.now() < (saved.nextPollAt || 0)) return { status: "pending", retryAfterSeconds: Math.max(1, Math.ceil(((saved.nextPollAt || Date.now() + 5000) - Date.now()) / 1000)) };
112
+ pollInFlight = true;
113
+ const epoch = authEpoch;
114
+ try {
115
+ const result = await publicRequest("/api/agent/v1/cli-login/poll", { deviceCode: saved.deviceCode });
116
+ if (epoch !== authEpoch || readConfig().pendingMcpLogin?.deviceCode !== saved.deviceCode) return { status: "cancelled", nextTool: "launchchair_login" };
117
+ if (result.status === "approved" && result.token) {
118
+ activate(result.token);
119
+ return { status: "authenticated", tokenRecord: result.tokenRecord, nextTool: "launchchair_status", message: "Login approved; token saved privately. MCP is ready without restarting." };
120
+ }
121
+ const retryAfterSeconds = Math.max(5, Number(result.retryAfterSeconds || result.interval) || 5);
122
+ saveConfig({ pendingMcpLogin: { ...saved, nextPollAt: Date.now() + retryAfterSeconds * 1000 } });
123
+ return { status: result.status || "pending", retryAfterSeconds };
124
+ } catch (error) {
125
+ if ([400, 403, 404, 410].includes(error.status)) saveConfig({ pendingMcpLogin: undefined });
126
+ throw error;
127
+ } finally { pollInFlight = false; }
128
+ }
129
+ async function dispatch(name, args) {
130
+ if (name === "launchchair_login") {
131
+ let pending = readConfig().pendingMcpLogin;
132
+ if (!pending || pending.expiresAt <= Date.now() || args.restart === true) {
133
+ const epoch = ++authEpoch;
134
+ const started = await publicRequest("/api/agent/v1/cli-login/start", { clientName: "LaunchChair MCP", tokenName: args.tokenName || "LaunchChair MCP" });
135
+ if (epoch !== authEpoch) return { status: "cancelled", nextTool: "launchchair_login" };
136
+ if (!started.deviceCode || !started.verificationUriComplete) return { status: "rate_limited", retryAfterSeconds: started.retryAfterSeconds || 10 };
137
+ const approvalUrl = new URL(started.verificationUriComplete);
138
+ if (approvalUrl.origin !== new URL(config.baseUrl).origin) throw new Error("Login approval URL does not match the configured LaunchChair origin.");
139
+ pending = { deviceCode: started.deviceCode, userCode: started.userCode, approvalUrl: approvalUrl.toString(), expiresAt: Date.now() + Number(started.expiresIn || 600) * 1000, nextPollAt: 0 };
140
+ saveConfig({ pendingMcpLogin: pending });
141
+ }
142
+ return { status: "approval_required", approvalUrl: pending.approvalUrl, userCode: pending.userCode, expiresAt: new Date(pending.expiresAt).toISOString(), nextTool: "launchchair_login_status", message: "Open this LaunchChair URL, sign in if needed, and approve this MCP. The workspace owner authorizes token creation." };
143
+ }
144
+ if (name === "launchchair_login_status") return loginStatus();
145
+ if (name === "launchchair_logout") {
146
+ if (args.revoke === true && currentToken()) {
147
+ const tokens = await api("/api/agent/v1/tokens");
148
+ await api(`/api/agent/v1/tokens/${encodeURIComponent(tokens.currentTokenId)}/revoke`, { method: "POST", body: {} });
149
+ }
150
+ disconnect();
151
+ return { status: "logged_out", revoked: args.revoke === true };
152
+ }
153
+ if (name === "launchchair_list_tokens") return api("/api/agent/v1/tokens");
154
+ if (name === "launchchair_create_token") {
155
+ const { activate: useToken, ...body } = args;
156
+ const created = await api("/api/agent/v1/tokens", { method: "POST", body });
157
+ if (!created.token || !created.tokenRecord?.id) throw new Error("Token creation returned incomplete credentials.");
158
+ const credentialPath = path.join(configDir, "tokens", `${randomUUID()}.json`);
159
+ mkdirSync(path.dirname(credentialPath), { recursive: true, mode: 0o700 });
160
+ writeFileSync(credentialPath, JSON.stringify({ agentToken: created.token, baseUrl: config.baseUrl, tokenRecord: created.tokenRecord }) + "\n", { mode: 0o600, flag: "wx" });
161
+ if (useToken === true) activate(created.token);
162
+ return { tokenRecord: created.tokenRecord, credentialPath, activated: useToken === true, message: "Token secret saved privately; do not print the credential file in chat." };
163
+ }
164
+ if (name === "launchchair_revoke_token") {
165
+ const result = await api(`/api/agent/v1/tokens/${encodeURIComponent(args.tokenId)}/revoke`, { method: "POST", body: {} });
166
+ if (result.currentTokenRevoked) disconnect();
167
+ return result;
168
+ }
169
+ if (name === "launchchair_get_run" || name === "launchchair_cancel_run") return api(`/api/agent/v1/runs/${encodeURIComponent(args.runId)}`, name.endsWith("cancel_run") ? { method: "PATCH", body: {} } : {});
170
+ if (name === "launchchair_next_step") {
171
+ const projectId = await resolveProjectId(args);
172
+ const { projectId: _id, projectName: _name, ...body } = args;
173
+ if (projectSteps.has(projectId)) return { status: "waiting_for_run", projectId, message: "A next_step request is already in progress for this project. Wait for its result." };
174
+ projectSteps.add(projectId);
175
+ try {
176
+ const step = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/loop/continue`, { method: "POST", body });
177
+ if (step.status === "needs_human_input" && step.current) return { ...step, hostAction: { tool: step.current.cardId ? "launchchair_get_build_cards" : "launchchair_get_bucket", arguments: { projectId, phaseKey: step.current.phaseKey, bucketKey: step.current.bucketKey }, instructions: "Inspect the checkpoint and act with existing user authorization and connected tools. Save verified form/QA results or select a choice consistent with the supplied idea. Ask the user only for information or authorization that is actually missing." } };
178
+ if (!["queued_run", "waiting_for_run"].includes(step.status) || !step.run?.id) return step;
179
+ const claimed = await api("/api/agent/v1/runs/next", { method: "POST", body: { projectId, runId: step.run.id, clientName: config.clientName, clientType: config.clientType, clientVersion: config.clientVersion } });
180
+ if (!claimed.run) return { ...step, run: { id: step.run.id }, status: "waiting_for_run", message: "This run is already claimed. Poll get_run; do not execute it again." };
181
+ return { ...step, status: "run_ready", run: claimed.run, execution: "host", nextTool: "launchchair_complete_run", instructions: MCP_WORKFLOW_INSTRUCTIONS };
182
+ } finally { projectSteps.delete(projectId); }
183
+ }
184
+ if (name === "launchchair_bind_repository") {
185
+ const projectId = await resolveProjectId(args);
186
+ const { workspace } = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/runner-workspace`);
187
+ const repositoryUrl = workspace?.repository?.repoUrl;
188
+ if (!repositoryUrl) throw new Error("Save the project repository URL in LaunchChair before binding a clone.");
189
+ const bound = bindProjectWorkspace({ configPath, projectId, cwd: args.cwd, repositoryUrl });
190
+ return { projectId, binding: bound };
191
+ }
192
+ if (name === "launchchair_research_opportunities") return { audience: args.audience, maxCandidates: args.maxCandidates || 5, validationGate: args.validationGate ?? null, ideaIntent: "explore", steps: ["Research the audience's complaints, current workflows, substitutes, willingness to pay and named competitors using the host's search tools. Cite real sources and distinguish hypotheses from evidence.", "Create separate Product candidates with ideaSnapshot, ideaIntent=explore and a stable idempotencyKey. Include validationGate only when the user requested a score threshold.", "For each candidate call next_step with stopPhaseKey=market_validation, execute the prompt, complete_run and repeat. Inspect errors and perform requested remediation.", "Compare using compare_projects. Build only a qualifying candidate; if none qualifies, continue bounded research or report the unmet thresholds honestly.", "Use host GitHub/Vercel/Supabase plugins after the gate passes, record verified setup with update_bucket, bind_repository, then next_step through launch_sales."], instructions: MCP_WORKFLOW_INSTRUCTIONS };
193
+ if (name === "launchchair_compare_projects") {
194
+ if (!Array.isArray(args.projectIds) || !args.projectIds.length || args.projectIds.length > 20) throw new Error("Provide 1–20 projectIds.");
195
+ const thresholds = args.validationGate && typeof args.validationGate === "object" ? { minPain: 85, minOpportunity: 85, maxSaturation: 30, ...args.validationGate } : { minPain: 85, minOpportunity: 85, maxSaturation: 30 };
196
+ if (Object.values(thresholds).some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100)) throw new Error("Validation thresholds must be numbers between 0 and 100.");
197
+ const candidates = [];
198
+ for (const projectId of [...new Set(args.projectIds)]) {
199
+ const payload = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/scorecards`);
200
+ const validation = payload.validationScorecards || payload.scorecards;
201
+ const cards = Array.isArray(validation) ? validation : validation?.scorecards || [];
202
+ const score = (id) => cards.find((entry) => entry.id === id)?.score;
203
+ const pain = score("icp_pain"), opportunity = score("wedge_opportunity"), saturation = score("market_saturation");
204
+ const qualifies = [pain, opportunity, saturation].every((value) => typeof value === "number") && pain >= thresholds.minPain && opportunity >= thresholds.minOpportunity && saturation <= thresholds.maxSaturation;
205
+ candidates.push({ projectId, qualifies, pain, opportunity, saturation, validationScorecards: payload });
206
+ }
207
+ return { thresholds, candidates, qualifyingProjectIds: candidates.filter((candidate) => candidate.qualifies).map((candidate) => candidate.projectId), message: "These are calculated LaunchChair validation scores, not a guarantee of market success. Preserve supplied ideas; only explore alternatives when requested." };
208
+ }
209
+ const definition = definitions.find(([short]) => name === `launchchair_${short}`);
210
+ if (!definition) return undefined;
211
+ const [, , , , method, suffix] = definition;
212
+ const projectId = await resolveProjectId(args);
213
+ let endpoint = `/api/agent/v1/projects/${encodeURIComponent(projectId)}${suffix.replace(/:(\w+)/g, (_, key) => {
214
+ if (!args[key]) throw new Error(`${key} is required.`);
215
+ return encodeURIComponent(args[key]);
216
+ })}`;
217
+ if (name === "launchchair_get_bucket_prompt") {
218
+ const query = new URLSearchParams();
219
+ if (args.variant) query.set("variant", args.variant);
220
+ if (args.campaignId) query.set("campaignId", args.campaignId);
221
+ if (query.size) endpoint += `?${query}`;
222
+ }
223
+ const body = args.payload || Object.fromEntries(Object.entries(args).filter(([key]) => !["projectId", "projectName", ...(name === "launchchair_queue_run" ? [] : ["phaseKey", "bucketKey", "cardId"])].includes(key)));
224
+ return api(endpoint, method === "GET" ? {} : { method, body });
225
+ }
226
+ return { currentToken, dispatch };
227
+ }