@bridge_gpt/mcp-server 0.2.28 → 0.2.29

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.
@@ -57,10 +57,23 @@
57
57
  * repository prompt asks for a new project name rather than an existing
58
58
  * registration (see `RepoNamePromptMode`).
59
59
  *
60
- * BOOTSTRAP-INVITE MODE (BAPI-606) — one of the two exceptions to "this command
61
- * consumes a key, it does not create one". With `--invite` (or `BAPI_INVITE`) there is no
62
- * API key yet, so the pre-flight ping of Step 2 CANNOT be made: the exchange is
63
- * what mints the key, and it REPLACES that ping. The order becomes:
60
+ * ORDINARY-ENTRY INVITE RECLASSIFICATION (BAPI-661) — the have-key branch's own
61
+ * credential input (`--api-key`, `BAPI_API_KEY`, or its hidden prompt) is
62
+ * CREDENTIAL-AGNOSTIC: it accepts a full API key OR a bootstrap-invite token,
63
+ * because a user handed only an invite has no way to know it belongs in a
64
+ * different flag. `classifyEnteredCredential` inspects the resolved value
65
+ * immediately after input resolution (before repository-name resolution); a
66
+ * `bapi_inv_…` value flips the branch's `bootstrapInviteMode`/`inviteToken` and
67
+ * the run falls into the SAME bootstrap-invite protocol described below, verbatim.
68
+ * Explicit `--invite`/`BAPI_INVITE` and `--email`/`BAPI_SIGNUP_EMAIL` are never
69
+ * reclassified — they are already unambiguous and remain authoritative.
70
+ *
71
+ * BOOTSTRAP-INVITE MODE (BAPI-606) — one of the exceptions to "this command
72
+ * consumes a key, it does not create one" (the ordinary-entry reclassification
73
+ * above is the other). With `--invite` (or `BAPI_INVITE`, or a reclassified
74
+ * ordinary-entry value) there is no API key yet, so the pre-flight ping of Step 2
75
+ * CANNOT be made: the exchange is what mints the key, and it REPLACES that ping.
76
+ * The order becomes:
64
77
  *
65
78
  * resolve bootstrap-invite token (no-echo prompt by DEFAULT — the delivered
66
79
  * one-liner is secret-free) → resolve repo name → scaffold → generate
@@ -185,11 +198,15 @@ export function getInstallBridgeUsage() {
185
198
  "an interactive terminal keeps the existing deterministic behavior and no prompt.",
186
199
  "",
187
200
  "Inputs (the only two irreducible ones):",
188
- " --api-key <key> Bridge API key. Falls back to the BAPI_API_KEY env var,",
189
- " then an interactive (no-echo) prompt. Generate one in the",
190
- " Bridge API web UI Security page — this command consumes a",
191
- " key, it does not create one (--email and --invite are the",
192
- " exceptions: they CREATE the project and its first admin key).",
201
+ " --api-key <key> Bridge API key OR bootstrap invite. Falls back to the",
202
+ " BAPI_API_KEY env var, then an interactive (no-echo) prompt.",
203
+ " Generate a key in the Bridge API web UI Security page — this",
204
+ " command consumes a key, it does not create one. A value",
205
+ " detected as a bootstrap invite (bapi_inv_…), from any of the",
206
+ " three sources above, is instead redeemed to CREATE a new",
207
+ " project and its first admin key — the same as --invite —",
208
+ " so it skips repository lookup entirely (--email and --invite",
209
+ " remain the explicit, preferred entry points for a new project).",
193
210
  " NEVER printed or logged.",
194
211
  " --repo <name> Repository name. --repo and BAPI_REPO_NAME still take",
195
212
  " priority and short-circuit before any network call. When",
@@ -710,8 +727,16 @@ export function createDefaultInstallBridgeDeps() {
710
727
  };
711
728
  }
712
729
  /**
713
- * Resolve the API key: `--api-key` → `BAPI_API_KEY` env → interactive no-echo
714
- * prompt. Fails (secret-free) when none is available and stdin is non-interactive.
730
+ * Resolve the ordinary credential entry: `--api-key` → `BAPI_API_KEY` env →
731
+ * interactive no-echo prompt. Fails (secret-free) when none is available and
732
+ * stdin is non-interactive.
733
+ *
734
+ * Despite the name, this is CREDENTIAL-AGNOSTIC (BAPI-661): the returned value may
735
+ * be a full Bridge API key or a bootstrap-invite token (`bapi_inv_…`) — the caller
736
+ * classifies it with {@link classifyEnteredCredential} immediately after this
737
+ * resolves and routes an invite into the existing redemption workflow. This
738
+ * function itself performs no classification; it only resolves which raw string
739
+ * was entered.
715
740
  */
