@leadbay/mcp 0.21.1 → 0.21.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog — @leadbay/mcp
2
2
 
3
+ ## 0.21.2 — 2026-06-17
4
+
5
+ - **Early host shutdown no longer kills the OAuth flow mid-registration** (review P1): Claude Desktop's probe→teardown can close stdin within ~100ms — while the background bootstrap is still in region-probe/discovery/registration, before any browser-open exists. `shutdown()` now waits (bounded ~4s) on the whole bootstrap task, not just the browser-open promise, so the flow reaches the authorize-URL mint + open dispatch instead of dying early. Verified: stdin closed at 1s still produced `spawn OK` at ~2.7s.
6
+ - **Terminal bootstrap failures surface `AUTH_FAILED`, not a forever-"pending"** (review P2): a non-browser failure (region probe / discovery / registration / token exchange) with no sign-in URL used to leave tools reporting "a browser window should have opened…" indefinitely. The failure reason is now recorded and the gate returns `AUTH_FAILED` with the real error + restart guidance; it takes priority over a stale sign-in link.
7
+
8
+ - **Browser auto-open now reconstructs a missing `DISPLAY`/`WAYLAND_DISPLAY` on Linux**: a real Claude Desktop install log showed the spawned server's env had `DISPLAY=<unset> WAYLAND=<unset>` (the host strips them inconsistently — present on some launches, absent on others). Without a display var, `xdg-open` spawns "successfully" but can't reach the display server, so no tab opens — the silent install failure. `openInBrowser` now backfills the missing vars from `XDG_RUNTIME_DIR` (the `wayland-N` socket) and `/tmp/.X11-unix` (defaulting to `:0`) before spawning the launcher, and passes that env to the child. Already-set vars are left untouched; non-Linux is unaffected.
9
+ - **OAuth client registration is cached & reused** (the actual "nothing opens" root cause): bootstrap was registering a fresh Dynamic-Client-Registration client on every launch, and Claude Desktop's probe-restarts (several launches per install) blew past the backend's ~10-registrations/IP/hour cap — so bootstrap 429'd *before* building a sign-in URL. The registered `client_id` is now persisted per auth server in `~/.leadbay/oauth-client.json` and reused (loopback clients accept any 127.0.0.1 port per RFC 8252), so registration happens at most once and the 429 never recurs.
10
+
11
+ - **OAuth-on-install for the Claude Desktop `.dxt` no longer fails with "Unable to connect to extension server"**: the bundled stdio server ran the full interactive browser OAuth flow (up to 5 minutes) at startup, *before* answering the MCP `initialize` handshake — so Claude Desktop, which gives a launched extension only a few seconds to respond, timed out the connection and marked the server unreachable. The OAuth bootstrap is now **non-blocking**: the server answers `initialize` immediately with a real (tokenless) client, runs the browser sign-in in the background, and the first tool call returns a transient `AUTH_PENDING` envelope ("Signing you in to Leadbay — a browser window should have opened…") until the token lands. The moment the loopback callback completes, the token is set on the live client and the next tool call executes authenticated — no server rebuild, no restart.
12
+ - **Browser auto-open now survives Claude Desktop's install-time probe race**: a freshly-installed extension is probed with rapid connect→shutdown cycles (the first spawned process can live <100ms — confirmed in the install logs), which killed the process before the background OAuth flow reached the browser-launch step, so no tab opened on install even though it works on a stable session. The bootstrap now fires the browser-open the moment the authorize URL is known (tracked in an in-flight handle), and the shutdown path waits up to 1.5s for that spawn to dispatch before exiting — the detached launcher then survives our exit. Net: the browser opens on install. The clickable sign-in link remains as the backstop.
13
+ - **The sign-in link is also surfaced to the user instead of relying solely on auto-opening a browser**: the spawned `.dxt` stdio process frequently can't open a GUI browser at all — Claude Desktop strips `PATH` *and* `DISPLAY`/`WAYLAND_DISPLAY` from the child env, so `xdg-open`/`open` either `ENOENT`s or (worse) silently exits 0 without launching anything, leaving the user with no link and no error. The bootstrap now captures the live OAuth authorize URL and the gate returns it as a **clickable sign-in link** in the tool envelope ("Open this link to authorize Leadbay…"); the loopback listener stays alive in the background, so clicking it completes the flow and the next tool call is authenticated. The browser auto-open is still attempted (best-effort, now via absolute launcher paths `/usr/bin/open` / `%SystemRoot%\System32\cmd.exe` / `/usr/bin/xdg-open`) for environments where it works — but the surfaced link is the reliable path and no longer depends on it.
14
+
3
15
  ## 0.21.1 — 2026-06-16
4
16
 
5
17
  - **CSV import no longer 400s on a lead status the agent didn't uppercase** (product#3745): `leadbay_import_leads` / `leadbay_import_and_qualify` forwarded `default_status` / `statuses` values verbatim to `POST /imports/{id}/update_mappings`, whose backend `MappingsPayload` decodes them as the strict, case-sensitive `LeadStatus` enum (`DEFAULT, INBOUND, UNWANTED, WANTED, LOST, WON`). A value like "Won" failed deserialization and the whole call 400'd with an opaque "JSON deserialization error" before any record committed — JM hit this trying to tag 179 companies as Won. The MCP now owns the canonical set and enforces it before sending: status values are matched case-insensitively to their enum member ("Won" → "WON"), an empty default means no default, and a genuinely unknown status returns a clear `IMPORT_INVALID_STATUS` error naming the valid values instead of an opaque backend 400. The two tools' input schemas now declare the enum.
