@launchchair/agent 0.4.12 → 0.5.0
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 +24 -9
- package/README.md +44 -14
- package/launchchair-cli.mjs +176 -17
- package/local-bridge.mjs +2 -1
- package/mcp-bridge-server.mjs +54 -52
- package/mcp-capabilities.mjs +227 -0
- package/package.json +2 -1
- package/project-workspace.mjs +4 -1
- package/server.json +4 -4
package/AGENTS.md
CHANGED
|
@@ -35,6 +35,7 @@ mkdir -p "$HOME/launchchair-agent"
|
|
|
35
35
|
curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/local-bridge.mjs" -o "$HOME/launchchair-agent/local-bridge.mjs"
|
|
36
36
|
curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/launchchair-cli.mjs" -o "$HOME/launchchair-agent/launchchair-cli.mjs"
|
|
37
37
|
curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/mcp-bridge-server.mjs" -o "$HOME/launchchair-agent/mcp-bridge-server.mjs"
|
|
38
|
+
curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/mcp-capabilities.mjs" -o "$HOME/launchchair-agent/mcp-capabilities.mjs"
|
|
38
39
|
curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/project-workspace.mjs" -o "$HOME/launchchair-agent/project-workspace.mjs"
|
|
39
40
|
curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/runner-command.mjs" -o "$HOME/launchchair-agent/runner-command.mjs"
|
|
40
41
|
curl -fsSL "https://www.launchchair.io/api/agent/v1/bridge/codex-runner.mjs" -o "$HOME/launchchair-agent/codex-runner.mjs"
|
|
@@ -75,7 +76,7 @@ LAUNCHCHAIR_BASE_URL="https://www.launchchair.io"
|
|
|
75
76
|
LAUNCHCHAIR_PROJECT_ID="optional project UUID; omit or leave blank if creating or discovering a project"
|
|
76
77
|
```
|
|
77
78
|
|
|
78
|
-
`launchchair login`
|
|
79
|
+
`launchchair login` saves the token to `~/.launchchair/config.json`, shared by the CLI and MCP. Use `LAUNCHCHAIR_AGENT_TOKEN` as a manual fallback when browser login is skipped. A fresh saved login takes precedence over stale environment credentials; logout disables authentication until login. Use a separate `LAUNCHCHAIR_CONFIG_DIR` for a separate per-client account.
|
|
79
80
|
The packaged MCP and local bridges also read the same saved CLI config as a fallback, so a desktop MCP launch should not require pasting a token into Codex or Claude config after browser login. Guided setup verifies standards-compliant MCP initialize and tool discovery before it reports success.
|
|
80
81
|
|
|
81
82
|
For Codex Desktop local runs, choose the runner globally and bind each project clone independently:
|
|
@@ -122,12 +123,12 @@ Prefer `launchchair setup codex` so the CLI writes native config and verifies MC
|
|
|
122
123
|
|
|
123
124
|
## What To Do First In A Fresh Chat
|
|
124
125
|
|
|
125
|
-
1.
|
|
126
|
-
2.
|
|
126
|
+
1. Call `launchchair_status`. If login is required, call `launchchair_login`, present the approval URL, then poll `launchchair_login_status` at its returned interval.
|
|
127
|
+
2. If paid access is required, present the returned upgrade URL and wait for the user to upgrade before resuming the blocked operation.
|
|
127
128
|
3. If `LAUNCHCHAIR_PROJECT_ID` is blank and the user named a project, call `launchchair_list_projects` and select the matching project.
|
|
128
129
|
4. If the user asks to create a new LaunchChair project, call `launchchair_create_project`.
|
|
129
|
-
5.
|
|
130
|
-
6.
|
|
130
|
+
5. To pick up queued work or advance the workflow, call `launchchair_next_step`, execute its claimed prompt in the current host agent, and submit the result with `launchchair_complete_run`.
|
|
131
|
+
6. Use `launchchair_run_once` or `executionMode=local_runner` only when the user explicitly requests a separate local runner.
|
|
131
132
|
7. If LaunchChair asks for a user-choice bucket such as wedge, pricing, or feature direction, call `launchchair_select_choice`. Do not use generic SPEC_UPDATE apply for user-choice buckets.
|
|
132
133
|
8. When the user asks for validation scores or wants to compare ideas, call `launchchair_get_scorecards`. Report validation scorecards separately from generated direction-option scores.
|
|
133
134
|
|
|
@@ -218,7 +219,7 @@ Avoid long explanations of local file searching when LaunchChair MCP tools are a
|
|
|
218
219
|
Run a full project loop:
|
|
219
220
|
|
|
220
221
|
```text
|
|
221
|
-
Call
|
|
222
|
+
Call launchchair_next_step with projectId and optional stopPhaseKey, execute the claimed prompt with the host's tools, and call launchchair_complete_run. Repeat until the requested target or a genuine checkpoint.
|
|
222
223
|
```
|
|
223
224
|
|
|
224
225
|
Create a project from a new idea:
|
|
@@ -233,18 +234,20 @@ Find a project by name:
|
|
|
233
234
|
Call launchchair_list_projects with query set to the user-provided project name.
|
|
234
235
|
```
|
|
235
236
|
|
|
236
|
-
|
|
237
|
+
Claim one queued UI job for execution by the current agent:
|
|
237
238
|
|
|
238
239
|
```text
|
|
239
|
-
Call
|
|
240
|
+
Call launchchair_next_step with projectId.
|
|
240
241
|
```
|
|
241
242
|
|
|
242
|
-
Run one queued UI job
|
|
243
|
+
Run one queued UI job through an explicitly requested local runner:
|
|
243
244
|
|
|
244
245
|
```bash
|
|
245
246
|
launchchair run-once
|
|
246
247
|
```
|
|
247
248
|
|
|
249
|
+
Every MCP tool is also accessible through `launchchair call TOOL --json '{...}'` or `--input FILE`. Use `launchchair tools --json` for schemas. Tool results are JSON, tool errors exit nonzero, and token secrets remain in private files. Prefer host execution for agents with connected GitHub/Vercel/Supabase tools.
|
|
250
|
+
|
|
248
251
|
Select a generated wedge:
|
|
249
252
|
|
|
250
253
|
```text
|
|
@@ -259,3 +262,15 @@ Call launchchair_select_choice with domain="wedge" and optionTitle or optionId.
|
|
|
259
262
|
- Do not run production SQL without explicit user authorization and a known project ref.
|
|
260
263
|
- Do not mark QA complete without matching evidence. For Feature projects, produce that evidence by running the repository-native or browser checks whenever possible.
|
|
261
264
|
- Do not search the whole filesystem for LaunchChair source when the public docs and MCP tools are available.
|
|
265
|
+
|
|
266
|
+
## MCP login and host execution
|
|
267
|
+
|
|
268
|
+
The MCP can start without a token. Call `launchchair_login`, show the returned LaunchChair approval URL, and call `launchchair_login_status` after approval. Respect its poll interval. Tokens are stored privately in the local config and never returned to the conversation. Existing tokens need a fresh owner-approved login for `tokens.manage`. Use `launchchair_create_token` to save a scoped credential file, `launchchair_list_tokens` for redacted metadata, and `launchchair_revoke_token`/`launchchair_logout` for cleanup.
|
|
269
|
+
|
|
270
|
+
Prefer `launchchair_next_step`, execute the claimed prompt in the current agent with its already-connected plugins, call `launchchair_complete_run`, and repeat. Do not spawn another model or local CLI unless explicitly requested (`executionMode=local_runner`). A waiting run is already claimed; poll it instead of executing it twice. Inspect forms/checklists with `launchchair_get_bucket`, save allowed patches with `launchchair_update_bucket`, and supply factual `executionEvidence` for setup and verification. Use `launchchair_get_starter` after validation to read the starter files and the host's GitHub/Vercel/Supabase tools to create and deploy the user's app. Bind the verified clone with `launchchair_bind_repository`. Use `stopPhaseKey=launch_sales` for the complete Product workflow.
|
|
271
|
+
|
|
272
|
+
Preserve a supplied idea and specified requirements, even with low validation scores. `ideaIntent=preserve` is the default. Report risks and continue the requested work when no score gate was requested; never pivot merely to improve a score. Choose a generated direction consistent with the supplied idea. Only use `ideaIntent=explore` for explicit discovery or pivot requests.
|
|
273
|
+
|
|
274
|
+
For “do not build until VERY high validation,” create candidates with `validationGate=very_high` (pain >=85, opportunity >=85, saturation <=30), validate through `market_validation`, and compare with `launchchair_compare_projects`. The gate is enforced on LaunchChair build/setup actions and cannot be weakened through generic mutations. Do not provision external resources until it passes. If no candidate qualifies, report the unmet thresholds honestly. Scores are research estimates, not a guarantee of market demand. Do not fabricate evidence or replace validation scores with direction-option scores.
|
|
275
|
+
|
|
276
|
+
Research sources, competitor notes, Pexels assets, logo upload, form/checklist data, prompts, build cards, run management, and markdown exports are available as MCP tools. Tool descriptions distinguish data writes from actual external execution. Existing user authorization carries forward; ask only at a genuine missing authorization or information boundary.
|
package/README.md
CHANGED
|
@@ -4,24 +4,19 @@ LaunchChair Agent packages the LaunchChair CLI, MCP bridge, local bridge, and ru
|
|
|
4
4
|
|
|
5
5
|
Use it when you want an agent to create or discover LaunchChair projects, run product phases, continue project loops, pick up `Run with agent` jobs from LaunchChair, or expose LaunchChair tools through MCP.
|
|
6
6
|
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
From this package directory:
|
|
7
|
+
## Install or update
|
|
10
8
|
|
|
11
9
|
```bash
|
|
12
|
-
npm
|
|
13
|
-
|
|
14
|
-
launchchair help
|
|
10
|
+
npm install -g @launchchair/agent@latest
|
|
11
|
+
launchchair --version
|
|
15
12
|
launchchair doctor
|
|
16
13
|
```
|
|
17
14
|
|
|
18
|
-
|
|
15
|
+
Version 0.5.0 includes the expanded MCP, browser login, private token management, host-agent execution, idea preservation, and optional validation gates. Existing installations must update the package and restart their MCP connection to load the new tools.
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
launchchair
|
|
23
|
-
launchchair doctor
|
|
24
|
-
```
|
|
17
|
+
After updating, run `launchchair setup codex --skip-login` or `launchchair setup claude --skip-login` to refresh the installed path and verify tool discovery. Then restart/reload that client. Your saved account login and project bindings remain in `~/.launchchair/config.json`. Older tokens still work within their existing scopes; run `launchchair login` again with workspace-owner approval if you need the new `tokens.manage` scope.
|
|
18
|
+
|
|
19
|
+
For local release testing, run `npm pack ./packages/launchchair-agent --pack-destination /tmp` from the repository root and install the resulting tarball into an isolated npm prefix before publishing.
|
|
25
20
|
|
|
26
21
|
## Login
|
|
27
22
|
|
|
@@ -49,7 +44,7 @@ Tokens are masked in CLI output. Do not paste real tokens into public docs, scre
|
|
|
49
44
|
|
|
50
45
|
## Guided Setup
|
|
51
46
|
|
|
52
|
-
Use `setup` as the easiest path. It starts browser login when no token is saved yet, writes the global MCP config for your agent, creates isolated per-project workspaces, and then
|
|
47
|
+
Use `setup` as the easiest path. It starts browser login when no token is saved yet, writes the global MCP config for your agent, creates isolated per-project workspaces, and then verifies MCP discovery and an authenticated status call before reporting authenticated success. Unauthenticated discovery is reported separately as login required.
|
|
53
48
|
|
|
54
49
|
Codex Desktop:
|
|
55
50
|
|
|
@@ -118,6 +113,29 @@ launchchair mcp-config claude
|
|
|
118
113
|
launchchair install-agent-md --cwd /absolute/path/to/repo
|
|
119
114
|
```
|
|
120
115
|
|
|
116
|
+
## Use any MCP tool from the terminal
|
|
117
|
+
|
|
118
|
+
`tools` discovers the same catalog as your MCP connection (47 tools when authenticated). `call` uses the bundled MCP implementation, including its login, permission checks, private credential storage, and host-execution defaults. Tool errors return a nonzero exit status and preserve details such as an upgrade URL.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
launchchair tools --json
|
|
122
|
+
launchchair call status
|
|
123
|
+
launchchair call next_step --json '{"projectId":"PROJECT_UUID"}'
|
|
124
|
+
launchchair call complete_run --input completed-run.json
|
|
125
|
+
launchchair call list_tokens
|
|
126
|
+
launchchair call create_token --json '{"name":"Build agent","scopes":["spec.read"]}'
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Tool names work with or without the `launchchair_` prefix. Use `--input -` to read JSON arguments from stdin. `next_step` returns a claimed prompt for your current agent to execute with its connected plugins; it does not start another agent. `complete_run` submits the actual result and execution evidence. Token creation returns a private credential-file path, never the secret in terminal output.
|
|
130
|
+
|
|
131
|
+
Project creation preserves a supplied idea by default, even when validation is low. Only request exploration and a score gate when that matches the user's intent:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
launchchair create --name "Groomer candidate" --one-liner "Reduce missed appointments" --idea-intent explore --validation-gate very_high --save-project
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The gate requires pain >=85, opportunity >=85, and saturation <=30 before build/setup. For custom thresholds or the full Feature input schema, use `launchchair call create_project` or `launchchair call create_feature_project` with JSON arguments.
|
|
138
|
+
|
|
121
139
|
Feature projects can be created and planned from the CLI for every authenticated LaunchChair workspace:
|
|
122
140
|
|
|
123
141
|
```bash
|
|
@@ -158,7 +176,7 @@ launchchair mcp-config claude
|
|
|
158
176
|
|
|
159
177
|
The recommended config launches the MCP server through the exact Node executable and CLI file path generated by `launchchair setup` or `launchchair mcp-config`. It also pins `LAUNCHCHAIR_CONFIG_DIR` to the exact directory used by `launchchair login`, so Codex and Claude users do not need to paste tokens into MCP config after browser login—even when the desktop app starts with a different home or shell environment. The server uses standard newline-delimited JSON-RPC over stdio, as required by MCP clients.
|
|
160
178
|
|
|
161
|
-
Use `LAUNCHCHAIR_AGENT_TOKEN`
|
|
179
|
+
Use `LAUNCHCHAIR_AGENT_TOKEN` as a manual fallback when you skip `launchchair login` or your desktop app cannot read the saved config. A saved browser login takes precedence over stale environment credentials. Logout disables both CLI and MCP authentication until another login. For a separate per-client account, use a separate `LAUNCHCHAIR_CONFIG_DIR`. If you use a desktop custom MCP form, run `launchchair mcp-config codex --ui-fields` or `launchchair mcp-config claude --ui-fields` and paste the printed command, each argument, env vars, and working directory exactly as shown.
|
|
162
180
|
|
|
163
181
|
## Local Bridge
|
|
164
182
|
|
|
@@ -207,3 +225,15 @@ This package includes `AGENTS.md`. Fresh Codex, Claude, and Hermes chats should
|
|
|
207
225
|
- MCP docs: https://www.launchchair.io/docs/mcp
|
|
208
226
|
- Agent API docs: https://www.launchchair.io/docs/agent-api
|
|
209
227
|
- Machine-readable catalog: https://www.launchchair.io/api/agent/v1/docs
|
|
228
|
+
|
|
229
|
+
## MCP login and host execution
|
|
230
|
+
|
|
231
|
+
The MCP can start without a token. Call `launchchair_login`, show the returned LaunchChair approval URL, and call `launchchair_login_status` after approval. Respect its poll interval. Tokens are stored privately in the local config and never returned to the conversation. Existing tokens need a fresh owner-approved login for `tokens.manage`. Use `launchchair_create_token` to save a scoped credential file, `launchchair_list_tokens` for redacted metadata, and `launchchair_revoke_token`/`launchchair_logout` for cleanup.
|
|
232
|
+
|
|
233
|
+
Prefer `launchchair_next_step`, execute the claimed prompt in the current agent with its already-connected plugins, call `launchchair_complete_run`, and repeat. Do not spawn another model or local CLI unless explicitly requested (`executionMode=local_runner`). A waiting run is already claimed; poll it instead of executing it twice. Inspect forms/checklists with `launchchair_get_bucket`, save allowed patches with `launchchair_update_bucket`, and supply factual `executionEvidence` for setup and verification. Use `launchchair_get_starter` after validation to read the starter files and the host's GitHub/Vercel/Supabase tools to create and deploy the user's app. Bind the verified clone with `launchchair_bind_repository`. Use `stopPhaseKey=launch_sales` for the complete Product workflow.
|
|
234
|
+
|
|
235
|
+
Preserve a supplied idea and specified requirements, even with low validation scores. `ideaIntent=preserve` is the default. Report risks and continue the requested work when no score gate was requested; never pivot merely to improve a score. Choose a generated direction consistent with the supplied idea. Only use `ideaIntent=explore` for explicit discovery or pivot requests.
|
|
236
|
+
|
|
237
|
+
For “do not build until VERY high validation,” create candidates with `validationGate=very_high` (pain >=85, opportunity >=85, saturation <=30), validate through `market_validation`, and compare with `launchchair_compare_projects`. The gate is enforced on LaunchChair build/setup actions and cannot be weakened through generic mutations. Do not provision external resources until it passes. If no candidate qualifies, report the unmet thresholds honestly. Scores are research estimates, not a guarantee of market demand. Do not fabricate evidence or replace validation scores with direction-option scores.
|
|
238
|
+
|
|
239
|
+
Research sources, competitor notes, Pexels assets, logo upload, form/checklist data, prompts, build cards, run management, and markdown exports are available as MCP tools. Tool descriptions distinguish data writes from actual external execution. Existing user authorization carries forward; ask only at a genuine missing authorization or information boundary.
|
package/launchchair-cli.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
unbindProjectWorkspace,
|
|
21
21
|
} from "./project-workspace.mjs";
|
|
22
22
|
|
|
23
|
-
const VERSION = "0.
|
|
23
|
+
const VERSION = "0.5.0";
|
|
24
24
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
25
25
|
const DEFAULT_BASE_URL = "https://www.launchchair.io";
|
|
26
26
|
const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
|
|
@@ -109,10 +109,16 @@ function configValue(key, envNames = [], fallback = "") {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
function token() {
|
|
112
|
+
const config = loadConfig();
|
|
113
|
+
if (config.mcpLoggedOut === true) return "";
|
|
114
|
+
if (config.mcpPreferSavedToken === true && config.agentToken) return config.agentToken;
|
|
112
115
|
return configValue("agentToken", ["LAUNCHCHAIR_AGENT_TOKEN", "LAUNCHCHAIR_API_KEY"]);
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
function tokenSource() {
|
|
119
|
+
const config = loadConfig();
|
|
120
|
+
if (config.mcpLoggedOut === true) return "logged out";
|
|
121
|
+
if (config.mcpPreferSavedToken === true && config.agentToken) return CONFIG_PATH;
|
|
116
122
|
if (env("LAUNCHCHAIR_AGENT_TOKEN")) return "LAUNCHCHAIR_AGENT_TOKEN";
|
|
117
123
|
if (env("LAUNCHCHAIR_API_KEY")) return "LAUNCHCHAIR_API_KEY";
|
|
118
124
|
if (loadConfig().agentToken) return CONFIG_PATH;
|
|
@@ -171,6 +177,7 @@ Use MCP-only when you want agent tools inside Codex/Claude/Hermes.
|
|
|
171
177
|
Use CLI + MCP when you also want quick terminal commands, diagnostics, and bridge control.
|
|
172
178
|
|
|
173
179
|
Commands:
|
|
180
|
+
--version Show the installed package version
|
|
174
181
|
doctor Check token, docs, API reachability, and local bridge files
|
|
175
182
|
login [--token TOKEN] Open browser login and save a LaunchChair Agent API token locally
|
|
176
183
|
logout Remove the saved local token
|
|
@@ -183,6 +190,9 @@ Commands:
|
|
|
183
190
|
run-once Claim and run one queued LaunchChair job through the local bridge
|
|
184
191
|
bridge Start the long-running local bridge for Run with Agent
|
|
185
192
|
mcp-server Run the stdio MCP server using saved CLI config
|
|
193
|
+
tools [--json] List the available MCP tools and their input schemas
|
|
194
|
+
call TOOL [--json JSON|--input FILE]
|
|
195
|
+
Call any MCP tool; JSON output, nonzero exit on tool errors
|
|
186
196
|
setup codex|claude|claude-desktop|claude-code|terminal [options]
|
|
187
197
|
Guided global CLI/MCP setup with isolated project workspaces
|
|
188
198
|
bind --project-id ID --cwd PATH Bind one LaunchChair project to a verified local Git clone
|
|
@@ -202,6 +212,8 @@ Create options:
|
|
|
202
212
|
--start-loop
|
|
203
213
|
--save-project Save the created project id as the CLI default
|
|
204
214
|
--idempotency-key KEY
|
|
215
|
+
--idea-intent preserve|explore Default: preserve the supplied idea, even with low scores
|
|
216
|
+
--validation-gate very_high Require pain >=85, opportunity >=85, saturation <=30 before build
|
|
205
217
|
|
|
206
218
|
Feature create options:
|
|
207
219
|
--project-kind feature
|
|
@@ -245,7 +257,7 @@ Login options:
|
|
|
245
257
|
--timeout SECONDS Default: 600
|
|
246
258
|
|
|
247
259
|
Env:
|
|
248
|
-
LAUNCHCHAIR_AGENT_TOKEN Optional
|
|
260
|
+
LAUNCHCHAIR_AGENT_TOKEN Optional token fallback when no saved login takes precedence
|
|
249
261
|
LAUNCHCHAIR_BASE_URL Default: https://www.launchchair.io
|
|
250
262
|
LAUNCHCHAIR_PROJECT_ID Optional default project id
|
|
251
263
|
LC_AGENT_CODEX_CWD Legacy candidate only; verified per project before use
|
|
@@ -255,6 +267,11 @@ MCP config options:
|
|
|
255
267
|
--ui-fields Print fields for Codex Desktop's custom MCP form
|
|
256
268
|
--copy-config Copy generated output to clipboard on macOS
|
|
257
269
|
|
|
270
|
+
Tool call options:
|
|
271
|
+
--json JSON Tool arguments as a JSON object; defaults to {}
|
|
272
|
+
--input FILE Read tool arguments from a JSON file; use - for stdin
|
|
273
|
+
--timeout SECONDS Request timeout. Default: 60, max: 3600
|
|
274
|
+
|
|
258
275
|
Config:
|
|
259
276
|
${CONFIG_PATH}
|
|
260
277
|
`);
|
|
@@ -308,7 +325,13 @@ async function api(pathname, options = {}) {
|
|
|
308
325
|
if (!response.ok) {
|
|
309
326
|
const retryAfter = response.headers.get("retry-after");
|
|
310
327
|
const suffix = retryAfter ? ` Retry after ${retryAfter}s.` : "";
|
|
311
|
-
|
|
328
|
+
const next = body?.upgradeUrl
|
|
329
|
+
? ` Upgrade: ${body.upgradeUrl}`
|
|
330
|
+
: response.status === 401 ? " Run launchchair login to reconnect." : "";
|
|
331
|
+
const error = new Error(`${body?.error ?? response.statusText}${suffix}${next}`);
|
|
332
|
+
error.status = response.status;
|
|
333
|
+
error.retryAfterSeconds = Number(retryAfter) || 10;
|
|
334
|
+
throw error;
|
|
312
335
|
}
|
|
313
336
|
return body;
|
|
314
337
|
}
|
|
@@ -331,7 +354,10 @@ async function publicApi(pathname, options = {}) {
|
|
|
331
354
|
if (!response.ok) {
|
|
332
355
|
const retryAfter = response.headers.get("retry-after");
|
|
333
356
|
const suffix = retryAfter ? ` Retry after ${retryAfter}s.` : "";
|
|
334
|
-
|
|
357
|
+
const error = new Error(`${body?.error ?? response.statusText}${suffix}`);
|
|
358
|
+
error.status = response.status;
|
|
359
|
+
error.retryAfterSeconds = Number(retryAfter) || 10;
|
|
360
|
+
throw error;
|
|
335
361
|
}
|
|
336
362
|
return body;
|
|
337
363
|
}
|
|
@@ -391,6 +417,8 @@ async function login(args) {
|
|
|
391
417
|
}
|
|
392
418
|
saveConfig({
|
|
393
419
|
agentToken: incomingToken,
|
|
420
|
+
mcpLoggedOut: false,
|
|
421
|
+
mcpPreferSavedToken: true,
|
|
394
422
|
baseUrl: incomingBaseUrl ?? baseUrl(),
|
|
395
423
|
projectId: incomingProjectId || defaultProjectId() || undefined,
|
|
396
424
|
});
|
|
@@ -434,7 +462,7 @@ async function login(args) {
|
|
|
434
462
|
|
|
435
463
|
const timeoutSeconds =
|
|
436
464
|
typeof args.timeout === "string" && Number.isFinite(Number(args.timeout)) ? Math.max(10, Number(args.timeout)) : Number(start.expiresIn ?? 600);
|
|
437
|
-
|
|
465
|
+
let intervalSeconds = Math.max(5, Number(start.interval ?? 5));
|
|
438
466
|
const startedAt = Date.now();
|
|
439
467
|
print("");
|
|
440
468
|
print("Waiting for approval. Leave this terminal open.");
|
|
@@ -449,10 +477,12 @@ async function login(args) {
|
|
|
449
477
|
});
|
|
450
478
|
} catch (error) {
|
|
451
479
|
const message = error instanceof Error ? error.message : String(error);
|
|
480
|
+
if (error.status === 429) { intervalSeconds = Math.max(5, error.retryAfterSeconds); continue; }
|
|
452
481
|
if (/pending/i.test(message)) continue;
|
|
453
482
|
throw error;
|
|
454
483
|
}
|
|
455
484
|
if (poll?.status === "pending") {
|
|
485
|
+
intervalSeconds = Math.max(5, Number(poll.interval) || intervalSeconds);
|
|
456
486
|
write(paint(".", "dim"));
|
|
457
487
|
continue;
|
|
458
488
|
}
|
|
@@ -460,6 +490,8 @@ async function login(args) {
|
|
|
460
490
|
print("");
|
|
461
491
|
saveConfig({
|
|
462
492
|
agentToken: poll.token,
|
|
493
|
+
mcpLoggedOut: false,
|
|
494
|
+
mcpPreferSavedToken: true,
|
|
463
495
|
baseUrl: baseUrl(),
|
|
464
496
|
projectId: incomingProjectId || defaultProjectId() || undefined,
|
|
465
497
|
});
|
|
@@ -478,11 +510,12 @@ async function login(args) {
|
|
|
478
510
|
}
|
|
479
511
|
|
|
480
512
|
function logout() {
|
|
481
|
-
deleteConfigKeys(["agentToken"]);
|
|
513
|
+
deleteConfigKeys(["agentToken", "pendingMcpLogin"]);
|
|
514
|
+
saveConfig({ mcpLoggedOut: true });
|
|
482
515
|
title("CLI logout");
|
|
483
516
|
ok(`Removed saved token from ${CONFIG_PATH}`);
|
|
484
517
|
if (env("LAUNCHCHAIR_AGENT_TOKEN") || env("LAUNCHCHAIR_API_KEY")) {
|
|
485
|
-
warn("An environment token is still set
|
|
518
|
+
warn("An environment token is still set; it remains ignored until you log in again.");
|
|
486
519
|
}
|
|
487
520
|
}
|
|
488
521
|
|
|
@@ -606,6 +639,14 @@ async function createProject(args) {
|
|
|
606
639
|
throw new Error("--project-kind must be product or feature.");
|
|
607
640
|
}
|
|
608
641
|
const featureProject = projectKind === "feature";
|
|
642
|
+
const ideaIntent = args["idea-intent"] ?? "preserve";
|
|
643
|
+
if (!["preserve", "explore"].includes(ideaIntent)) {
|
|
644
|
+
throw new Error("--idea-intent must be preserve or explore.");
|
|
645
|
+
}
|
|
646
|
+
const validationGate = args["validation-gate"];
|
|
647
|
+
if (validationGate !== undefined && validationGate !== "very_high") {
|
|
648
|
+
throw new Error("--validation-gate must be very_high. For custom thresholds, use launchchair call create_project --input FILE.");
|
|
649
|
+
}
|
|
609
650
|
const featureDescription = String(args["feature-description"] ?? args["one-liner"] ?? "").trim();
|
|
610
651
|
const featureProblem = String(args.problem ?? "").trim();
|
|
611
652
|
if (featureProject && (!featureDescription || !featureProblem)) {
|
|
@@ -664,7 +705,7 @@ async function createProject(args) {
|
|
|
664
705
|
const body = await api("/api/agent/v1/projects", {
|
|
665
706
|
method: "POST",
|
|
666
707
|
headers,
|
|
667
|
-
body: JSON.stringify(requestBody),
|
|
708
|
+
body: JSON.stringify({ ...requestBody, ideaIntent, validationGate }),
|
|
668
709
|
});
|
|
669
710
|
const projectId = body?.project?.id ?? body?.projectId ?? "";
|
|
670
711
|
title("Project created");
|
|
@@ -876,6 +917,100 @@ function spawnMcpServer(extraArgs = []) {
|
|
|
876
917
|
});
|
|
877
918
|
}
|
|
878
919
|
|
|
920
|
+
// Use the bundled MCP so terminal calls share its login, scope checks, and host workflow.
|
|
921
|
+
async function requestMcp(method, params, timeoutMs = 60_000) {
|
|
922
|
+
const child = spawn(process.execPath, [path.join(SCRIPT_DIR, "mcp-bridge-server.mjs")], {
|
|
923
|
+
env: buildBridgeEnv(),
|
|
924
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
925
|
+
});
|
|
926
|
+
let buffer = "";
|
|
927
|
+
let serial = 0;
|
|
928
|
+
const pending = new Map();
|
|
929
|
+
const failPending = (error) => {
|
|
930
|
+
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(error); }
|
|
931
|
+
pending.clear();
|
|
932
|
+
};
|
|
933
|
+
child.on("error", failPending);
|
|
934
|
+
child.on("exit", (code) => failPending(new Error(`MCP server exited before replying (code ${code ?? "unknown"}).`)));
|
|
935
|
+
child.stdin.on("error", failPending);
|
|
936
|
+
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
|
|
937
|
+
child.stdout.setEncoding("utf8");
|
|
938
|
+
child.stdout.on("data", (chunk) => {
|
|
939
|
+
buffer += chunk;
|
|
940
|
+
let newline;
|
|
941
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
942
|
+
const line = buffer.slice(0, newline).trim();
|
|
943
|
+
buffer = buffer.slice(newline + 1);
|
|
944
|
+
if (!line) continue;
|
|
945
|
+
let message;
|
|
946
|
+
try { message = JSON.parse(line); }
|
|
947
|
+
catch { failPending(new Error("MCP server returned invalid JSON.")); return; }
|
|
948
|
+
const entry = pending.get(message.id);
|
|
949
|
+
if (!entry) continue;
|
|
950
|
+
pending.delete(message.id);
|
|
951
|
+
clearTimeout(entry.timer);
|
|
952
|
+
if (message.error) entry.reject(new Error(message.error.message || "MCP request failed."));
|
|
953
|
+
else entry.resolve(message.result);
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
const rpc = (rpcMethod, rpcParams) => new Promise((resolve, reject) => {
|
|
957
|
+
const id = ++serial;
|
|
958
|
+
const timer = setTimeout(() => {
|
|
959
|
+
pending.delete(id);
|
|
960
|
+
reject(new Error("MCP request timed out. Check the project's run status before retrying a write."));
|
|
961
|
+
}, timeoutMs);
|
|
962
|
+
pending.set(id, { resolve, reject, timer });
|
|
963
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method: rpcMethod, params: rpcParams })}\n`);
|
|
964
|
+
});
|
|
965
|
+
try {
|
|
966
|
+
await rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "launchchair-cli", version: VERSION } });
|
|
967
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`);
|
|
968
|
+
return await rpc(method, params);
|
|
969
|
+
} finally {
|
|
970
|
+
child.stdin.end();
|
|
971
|
+
child.kill();
|
|
972
|
+
failPending(new Error("MCP client closed."));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function listTools(args) {
|
|
977
|
+
const result = await requestMcp("tools/list", {});
|
|
978
|
+
if (args.json === true) return print(JSON.stringify(result, null, 2));
|
|
979
|
+
title("MCP tools");
|
|
980
|
+
for (const tool of result.tools ?? []) print(`${tool.name}\n ${tool.description}`);
|
|
981
|
+
print("\nUse launchchair tools --json for input schemas, then launchchair call TOOL --json '{...}'.");
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function callTool(args) {
|
|
985
|
+
const requestedName = args._[0];
|
|
986
|
+
if (!requestedName) throw new Error("call requires a tool name. Run launchchair tools to list them.");
|
|
987
|
+
if (args.json !== undefined && args.input !== undefined) throw new Error("Use either --json or --input, not both.");
|
|
988
|
+
let input = "{}";
|
|
989
|
+
if (args.json !== undefined) {
|
|
990
|
+
if (typeof args.json !== "string") throw new Error("--json requires a JSON object.");
|
|
991
|
+
input = args.json;
|
|
992
|
+
} else if (args.input !== undefined) {
|
|
993
|
+
if (typeof args.input !== "string") throw new Error("--input requires a file path or - for stdin.");
|
|
994
|
+
input = readFileSync(args.input === "-" ? 0 : path.resolve(args.input), "utf8");
|
|
995
|
+
}
|
|
996
|
+
let toolArgs;
|
|
997
|
+
try { toolArgs = JSON.parse(input); }
|
|
998
|
+
catch { throw new Error("Tool arguments must be valid JSON."); }
|
|
999
|
+
if (!toolArgs || typeof toolArgs !== "object" || Array.isArray(toolArgs)) throw new Error("Tool arguments must be a JSON object.");
|
|
1000
|
+
const timeoutSeconds = args.timeout === undefined ? 60 : Number(args.timeout);
|
|
1001
|
+
if (typeof args.timeout === "boolean" || !Number.isFinite(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 3600) {
|
|
1002
|
+
throw new Error("--timeout must be between 1 and 3600 seconds.");
|
|
1003
|
+
}
|
|
1004
|
+
const result = await requestMcp("tools/call", {
|
|
1005
|
+
name: requestedName.startsWith("launchchair_") ? requestedName : `launchchair_${requestedName}`,
|
|
1006
|
+
arguments: toolArgs,
|
|
1007
|
+
}, timeoutSeconds * 1000);
|
|
1008
|
+
// LaunchChair tools return one JSON text block. Keep other MCP content intact.
|
|
1009
|
+
print(result.content?.length === 1 && result.content[0].type === "text"
|
|
1010
|
+
? result.content[0].text : JSON.stringify(result, null, 2));
|
|
1011
|
+
if (result.isError) process.exitCode = 1;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
879
1014
|
function normalizedMcpTarget(target) {
|
|
880
1015
|
return targetDetails(target || "codex").mcpTarget || "codex";
|
|
881
1016
|
}
|
|
@@ -885,13 +1020,11 @@ function tomlString(value) {
|
|
|
885
1020
|
}
|
|
886
1021
|
|
|
887
1022
|
function mcpEnvForTarget(target, options = {}) {
|
|
888
|
-
const includeTokenPlaceholder = options.includeTokenPlaceholder !== false;
|
|
889
1023
|
const details = targetDetails(target || "codex");
|
|
890
1024
|
const projectId = defaultProjectId();
|
|
891
1025
|
const envBlock = {
|
|
892
1026
|
LAUNCHCHAIR_BASE_URL: baseUrl(),
|
|
893
1027
|
LAUNCHCHAIR_CONFIG_DIR: CONFIG_DIR,
|
|
894
|
-
...(token() || !includeTokenPlaceholder ? {} : { LAUNCHCHAIR_AGENT_TOKEN: "<optional full lc_at token when launchchair login was not used>" }),
|
|
895
1028
|
...(projectId ? { LAUNCHCHAIR_PROJECT_ID: projectId } : {}),
|
|
896
1029
|
LC_AGENT_ROUTER_GPT_TARGET: details.routerTarget,
|
|
897
1030
|
LC_AGENT_ROUTER_CODEX_TARGET: details.routerTarget,
|
|
@@ -916,6 +1049,7 @@ function verifyMcpServer(target, timeoutMs = 20_000) {
|
|
|
916
1049
|
stdio: ["pipe", "pipe", "pipe"],
|
|
917
1050
|
});
|
|
918
1051
|
let settled = false;
|
|
1052
|
+
let discoveredTools = [];
|
|
919
1053
|
let stdout = "";
|
|
920
1054
|
let stderr = "";
|
|
921
1055
|
|
|
@@ -966,13 +1100,29 @@ function verifyMcpServer(target, timeoutMs = 20_000) {
|
|
|
966
1100
|
const toolNames = Array.isArray(response?.result?.tools)
|
|
967
1101
|
? response.result.tools.map((tool) => String(tool?.name || "")).filter(Boolean)
|
|
968
1102
|
: [];
|
|
969
|
-
const requiredTools = [
|
|
1103
|
+
const requiredTools = [
|
|
1104
|
+
"launchchair_status", "launchchair_create_project", "launchchair_list_projects",
|
|
1105
|
+
"launchchair_login", "launchchair_login_status", "launchchair_create_token",
|
|
1106
|
+
"launchchair_next_step", "launchchair_complete_run", "launchchair_get_validation_gate",
|
|
1107
|
+
"launchchair_update_bucket", "launchchair_get_starter", "launchchair_export_mvp_spec",
|
|
1108
|
+
];
|
|
970
1109
|
const missingTools = requiredTools.filter((name) => !toolNames.includes(name));
|
|
971
1110
|
if (missingTools.length) {
|
|
972
1111
|
finish(new Error(`MCP verification did not discover required tools: ${missingTools.join(", ")}`));
|
|
973
1112
|
return;
|
|
974
1113
|
}
|
|
975
|
-
|
|
1114
|
+
discoveredTools = toolNames;
|
|
1115
|
+
if (!token()) { finish(null, toolNames); continue; }
|
|
1116
|
+
send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "launchchair_status", arguments: {} } });
|
|
1117
|
+
}
|
|
1118
|
+
if (response?.id === 3) {
|
|
1119
|
+
let status = {};
|
|
1120
|
+
try { status = JSON.parse(response.result?.content?.[0]?.text || "{}"); } catch {}
|
|
1121
|
+
if (response.error || response.result?.isError || status.authenticated !== true) {
|
|
1122
|
+
finish(new Error(`MCP authentication verification failed: ${status.error || response.error?.message || "not authenticated"}`));
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
finish(null, discoveredTools);
|
|
976
1126
|
}
|
|
977
1127
|
}
|
|
978
1128
|
});
|
|
@@ -1286,7 +1436,10 @@ function installLaunchChairSkill(args) {
|
|
|
1286
1436
|
"# LaunchChair workflow",
|
|
1287
1437
|
"",
|
|
1288
1438
|
"Use the installed LaunchChair MCP tools before web search or local repo discovery for LaunchChair project work.",
|
|
1289
|
-
"Call launchchair_status if capability context is unclear.",
|
|
1439
|
+
"Call launchchair_status if capability context is unclear. If login is required, use launchchair_login and launchchair_login_status; never print token secrets.",
|
|
1440
|
+
"Prefer launchchair_next_step and execute its claimed prompt in this host agent using existing plugins, then complete_run. Use local runners only when explicitly requested.",
|
|
1441
|
+
"Preserve supplied ideas and requirements even when validation is low. ideaIntent=preserve is the default; exploration and pivots require an explicit user request.",
|
|
1442
|
+
"Only impose a validation gate when requested. VERY high means validationGate=very_high (pain>=85, opportunity>=85, saturation<=30). Never fabricate evidence or weaken the gate.",
|
|
1290
1443
|
"For a new product, use launchchair_create_project.",
|
|
1291
1444
|
"For a feature in an existing product, use launchchair_create_feature_project. Never substitute a Product project if that tool is unavailable; report the setup or authentication issue instead.",
|
|
1292
1445
|
"Use planToBuildBoard=true for planning through the Feature build board.",
|
|
@@ -1326,6 +1479,8 @@ async function setup(args) {
|
|
|
1326
1479
|
}
|
|
1327
1480
|
saveConfig({
|
|
1328
1481
|
agentToken: incomingToken,
|
|
1482
|
+
mcpLoggedOut: false,
|
|
1483
|
+
mcpPreferSavedToken: true,
|
|
1329
1484
|
baseUrl: incomingBaseUrl ?? baseUrl(),
|
|
1330
1485
|
projectId: incomingProjectId || defaultProjectId() || undefined,
|
|
1331
1486
|
});
|
|
@@ -1412,11 +1567,11 @@ async function setup(args) {
|
|
|
1412
1567
|
}
|
|
1413
1568
|
}
|
|
1414
1569
|
|
|
1415
|
-
if (!details.terminalOnly &&
|
|
1570
|
+
if (!details.terminalOnly && args["skip-mcp-check"] !== true) {
|
|
1416
1571
|
const toolNames = await verifyMcpServer(details.mcpTarget);
|
|
1417
1572
|
print("");
|
|
1418
|
-
title("MCP verification passed");
|
|
1419
|
-
ok(`
|
|
1573
|
+
title(token() ? "MCP verification passed" : "MCP discovery verified — login required");
|
|
1574
|
+
ok(`Discovered ${toolNames.length} LaunchChair tools.${token() ? " Authenticated status verified." : " Call launchchair_login in your agent."}`);
|
|
1420
1575
|
} else if (!details.terminalOnly && !token()) {
|
|
1421
1576
|
warn("MCP verification was skipped because no LaunchChair token is configured.");
|
|
1422
1577
|
} else if (!details.terminalOnly && args["skip-mcp-check"] === true) {
|
|
@@ -1455,7 +1610,8 @@ async function setup(args) {
|
|
|
1455
1610
|
print(`2. Restart or reload ${details.label} so the MCP server is exposed to fresh chats.`);
|
|
1456
1611
|
}
|
|
1457
1612
|
print("Use the generated command and argument fields exactly as printed; setup resolves the local Node and CLI paths for this machine.");
|
|
1458
|
-
print("
|
|
1613
|
+
print("Your agent can use launchchair_next_step and launchchair_complete_run with its connected plugins.");
|
|
1614
|
+
print("For unattended local-runner jobs queued from LaunchChair's Run with agent button, optionally run:");
|
|
1459
1615
|
print(` ${paint("launchchair bridge", "cyan")}`);
|
|
1460
1616
|
print("Start a fresh agent chat. The agent should discover the LaunchChair MCP tools automatically.");
|
|
1461
1617
|
}
|
|
@@ -1479,6 +1635,7 @@ async function main() {
|
|
|
1479
1635
|
const [command = "help", ...rest] = process.argv.slice(2);
|
|
1480
1636
|
const args = parseArgs(rest);
|
|
1481
1637
|
if (command === "help" || command === "--help" || command === "-h") return help();
|
|
1638
|
+
if (command === "--version" || command === "-v" || command === "version") return print(VERSION);
|
|
1482
1639
|
if (command === "doctor") return doctor();
|
|
1483
1640
|
if (command === "login") return login(args);
|
|
1484
1641
|
if (command === "logout") return logout();
|
|
@@ -1490,6 +1647,8 @@ async function main() {
|
|
|
1490
1647
|
if (command === "run-once") return spawnBridge(["--once", "--verbose"]);
|
|
1491
1648
|
if (command === "bridge") return spawnBridge(["--verbose"]);
|
|
1492
1649
|
if (command === "mcp-server") return spawnMcpServer();
|
|
1650
|
+
if (command === "tools") return listTools(args);
|
|
1651
|
+
if (command === "call") return callTool(args);
|
|
1493
1652
|
if (command === "setup") return setup(args);
|
|
1494
1653
|
if (command === "bind") return bindProject(args);
|
|
1495
1654
|
if (command === "unbind") return unbindProject(args);
|
package/local-bridge.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
import { resolveProjectWorkspace } from "./project-workspace.mjs";
|
|
19
19
|
import { buildRunnerCommand, parseRunnerCommand } from "./runner-command.mjs";
|
|
20
20
|
|
|
21
|
-
const VERSION = "0.
|
|
21
|
+
const VERSION = "0.5.0";
|
|
22
22
|
const CLIENT_TYPES = ["codex", "chatgpt", "claude", "claude_code", "other"];
|
|
23
23
|
const AGENT_SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
24
24
|
const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
|
|
@@ -402,6 +402,7 @@ async function executeRunner(run) {
|
|
|
402
402
|
stdio: ["pipe", "pipe", "pipe"],
|
|
403
403
|
env: {
|
|
404
404
|
...process.env,
|
|
405
|
+
...(resolved.workspace.source === "isolated" && run?.runnerWorkspace?.requiresRepository !== true ? { LC_AGENT_CODEX_SKIP_GIT_CHECK: "1" } : {}),
|
|
405
406
|
LC_AGENT_RUN_ID: String(run.id ?? ""),
|
|
406
407
|
LC_AGENT_PROJECT_ID: String(run.projectId ?? ""),
|
|
407
408
|
LC_AGENT_PHASE_KEY: String(run.phaseKey ?? ""),
|
package/mcp-bridge-server.mjs
CHANGED
|
@@ -19,8 +19,9 @@ 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";
|
|
22
23
|
|
|
23
|
-
const VERSION = "0.
|
|
24
|
+
const VERSION = "0.5.0";
|
|
24
25
|
const SERVER_NAME = "launchchair-agent-mcp";
|
|
25
26
|
const AGENT_SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
26
27
|
const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
|
|
@@ -108,7 +109,7 @@ function builtInRunnerCommand(target) {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
const config = {
|
|
111
|
-
baseUrl: env("LAUNCHCHAIR_BASE_URL", savedConfigValue("baseUrl", "
|
|
112
|
+
baseUrl: env("LAUNCHCHAIR_BASE_URL", savedConfigValue("baseUrl", "https://www.launchchair.io")).replace(/\/+$/, ""),
|
|
112
113
|
token: env("LAUNCHCHAIR_AGENT_TOKEN", env("LAUNCHCHAIR_API_KEY", savedConfigValue("agentToken"))),
|
|
113
114
|
projectId: env("LAUNCHCHAIR_PROJECT_ID", savedConfigValue("projectId")) || null,
|
|
114
115
|
clientName: env("LC_AGENT_CLIENT_NAME", "MCP Bridge"),
|
|
@@ -135,70 +136,52 @@ const config = {
|
|
|
135
136
|
},
|
|
136
137
|
};
|
|
137
138
|
|
|
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
|
-
}
|
|
139
|
+
let statusPromise = null;
|
|
140
|
+
let initialized = false;
|
|
141
|
+
const capabilities = createMcpCapabilities({ config, configPath: CONFIG_PATH, api, resolveProjectId, onAuthChange: () => { statusPromise = null; if (initialized) writeMessage({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }); } });
|
|
162
142
|
|
|
163
143
|
async function api(path, { method = "GET", body } = {}) {
|
|
144
|
+
const token = capabilities.currentToken();
|
|
145
|
+
if (!token) {
|
|
146
|
+
const error = new Error("Login required. Call launchchair_login and complete browser approval.");
|
|
147
|
+
error.status = 401;
|
|
148
|
+
error.payload = { code: "login_required", nextTool: "launchchair_login" };
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
164
151
|
const response = await fetch(`${config.baseUrl}${path}`, {
|
|
165
152
|
method,
|
|
166
153
|
headers: {
|
|
167
|
-
Authorization: `Bearer ${
|
|
154
|
+
Authorization: `Bearer ${token}`,
|
|
168
155
|
"Content-Type": "application/json",
|
|
169
156
|
},
|
|
170
157
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
158
|
+
signal: AbortSignal.timeout(30_000),
|
|
171
159
|
});
|
|
172
160
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
} catch (_error) {
|
|
177
|
-
payload = null;
|
|
178
|
-
}
|
|
161
|
+
const raw = await response.text();
|
|
162
|
+
let payload;
|
|
163
|
+
try { payload = raw ? JSON.parse(raw) : null; } catch { payload = { content: raw, contentType: response.headers.get("content-type") }; }
|
|
179
164
|
|
|
180
165
|
if (!response.ok) {
|
|
181
166
|
const err = new Error(`HTTP ${response.status}: ${String(payload?.error ?? response.statusText ?? "Request failed")}`);
|
|
182
167
|
err.status = response.status;
|
|
183
|
-
err.payload = payload;
|
|
168
|
+
err.payload = response.status === 401 ? { ...payload, code: "login_required", nextTool: "launchchair_login" } : payload;
|
|
184
169
|
err.retryAfterSeconds = Number.parseInt(response.headers.get("retry-after") ?? "", 10) || null;
|
|
185
170
|
throw err;
|
|
186
171
|
}
|
|
187
172
|
return payload;
|
|
188
173
|
}
|
|
189
174
|
|
|
190
|
-
let statusPromise = null;
|
|
191
|
-
|
|
192
175
|
function loadStatus() {
|
|
193
176
|
if (!statusPromise) {
|
|
194
|
-
statusPromise = api("/api/agent/v1").catch(() => null);
|
|
177
|
+
statusPromise = api("/api/agent/v1").catch(() => { statusPromise = null; return null; });
|
|
195
178
|
}
|
|
196
179
|
return statusPromise;
|
|
197
180
|
}
|
|
198
181
|
|
|
199
182
|
function serverInstructions(status) {
|
|
200
183
|
const featureInstructions = String(status?.capabilities?.mcpServerInstructions ?? "").trim();
|
|
201
|
-
return
|
|
184
|
+
return `${BASE_SERVER_INSTRUCTIONS} ${featureInstructions} ${MCP_WORKFLOW_INSTRUCTIONS}`;
|
|
202
185
|
}
|
|
203
186
|
|
|
204
187
|
function targetForRun(run) {
|
|
@@ -291,6 +274,7 @@ async function executeRunner(run) {
|
|
|
291
274
|
LC_AGENT_PROMPT_MODE: String(run.promptMode ?? ""),
|
|
292
275
|
LC_AGENT_RUN_TYPE: String(run.runType ?? ""),
|
|
293
276
|
[workspaceEnvName]: workspace.path,
|
|
277
|
+
...(workspace.source === "isolated" && run?.runnerWorkspace?.requiresRepository !== true ? { LC_AGENT_CODEX_SKIP_GIT_CHECK: "1" } : {}),
|
|
294
278
|
},
|
|
295
279
|
});
|
|
296
280
|
let stdout = "";
|
|
@@ -347,6 +331,7 @@ async function claimNextRun(args = {}) {
|
|
|
347
331
|
method: "POST",
|
|
348
332
|
body: {
|
|
349
333
|
projectId,
|
|
334
|
+
...(args.runId ? { runId: String(args.runId) } : {}),
|
|
350
335
|
clientName: String(args.clientName ?? "").trim() || config.clientName,
|
|
351
336
|
clientType: normalizeTarget(args.clientType ?? config.clientType, "other"),
|
|
352
337
|
clientVersion: String(args.clientVersion ?? "").trim() || config.clientVersion,
|
|
@@ -600,15 +585,19 @@ async function continueProjectLoop(args = {}) {
|
|
|
600
585
|
break;
|
|
601
586
|
}
|
|
602
587
|
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
: null;
|
|
606
|
-
|
|
607
|
-
|
|
588
|
+
const claimed = loop.run?.id ? await claimNextRun({ ...args, projectId, runId: loop.run.id }) : null;
|
|
589
|
+
if (!claimed) {
|
|
590
|
+
final = { ...loop, run: loop.run?.id ? { id: loop.run.id } : null, message: "Run already claimed; do not execute it again." };
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
const run = { ...claimed, runnerWorkspace: claimed.runnerWorkspace ?? loop.runnerWorkspace ?? runnerWorkspaceContext };
|
|
594
|
+
let execution;
|
|
595
|
+
try { execution = await executeRunner(run); }
|
|
596
|
+
catch (error) {
|
|
597
|
+
await completeRun({ runId: run.id, status: "failed", error: error.message, resultRaw: error.stdout || error.stderr || "" });
|
|
598
|
+
final = { status: "stopped_after_failed_step", projectId, runId: run.id, message: error.message };
|
|
608
599
|
break;
|
|
609
600
|
}
|
|
610
|
-
|
|
611
|
-
const execution = await executeRunner(run);
|
|
612
601
|
const stdout = String(execution.stdout ?? "").trim();
|
|
613
602
|
const stderr = String(execution.stderr ?? "").trim();
|
|
614
603
|
const output = stdout || stderr;
|
|
@@ -695,6 +684,7 @@ async function createProject(args = {}) {
|
|
|
695
684
|
delete createArgs.maxSteps;
|
|
696
685
|
delete createArgs.fullLoop;
|
|
697
686
|
delete createArgs.buildMaxSteps;
|
|
687
|
+
delete createArgs.executionMode;
|
|
698
688
|
const body = {
|
|
699
689
|
...createArgs,
|
|
700
690
|
name: String(args.name ?? args.projectName ?? "").trim(),
|
|
@@ -721,6 +711,8 @@ async function createProject(args = {}) {
|
|
|
721
711
|
return payload;
|
|
722
712
|
}
|
|
723
713
|
|
|
714
|
+
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 } };
|
|
715
|
+
|
|
724
716
|
const planning = await continueProjectLoop({
|
|
725
717
|
projectId,
|
|
726
718
|
stopPhaseKey: "build",
|
|
@@ -999,8 +991,8 @@ function annotateTool(tool) {
|
|
|
999
991
|
...tool,
|
|
1000
992
|
annotations: {
|
|
1001
993
|
...(tool.annotations ?? {}),
|
|
1002
|
-
readOnlyHint: READ_ONLY_TOOLS.has(tool.name),
|
|
1003
|
-
destructiveHint: false,
|
|
994
|
+
readOnlyHint: tool.annotations?.readOnlyHint ?? READ_ONLY_TOOLS.has(tool.name),
|
|
995
|
+
destructiveHint: tool.annotations?.destructiveHint ?? false,
|
|
1004
996
|
openWorldHint: true,
|
|
1005
997
|
},
|
|
1006
998
|
};
|
|
@@ -1016,7 +1008,12 @@ function mergeToolOverrides(baseTools, overrides) {
|
|
|
1016
1008
|
merged.push(override);
|
|
1017
1009
|
}
|
|
1018
1010
|
}
|
|
1019
|
-
return merged.map(
|
|
1011
|
+
return merged.map((tool) => {
|
|
1012
|
+
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" } } } };
|
|
1013
|
+
if (tool.name === "launchchair_claim_next_run") tool = { ...tool, inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties, runId: { type: "string" } } } };
|
|
1014
|
+
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" } } } };
|
|
1015
|
+
return annotateTool(tool);
|
|
1016
|
+
});
|
|
1020
1017
|
}
|
|
1021
1018
|
|
|
1022
1019
|
function writeMessage(obj) {
|
|
@@ -1059,7 +1056,7 @@ async function handleRpc(request) {
|
|
|
1059
1056
|
writeResult(id, {
|
|
1060
1057
|
protocolVersion: "2024-11-05",
|
|
1061
1058
|
capabilities: {
|
|
1062
|
-
tools: {},
|
|
1059
|
+
tools: { listChanged: true },
|
|
1063
1060
|
},
|
|
1064
1061
|
serverInfo: {
|
|
1065
1062
|
name: SERVER_NAME,
|
|
@@ -1071,6 +1068,7 @@ async function handleRpc(request) {
|
|
|
1071
1068
|
}
|
|
1072
1069
|
|
|
1073
1070
|
if (method === "notifications/initialized") {
|
|
1071
|
+
initialized = true;
|
|
1074
1072
|
return;
|
|
1075
1073
|
}
|
|
1076
1074
|
|
|
@@ -1079,7 +1077,7 @@ async function handleRpc(request) {
|
|
|
1079
1077
|
const overrides = Array.isArray(status?.capabilities?.mcpToolOverrides)
|
|
1080
1078
|
? status.capabilities.mcpToolOverrides
|
|
1081
1079
|
: [];
|
|
1082
|
-
const tools = mergeToolOverrides(MCP_TOOLS, overrides);
|
|
1080
|
+
const tools = mergeToolOverrides([...MCP_TOOLS, ...EXTRA_MCP_TOOLS], overrides);
|
|
1083
1081
|
writeResult(id, { tools });
|
|
1084
1082
|
return;
|
|
1085
1083
|
}
|
|
@@ -1089,9 +1087,13 @@ async function handleRpc(request) {
|
|
|
1089
1087
|
const toolName = String(params.name ?? "");
|
|
1090
1088
|
const args = typeof params.arguments === "object" && params.arguments ? params.arguments : {};
|
|
1091
1089
|
try {
|
|
1090
|
+
if (EXTRA_MCP_TOOLS.some((tool) => tool.name === toolName)) {
|
|
1091
|
+
writeResult(id, textResult(await capabilities.dispatch(toolName, args)));
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1092
1094
|
if (toolName === "launchchair_status") {
|
|
1093
1095
|
const status = await api("/api/agent/v1");
|
|
1094
|
-
writeResult(id, textResult(status));
|
|
1096
|
+
writeResult(id, textResult({ ...status, authenticated: true }));
|
|
1095
1097
|
return;
|
|
1096
1098
|
}
|
|
1097
1099
|
if (toolName === "launchchair_create_project") {
|
|
@@ -1140,7 +1142,7 @@ async function handleRpc(request) {
|
|
|
1140
1142
|
return;
|
|
1141
1143
|
}
|
|
1142
1144
|
if (toolName === "launchchair_continue_project") {
|
|
1143
|
-
const payload = await continueProjectLoop(args);
|
|
1145
|
+
const payload = args.executionMode === "local_runner" ? await continueProjectLoop(args) : await capabilities.dispatch("launchchair_next_step", { ...args, executionMode: undefined, maxSteps: undefined });
|
|
1144
1146
|
writeResult(id, textResult(payload));
|
|
1145
1147
|
return;
|
|
1146
1148
|
}
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@launchchair/agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "io.launchchair/agent",
|
|
5
5
|
"description": "LaunchChair Agent CLI, MCP bridge, local bridge, and runners for Codex, Claude Code, ChatGPT, Claude, and Hermes workflows.",
|
|
6
6
|
"type": "module",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"launchchair-cli.mjs",
|
|
16
16
|
"local-bridge.mjs",
|
|
17
17
|
"mcp-bridge-server.mjs",
|
|
18
|
+
"mcp-capabilities.mjs",
|
|
18
19
|
"project-workspace.mjs",
|
|
19
20
|
"runner-command.mjs",
|
|
20
21
|
"codex-runner.mjs",
|
package/project-workspace.mjs
CHANGED
|
@@ -91,7 +91,10 @@ export function readGitOrigin(repoPath) {
|
|
|
91
91
|
const gitDir = gitDirectory(repoPath);
|
|
92
92
|
if (!gitDir) return "";
|
|
93
93
|
try {
|
|
94
|
-
|
|
94
|
+
// Linked worktrees keep remote configuration in their common Git directory.
|
|
95
|
+
const commonPointer = path.join(gitDir, "commondir");
|
|
96
|
+
const commonDir = existsSync(commonPointer) ? path.resolve(gitDir, readFileSync(commonPointer, "utf8").trim()) : gitDir;
|
|
97
|
+
const raw = readFileSync(path.join(commonDir, "config"), "utf8");
|
|
95
98
|
const remoteBlock = raw.match(/\[remote\s+"origin"\]([\s\S]*?)(?=\n\[|$)/i)?.[1] ?? "";
|
|
96
99
|
return remoteBlock.match(/^\s*url\s*=\s*(.+)$/im)?.[1]?.trim() ?? "";
|
|
97
100
|
} catch {
|
package/server.json
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"name": "io.launchchair/agent",
|
|
4
4
|
"title": "LaunchChair",
|
|
5
5
|
"description": "Plan, validate, and build products and features with LaunchChair.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.5.0",
|
|
7
7
|
"websiteUrl": "https://www.launchchair.io/docs/mcp",
|
|
8
8
|
"packages": [
|
|
9
9
|
{
|
|
10
10
|
"registryType": "npm",
|
|
11
11
|
"identifier": "@launchchair/agent",
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.5.0",
|
|
13
13
|
"runtimeHint": "npx",
|
|
14
14
|
"runtimeArguments": [
|
|
15
15
|
{
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"environmentVariables": [
|
|
30
30
|
{
|
|
31
31
|
"name": "LAUNCHCHAIR_AGENT_TOKEN",
|
|
32
|
-
"description": "
|
|
33
|
-
"isRequired":
|
|
32
|
+
"description": "Optional pre-existing token. Without it, call launchchair_login for browser approval and private token storage.",
|
|
33
|
+
"isRequired": false,
|
|
34
34
|
"isSecret": true,
|
|
35
35
|
"format": "string"
|
|
36
36
|
}
|