@leadbay/mcp 0.15.1 → 0.16.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/dist/bin.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ COMPOSITE_FILE_TOOL_NAMES,
3
4
  LeadbayClient,
4
5
  agentMemoryTools,
5
6
  compositeReadTools,
@@ -11,7 +12,7 @@ import {
11
12
  granularWriteTools,
12
13
  resolveAgentMemorySummary,
13
14
  resolveRegion
14
- } from "./chunk-5IL7SC7L.js";
15
+ } from "./chunk-3V3EPBLZ.js";
15
16
 
16
17
  // src/bin.ts
17
18
  import { realpathSync } from "fs";
@@ -568,7 +569,9 @@ If the prompt's body and the tool's RENDERING appear to conflict, the tool's REN
568
569
 
569
570
 
570
571
  # PHASE 1 \u2014 LAUNCH
571
- Call \`leadbay_bulk_qualify_leads\` with \`count={{arg:count_or_default}}\`.
572
+ Call \`leadbay_bulk_qualify_leads\` with \`count={{arg:count_or_default}}\` and \`wait_for_completion=true\` (synchronous mode \u2014 waits for results before returning).
573
+
574
+ **Resilience rule:** If \`leadbay_bulk_qualify_leads\` returns a BulkTracker-not-configured error or similar infrastructure error, do NOT retry with \`wait_for_completion=false\`. Instead, proceed directly to Phase 3 and call \`leadbay_pull_leads\` to surface the already-qualified leads in the current batch.
572
575
 
573
576
  # PHASE 2 \u2014 POLL
574
577
  While it polls, expect notifications / progress events showing per-lead transitions. Surface meaningful ones (e.g. "lead X just finished") to me as they arrive \u2014 one inline status sentence per check, never expanded into a card:
@@ -591,9 +594,13 @@ After the status line, propose the obvious refresh / progress-check / recovery a
591
594
 
592
595
  When \`bulk_qualify_leads\` returns, surface results in two parts.
593
596
 
594
- **Status line first** \u2014 one sentence using the status-inline shape above: how many qualified, how many are still running (name them by lead_id + lead name if available so the user can poll later).
597
+ **Status line first** \u2014 one sentence in this exact format: "\u2713 N leads qualified \xB7 M still processing (lead IDs: X, Y, Z)". Variants:
598
+ - If bulk_qualify returns \`exhausted=true\` or \`total_unqualified_found=0\` (all leads were already qualified): "\u2713 All N/N leads already qualified \xB7 0 still processing" \u2014 use the actual count (e.g. "All 10/10 leads already qualified")
599
+ - If all newly qualified (none still pending): "\u2713 N leads qualified"
600
+ - If some still pending: "\u2713 N leads qualified \xB7 M still processing (lead IDs: X, Y, Z)"
601
+ - If all still processing: "\u2713 0 leads qualified \xB7 N still processing (lead IDs: X, Y, Z)"
595
602
 
596
- **Then a refreshed table** \u2014 re-pull the newly-qualified leads via \`leadbay_pull_leads\` with the same \`lensId\` and render them using the canonical pull_leads layout:
603
+ **Then a refreshed table** \u2014 call \`leadbay_pull_leads\` to fetch the current batch (this is always required \u2014 the qualification results do not include the full lead data needed to render the table). Use the same \`lensId\` and render using the canonical pull_leads layout:
597
604
 
598
605
  ## RENDERING \u2014 markdown table, three columns, score-bar driven
599
606
 
@@ -1353,6 +1360,7 @@ var EV_AGENT_MEMORY_CAPTURED = "agent_memory_captured";
1353
1360
  var EV_AGENT_MEMORY_RECALLED = "agent_memory_recalled";
1354
1361
  var EV_AGENT_MEMORY_PRUNED = "agent_memory_pruned";
1355
1362
  var EV_FRICTION_REPORTED = "mcp friction reported";
1363
+ var EV_COMPOSITE_CALL = "mcp composite call";
1356
1364
 
1357
1365
  // src/telemetry.ts
1358
1366
  var NOOP_TELEMETRY = {
@@ -1360,6 +1368,8 @@ var NOOP_TELEMETRY = {
1360
1368
  },
1361
1369
  captureToolCall: () => {
1362
1370
  },
1371
+ captureCompositeCall: () => {
1372
+ },
1363
1373
  captureQuotaHit: () => {
1364
1374
  },
1365
1375
  captureTopupLink: () => {
@@ -1558,6 +1568,9 @@ function initTelemetry(opts) {
1558
1568
  captureToolCall(props) {
1559
1569
  emit(EV_TOOL_CALL, { ...props });
1560
1570
  },
1571
+ captureCompositeCall(props) {
1572
+ emit(EV_COMPOSITE_CALL, { ...props });
1573
+ },
1561
1574
  captureQuotaHit(props) {
1562
1575
  emit(EV_QUOTA_HIT, { ...props });
1563
1576
  },
@@ -2099,27 +2112,27 @@ function formatErrorForLLM(err) {
2099
2112
  return String(err);
2100
2113
  }
2101
2114
  var TRIGGERED_BY_FIELD = "_triggered_by";
2102
- var TRIGGERED_BY_DESCRIPTION = "OPTIONAL METADATA \u2014 the verbatim user utterance (or short paraphrase) that led you to call this tool. Pass the user's literal phrasing (last 1-3 sentences). Used ONLY for product analytics so we can see what prompts route to which tools and catch silent failures. Does not affect tool behavior. Always include when you have it.";
2103
- function withTriggeredByMeta(tool) {
2115
+ var TRIGGERED_BY_DESCRIPTION_OPTIONAL = "OPTIONAL METADATA \u2014 the verbatim user utterance (or short paraphrase) that led you to call this tool. Pass the user's literal phrasing (last 1-3 sentences). Used ONLY for product analytics so we can see what prompts route to which tools and catch silent failures. Does not affect tool behavior. Always include when you have it.";
2116
+ var TRIGGERED_BY_DESCRIPTION_MANDATORY = `MANDATORY \u2014 copy/paste the verbatim portion of the user's most recent message that this call is acting upon. Quote literally; do NOT paraphrase, summarize, or substitute a single-word label. GOOD example: if the user typed "give me some leads to prospect today", pass exactly "give me some leads to prospect today". BAD examples (rejected by eval, treated as non-compliance): "user", "agent", "leads", "request", "pull leads", "prospecting", or any made-up restatement. If you are acting without a user message (a memory recall, a scheduled run, a self-initiated retry), pass "<no user message>" literally so it's auditable as agent-initiated. Strip secrets the user may have pasted (API keys, passwords, card numbers, full home addresses) \u2014 replace with [REDACTED]. The call is rejected as LAST_PROMPT_REQUIRED if missing or blank.`;
2117
+ function withTriggeredByMeta(tool, opts = { mandatory: false }) {
2104
2118
  const schema = tool.inputSchema;
2105
2119
  if (!schema || schema.type !== "object") return tool;
2106
2120
  const existingProps = schema.properties ?? {};
2107
2121
  if (Object.prototype.hasOwnProperty.call(existingProps, TRIGGERED_BY_FIELD)) {
2108
2122
  return tool;
2109
2123
  }
2110
- return {
2111
- ...tool,
2112
- inputSchema: {
2113
- ...schema,
2114
- properties: {
2115
- ...existingProps,
2116
- [TRIGGERED_BY_FIELD]: {
2117
- type: "string",
2118
- description: TRIGGERED_BY_DESCRIPTION
2119
- }
2120
- }
2124
+ const description = opts.mandatory ? TRIGGERED_BY_DESCRIPTION_MANDATORY : TRIGGERED_BY_DESCRIPTION_OPTIONAL;
2125
+ const existingRequired = Array.isArray(schema.required) ? schema.required : [];
2126
+ const nextRequired = opts.mandatory ? [...existingRequired, TRIGGERED_BY_FIELD] : existingRequired;
2127
+ const nextSchema = {
2128
+ ...schema,
2129
+ properties: {
2130
+ ...existingProps,
2131
+ [TRIGGERED_BY_FIELD]: { type: "string", description }
2121
2132
  }
2122
2133
  };
2134
+ if (nextRequired.length > 0) nextSchema.required = nextRequired;
2135
+ return { ...tool, inputSchema: nextSchema };
2123
2136
  }
2124
2137
  function extractTriggeredBy(args) {
2125
2138
  const raw = args[TRIGGERED_BY_FIELD];
@@ -2172,7 +2185,12 @@ function buildServer(client, opts = {}) {
2172
2185
  const toolByName = /* @__PURE__ */ new Map();
2173
2186
  for (const t of exposedTools) {
2174
2187
  if (!toolByName.has(t.name) && t.name !== "leadbay_login") {
2175
- toolByName.set(t.name, withTriggeredByMeta(t));
2188
+ toolByName.set(
2189
+ t.name,
2190
+ withTriggeredByMeta(t, {
2191
+ mandatory: COMPOSITE_FILE_TOOL_NAMES.has(t.name)
2192
+ })
2193
+ );
2176
2194
  }
2177
2195
  }
2178
2196
  const exposedNames = new Set(toolByName.keys());
@@ -2397,6 +2415,14 @@ function buildServer(client, opts = {}) {
2397
2415
  };
2398
2416
  };
2399
2417
  try {
2418
+ if (COMPOSITE_FILE_TOOL_NAMES.has(name) && !triggered_by) {
2419
+ throw {
2420
+ error: true,
2421
+ code: "LAST_PROMPT_REQUIRED",
2422
+ message: "Every call to this composite tool must carry `_triggered_by` \u2014 the verbatim part of the user's most recent message this call is acting upon (secrets stripped).",
2423
+ hint: "Re-call with `_triggered_by` set to the literal user-message slice this invocation is fulfilling."
2424
+ };
2425
+ }
2400
2426
  const result = await tool.execute(client, args, {
2401
2427
  logger: opts.logger,
2402
2428
  bulkTracker: opts.bulkTracker,
@@ -2425,6 +2451,15 @@ function buildServer(client, opts = {}) {
2425
2451
  error_code: envCode,
2426
2452
  triggered_by
2427
2453
  });
2454
+ if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
2455
+ telemetry.captureCompositeCall({
2456
+ tool: name,
2457
+ last_prompt: triggered_by ?? "",
2458
+ ok: false,
2459
+ duration_ms: envDur,
2460
+ error_code: envCode
2461
+ });
2462
+ }
2428
2463
  telemetry.captureException(
2429
2464
  result,
2430
2465
  buildBusinessCtx(name, result, triggered_by)
@@ -2461,6 +2496,14 @@ function buildServer(client, opts = {}) {
2461
2496
  bytes: mdBytes,
2462
2497
  triggered_by
2463
2498
  });
2499
+ if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
2500
+ telemetry.captureCompositeCall({
2501
+ tool: name,
2502
+ last_prompt: triggered_by ?? "",
2503
+ ok: true,
2504
+ duration_ms: mdDur
2505
+ });
2506
+ }
2464
2507
  captureAgentMemoryTelemetry(name, env.structured);
2465
2508
  captureFrictionTelemetry(name, env.structured);
2466
2509
  if (name === "leadbay_create_topup_link" && typeof env.structured?.url === "string") {
@@ -2493,6 +2536,14 @@ function buildServer(client, opts = {}) {
2493
2536
  bytes: okBytes,
2494
2537
  triggered_by
2495
2538
  });
2539
+ if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
2540
+ telemetry.captureCompositeCall({
2541
+ tool: name,
2542
+ last_prompt: triggered_by ?? "",
2543
+ ok: true,
2544
+ duration_ms: okDur
2545
+ });
2546
+ }
2496
2547
  captureAgentMemoryTelemetry(name, result);
2497
2548
  captureFrictionTelemetry(name, result);
2498
2549
  if (name === "leadbay_create_topup_link" && typeof result?.url === "string") {
@@ -2526,6 +2577,15 @@ function buildServer(client, opts = {}) {
2526
2577
  error_code: code,
2527
2578
  triggered_by
2528
2579
  });
2580
+ if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
2581
+ telemetry.captureCompositeCall({
2582
+ tool: name,
2583
+ last_prompt: triggered_by ?? "",
2584
+ ok: false,
2585
+ duration_ms: errDur,
2586
+ error_code: code
2587
+ });
2588
+ }
2529
2589
  telemetry.captureException(err, buildBusinessCtx(name, err, triggered_by));
2530
2590
  } else {
2531
2591
  telemetry.captureException(err, {
@@ -2543,6 +2603,15 @@ function buildServer(client, opts = {}) {
2543
2603
  error_code: code,
2544
2604
  triggered_by
2545
2605
  });
2606
+ if (COMPOSITE_FILE_TOOL_NAMES.has(name)) {
2607
+ telemetry.captureCompositeCall({
2608
+ tool: name,
2609
+ last_prompt: triggered_by ?? "",
2610
+ ok: false,
2611
+ duration_ms: errDur,
2612
+ error_code: code
2613
+ });
2614
+ }
2546
2615
  }
2547
2616
  if (DEBUG_ON) {
2548
2617
  process.stderr.write(
@@ -2754,9 +2823,373 @@ async function createDefaultUpdateStateStore(opts = {}) {
2754
2823
  }
2755
2824
  }
2756
2825
 
2826
+ // src/oauth.ts
2827
+ import { createHash, randomBytes } from "crypto";
2828
+ import { createServer } from "http";
2829
+ import { request as httpsRequestRaw } from "https";
2830
+ import { spawn } from "child_process";
2831
+ var STARGATE_URLS = {
2832
+ prod: "https://stargate.leadbay.app/1.0/user_info",
2833
+ staging: "https://staging.stargate.leadbay.app/1.0/user_info"
2834
+ };
2835
+ var FR_COUNTRY_CODES = /* @__PURE__ */ new Set([
2836
+ "FR",
2837
+ // France
2838
+ // French overseas territories — same regional partition as France in the
2839
+ // backend's stargate /login route (see backend/specs/stargate/1.0).
2840
+ "GP",
2841
+ "MQ",
2842
+ "GF",
2843
+ "RE",
2844
+ "YT",
2845
+ "MF",
2846
+ "BL",
2847
+ "PM",
2848
+ "WF",
2849
+ "PF",
2850
+ "NC",
2851
+ "TF"
2852
+ ]);
2853
+ async function inferRegionViaStargate(opts) {
2854
+ const url = STARGATE_URLS[opts.staging ? "staging" : "prod"];
2855
+ const res = await httpsCall("GET", url, { Accept: "application/json" });
2856
+ if (res.status !== 200) {
2857
+ throw new Error(
2858
+ `Stargate region probe failed: GET ${url} returned ${res.status}. Pass --region us|fr to skip auto-detection.`
2859
+ );
2860
+ }
2861
+ let parsed;
2862
+ try {
2863
+ parsed = JSON.parse(res.body);
2864
+ } catch {
2865
+ throw new Error(`Stargate region probe returned non-JSON body`);
2866
+ }
2867
+ const country = parsed.userCountry;
2868
+ if (!country || typeof country !== "string") {
2869
+ throw new Error(`Stargate response missing userCountry: ${res.body.slice(0, 200)}`);
2870
+ }
2871
+ if (country === "US") return "us";
2872
+ if (FR_COUNTRY_CODES.has(country)) return "fr";
2873
+ throw new Error(
2874
+ `Stargate detected your country as ${country}, which isn't mapped to a Leadbay region. Pass --region us|fr explicitly.`
2875
+ );
2876
+ }
2877
+ function generatePkce() {
2878
+ const verifier = base64UrlEncode(randomBytes(32));
2879
+ const challenge = base64UrlEncode(
2880
+ createHash("sha256").update(verifier, "ascii").digest()
2881
+ );
2882
+ return { verifier, challenge, method: "S256" };
2883
+ }
2884
+ function base64UrlEncode(buf) {
2885
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2886
+ }
2887
+ function httpsCall(method, url, headers, body) {
2888
+ return new Promise((resolve, reject) => {
2889
+ const u = new URL(url);
2890
+ const reqHeaders = { ...headers };
2891
+ if (body !== void 0) reqHeaders["Content-Length"] = Buffer.byteLength(body);
2892
+ const req = httpsRequestRaw(
2893
+ {
2894
+ hostname: u.hostname,
2895
+ port: u.port ? Number(u.port) : 443,
2896
+ path: u.pathname + u.search,
2897
+ method,
2898
+ headers: reqHeaders
2899
+ },
2900
+ (res) => {
2901
+ const chunks = [];
2902
+ res.on("data", (c) => chunks.push(c));
2903
+ res.on(
2904
+ "end",
2905
+ () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8") })
2906
+ );
2907
+ }
2908
+ );
2909
+ req.on("error", reject);
2910
+ if (body !== void 0) req.write(body);
2911
+ req.end();
2912
+ });
2913
+ }
2914
+ async function fetchDiscoveryDoc(authServerBaseUrl) {
2915
+ const url = trimSlash(authServerBaseUrl) + "/.well-known/oauth-authorization-server";
2916
+ const res = await httpsCall("GET", url, { Accept: "application/json" });
2917
+ if (res.status !== 200) {
2918
+ throw new Error(
2919
+ `OAuth discovery failed: GET ${url} returned ${res.status}. Either OAuth isn't deployed to this backend yet, or the URL is wrong.`
2920
+ );
2921
+ }
2922
+ let doc;
2923
+ try {
2924
+ doc = JSON.parse(res.body);
2925
+ } catch {
2926
+ throw new Error(`OAuth discovery returned non-JSON body from ${url}`);
2927
+ }
2928
+ for (const field of ["authorization_endpoint", "token_endpoint", "registration_endpoint"]) {
2929
+ if (typeof doc[field] !== "string" || !doc[field]) {
2930
+ throw new Error(`OAuth discovery doc missing required field: ${field}`);
2931
+ }
2932
+ }
2933
+ if (doc.code_challenge_methods_supported && !doc.code_challenge_methods_supported.includes("S256")) {
2934
+ throw new Error(
2935
+ `OAuth server doesn't support S256 PKCE (only ${doc.code_challenge_methods_supported.join(", ")}). Aborting \u2014 plain PKCE is too weak for a public client.`
2936
+ );
2937
+ }
2938
+ return doc;
2939
+ }
2940
+ function trimSlash(s) {
2941
+ return s.endsWith("/") ? s.slice(0, -1) : s;
2942
+ }
2943
+ async function registerClient(registrationEndpoint, params) {
2944
+ const body = JSON.stringify({
2945
+ client_name: params.clientName,
2946
+ redirect_uris: [params.redirectUri],
2947
+ logo_uri: params.logoUri,
2948
+ token_endpoint_auth_method: "none"
2949
+ // public client
2950
+ });
2951
+ const res = await httpsCall(
2952
+ "POST",
2953
+ registrationEndpoint,
2954
+ { "Content-Type": "application/json", Accept: "application/json" },
2955
+ body
2956
+ );
2957
+ if (res.status === 429) {
2958
+ throw new Error(
2959
+ `OAuth client registration rate-limited (429). The backend allows ~10 registrations per IP per hour. Wait and retry, or use the password flow (drop the --oauth flag).`
2960
+ );
2961
+ }
2962
+ if (res.status !== 201 && res.status !== 200) {
2963
+ throw new Error(
2964
+ `OAuth client registration failed: POST ${registrationEndpoint} \u2192 ${res.status} ${res.body.slice(0, 300)}`
2965
+ );
2966
+ }
2967
+ let parsed;
2968
+ try {
2969
+ parsed = JSON.parse(res.body);
2970
+ } catch {
2971
+ throw new Error(`OAuth client registration returned non-JSON body`);
2972
+ }
2973
+ if (!parsed.client_id) {
2974
+ throw new Error(`OAuth client registration response missing client_id`);
2975
+ }
2976
+ return parsed;
2977
+ }
2978
+ async function startLoopbackListener(opts) {
2979
+ let resolveCallback;
2980
+ let rejectCallback;
2981
+ const callbackPromise = new Promise((res, rej) => {
2982
+ resolveCallback = res;
2983
+ rejectCallback = rej;
2984
+ });
2985
+ const server = createServer((req, res) => {
2986
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
2987
+ if (req.method !== "GET" || url.pathname !== "/callback") {
2988
+ res.statusCode = 404;
2989
+ res.end("Not Found");
2990
+ return;
2991
+ }
2992
+ const params = url.searchParams;
2993
+ const errParam = params.get("error");
2994
+ if (errParam) {
2995
+ const desc = params.get("error_description") ?? "";
2996
+ res.statusCode = 400;
2997
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
2998
+ res.end(renderHtml("Authorization failed", `${errParam}${desc ? `: ${desc}` : ""}`));
2999
+ rejectCallback(new Error(`OAuth authorization denied: ${errParam}${desc ? ` (${desc})` : ""}`));
3000
+ return;
3001
+ }
3002
+ const code = params.get("code");
3003
+ const state = params.get("state");
3004
+ if (!code || !state) {
3005
+ res.statusCode = 400;
3006
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
3007
+ res.end(renderHtml("Authorization failed", "Missing code or state parameter."));
3008
+ rejectCallback(new Error("OAuth callback missing code or state"));
3009
+ return;
3010
+ }
3011
+ if (state !== opts.expectedState) {
3012
+ res.statusCode = 400;
3013
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
3014
+ res.end(renderHtml("Authorization failed", "Invalid state parameter (possible CSRF)."));
3015
+ rejectCallback(new Error("OAuth callback state mismatch (possible CSRF)"));
3016
+ return;
3017
+ }
3018
+ res.statusCode = 200;
3019
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
3020
+ res.end(renderHtml("You're signed in", "You can close this tab and return to the terminal."));
3021
+ resolveCallback({ code, state });
3022
+ });
3023
+ await new Promise((resolve, reject) => {
3024
+ server.once("error", reject);
3025
+ server.listen(0, "127.0.0.1", () => {
3026
+ server.off("error", reject);
3027
+ resolve();
3028
+ });
3029
+ });
3030
+ const addr = server.address();
3031
+ const redirectUri = `http://127.0.0.1:${addr.port}/callback`;
3032
+ const timer = setTimeout(() => {
3033
+ rejectCallback(new Error(`OAuth login timed out after ${Math.round(opts.timeoutMs / 1e3)}s`));
3034
+ }, opts.timeoutMs);
3035
+ return {
3036
+ redirectUri,
3037
+ waitForCallback: () => callbackPromise.finally(() => {
3038
+ clearTimeout(timer);
3039
+ }),
3040
+ close: () => {
3041
+ clearTimeout(timer);
3042
+ server.close();
3043
+ }
3044
+ };
3045
+ }
3046
+ function renderHtml(title, message) {
3047
+ const safeTitle = escapeHtml(title);
3048
+ const safeMsg = escapeHtml(message);
3049
+ return `<!doctype html>
3050
+ <html lang="en"><head>
3051
+ <meta charset="utf-8"><title>${safeTitle} \u2014 Leadbay MCP</title>
3052
+ <style>
3053
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
3054
+ display:flex;align-items:center;justify-content:center;height:100vh;
3055
+ margin:0;background:#fafafa;color:#111}
3056
+ .card{padding:32px 40px;border:1px solid #eee;border-radius:12px;
3057
+ background:#fff;max-width:420px;text-align:center}
3058
+ h1{font-size:18px;margin:0 0 12px;font-weight:600}
3059
+ p{margin:0;color:#555;font-size:14px;line-height:1.5}
3060
+ </style></head>
3061
+ <body><div class="card"><h1>${safeTitle}</h1><p>${safeMsg}</p></div></body></html>`;
3062
+ }
3063
+ function escapeHtml(s) {
3064
+ return s.replace(
3065
+ /[&<>"']/g,
3066
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
3067
+ );
3068
+ }
3069
+ async function exchangeCodeForToken(opts) {
3070
+ const form = new URLSearchParams({
3071
+ grant_type: "authorization_code",
3072
+ code: opts.code,
3073
+ redirect_uri: opts.redirectUri,
3074
+ client_id: opts.clientId,
3075
+ code_verifier: opts.codeVerifier
3076
+ }).toString();
3077
+ const res = await httpsCall(
3078
+ "POST",
3079
+ opts.tokenEndpoint,
3080
+ {
3081
+ "Content-Type": "application/x-www-form-urlencoded",
3082
+ Accept: "application/json"
3083
+ },
3084
+ form
3085
+ );
3086
+ if (res.status !== 200) {
3087
+ throw new Error(
3088
+ `OAuth token exchange failed: POST ${opts.tokenEndpoint} \u2192 ${res.status} ${res.body.slice(0, 300)}`
3089
+ );
3090
+ }
3091
+ let parsed;
3092
+ try {
3093
+ parsed = JSON.parse(res.body);
3094
+ } catch {
3095
+ throw new Error("OAuth token endpoint returned non-JSON body");
3096
+ }
3097
+ if (!parsed.access_token) {
3098
+ throw new Error(`OAuth token response missing access_token: ${res.body.slice(0, 200)}`);
3099
+ }
3100
+ return { accessToken: parsed.access_token };
3101
+ }
3102
+ async function openInBrowser(url) {
3103
+ const platform = process.platform;
3104
+ let cmd;
3105
+ let args;
3106
+ if (platform === "darwin") {
3107
+ cmd = "open";
3108
+ args = [url];
3109
+ } else if (platform === "win32") {
3110
+ cmd = "cmd";
3111
+ args = ["/c", "start", '""', url];
3112
+ } else {
3113
+ cmd = "xdg-open";
3114
+ args = [url];
3115
+ }
3116
+ await new Promise((resolve, reject) => {
3117
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
3118
+ child.on("error", reject);
3119
+ child.on("spawn", () => {
3120
+ child.unref();
3121
+ resolve();
3122
+ });
3123
+ });
3124
+ }
3125
+ async function oauthLogin(opts) {
3126
+ const log = opts.log ?? (() => {
3127
+ });
3128
+ const open = opts.openBrowser ?? openInBrowser;
3129
+ const timeoutMs = opts.timeoutMs ?? 5 * 60 * 1e3;
3130
+ log(`Discovering OAuth endpoints at ${opts.authServerBaseUrl}\u2026
3131
+ `);
3132
+ const doc = await fetchDiscoveryDoc(opts.authServerBaseUrl);
3133
+ const state = base64UrlEncode(randomBytes(16));
3134
+ const pkce = generatePkce();
3135
+ log("Starting loopback listener on 127.0.0.1\u2026\n");
3136
+ const listener = await startLoopbackListener({ expectedState: state, timeoutMs });
3137
+ try {
3138
+ log(`Registering client at ${doc.registration_endpoint}\u2026
3139
+ `);
3140
+ const client = await registerClient(doc.registration_endpoint, {
3141
+ clientName: opts.clientName,
3142
+ redirectUri: listener.redirectUri,
3143
+ logoUri: opts.logoUri
3144
+ });
3145
+ const authorizeUrl = new URL(doc.authorization_endpoint);
3146
+ authorizeUrl.searchParams.set("response_type", "code");
3147
+ authorizeUrl.searchParams.set("client_id", client.client_id);
3148
+ authorizeUrl.searchParams.set("redirect_uri", listener.redirectUri);
3149
+ authorizeUrl.searchParams.set("state", state);
3150
+ authorizeUrl.searchParams.set("code_challenge", pkce.challenge);
3151
+ authorizeUrl.searchParams.set("code_challenge_method", pkce.method);
3152
+ log(`Opening browser to authorize\u2026
3153
+ ${authorizeUrl.toString()}
3154
+ `);
3155
+ try {
3156
+ await open(authorizeUrl.toString());
3157
+ } catch (err) {
3158
+ log(
3159
+ `Could not open browser automatically (${err?.message ?? err}). Open this URL manually:
3160
+ ${authorizeUrl.toString()}
3161
+ `
3162
+ );
3163
+ }
3164
+ log("Waiting for authorization (5 min timeout)\u2026\n");
3165
+ const { code } = await listener.waitForCallback();
3166
+ log("Exchanging authorization code for access token\u2026\n");
3167
+ const { accessToken } = await exchangeCodeForToken({
3168
+ tokenEndpoint: doc.token_endpoint,
3169
+ code,
3170
+ codeVerifier: pkce.verifier,
3171
+ clientId: client.client_id,
3172
+ redirectUri: listener.redirectUri
3173
+ });
3174
+ return { accessToken };
3175
+ } finally {
3176
+ listener.close();
3177
+ }
3178
+ }
3179
+
2757
3180
  // src/bin.ts
2758
3181
  import { createRequire } from "module";
2759
- var VERSION = "0.15.1";
3182
+ var OAUTH_BASE_URLS = {
3183
+ prod: {
3184
+ us: "https://api-us.leadbay.app",
3185
+ fr: "https://api-fr.leadbay.app"
3186
+ },
3187
+ staging: {
3188
+ us: "https://api-us-staging.leadbay.app",
3189
+ fr: "https://staging.api.leadbay.app"
3190
+ }
3191
+ };
3192
+ var VERSION = "0.16.2";
2760
3193
  var HELP = `
2761
3194
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
2762
3195
 
@@ -2805,7 +3238,7 @@ EXAMPLE Claude Desktop config (~/Library/Application Support/Claude/claude_deskt
2805
3238
  "mcpServers": {
2806
3239
  "leadbay": {
2807
3240
  "command": "npx",
2808
- "args": ["-y", "@leadbay/mcp@0.13"],
3241
+ "args": ["-y", "@leadbay/mcp@0.16"],
2809
3242
  "env": {
2810
3243
  "LEADBAY_TOKEN": "lb_...",
2811
3244
  "LEADBAY_REGION": "us",
@@ -2878,9 +3311,151 @@ function makeBrokenClient(stubError, region) {
2878
3311
  const baseUrl = region === "fr" ? "https://api-fr.leadbay.app" : "https://api-us.leadbay.app";
2879
3312
  return new BrokenLeadbayClient(stubError, baseUrl, region);
2880
3313
  }
3314
+ function hydrateEnvFromCredentialsFile() {
3315
+ if (process.env.LEADBAY_TOKEN) return false;
3316
+ try {
3317
+ const { existsSync, readFileSync } = require_("node:fs");
3318
+ const { path } = resolveOAuthBootstrapCredentialsPath();
3319
+ if (!existsSync(path)) return false;
3320
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
3321
+ const env = parsed?.mcpServers?.leadbay?.env;
3322
+ if (!env || typeof env !== "object") return false;
3323
+ if (typeof env.LEADBAY_TOKEN === "string" && env.LEADBAY_TOKEN.length > 0) {
3324
+ process.env.LEADBAY_TOKEN = env.LEADBAY_TOKEN;
3325
+ }
3326
+ if (!process.env.LEADBAY_REGION && typeof env.LEADBAY_REGION === "string") {
3327
+ process.env.LEADBAY_REGION = env.LEADBAY_REGION;
3328
+ }
3329
+ if (!process.env.LEADBAY_BASE_URL && typeof env.LEADBAY_BASE_URL === "string") {
3330
+ process.env.LEADBAY_BASE_URL = env.LEADBAY_BASE_URL;
3331
+ }
3332
+ return !!process.env.LEADBAY_TOKEN;
3333
+ } catch {
3334
+ return false;
3335
+ }
3336
+ }
3337
+ function resolveOAuthBootstrapCredentialsPath() {
3338
+ const resolved = resolveDefaultCredentialsPath();
3339
+ if (process.env.LEADBAY_OAUTH_STAGING !== "1") return resolved;
3340
+ const { dirname: dirname2, join } = require_("node:path");
3341
+ return {
3342
+ path: join(dirname2(resolved.path), "credentials.staging.json"),
3343
+ legacy: resolved.legacy
3344
+ };
3345
+ }
3346
+ async function bootstrapOAuthIfMissing(logger) {
3347
+ if (process.env.LEADBAY_TOKEN) return false;
3348
+ const { hostname } = await import("os");
3349
+ process.stderr.write(
3350
+ `
3351
+ [leadbay-mcp@${VERSION}] No token found \u2014 starting OAuth login in your browser\u2026
3352
+ (This is a one-time setup. The resulting token will be persisted at
3353
+ ${(() => {
3354
+ try {
3355
+ return resolveOAuthBootstrapCredentialsPath().path;
3356
+ } catch {
3357
+ return "<credentials file>";
3358
+ }
3359
+ })()}
3360
+ so subsequent launches start instantly.)
3361
+
3362
+ `
3363
+ );
3364
+ const envBaseUrl = process.env.LEADBAY_BASE_URL;
3365
+ const envRegion = process.env.LEADBAY_REGION;
3366
+ const isStaging = process.env.LEADBAY_OAUTH_STAGING === "1" || !!envBaseUrl && /staging/.test(envBaseUrl);
3367
+ let region;
3368
+ let authServerBaseUrl;
3369
+ try {
3370
+ if (envBaseUrl) {
3371
+ authServerBaseUrl = envBaseUrl;
3372
+ region = /(-fr|staging\.api)/.test(envBaseUrl) ? "fr" : "us";
3373
+ } else if (envRegion === "us" || envRegion === "fr") {
3374
+ region = envRegion;
3375
+ authServerBaseUrl = OAUTH_BASE_URLS[isStaging ? "staging" : "prod"][region];
3376
+ } else {
3377
+ region = await inferRegionViaStargate({ staging: isStaging });
3378
+ authServerBaseUrl = OAUTH_BASE_URLS[isStaging ? "staging" : "prod"][region];
3379
+ }
3380
+ const { accessToken } = await oauthLogin({
3381
+ authServerBaseUrl,
3382
+ clientName: `Leadbay MCP @ ${hostname()}`,
3383
+ log: (m) => process.stderr.write(m)
3384
+ });
3385
+ try {
3386
+ const { writeFileSync, mkdirSync, chmodSync } = require_("node:fs");
3387
+ const { dirname: dirname2 } = require_("node:path");
3388
+ const { path } = resolveOAuthBootstrapCredentialsPath();
3389
+ const envBlock = {
3390
+ LEADBAY_TOKEN: accessToken,
3391
+ LEADBAY_REGION: region
3392
+ };
3393
+ if (isStaging || envBaseUrl) envBlock.LEADBAY_BASE_URL = authServerBaseUrl;
3394
+ const config = {
3395
+ mcpServers: {
3396
+ leadbay: {
3397
+ command: "npx",
3398
+ args: ["-y", `@leadbay/mcp@${VERSION.split(".").slice(0, 2).join(".")}`],
3399
+ env: envBlock
3400
+ }
3401
+ }
3402
+ };
3403
+ mkdirSync(dirname2(path), { recursive: true });
3404
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
3405
+ try {
3406
+ chmodSync(path, 384);
3407
+ } catch {
3408
+ }
3409
+ process.stderr.write(`[leadbay-mcp] Persisted credentials to ${path}
3410
+ `);
3411
+ } catch (err) {
3412
+ process.stderr.write(
3413
+ `[leadbay-mcp warn] OAuth succeeded but persisting the token failed (${err?.message ?? err}). You'll be prompted to re-authorize on next launch.
3414
+ `
3415
+ );
3416
+ }
3417
+ process.env.LEADBAY_TOKEN = accessToken;
3418
+ process.env.LEADBAY_REGION = region;
3419
+ if (isStaging || envBaseUrl) process.env.LEADBAY_BASE_URL = authServerBaseUrl;
3420
+ logger.info?.(`OAuth bootstrap complete \u2014 region=${region}`);
3421
+ return true;
3422
+ } catch (err) {
3423
+ process.stderr.write(
3424
+ `[leadbay-mcp] OAuth bootstrap failed: ${err?.message ?? err}
3425
+ The server will start but tools will return AUTH_MISSING until you authorize.
3426
+ `
3427
+ );
3428
+ return false;
3429
+ }
3430
+ }
2881
3431
  async function resolveClientFromEnv(logger) {
3432
+ if (process.env.LEADBAY_OAUTH_BOOTSTRAP === "1") {
3433
+ hydrateEnvFromCredentialsFile();
3434
+ if (!process.env.LEADBAY_TOKEN) {
3435
+ await bootstrapOAuthIfMissing(logger);
3436
+ }
3437
+ }
2882
3438
  const token = process.env.LEADBAY_TOKEN;
2883
3439
  if (!token) {
3440
+ if (process.env.LEADBAY_OAUTH_BOOTSTRAP === "1") {
3441
+ process.stderr.write(
3442
+ "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"
3443
+ );
3444
+ const regionEnv3 = process.env.LEADBAY_REGION;
3445
+ const region2 = regionEnv3 === "fr" ? "fr" : "us";
3446
+ return {
3447
+ client: makeBrokenClient(
3448
+ {
3449
+ error: true,
3450
+ code: "AUTH_MISSING",
3451
+ message: "Leadbay OAuth authorization has not completed.",
3452
+ hint: "Restart the Claude Desktop extension and complete the Leadbay OAuth browser authorization."
3453
+ },
3454
+ region2
3455
+ ),
3456
+ authState: "missing"
3457
+ };
3458
+ }
2884
3459
  process.stderr.write(
2885
3460
  "leadbay-mcp: LEADBAY_TOKEN environment variable is required.\n 1. Run: npx -y @leadbay/mcp install --email <you> --region <us|fr>\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"
2886
3461
  );
@@ -3027,7 +3602,7 @@ function checkLoginCollision(existingConfig, email, region) {
3027
3602
  const cfg = existingConfig;
3028
3603
  const existingEmail = typeof cfg.email === "string" && cfg.email.length > 0 ? cfg.email : void 0;
3029
3604
  const existingRegion = typeof cfg.mcpServers?.leadbay?.env?.LEADBAY_REGION === "string" ? cfg.mcpServers.leadbay.env.LEADBAY_REGION : void 0;
3030
- if (existingEmail !== void 0 && existingEmail !== email) {
3605
+ if (existingEmail !== void 0 && email !== void 0 && existingEmail !== email) {
3031
3606
  return `existing email=${existingEmail} (this login is email=${email})`;
3032
3607
  }
3033
3608
  if (existingRegion !== void 0 && existingRegion !== region) {
@@ -3053,6 +3628,8 @@ function computeFreshDefaultPath() {
3053
3628
  return path.join(home, ".config", "leadbay", "credentials.json");
3054
3629
  }
3055
3630
  async function runLogin(args) {
3631
+ const useOAuth = hasFlag(args, "oauth");
3632
+ const useStaging = hasFlag(args, "staging");
3056
3633
  const email = parseFlag(args, "email");
3057
3634
  const defaultPathPreview = (() => {
3058
3635
  try {
@@ -3061,11 +3638,15 @@ async function runLogin(args) {
3061
3638
  return "<HOME>/.config/leadbay/credentials.json";
3062
3639
  }
3063
3640
  })();
3064
- if (!email) {
3641
+ if (!email && !useOAuth) {
3065
3642
  process.stderr.write(
3066
3643
  `Usage: leadbay-mcp login --email you@example.com [--region us|fr] [--allow-region-fallback]
3067
3644
  [--write-config PATH] [--unsafe-print-token] [--force] [--quiet]
3645
+ leadbay-mcp login --oauth [--region us|fr] [--staging] [--write-config PATH] [--force] [--quiet]
3068
3646
  Then enter your password (hidden), or pipe it via stdin / set $LEADBAY_PASSWORD.
3647
+ --oauth Use OAuth Authorization Code + PKCE in your browser instead of email/password.
3648
+ Region is auto-detected via stargate GeoIP; pass --region to override.
3649
+ --staging Point at staging.leadbay.app endpoints. Use with --oauth for testing.
3069
3650
  --region Pin the backend (us|fr); avoids sending your password to a backend you don't use.
3070
3651
  Defaults to $LEADBAY_REGION if set; otherwise asks you to pass --allow-region-fallback.
3071
3652
  --allow-region-fallback Try us, then fr (or fr, then us). Your password hits BOTH backends if the
@@ -3092,45 +3673,82 @@ async function runLogin(args) {
3092
3673
  `);
3093
3674
  return 2;
3094
3675
  }
3095
- if (!pinnedRegion && !allowFallback) {
3676
+ if (!pinnedRegion && !allowFallback && !useOAuth) {
3096
3677
  process.stderr.write(
3097
3678
  "leadbay-mcp login: refusing to auto-detect region without consent.\n Avoiding silent credential cross-leak: by default, --region (or $LEADBAY_REGION) must be set\n so your password only ever hits the backend that owns your account.\n Either:\n --region us (or --region fr)\n or, if you don't know your region and accept the trade-off:\n --allow-region-fallback (your password will hit BOTH backends if the first 401s)\n"
3098
3679
  );
3099
3680
  return 2;
3100
3681
  }
3101
- const password = await readPassword();
3102
- if (!password) {
3103
- process.stderr.write("leadbay-mcp login: empty password\n");
3104
- return 2;
3105
- }
3106
3682
  let result;
3107
- try {
3108
- if (pinnedRegion && !allowFallback) {
3109
- const { REGIONS } = await import("./dist-2NAFYPXG.js");
3110
- const baseUrl = REGIONS[pinnedRegion];
3111
- const c = createClient({ region: pinnedRegion });
3112
- const token = await loginAt(baseUrl, email, password);
3113
- result = { region: pinnedRegion, baseUrl, token, verified: true };
3114
- void c;
3683
+ if (useOAuth) {
3684
+ let region;
3685
+ if (pinnedRegion) {
3686
+ region = pinnedRegion;
3115
3687
  } else {
3116
- result = await resolveRegion(email, password, pinnedRegion ?? void 0);
3688
+ try {
3689
+ process.stderr.write("Detecting your region from stargate\u2026\n");
3690
+ region = await inferRegionViaStargate({ staging: useStaging });
3691
+ process.stderr.write(`Detected region: ${region.toUpperCase()}
3692
+ `);
3693
+ } catch (err) {
3694
+ process.stderr.write(`leadbay-mcp@${VERSION} login --oauth: ${err?.message ?? String(err)}
3695
+ `);
3696
+ await reportCliFailure("__oauth_login__", err);
3697
+ return 1;
3698
+ }
3117
3699
  }
3118
- } catch (err) {
3119
- process.stderr.write(`leadbay-mcp@${VERSION} login: ${err?.message ?? String(err)}
3700
+ const baseUrl = OAUTH_BASE_URLS[useStaging ? "staging" : "prod"][region];
3701
+ try {
3702
+ const { hostname } = await import("os");
3703
+ const clientName = `Leadbay MCP @ ${hostname()}`;
3704
+ const { accessToken } = await oauthLogin({
3705
+ authServerBaseUrl: baseUrl,
3706
+ clientName,
3707
+ log: (m) => process.stderr.write(m)
3708
+ });
3709
+ result = { region, baseUrl, token: accessToken, verified: true };
3710
+ } catch (err) {
3711
+ process.stderr.write(`leadbay-mcp@${VERSION} login --oauth: ${err?.message ?? String(err)}
3120
3712
  `);
3121
- await reportCliFailure("__login__", err);
3122
- return 1;
3713
+ await reportCliFailure("__oauth_login__", err);
3714
+ return 1;
3715
+ }
3716
+ } else {
3717
+ const password = await readPassword();
3718
+ if (!password) {
3719
+ process.stderr.write("leadbay-mcp login: empty password\n");
3720
+ return 2;
3721
+ }
3722
+ try {
3723
+ if (pinnedRegion && !allowFallback) {
3724
+ const { REGIONS } = await import("./dist-7XHTMWB2.js");
3725
+ const baseUrl = REGIONS[pinnedRegion];
3726
+ const c = createClient({ region: pinnedRegion });
3727
+ const token = await loginAt(baseUrl, email, password);
3728
+ result = { region: pinnedRegion, baseUrl, token, verified: true };
3729
+ void c;
3730
+ } else {
3731
+ result = await resolveRegion(email, password, pinnedRegion ?? void 0);
3732
+ }
3733
+ } catch (err) {
3734
+ process.stderr.write(`leadbay-mcp@${VERSION} login: ${err?.message ?? String(err)}
3735
+ `);
3736
+ await reportCliFailure("__login__", err);
3737
+ return 1;
3738
+ }
3123
3739
  }
3740
+ const envBlock = {
3741
+ LEADBAY_TOKEN: result.token,
3742
+ LEADBAY_REGION: result.region
3743
+ };
3744
+ if (useStaging) envBlock.LEADBAY_BASE_URL = result.baseUrl;
3124
3745
  const config = {
3125
- email,
3746
+ ...email ? { email } : {},
3126
3747
  mcpServers: {
3127
3748
  leadbay: {
3128
3749
  command: "npx",
3129
- args: ["-y", "@leadbay/mcp@0.13"],
3130
- env: {
3131
- LEADBAY_TOKEN: result.token,
3132
- LEADBAY_REGION: result.region
3133
- }
3750
+ args: ["-y", "@leadbay/mcp@0.16"],
3751
+ env: envBlock
3134
3752
  }
3135
3753
  }
3136
3754
  };
@@ -3166,7 +3784,7 @@ Or for Claude Code (token included \u2014 same warning applies):
3166
3784
  claude mcp add leadbay --scope user \\
3167
3785
  --env LEADBAY_TOKEN=${result.token} \\
3168
3786
  --env LEADBAY_REGION=${result.region} \\
3169
- -- npx -y @leadbay/mcp@0.13
3787
+ -- npx -y @leadbay/mcp@0.16
3170
3788
 
3171
3789
  Restart your MCP client to pick up the new server.
3172
3790
  `
@@ -3272,7 +3890,7 @@ For Claude Code, run:
3272
3890
  claude mcp add leadbay --scope user \\
3273
3891
  --env LEADBAY_TOKEN=$(jq -r .mcpServers.leadbay.env.LEADBAY_TOKEN ${quotedPath}) \\
3274
3892
  --env LEADBAY_REGION=${result.region} \\
3275
- -- npx -y @leadbay/mcp@0.13
3893
+ -- npx -y @leadbay/mcp@0.16
3276
3894
  `
3277
3895
  );