package/dist/bin.js CHANGED
@@ -22491,6 +22491,49 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` (the
22491
22491
 
22492
22492
  You don't need to memorize every tool here \u2014 each tool's own description carries a RENDERING block (how to present the response) and a NEXT STEPS block (observation \u2192 suggestion table). Read the relevant tool's description in full when the user picks an entry point. This overview just gets you to the right starting tool.
22493
22493
 
22494
+ ## Proposing a next step \u2014 only when it genuinely helps
22495
+
22496
+ After reporting account state, you MAY propose a concrete next step \u2014 but only when one is genuinely useful, not by reflex. A reflexive "want me to also\u2026?" on every turn is noise; the user notices and it erodes trust.
22497
+
22498
+ **Propose a next step when** the overview surfaced an obvious unfinished thread or a blocker the user would want resolved \u2014 a fresh discovery batch waiting, follow-ups due today, or a quota/auth blocker with a specific unblock action. In those cases the next move is real and worth offering.
22499
+
22500
+ **Skip it when** there's no clear unfinished thread, the user only wanted the status (a bare "where do I stand?"), or the work they asked for is plainly done. A status read that ends cleanly is a complete answer \u2014 don't manufacture a next step just to have one.
22501
+
22502
+ **Lean on memory.** Check the \`_meta.agent_memory.summary\` for prior signal on how this user reacts to next-step offers. If the memory shows they routinely dismiss them, default to NOT proposing (let them ask). If they routinely act on them, lean toward proposing. When the user dismisses or accepts a proposal this turn, that's a material signal \u2014 call \`leadbay_agent_memory_capture\` (\`source:"inferred"\`, low confidence) so the preference compounds across sessions.
22503
+
22504
+ **When you do propose, the proposal IS a native choice dialog \u2014 never a prose "let me know if\u2026".** Route 2\u20134 mutually-exclusive next moves into your host's next-step widget (\`ask_user_input_v0\` on Claude chat / ChatGPT, \`AskUserQuestion\` on Claude cowork / Claude Code). The widget is the question; do not also list the same options as prose.
22505
+
22506
+ **ALWAYS render NEXT STEPS via your host's next-step widget.** Use whichever is in your tool set \u2014 the NAME and SCHEMA differ: **\`ask_user_input_v0\`** (Claude chat / ChatGPT) takes plain-string options with \`type:"single_select"\`; **\`AskUserQuestion\`** (Claude cowork / Claude Code) takes object options \`{label, description}\` plus a required short \`header\` (\u226412 chars) and \`multiSelect\`, NO \`type\` field, and never add an "Other" option (the host adds it). Match the schema to the tool you actually have \u2014 the wrong schema fails silently and you fall back to prose. Prose bullets are the fallback ONLY when NEITHER widget exists. Any turn that would end with a choice must be the widget \u2014 the widget IS the question.
22507
+
22508
+ **If the tool result carries a \`next_steps\` object, that is the source of truth \u2014 use it directly.** Each option has a short \`.label\` (\u22645 words) and a full \`.description\`. Map \`next_steps.options[]\` into your host widget VERBATIM and in order: for \`AskUserQuestion\` (cowork / Claude Code) pass each as \`{label, description}\`; for \`ask_user_input_v0\` (Claude chat / ChatGPT, string options only) pass each option's \`.description\` as the string (it's the full sentence). Do NOT reword, reorder, drop, or prose-ify them \u2014 they're built deterministically by the server so the offer (incl. the artifact option at position 0) fires every time. Fall back to the table below only when there is NO \`next_steps\` field.
22509
+
22510
+ **One exception \u2014 skip the widget** when the user's original message contained a complete sequential instruction chain ("show me X and then do Y") AND all stated steps have been completed. In that case, end with STOP directly \u2014 the user stated their full plan and does not need a "what next?" prompt.
22511
+ - Skip example: "Show me today's leads and then research the top one for me." \u2192 after research completes, emit STOP without the widget.
22512
+ - Do NOT skip for: plain requests ("show me today's leads", "run my check-in"), recurring-language requests ("I do this every day"), or requests where only one action was stated.
22513
+
22514
+ Pick 2\u20134 rows from the (Observation, Suggest, Calls) table below most relevant to the response, then call your host's widget with ITS schema (per the schema rules above \u2014 wrong schema fails silently):
22515
+ - \`ask_user_input_v0\`: \`{questions:[{question,type:"single_select",options:["<Suggest 1>","<Suggest 2>"]}]}\`
22516
+ - \`AskUserQuestion\`: \`{questions:[{question,header:"Next step",multiSelect:false,options:[{label:"<\u22645 words>",description:"<Suggest 1>"}]}]}\`
22517
+
22518
+ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutually-exclusive options, AskUserQuestion labels \u22645 words (full text in \`description\`), max 3 questions. Table stays internal; never recite it.
22519
+
22520
+ ---
22521
+
22522
+
22523
+
22524
+ The overview itself returns no \`next_steps\` object, so when you DO propose, build the options from this table \u2014 pick the 2\u20134 rows that match what the account state actually showed. If none apply cleanly, propose none (the status read was complete) rather than inventing an option.
22525
+
22526
+ All \`Calls\` below are agent-callable \`leadbay_*\` tools (never an MCP prompt name like \`leadbay_daily_check_in\` \u2014 the agent cannot invoke a prompt from a turn; route to the underlying tool instead).
22527
+
22528
+ | Observation | Suggest | Calls |
22529
+ |---------------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------------------------|
22530
+ | Fresh discovery batch waiting / user wants new leads | "See today's best new leads" | leadbay_pull_leads(lensId = pinned) |
22531
+ | Follow-ups due / known leads to re-engage | "Show follow-ups due now" | leadbay_pull_followups |
22532
+ | Quota/credit read shows low or exhausted balance | "Review what's eating your quota" | leadbay_account_status (deeper read) |
22533
+ | Auth/connection blocker (e.g. 401 / AUTH_EXPIRED on a read) | "Reconnect Leadbay to unblock actions" | (guide the user to re-authenticate \u2014 no tool call) |
22534
+ | Lens audience looks mismatched (batch is off-ICP) | "Adjust the lens audience to match your ICP" | ASK first \u2014 collect the target sectors / sizes / exclusions, THEN leadbay_adjust_audience(...) with those params. NEVER call it with no args (an empty call writes the current filter / may clone the default lens \u2014 a no-op or unwanted change). |
22535
+ | Status is healthy and nothing is pending | propose nothing \u2014 the overview is a complete answer | \u2014 |
22536
+
22494
22537
  GATE \u2014 DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim \u2014 score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it.
22495
22538
 
22496
22539
  If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it.
@@ -24466,6 +24509,56 @@ function buildServer(client, opts = {}) {
24466
24509
  };
24467
24510
  };
24468
24511
  try {
24512
+ const bootstrapState = opts.bootstrapStatus?.() ?? { done: true };
24513
+ if (!bootstrapState.done) {
24514
+ const url = bootstrapState.signInUrl;
24515
+ const envelope = bootstrapState.failureMessage ? {
24516
+ error: true,
24517
+ code: "AUTH_FAILED",
24518
+ message: "Couldn't sign you in to Leadbay.",
24519
+ hint: `Sign-in failed: ${bootstrapState.failureMessage}
24520
+
24521
+ Restart the Leadbay extension in Claude Desktop to retry. If it keeps failing, check your network/region and that Leadbay is reachable.`
24522
+ } : url ? {
24523
+ // Prefer surfacing the live sign-in URL — the spawned MCP process
24524
+ // often can't open a GUI browser itself (no DISPLAY / sanitized
24525
+ // env), so a clickable link the agent renders is the reliable path.
24526
+ error: true,
24527
+ code: "AUTH_REQUIRED",
24528
+ message: "Sign in to Leadbay to finish connecting.",
24529
+ hint: `Open this link to authorize Leadbay, then re-run this tool:
24530
+
24531
+ ${url}
24532
+
24533
+ ` + (bootstrapState.openFailed ? "(The extension couldn't open your browser automatically.)" : "(A browser may have opened automatically \u2014 if not, use the link above.)")
24534
+ } : {
24535
+ error: true,
24536
+ code: "AUTH_PENDING",
24537
+ message: "Signing you in to Leadbay \u2014 a browser window should have opened. Authorize there, then try again.",
24538
+ hint: "Complete the Leadbay sign-in in your browser, then re-run this tool."
24539
+ };
24540
+ const pendingText = formatErrorForLLM(envelope);
24541
+ const pendingDur = Date.now() - callStart;
24542
+ telemetry.captureToolCall({
24543
+ tool: name,
24544
+ ok: false,
24545
+ duration_ms: pendingDur,
24546
+ format: "error-envelope",
24547
+ bytes: pendingText.length,
24548
+ error_code: envelope.code,
24549
+ triggered_by
24550
+ });
24551
+ if (DEBUG_ON) {
24552
+ process.stderr.write(
24553
+ `[leadbay-mcp debug] tool=${name} dur=${pendingDur}ms ok=false code=${envelope.code} (auth-bootstrap, no-sentry)
24554
+ `
24555
+ );
24556
+ }
24557
+ return {
24558
+ content: [{ type: "text", text: pendingText }],
24559
+ isError: true
24560
+ };
24561
+ }
24469
24562
  if (COMPOSITE_FILE_TOOL_NAMES.has(name) && !triggered_by) {
24470
24563
  const envelope = {
24471
24564
  error: true,
@@ -25597,6 +25690,18 @@ import { createHash as createHash5, randomBytes } from "crypto";
25597
25690
  import { createServer } from "http";
25598
25691
  import { request as httpsRequestRaw } from "https";
25599
25692
  import { spawn as spawn3 } from "child_process";
25693
+ import { readdirSync as readdirSync2 } from "fs";
25694
+ var LEADBAY_LOOPBACK_PORTS = [51789, 51790, 51791, 51792];
25695
+ var BrowserOpenFailedError = class extends Error {
25696
+ authorizeUrl;
25697
+ constructor(authorizeUrl, cause) {
25698
+ super(
25699
+ `Could not open a browser automatically: ${cause?.message ?? cause}`
25700
+ );
25701
+ this.name = "BrowserOpenFailedError";
25702
+ this.authorizeUrl = authorizeUrl;
25703
+ }
25704
+ };
25600
25705
  var STARGATE_URLS = {
25601
25706
  prod: "https://stargate.leadbay.app/1.0/user_info",
25602
25707
  staging: "https://staging.stargate.leadbay.app/1.0/user_info"
@@ -25789,13 +25894,24 @@ async function startLoopbackListener(opts) {
25789
25894
  res.end(renderHtml("You're signed in", "You can close this tab and return to the terminal."));
25790
25895
  resolveCallback({ code, state });
25791
25896
  });
25792
- await new Promise((resolve, reject) => {
25793
- server.once("error", reject);
25794
- server.listen(0, "127.0.0.1", () => {
25795
- server.off("error", reject);
25897
+ const bindPort = async (port) => new Promise((resolve, reject) => {
25898
+ const onErr = (e) => reject(e);
25899
+ server.once("error", onErr);
25900
+ server.listen(port, "127.0.0.1", () => {
25901
+ server.off("error", onErr);
25796
25902
  resolve();
25797
25903
  });
25798
25904
  });
25905
+ let bound = false;
25906
+ for (const port of opts.preferredPorts ?? []) {
25907
+ try {
25908
+ await bindPort(port);
25909
+ bound = true;
25910
+ break;
25911
+ } catch {
25912
+ }
25913
+ }
25914
+ if (!bound) await bindPort(0);
25799
25915
  const addr = server.address();
25800
25916
  const redirectUri = `http://127.0.0.1:${addr.port}/callback`;
