@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.
- package/AGENTS.md +27 -9
- package/README.md +62 -14
- package/launchchair-cli.mjs +176 -17
- package/local-bridge.mjs +2 -1
- package/mcp-bridge-server.mjs +80 -53
- package/mcp-capabilities.mjs +227 -0
- package/mcp-widget.html +1203 -0
- package/mcp-widgets.mjs +74 -0
- package/package.json +4 -1
- package/project-workspace.mjs +4 -1
- package/server.json +4 -4
package/mcp-bridge-server.mjs
CHANGED
|
@@ -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.
|
|
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", "
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 ${
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
|
604
|
-
|
|
605
|
-
: null;
|
|
606
|
-
|
|
607
|
-
|
|
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(
|
|
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: "
|
|
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
|
+
}
|