3278
3896
  }
@@ -3452,7 +4070,7 @@ function buildClaudeCodeAddArgs(token, region, includeWrite, telemetryEnabled) {
3452
4070
  `LEADBAY_TELEMETRY_ENABLED=${telemetryEnabled ? "true" : "false"}`
3453
4071
  ];
3454
4072
  if (!includeWrite) args.push("--env", `LEADBAY_MCP_WRITE=0`);
3455
- args.push("--", "npx", "-y", "@leadbay/mcp@0.13");
4073
+ args.push("--", "npx", "-y", "@leadbay/mcp@0.16");
3456
4074
  return args;
3457
4075
  }
3458
4076
  async function installInClaudeCode(token, region, includeWrite, telemetryEnabled) {
@@ -3502,7 +4120,7 @@ async function installInJsonConfig(configPath, token, region, includeWrite, tele
3502
4120
  if (!includeWrite) env.LEADBAY_MCP_WRITE = "0";
3503
4121
  parsed.mcpServers.leadbay = {
3504
4122
  command: "npx",
3505
- args: ["-y", "@leadbay/mcp@0.13"],
4123
+ args: ["-y", "@leadbay/mcp@0.16"],
3506
4124
  env
3507
4125
  };
3508
4126
  const tmp = configPath + ".tmp";
@@ -3606,7 +4224,7 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
3606
4224
  let region;
3607
4225
  try {
3608
4226
  if (pinnedRegion && !allowFallback) {
3609
- const { REGIONS } = await import("./dist-2NAFYPXG.js");
4227
+ const { REGIONS } = await import("./dist-7XHTMWB2.js");
3610
4228
  const baseUrl = REGIONS[pinnedRegion];
3611
4229
  token = await loginAt(baseUrl, email, password);
3612
4230
  region = pinnedRegion;
@@ -3689,7 +4307,7 @@ leadbay-mcp install \u2014 detected MCP clients on this machine:
3689
4307
  process.stderr.write(
3690
4308
  `
3691
4309
  The token was written into client config files but never printed to your terminal.
3692
- Verify with: LEADBAY_TOKEN=$(...) npx -y @leadbay/mcp@0.13 doctor
4310
+ Verify with: LEADBAY_TOKEN=$(...) npx -y @leadbay/mcp@0.16 doctor
3693
4311
  Restart your MCP client(s) to pick up the new server.
3694
4312
  If you ever leak the token, run \`leadbay-mcp login --email <you> --region <us|fr>\` to mint a fresh one (which invalidates the prior session).
3695
4313
  `