25801
25917
  const timer = setTimeout(() => {
@@ -25803,6 +25919,7 @@ async function startLoopbackListener(opts) {
25803
25919
  }, opts.timeoutMs);
25804
25920
  return {
25805
25921
  redirectUri,
25922
+ port: addr.port,
25806
25923
  waitForCallback: () => callbackPromise.finally(() => {
25807
25924
  clearTimeout(timer);
25808
25925
  }),
@@ -25868,28 +25985,79 @@ async function exchangeCodeForToken(opts) {
25868
25985
  }
25869
25986
  return { accessToken: parsed.access_token };
25870
25987
  }
25871
- async function openInBrowser(url) {
25988
+ function browserOpenCandidates(url) {
25872
25989
  const platform2 = process.platform;
25873
- let cmd;
25874
- let args;
25875
25990
  if (platform2 === "darwin") {
25876
- cmd = "open";
25877
- args = [url];
25878
- } else if (platform2 === "win32") {
25879
- cmd = "cmd";
25880
- args = ["/c", "start", '""', url];
25881
- } else {
25882
- cmd = "xdg-open";
25883
- args = [url];
25991
+ return [
25992
+ { cmd: "/usr/bin/open", args: [url] },
25993
+ { cmd: "open", args: [url] }
25994
+ ];
25884
25995
  }
25885
- await new Promise((resolve, reject) => {
25886
- const child = spawn3(cmd, args, { stdio: "ignore", detached: true });
25887
- child.on("error", reject);
25888
- child.on("spawn", () => {
25889
- child.unref();
25890
- resolve();
25891
- });
25892
- });
25996
+ if (platform2 === "win32") {
25997
+ const sysRoot = process.env.SystemRoot || process.env.windir || "C:\\Windows";
25998
+ const cmdExe = `${sysRoot}\\System32\\cmd.exe`;
25999
+ return [
26000
+ { cmd: cmdExe, args: ["/c", "start", '""', url] },
26001
+ { cmd: "cmd", args: ["/c", "start", '""', url] }
26002
+ ];
26003
+ }
26004
+ return [
26005
+ { cmd: "/usr/bin/xdg-open", args: [url] },
26006
+ { cmd: "/usr/local/bin/xdg-open", args: [url] },
26007
+ { cmd: "xdg-open", args: [url] }
26008
+ ];
26009
+ }
26010
+ function browserLaunchEnv(debug) {
26011
+ const env = { ...process.env };
26012
+ if (process.platform !== "linux") return env;
26013
+ const runtimeDir = env.XDG_RUNTIME_DIR;
26014
+ if (!env.WAYLAND_DISPLAY && runtimeDir) {
26015
+ try {
26016
+ const sock = readdirSync2(runtimeDir).find((f) => /^wayland-\d+$/.test(f));
26017
+ if (sock) {
26018
+ env.WAYLAND_DISPLAY = sock;
26019
+ debug?.(`browserLaunchEnv: injected WAYLAND_DISPLAY=${sock}`);
26020
+ }
26021
+ } catch {
26022
+ }
26023
+ }
26024
+ if (!env.DISPLAY) {
26025
+ try {
26026
+ const x = readdirSync2("/tmp/.X11-unix").map((f) => f.match(/^X(\d+)$/)?.[1]).filter((n) => !!n).sort((a, b) => Number(a) - Number(b))[0];
26027
+ env.DISPLAY = x !== void 0 ? `:${x}` : ":0";
26028
+ } catch {
26029
+ env.DISPLAY = ":0";
26030
+ }
26031
+ debug?.(`browserLaunchEnv: injected DISPLAY=${env.DISPLAY}`);
26032
+ }
26033
+ return env;
26034
+ }
26035
+ async function openInBrowser(url, debug) {
26036
+ const candidates = browserOpenCandidates(url);
26037
+ const launchEnv = browserLaunchEnv(debug);
26038
+ debug?.(
26039
+ `openInBrowser: platform=${process.platform} DISPLAY=${launchEnv.DISPLAY ?? "<unset>"} WAYLAND=${launchEnv.WAYLAND_DISPLAY ?? "<unset>"} DBUS=${launchEnv.DBUS_SESSION_BUS_ADDRESS ? "set" : "<unset>"} candidates=[${candidates.map((c) => c.cmd).join(", ")}]`
26040
+ );
26041
+ let lastErr;
26042
+ for (const { cmd, args } of candidates) {
26043
+ try {
26044
+ await new Promise((resolve, reject) => {
26045
+ const child = spawn3(cmd, args, { stdio: "ignore", detached: true, env: launchEnv });
26046
+ child.on("error", reject);
26047
+ child.on("spawn", () => {
26048
+ debug?.(`spawn OK: ${cmd} (pid=${child.pid})`);
26049
+ child.unref();
26050
+ resolve();
26051
+ });
26052
+ });
26053
+ return;
26054
+ } catch (err) {
26055
+ lastErr = err;
26056
+ debug?.(`spawn FAILED: ${cmd} \u2192 ${err?.code ?? err?.message ?? err}`);
26057
+ }
26058
+ }
26059
+ debug?.(`openInBrowser: ALL candidates failed (lastErr=${lastErr?.message ?? lastErr})`);
26060
+ throw lastErr ?? new Error("no browser launcher available");
25893
26061
  }
25894
26062
  async function oauthLogin(opts) {
25895
26063
  const log = opts.log ?? (() => {
@@ -25902,22 +26070,45 @@ async function oauthLogin(opts) {
25902
26070
  const state = base64UrlEncode(randomBytes(16));
25903
26071
  const pkce = generatePkce();
25904
26072
  log("Starting loopback listener on 127.0.0.1\u2026\n");
25905
- const listener = await startLoopbackListener({ expectedState: state, timeoutMs });
26073
+ const listener = await startLoopbackListener({
26074
+ expectedState: state,
26075
+ timeoutMs,
26076
+ preferredPorts: LEADBAY_LOOPBACK_PORTS
26077
+ });
25906
26078
  try {
25907
- log(`Registering client at ${doc.registration_endpoint}\u2026
26079
+ const boundPort = listener.port;
26080
+ let clientId = opts.getCachedClientId?.(boundPort);
26081
+ if (clientId) {
26082
+ log(`Reusing cached OAuth client_id (${clientId}) for port ${boundPort} \u2014 skipping registration.
25908
26083
  `);
25909
- const client = await registerClient(doc.registration_endpoint, {
25910
- clientName: opts.clientName,
25911
- redirectUri: listener.redirectUri,
25912
- logoUri: opts.logoUri
25913
- });
26084
+ } else {
26085
+ log(`Registering client at ${doc.registration_endpoint} (redirect ${listener.redirectUri})\u2026
26086
+ `);
26087
+ const registered = await registerClient(doc.registration_endpoint, {
26088
+ clientName: opts.clientName,
26089
+ redirectUri: listener.redirectUri,
26090
+ // exact bound-port redirect
26091
+ logoUri: opts.logoUri
26092
+ });
26093
+ clientId = registered.client_id;
26094
+ try {
26095
+ opts.onClientRegistered?.(clientId, boundPort);
26096
+ } catch {
26097
+ }
26098
+ }
25914
26099
  const authorizeUrl = new URL(doc.authorization_endpoint);
25915
26100
  authorizeUrl.searchParams.set("response_type", "code");
25916
- authorizeUrl.searchParams.set("client_id", client.client_id);
26101
+ authorizeUrl.searchParams.set("client_id", clientId);
25917
26102
  authorizeUrl.searchParams.set("redirect_uri", listener.redirectUri);
25918
26103
  authorizeUrl.searchParams.set("state", state);
25919
26104
  authorizeUrl.searchParams.set("code_challenge", pkce.challenge);
25920
26105
  authorizeUrl.searchParams.set("code_challenge_method", pkce.method);
26106
+ if (opts.onAuthorizeUrl) {
26107
+ try {
26108
+ opts.onAuthorizeUrl(authorizeUrl.toString());
26109
+ } catch {
26110
+ }
26111
+ }
25921
26112
  log(`Opening browser to authorize\u2026
25922
26113
  ${authorizeUrl.toString()}
25923
26114
  `);
@@ -25929,6 +26120,9 @@ async function oauthLogin(opts) {
25929
26120
  ${authorizeUrl.toString()}
25930
26121
  `
25931
26122
  );
26123
+ if (opts.failFastOnOpenError) {
26124
+ throw new BrowserOpenFailedError(authorizeUrl.toString(), err);
26125
+ }
25932
26126
  }
25933
26127
  log("Waiting for authorization (5 min timeout)\u2026\n");
25934
26128
  const { code } = await listener.waitForCallback();
@@ -25937,7 +26131,7 @@ async function oauthLogin(opts) {
25937
26131
  tokenEndpoint: doc.token_endpoint,
25938
26132
  code,
25939
26133
  codeVerifier: pkce.verifier,
25940
- clientId: client.client_id,
26134
+ clientId,
25941
26135
  redirectUri: listener.redirectUri
25942
26136
  });
25943
26137
  return { accessToken };
@@ -25958,7 +26152,7 @@ var OAUTH_BASE_URLS = {
25958
26152
  fr: "https://staging.api.leadbay.app"
25959
26153
  }
25960
26154
  };
25961
- var VERSION = "0.21.1";
26155
+ var VERSION = "0.21.2";
25962
26156
  var HELP = `
25963
26157
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
25964
26158
 
@@ -26087,8 +26281,65 @@ function resolveOAuthBootstrapCredentialsPath() {
26087
26281
  legacy: resolved.legacy
26088
26282
  };
26089
26283
  }
26284
+ var pendingSignInUrl;
26285
+ var browserOpenFailedAtBootstrap = false;
26286
+ var bootstrapFailureMessage;
26287
+ function bootstrapDebug(msg) {
26288
+ try {
26289
+ const { appendFileSync, mkdirSync } = require_("node:fs");
26290
+ const { join: join4 } = require_("node:path");
26291
+ const { homedir: homedir5 } = require_("node:os");
26292
+ const dir = join4(homedir5(), ".leadbay");
26293
+ mkdirSync(dir, { recursive: true });
26294
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
26295
+ appendFileSync(join4(dir, "oauth-bootstrap-debug.log"), `${ts} [pid ${process.pid}] ${msg}
26296
+ `);
26297
+ } catch {
26298
+ }
26299
+ }
26300
+ function oauthClientCachePath() {
26301
+ const { join: join4 } = require_("node:path");
26302
+ const { homedir: homedir5 } = require_("node:os");
26303
+ return join4(homedir5(), ".leadbay", "oauth-client.json");
26304
+ }
26305
+ function getCachedOAuthClientId(authServerBaseUrl, port) {
26306
+ try {
26307
+ const { readFileSync: readFileSync3 } = require_("node:fs");
26308
+ const parsed = JSON.parse(readFileSync3(oauthClientCachePath(), "utf8"));
26309
+ const byPort = parsed?.clients?.[authServerBaseUrl]?.byPort;
26310
+ const id = byPort?.[String(port)];
26311
+ return typeof id === "string" && id.length > 0 ? id : void 0;
26312
+ } catch {
26313
+ return void 0;
26314
+ }
26315
+ }
26316
+ function cacheOAuthClientId(authServerBaseUrl, clientId, port) {
26317
+ try {
26318
+ const { readFileSync: readFileSync3, writeFileSync, mkdirSync } = require_("node:fs");
26319
+ const { dirname: dirname3 } = require_("node:path");
26320
+ const path = oauthClientCachePath();
26321
+ let data = { clients: {} };
26322
+ try {
26323
+ data = JSON.parse(readFileSync3(path, "utf8"));
26324
+ if (!data || typeof data !== "object" || typeof data.clients !== "object") data = { clients: {} };
26325
+ } catch {
26326
+ }
26327
+ const server = data.clients[authServerBaseUrl];
26328
+ const byPort = server && typeof server === "object" && server.byPort && typeof server.byPort === "object" ? server.byPort : {};
26329
+ byPort[String(port)] = clientId;
26330
+ data.clients[authServerBaseUrl] = { byPort };
26331
+ mkdirSync(dirname3(path), { recursive: true });
26332
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
26333
+ } catch {
26334
+ }
26335
+ }
26336
+ var browserOpenInFlight = null;
26337
+ var bootstrapInFlight = null;
26090
26338
  async function bootstrapOAuthIfMissing(logger) {
26091
26339
  if (process.env.LEADBAY_TOKEN) return false;
26340
+ bootstrapDebug(
26341
+ `bootstrap START \u2014 clientName=Leadbay MCP, REGION=${process.env.LEADBAY_REGION ?? "<unset>"} BASE_URL=${process.env.LEADBAY_BASE_URL ?? "<unset>"}`
26342
+ );
26092
26343
  const { hostname } = await import("os");
26093
26344
  process.stderr.write(
26094
26345
  `
@@ -26124,7 +26375,49 @@ async function bootstrapOAuthIfMissing(logger) {
26124
26375
  const { accessToken } = await oauthLogin({
26125
26376
  authServerBaseUrl,
26126
26377
  clientName: `Leadbay MCP @ ${hostname()}`,
26127
- log: (m) => process.stderr.write(m)
26378
+ log: (m) => process.stderr.write(m),
26379
+ // The moment the URL is known: (1) stash it so the AUTH_PENDING envelope
26380
+ // surfaces a clickable link (reliable fallback), and (2) fire the browser
26381
+ // auto-open OURSELVES, tracked in browserOpenInFlight so shutdown waits
26382
+ // for the spawn to dispatch. This wins the install-time race: Claude
26383
+ // Desktop probes a fresh extension with <100ms connect→shutdown cycles,
26384
+ // and tracking the open lets us finish dispatching the detached child
26385
+ // even if SIGTERM/stdin-end arrives first. We pass our own opener as
26386
+ // `openBrowser` so oauthLogin doesn't ALSO open (no double tab).
26387
+ onAuthorizeUrl: (url) => {
26388
+ pendingSignInUrl = url;
26389
+ bootstrapDebug(`authorize URL ready \u2014 spawning browser open`);
26390
+ browserOpenInFlight = openInBrowser(url, bootstrapDebug).then(() => {
26391
+ bootstrapDebug(`auto-open dispatched OK`);
26392
+ }).catch((err) => {
26393
+ browserOpenFailedAtBootstrap = true;
26394
+ bootstrapDebug(`auto-open FAILED: ${err?.message ?? err}`);
26395
+ process.stderr.write(
26396
+ `[leadbay-mcp] auto-open browser failed (${err?.message ?? err}); user has the sign-in link.
26397
+ `
26398
+ );
26399
+ }).finally(() => {
26400
+ browserOpenInFlight = null;
26401
+ });
26402
+ },
26403
+ // No-op: we drive the open from onAuthorizeUrl (tracked) instead, so the
26404
+ // shutdown race can be handled. Returning resolved means oauthLogin won't
26405
+ // try its own open or hit the fail-fast path.
26406
+ openBrowser: async () => {
26407
+ },
26408
+ // Reuse a cached client_id (keyed by auth server + loopback port) so we
26409
+ // register at most once — avoids the 429 from re-registering on every
26410
+ // probe-restart. Port is part of the key because the backend pins the
26411
+ // exact redirect_uri (port included).
26412
+ getCachedClientId: (port) => {
26413
+ const id = getCachedOAuthClientId(authServerBaseUrl, port);
26414
+ if (id) bootstrapDebug(`reusing cached client_id=${id} (port ${port}) for ${authServerBaseUrl}`);
26415
+ return id;
26416
+ },
26417
+ onClientRegistered: (id, port) => {
26418
+ bootstrapDebug(`registered new client_id=${id} (port ${port}) \u2014 caching for ${authServerBaseUrl}`);
26419
+ cacheOAuthClientId(authServerBaseUrl, id, port);
26420
+ }
26128
26421
  });
26129
26422
  try {
26130
26423
  const { writeFileSync, mkdirSync, chmodSync } = require_("node:fs");
@@ -26161,12 +26454,27 @@ async function bootstrapOAuthIfMissing(logger) {
26161
26454
  process.env.LEADBAY_TOKEN = accessToken;
26162
26455
  process.env.LEADBAY_REGION = region;
26163
26456
  if (isStaging || envBaseUrl) process.env.LEADBAY_BASE_URL = authServerBaseUrl;
26457
+ pendingSignInUrl = void 0;
26458
+ bootstrapDebug(`bootstrap COMPLETE \u2014 token acquired, region=${region}`);
26164
26459
  logger.info?.(`OAuth bootstrap complete \u2014 region=${region}`);
26165
26460
  return true;
26166
26461
  } catch (err) {
26462
+ if (err instanceof BrowserOpenFailedError) {
26463
+ browserOpenFailedAtBootstrap = true;
26464
+ process.stderr.write(
26465
+ `[leadbay-mcp] Could not open a browser automatically: ${err.message}
26466
+ The sign-in link is surfaced to the user via the AUTH_PENDING envelope.
26467
+ `
26468
+ );
26469
+ return false;
26470
+ }
26471
+ const message = err?.message ?? String(err);
26472
+ bootstrapFailureMessage = message;
26473
+ pendingSignInUrl = void 0;
26474
+ bootstrapDebug(`bootstrap FAILED (non-open): ${message}`);
26167
26475
  process.stderr.write(
26168
- `[leadbay-mcp] OAuth bootstrap failed: ${err?.message ?? err}
26169
- The server will start but tools will return AUTH_MISSING until you authorize.
26476
+ `[leadbay-mcp] OAuth bootstrap failed: ${message}
26477
+ Tools will return AUTH_FAILED until you restart the extension to retry.
26170
26478
  `
26171
26479
  );
26172
26480
  return false;
@@ -26176,30 +26484,16 @@ async function resolveClientFromEnv(logger) {
26176
26484
  if (process.env.LEADBAY_OAUTH_BOOTSTRAP === "1") {
26177
26485
  hydrateEnvFromCredentialsFile();
26178
26486
  if (!process.env.LEADBAY_TOKEN) {
26179
- await bootstrapOAuthIfMissing(logger);
26487
+ const regionEnv2 = process.env.LEADBAY_REGION;
26488
+ const region = regionEnv2 === "fr" ? "fr" : "us";
26489
+ const config = { region };
26490
+ if (process.env.LEADBAY_BASE_URL) config.baseUrl = process.env.LEADBAY_BASE_URL;
26491
+ logger.info?.("OAuth bootstrap pending \u2014 server will come up unauthenticated, OAuth runs in background");
26492
+ return { client: createClient(config), authState: "pending" };
26180
26493
  }
26181
26494
  }
26182
26495
  const token = process.env.LEADBAY_TOKEN;
26183
26496
  if (!token) {
26184
- if (process.env.LEADBAY_OAUTH_BOOTSTRAP === "1") {
26185
- process.stderr.write(
26186
- "leadbay-mcp: OAuth authorization is required but no token is available.\n Restart the Claude Desktop extension to authorize Leadbay in your browser.\n\nRun `leadbay-mcp --help` for the full config template.\n"
26187
- );
26188
- const regionEnv3 = process.env.LEADBAY_REGION;
26189
- const region2 = regionEnv3 === "fr" ? "fr" : "us";
26190
- return {
26191
- client: makeBrokenClient(
26192
- {
26193
- error: true,
26194
- code: "AUTH_MISSING",
26195
- message: "Leadbay OAuth authorization has not completed.",
26196
- hint: "Restart the Claude Desktop extension and complete the Leadbay OAuth browser authorization."
26197
- },
26198
- region2
26199
- ),
26200
- authState: "missing"
26201
- };
26202
- }
26203
26497
  process.stderr.write(
26204
26498
  "leadbay-mcp: LEADBAY_TOKEN environment variable is required.\n 1. Run: npx -y @leadbay/mcp install --oauth\n 2. Set it in your MCP client config (e.g. claude_desktop_config.json).\n\nRun `leadbay-mcp --help` for the full config template.\n"
26205
26499
  );
@@ -27030,7 +27324,8 @@ async function main() {
27030
27324
  installStartupSafetyNets(logger);
27031
27325
  const telemetry = initTelemetry({ version: VERSION, logger });
27032
27326
  const { client, authState } = await resolveClientFromEnv(logger);
27033
- telemetry.identify(client);
27327
+ const bootstrapPending = authState === "pending";
27328
+ if (!bootstrapPending) telemetry.identify(client);
27034
27329
  telemetry.captureStartup({
27035
27330
  auth_state: authState,
27036
27331
  region: client.region
@@ -27078,14 +27373,71 @@ async function main() {
27078
27373
  notificationsInbox,
27079
27374
  version: VERSION,
27080
27375
  telemetry,
27081
- updateStateStore
27376
+ updateStateStore,
27377
+ // Non-blocking OAuth bootstrap gate. Read per tool call: once the
27378
+ // background flow lands the token (client.isAuthenticated → true) this
27379
+ // reports done and tools execute. While waiting it surfaces the live
27380
+ // sign-in URL (captured via onAuthorizeUrl) so the agent can render a
27381
+ // clickable link — the reliable path when the spawned process can't open
27382
+ // a browser itself.
27383
+ bootstrapStatus: bootstrapPending ? () => client.isAuthenticated ? { done: true } : {
27384
+ done: false,
27385
+ signInUrl: pendingSignInUrl,
27386
+ openFailed: browserOpenFailedAtBootstrap,
27387
+ failureMessage: bootstrapFailureMessage
27388
+ } : void 0
27082
27389
  });
27083
27390
  const transport = new StdioServerTransport();
27084
27391
  logger.info?.(
27085
27392
  `Starting MCP server v${VERSION} (advanced=${includeAdvanced}, write=${includeWrite}, baseUrl=${client.baseUrl}, bulk_store=${bulkTracker.durability}, notifications_ws=${WS_DISABLED ? "disabled" : "enabled"}, auth_state=${authState})`
27086
27393
  );
27087
27394
  await server.connect(transport);
27395
+ if (bootstrapPending) {
27396
+ bootstrapDebug(`server connected; launching background OAuth bootstrap`);
27397
+ bootstrapInFlight = (async () => {
27398
+ const ok = await bootstrapOAuthIfMissing(logger);
27399
+ if (!ok) return;
27400
+ const region = process.env.LEADBAY_REGION === "fr" ? "fr" : "us";
27401
+ const apiBaseUrl = process.env.LEADBAY_BASE_URL ?? REGIONS[region];
27402
+ client.setBaseUrl(apiBaseUrl, region);
27403
+ client.setToken(process.env.LEADBAY_TOKEN);
27404
+ logger.info?.(`OAuth bootstrap landed \u2014 client authenticated (region=${region})`);
27405
+ telemetry.identify(client);
27406
+ if (process.env.LEADBAY_NOTIFICATIONS_WS_DISABLED !== "1" && !notificationsWs) {
27407
+ notificationsWs = new NotificationsWsClient({
27408
+ client,
27409
+ inbox: notificationsInbox,
27410
+ logger
27411
+ });
27412
+ void notificationsWs.start().catch((err) => {
27413
+ logger.warn?.(`notifications.ws start_failed (post-oauth): ${err?.message ?? err}`);
27414
+ });
27415
+ }
27416
+ })().catch((err) => {
27417
+ logger.warn?.(`oauth.bootstrap_bg_failed ${err?.message ?? err}`);
27418
+ });
27419
+ }
27088
27420
  const shutdown = async (code) => {
27421
+ if (bootstrapInFlight && !client.isAuthenticated) {
27422
+ bootstrapDebug(`shutdown(code=${code}) while bootstrap in flight \u2014 waiting up to 4s for URL/open`);
27423
+ try {
27424
+ await Promise.race([
27425
+ bootstrapInFlight,
27426
+ new Promise((r) => setTimeout(r, 4e3))
27427
+ ]);
27428
+ } catch {
27429
+ }
27430
+ }
27431
+ if (browserOpenInFlight) {
27432
+ bootstrapDebug(`shutdown(code=${code}) browser-open still in flight \u2014 waiting up to 1.5s`);
27433
+ try {
27434
+ await Promise.race([
27435
+ browserOpenInFlight,
27436
+ new Promise((r) => setTimeout(r, 1500))
27437
+ ]);
27438
+ } catch {
27439
+ }
27440
+ }
27089
27441
  try {
27090
27442
  notificationsWs?.stop();
27091
27443
  } catch {
@@ -27131,10 +27483,12 @@ export {
27131
27483
  buildClaudeCodeRemoveArgs,
27132
27484
  buildCodexConfigBlock,
27133
27485
  buildShellExportBlock,
27486
+ cacheOAuthClientId,
27134
27487
  checkLoginCollision,
27135
27488
  computeFreshDefaultPath,
27136
27489
  detectClaudeDesktopMode,
27137
27490
  formatInstallOsLabel,
27491
+ getCachedOAuthClientId,
27138
27492
  installInClaudeCode,
27139
27493
  installInCodexConfig,
27140
27494
  installInJsonConfig,