716
741
  export async function resolveApiKey(options, deps) {
717
742
  if (typeof options.apiKey === "string" && options.apiKey.trim().length > 0) {
@@ -722,16 +747,20 @@ export async function resolveApiKey(options, deps) {
722
747
  return { ok: true, value: fromEnv.trim() };
723
748
  }
724
749
  if (deps.isTTY && deps.promptSecret) {
725
- const entered = (await deps.promptSecret("Bridge API key (input hidden): ")).trim();
750
+ const entered = (await deps.promptSecret("Bridge API key or invite (input hidden): ")).trim();
726
751
  if (entered.length > 0) {
727
752
  return { ok: true, value: entered };
728
753
  }
729
- return { ok: false, error: "No API key entered." };
754
+ return {
755
+ ok: false,
756
+ error: "No Bridge API key or invite entered. Pass --api-key, set the BAPI_API_KEY environment " +
757
+ "variable, or try the hidden prompt again.",
758
+ };
730
759
  }
731
760
  return {
732
761
  ok: false,
733
- error: "An API key is required. Pass --api-key or set the BAPI_API_KEY environment variable " +
734
- "(no interactive terminal is available to prompt for it).",
762
+ error: "A Bridge API key or invite is required. Pass --api-key or set the BAPI_API_KEY " +
763
+ "environment variable (no interactive terminal is available to prompt for it).",
735
764
  };
736
765
  }
737
766
  /**
@@ -836,8 +865,8 @@ export function resolveInstallBridgeOnboardingBranch(options, env) {
836
865
  return { kind: "need-key", method: "self-serve" };
837
866
  return { kind: "have-key" };
838
867
  }
839
- /** The exact visible text of the bare-TTY onboarding selector (BAPI-626). */
840
- export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key? [Y/n] ";
868
+ /** The exact visible text of the bare-TTY onboarding selector (BAPI-626, BAPI-661). */
869
+ export const INSTALL_BRIDGE_KEY_SELECTOR_PROMPT = "Do you have a Bridge API key or invite? [Y/n] ";
841
870
  /**
842
871
  * Interactive wrapper around {@link resolveInstallBridgeOnboardingBranch}.
843
872
  *
@@ -1000,7 +1029,8 @@ export function promptMultiSelectViaReadline(promptText, options, defaults, inpu
1000
1029
  const mark = selected.has(opt.id) ? "[x]" : "[ ]";
1001
1030
  output.write(` ${idx + 1}. ${mark} ${opt.label}\n`);
1002
1031
  });
1003
- output.write("Type numbers to toggle, e.g. 1,3 — then Enter. Enter with ≥1 selected accepts.\n");
1032
+ output.write("Type numbers to toggle, e.g. 1,3 — then Enter.\n");
1033
+ output.write("Press Enter on an empty line to confirm — re-typing a number un-selects it.\n");
1004
1034
  output.write("Enter numbers to toggle (comma-separated), or press Enter to accept: ");
1005
1035
  };
1006
1036
  const rl = readline.createInterface({ input, output });
@@ -1195,20 +1225,41 @@ export async function chooseInstallBridgeLaunchAgent(agents, deps) {
1195
1225
  * completes.
1196
1226
  *
1197
1227
  * Condition-specific warning prefixes are added by the caller, never inside here.
1228
+ *
1229
+ * `toolLabels` (BAPI-661) names the actually-selected platform(s) in the
1230
+ * `"configured"` message so "open it in an AI coding tool" becomes "open it in
1231
+ * GitHub Copilot (VS Code)" when that is what was selected — an empty array (the
1232
+ * explicit empty-selection call site, or any caller with nothing to name) falls
1233
+ * back to the prior generic "an AI coding tool" wording unchanged.
1198
1234
  */
1199
- export function buildManualInstallBridgeContinuation(kind) {
1235
+ export function buildManualInstallBridgeContinuation(kind, toolLabels) {
1200
1236
  if (kind === "empty-selection") {
1201
1237
  return [
1202
1238
  "No AI coding tools were configured, so nothing was set up for this project.",
1203
1239
  "Re-run install-bridge and select at least one tool to configure it.",
1204
1240
  ].join("\n");
1205
1241
  }
1242
+ const toolPhrase = formatToolLabelPhrase(toolLabels);
1206
1243
  return [
1207
- "To finish configuring this project, open it in an AI coding tool that has the",
1244
+ `To finish configuring this project, open it in ${toolPhrase} that has the`,
1208
1245
  "Bridge MCP server configured and run /install-bridge.",
1209
1246
  "Until the project is configured, your Bridge MCP tools stay limited.",
1210
1247
  ].join("\n");
1211
1248
  }
1249
+ /**
1250
+ * Format 0, 1, or many tool labels into a readable phrase for the continuation
1251
+ * message. Empty falls back to the generic "an AI coding tool"; one label is used
1252
+ * bare; two or more join with a serial (Oxford) comma and "and".
1253
+ */
1254
+ function formatToolLabelPhrase(labels) {
1255
+ if (labels.length === 0)
1256
+ return "an AI coding tool";
1257
+ if (labels.length === 1)
1258
+ return labels[0];
1259
+ if (labels.length === 2)
1260
+ return `${labels[0]} and ${labels[1]}`;
1261
+ return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`;
1262
+ }
1212
1263
  /** The exact consent-prompt prefix, shared with tests so the copy cannot drift. */
1213
1264
  export const INSTALL_BRIDGE_LAUNCH_CONSENT_PROMPT_PREFIX = "Bridge can configure and set up this project for you automatically. Open a ";
1214
1265
  /**
@@ -1287,6 +1338,17 @@ function hostConfigTargetsForPlatforms(platforms) {
1287
1338
  .filter((t) => t.scope === "project" && t.format === "json")
1288
1339
  .map((t) => ({ relPath: t.relPath, topLevelKey: t.topLevelKey }));
1289
1340
  }
1341
+ /**
1342
+ * Derive the human-facing labels for a set of selected platforms, in registry
1343
+ * (`HOST_PLATFORM_ORDER`) order rather than the caller's input order, so a
1344
+ * continuation message names selected tools deterministically (BAPI-661).
1345
+ * Deduplicates by platform ID and reads every label from `MCP_HOST_TARGETS` —
1346
+ * never a second, hand-maintained label map.
1347
+ */
1348
+ export function labelsForPlatforms(platforms) {
1349
+ const set = new Set(platforms);
1350
+ return HOST_PLATFORM_ORDER.filter((id) => set.has(id)).map((id) => MCP_HOST_TARGETS[id].label);
1351
+ }
1290
1352
  /** Resolve which project-local host configs to write, mirroring runInit detection. */
1291
1353
  async function resolveHostConfigTargets(deps) {
1292
1354
  const targets = [
@@ -1447,9 +1509,19 @@ export function buildPingUrl(baseUrl, repoName) {
1447
1509
  return url.toString();
1448
1510
  }
1449
1511
  /**
1450
- * Verify connectivity. Distinguishes a rejected key (401/403) from an unknown
1451
- * repo / not-found (404) where the response allows. The key is sent in the
1452
- * `X-API-Key` header and NEVER appears in any returned message.
1512
+ * Fixed, secret-free fallback for an HTTP 403 whose body carries no usable
1513
+ * `detail` (absent, malformed, unreadable, or a non-string `detail`). Deliberately
1514
+ * does NOT claim the key is invalid or expired — a 403 means the credential was
1515
+ * understood but denied access, which is a different (and often more actionable)
1516
+ * failure than an invalid/expired key.
1517
+ */
1518
+ const CONNECTIVITY_ACCESS_DENIED_FALLBACK = "The Bridge API denied access to this repository (HTTP 403). Verify the repo_name and that this " +
1519
+ "credential is authorized for it.";
1520
+ /**
1521
+ * Verify connectivity. Distinguishes a rejected/expired credential (401) from a
1522
+ * repository-specific access denial (403, BAPI-661) from an unknown repo /
1523
+ * not-found (404) where the response allows. The key is sent in the `X-API-Key`
1524
+ * header and NEVER appears in any returned message.
1453
1525
  */
1454
1526
  export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
1455
1527
  const url = buildPingUrl(baseUrl, repoName);
@@ -1474,7 +1546,7 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
1474
1546
  }
1475
1547
  if (resp.ok)
1476
1548
  return { ok: true };
1477
- if (resp.status === 401 || resp.status === 403) {
1549
+ if (resp.status === 401) {
1478
1550
  return {
1479
1551
  ok: false,
1480
1552
  message: `The Bridge API rejected the credential (HTTP ${resp.status}). The API key may be invalid or expired ` +
@@ -1482,6 +1554,27 @@ export async function verifyConnectivity(deps, baseUrl, repoName, apiKey) {
1482
1554
  "permission error.)",
1483
1555
  };
1484
1556
  }
1557
+ if (resp.status === 403) {
1558
+ // BAPI-661: a 403 means the credential was understood but access to THIS repo
1559
+ // was denied — the server's `detail` (e.g. "repository '<name>' is not
1560
+ // registered; verify the repo_name") is a safe, specific, non-secret message
1561
+ // that is far more actionable than the blanket invalid-key diagnosis 401 uses.
1562
+ // Only a nonblank string `detail` is ever surfaced; any other shape (missing
1563
+ // body, invalid JSON, a non-string/blank detail, or a body-read exception)
1564
+ // collapses to the fixed fallback so nothing unvalidated ever reaches the user.
1565
+ let detail;
1566
+ try {
1567
+ const body = (await resp.json());
1568
+ detail = body?.detail;
1569
+ }
1570
+ catch {
1571
+ return { ok: false, message: CONNECTIVITY_ACCESS_DENIED_FALLBACK };
1572
+ }
1573
+ if (typeof detail === "string" && detail.trim().length > 0) {
1574
+ return { ok: false, message: detail.trim() };
1575
+ }
1576
+ return { ok: false, message: CONNECTIVITY_ACCESS_DENIED_FALLBACK };
1577
+ }
1485
1578
  if (resp.status === 404) {
1486
1579
  return {
1487
1580
  ok: false,
@@ -1651,6 +1744,21 @@ export async function exchangeBootstrapInvite(deps, baseUrl, token, repoName, ke
1651
1744
  * treated as a `failed` mint rather than fed into the redemption path.
1652
1745
  */
1653
1746
  export const BOOTSTRAP_INVITE_TOKEN_PREFIX = "bapi_inv_";
1747
+ /**
1748
+ * Classify a value entered through the ORDINARY credential input (`--api-key`,
1749
+ * `BAPI_API_KEY`, or the have-key hidden prompt) as a bootstrap-invite token or a
1750
+ * full API key (BAPI-661). This lets a user who was handed an invite but reaches
1751
+ * for the ordinary key input still get redeemed correctly, instead of the invite
1752
+ * being sent as an (invalid) API key to repository resolution / connectivity.
1753
+ *
1754
+ * Pure and total: trims first, then does a case-sensitive prefix check against
1755
+ * {@link BOOTSTRAP_INVITE_TOKEN_PREFIX}. Blank and every non-matching value is
1756
+ * `"api-key"` — the existing resolvers already reject a blank value, so this
1757
+ * function does not need to special-case it. Never logs or returns the value.
1758
+ */
1759
+ export function classifyEnteredCredential(value) {
1760
+ return value.trim().startsWith(BOOTSTRAP_INVITE_TOKEN_PREFIX) ? "invite" : "api-key";
1761
+ }
1654
1762
  /** The self-serve mint endpoint: `<base>/setup/bootstrap/self-serve`. No query string — ever. */
1655
1763
  export function buildSelfServeMintUrl(baseUrl) {
1656
1764
  return `${baseUrl.replace(/\/+$/, "")}/setup/bootstrap/self-serve`;
@@ -1996,8 +2104,14 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
1996
2104
  return 1;
1997
2105
  }
1998
2106
  const branch = branchResult.branch;
1999
- const bootstrapInviteMode = branch.kind === "need-key";
2000
- const selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
2107
+ // Mutable (BAPI-661): an ordinary have-key credential that classifies as a
2108
+ // bootstrap invite (see the have-key arm below) flips `bootstrapInviteMode` to
2109
+ // true AFTER branch selection, before repository-name resolution — everything
2110
+ // downstream that branches on these two flags then follows the existing
2111
+ // bootstrap-invite protocol automatically. Explicit `--invite`/`BAPI_INVITE` and
2112
+ // `--email`/`BAPI_SIGNUP_EMAIL` never pass through this reclassification.
2113
+ let bootstrapInviteMode = branch.kind === "need-key";
2114
+ let selfServeSignupMode = branch.kind === "need-key" && branch.method === "self-serve";
2001
2115
  // ---- Resolve inputs (may prompt when interactive) ----
2002
2116
  // Resolve the credential/entry input first so a fully-empty non-interactive
2003
2117
  // invocation fails with the (more relevant) missing-input message before the repo
@@ -2030,7 +2144,20 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2030
2144
  errorLog(`Error: ${keyResult.error}`);
2031
2145
  return 1;
2032
2146
  }
2033
- apiKey = keyResult.value;
2147
+ // BAPI-661: an invite pasted into the ordinary credential input (flag, env, or
2148
+ // hidden prompt) is redeemed like an explicit --invite, not sent as an API key
2149
+ // to repository resolution / connectivity. Reclassifying BEFORE that
2150
+ // resolution is what lets the rest of the function reuse the existing
2151
+ // bootstrap-invite protocol verbatim.
2152
+ if (classifyEnteredCredential(keyResult.value) === "invite") {
2153
+ inviteToken = keyResult.value.trim();
2154
+ apiKey = "";
2155
+ bootstrapInviteMode = true;
2156
+ selfServeSignupMode = false;
2157
+ }
2158
+ else {
2159
+ apiKey = keyResult.value;
2160
+ }
2034
2161
  }
2035
2162
  // baseUrl is resolved BEFORE repository input because the have-key branch needs
2036
2163
  // it for the server-resolution request.
@@ -2077,7 +2204,11 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2077
2204
  else {
2078
2205
  // Feature-detect + degrade: 404 (old server), 409 (unresolved/ambiguous/
2079
2206
  // client-scoped), and network/other errors all fall back to the existing
2080
- // local resolution WITHOUT a cause-specific message or leaked detail.
2207
+ // local resolution WITHOUT a cause-specific message or leaked detail, but
2208
+ // (BAPI-661) WITH a visible signal that the fallback is happening, so a
2209
+ // guessed name is never confirmed silently as if it had been verified.
2210
+ log("Couldn't auto-resolve your repo from the key; falling back to a guessed name — confirm " +
2211
+ "it matches the setup UI.");
2081
2212
  const repoResult = await resolveRepoName(options, deps, "existing-registration");
2082
2213
  if (!repoResult.ok) {
2083
2214
  errorLog(`Error: ${repoResult.error}`);
@@ -2159,7 +2290,9 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2159
2290
  // Return BEFORE any config/credential write, materialization, prewarm, or spawn, so a
2160
2291
  // bare `--tools=` (or an EOF-resolved picker) cleanly does nothing but guide a re-run.
2161
2292
  if (planLaunch.kind === "manual" && planLaunch.reason === "empty-selection") {
2162
- log(buildManualInstallBridgeContinuation("empty-selection"));
2293
+ // Explicit empty selection: no labels to name, so the generic fallback is
2294
+ // intentional here — pass [] rather than `labelsForPlatforms(selectedPlatforms)`.
2295
+ log(buildManualInstallBridgeContinuation("empty-selection", []));
2163
2296
  return 0;
2164
2297
  }
2165
2298
  // ---- Finalize the launch agent for spawn-capable decisions ----
@@ -2609,7 +2742,7 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2609
2742
  // deterministic setup is complete, so this is a non-fatal warning (exit 0), with
2610
2743
  // the same shared continuation so the remediation is consistent.
2611
2744
  errorLog(`Warning: setup steps completed, but the agent session could not be opened (${spawnResult.error}).`);
2612
- log(buildManualInstallBridgeContinuation("configured"));
2745
+ log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2613
2746
  return 0;
2614
2747
  }
2615
2748
  log("");
@@ -2623,13 +2756,13 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
2623
2756
  return 0;
2624
2757
  }
2625
2758
  // Declined (or non-TTY suppression): the deterministic setup is complete and durable.
2626
- log(buildManualInstallBridgeContinuation("configured"));
2759
+ log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2627
2760
  return 0;
2628
2761
  }
2629
2762
  // No launchable session (a no-launchable selection, or a non-TTY / declined chooser):
2630
2763
  // finalAgentName is null, so nothing was materialized. The configured hosts are
2631
2764
  // durable — print the shared continuation + limited-tools note (never the
2632
2765
  // empty-selection variant, which already returned above).
2633
- log(buildManualInstallBridgeContinuation("configured"));
2766
+ log(buildManualInstallBridgeContinuation("configured", labelsForPlatforms(selectedPlatforms)));
2634
2767
  return 0;
2635
2768
  }