@bike4mind/cli 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { $ as PROMPT_TEXT_MAX, B as CreateArtifactRequestSchema, D as AGENT_QUEST_MANIFEST, Dt as replacePromptVariables, E as AGENT_QUEST_ID, Et as questReplyText, F as BriefcasePromptIdSchema, Ft as ttsBase64ResponseSchema, G as ImagePromptResolutionSchema, It as ttsRequestSchema, Lt as ttsResponseTooLargeSchema, M as ArtifactTypeSchema, N as BRIEFCASE_CATALOG_QUERIES, Nt as supportedVoiceGenerationVendor, O as AGENT_QUEST_MCP_URI, U as GENERATED_IMAGE_EXTENSION_RE, V as DEFAULT_TTS_PROVIDER, W as ImageModels, b as logger, dt as buildPromptContext, ht as generatedAudioResponseSchema, p as requireApiUrl, st as VisibilitySchema, t as ConfigStore, v as NotAuthenticatedError, wt as joinReplySlots, y as parseApiUrl } from "./ConfigStore-DUATzLxB.mjs";
3
- import { a as ApiClient, i as parseRetryAfterSeconds, o as isProviderKeyFailure, r as createClient, t as B4mApiError } from "./dist-DPP7UmK9.mjs";
2
+ import { Bt as supportedVoiceGenerationVendor, D as AGENT_QUEST_MANIFEST, E as AGENT_QUEST_ID, F as BRIEFCASE_CATALOG_QUERIES, H as CreateArtifactRequestSchema, Ht as ttsBase64ResponseSchema, J as ImageModels, L as BriefcasePromptIdSchema, N as ArtifactTypeSchema, Nt as questReplyText, O as AGENT_QUEST_MCP_URI, Pt as replacePromptVariables, St as generatedAudioResponseSchema, U as DEFAULT_TTS_PROVIDER, Ut as ttsRequestSchema, Wt as ttsResponseTooLargeSchema, Y as ImagePromptResolutionSchema, _t as buildPromptContext, b as logger, j as ApiKeyScope, jt as joinReplySlots, p as requireApiUrl, pt as VisibilitySchema, q as GENERATED_IMAGE_EXTENSION_RE, rt as PROMPT_TEXT_MAX, t as ConfigStore, v as NotAuthenticatedError, y as parseApiUrl } from "./ConfigStore-BwqsQ0U9.mjs";
3
+ import { n as isProviderKeyFailure, t as ApiClient } from "./ApiClient-_JrM_xWw.mjs";
4
+ import { i as parseRetryAfterSeconds, r as createClient, t as B4mApiError } from "./dist-Ul2qwn5x.mjs";
4
5
  import { z as z$1 } from "zod";
5
6
  import { isAxiosError } from "axios";
6
- import { createHash, randomUUID } from "node:crypto";
7
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
7
8
  import { Writable } from "node:stream";
8
9
  import { createServer } from "node:http";
9
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -13,6 +14,82 @@ import { ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, McpError }
13
14
  //#region src/mcp/b4mApiClient.ts
14
15
  const NOTEBOOK_ID_PATTERN = /^[a-f0-9]{24}$/i;
15
16
  const STOP_REPLY_TIMEOUT_MS = 1e4;
17
+ /** Only the route is known here: the solver roster and limits are fetched at runtime. */
18
+ const SOLVER_ROUTE = "/api/premium-optihashi/quantum/solve";
19
+ /**
20
+ * The catalog and the key-scope lookup run before the stdio handshake completes, so they must not
21
+ * inherit the client's 10-minute default: a hung backend would otherwise stall `initialize` for every
22
+ * user, entitled or not.
23
+ */
24
+ const SOLVER_CATALOG_TIMEOUT_MS = 5e3;
25
+ /** Bob's routes. Bob is deployed apart from the host, so a server may not serve them. */
26
+ const BOB_ROUTE = "/api/premium-bob";
27
+ /**
28
+ * One Bob run as `GET bob-run?id=&view=agent` answers it. Every field the tools read is required or
29
+ * nullable here, so a server that ignores `view=agent` (and answers the app's own shape) fails the
30
+ * parse rather than reading as a run with no report.
31
+ */
32
+ const bobAgentRunSchema = z$1.looseObject({
33
+ runId: z$1.string(),
34
+ url: z$1.string(),
35
+ status: z$1.enum([
36
+ "reading",
37
+ "ok",
38
+ "failed"
39
+ ]),
40
+ progress: z$1.object({
41
+ done: z$1.number(),
42
+ total: z$1.number()
43
+ }).nullish(),
44
+ createdAt: z$1.string(),
45
+ settledAt: z$1.string().nullish(),
46
+ error: z$1.string().nullish(),
47
+ scanOnly: z$1.string().nullish(),
48
+ reportMarkdown: z$1.string().nullish(),
49
+ appPath: z$1.string(),
50
+ publishedPath: z$1.string().nullish(),
51
+ visibility: z$1.string().nullish()
52
+ });
53
+ const bobAgentComparisonSchema = z$1.looseObject({
54
+ comparisonId: z$1.string(),
55
+ status: z$1.enum([
56
+ "reading",
57
+ "ok",
58
+ "failed"
59
+ ]),
60
+ you: bobAgentRunSchema.nullable(),
61
+ them: bobAgentRunSchema.nullable(),
62
+ summaryMarkdown: z$1.string().nullish(),
63
+ appPath: z$1.string()
64
+ });
65
+ /**
66
+ * One row of `GET bob-run?list=recent`: a single-site run or a two-site comparison (`you` and
67
+ * `them` are the two URLs). A server that predates comparison rows sends runs with no `kind`.
68
+ */
69
+ const bobRecentRowSchema = z$1.union([z$1.looseObject({
70
+ kind: z$1.literal("run").optional(),
71
+ runId: z$1.string(),
72
+ url: z$1.string(),
73
+ createdAt: z$1.string().nullish()
74
+ }), z$1.looseObject({
75
+ kind: z$1.literal("comparison"),
76
+ comparisonId: z$1.string(),
77
+ you: z$1.string(),
78
+ them: z$1.string(),
79
+ createdAt: z$1.string().nullish()
80
+ })]);
81
+ const bobRecentListSchema = z$1.object({ runs: z$1.array(z$1.unknown()) });
82
+ /** A start's 200 refusal: the daily cap or a credits refusal, in Bob's words. */
83
+ const bobRefusalSchema = z$1.object({
84
+ ok: z$1.literal(false),
85
+ error: z$1.string()
86
+ });
87
+ const bobOrganizationField = (organizationId) => organizationId === void 0 ? {} : { organizationId };
88
+ const keyScopeListSchema = z$1.array(z$1.object({
89
+ keyPrefix: z$1.string().optional(),
90
+ scopes: z$1.array(z$1.string()).optional(),
91
+ status: z$1.string().optional()
92
+ }));
16
93
  /**
17
94
  * An empty or dot-segment id collapses `/api/sessions/{id}` to `/api/sessions`, whose DELETE wipes every
18
95
  * notebook the caller owns, so write paths refuse anything that is not an ObjectId before any request.
@@ -36,6 +113,7 @@ function writableNotebookId(notebookId) {
36
113
  var B4mApiClient = class {
37
114
  constructor(baseURL, configStore, apiKey) {
38
115
  this.baseURL = baseURL;
116
+ this.apiKey = apiKey;
39
117
  this.client = new ApiClient(baseURL, configStore, apiKey);
40
118
  this.sdk = createClient({
41
119
  baseUrl: baseURL,
@@ -293,7 +371,99 @@ var B4mApiClient = class {
293
371
  async getBriefcasePrompt(promptId) {
294
372
  return (await this.client.get(`/api/briefcase/prompts/${encodeURIComponent(promptId)}`)).prompt;
295
373
  }
374
+ /**
375
+ * Scopes held by the API key this client authenticates with, read from the caller's own key list by
376
+ * matching the key's stored prefix. Undefined for a login (JWT) caller, who has no scopes to check;
377
+ * an empty list when the key is not found or cannot be told apart from another, so a caller that cannot be
378
+ * identified is never treated as scoped. Stored prefixes differ in length (older keys keep a shorter one),
379
+ * and the list can include other keys, so the longest matching prefix wins and a tie at that length is refused.
380
+ */
381
+ async getOwnKeyScopes() {
382
+ const apiKey = this.apiKey;
383
+ if (!apiKey) return void 0;
384
+ const matches = keyScopeListSchema.parse(await this.client.get("/api/user-api-keys", { timeout: SOLVER_CATALOG_TIMEOUT_MS })).filter((k) => k.status === "active" && !!k.keyPrefix && apiKey.startsWith(k.keyPrefix));
385
+ const longest = matches.reduce((length, key) => Math.max(length, key.keyPrefix?.length ?? 0), 0);
386
+ const best = matches.filter((k) => k.keyPrefix?.length === longest);
387
+ return best.length === 1 ? best[0].scopes ?? [] : [];
388
+ }
389
+ /** GET on the entitlement-gated solver route: the runnable rosters and limits for this caller. */
390
+ async getSolverCatalog() {
391
+ return this.client.get(SOLVER_ROUTE, { timeout: SOLVER_CATALOG_TIMEOUT_MS });
392
+ }
393
+ /** POST on the same route: run a bounded solver race. The body is validated server-side. */
394
+ async runSolverRace(body) {
395
+ return this.client.post(SOLVER_ROUTE, body);
396
+ }
397
+ /** POST bob-run: start one site read under a caller-minted run id. Throws Bob's words on a refusal. */
398
+ async startBobRun(args) {
399
+ const body = {
400
+ url: args.url,
401
+ runId: args.runId,
402
+ ...bobOrganizationField(args.organizationId)
403
+ };
404
+ throwBobRefusal(await this.client.post(`${BOB_ROUTE}/bob-run`, body));
405
+ }
406
+ async getBobRun(runId) {
407
+ const result = await this.client.get(`${BOB_ROUTE}/bob-run`, { params: {
408
+ id: runId,
409
+ view: "agent"
410
+ } });
411
+ return this.parseBobView(z$1.object({ run: bobAgentRunSchema }), result, "run").run;
412
+ }
413
+ /** POST bob-compare: start a read of each site, linked as one comparison. Throws Bob's words on a refusal. */
414
+ async startBobComparison(args) {
415
+ const { organizationId, ...ids } = args;
416
+ const body = {
417
+ ...ids,
418
+ ...bobOrganizationField(organizationId)
419
+ };
420
+ throwBobRefusal(await this.client.post(`${BOB_ROUTE}/bob-compare`, body));
421
+ }
422
+ async getBobComparison(comparisonId) {
423
+ const result = await this.client.get(`${BOB_ROUTE}/bob-compare`, { params: {
424
+ id: comparisonId,
425
+ view: "agent"
426
+ } });
427
+ return this.parseBobView(z$1.object({ comparison: bobAgentComparisonSchema }), result, "comparison").comparison;
428
+ }
429
+ /** POST bob-recheck: re-run one check of a run against its site. Answers with the new reading. */
430
+ async recheckBob(args) {
431
+ const result = await this.client.post(`${BOB_ROUTE}/bob-recheck`, args);
432
+ throwBobRefusal(result);
433
+ return result.recheck;
434
+ }
435
+ /**
436
+ * GET bob-run?list=recent: the caller's finished runs and comparisons, newest first. The server caps
437
+ * `limit`. A row of a kind this CLI does not know is dropped, so a newer server cannot break the list.
438
+ */
439
+ async listBobReports(limit) {
440
+ const result = await this.client.get(`${BOB_ROUTE}/bob-run`, { params: {
441
+ list: "recent",
442
+ ...limit !== void 0 ? { limit } : {}
443
+ } });
444
+ return bobRecentListSchema.parse(result).runs.flatMap((row) => {
445
+ const parsed = bobRecentRowSchema.safeParse(row);
446
+ return parsed.success ? [parsed.data] : [];
447
+ });
448
+ }
449
+ /** GET bob-run?quota: runs left under the daily cap, for the account a start would bill. */
450
+ async getBobQuota(organizationId) {
451
+ return (await this.client.get(`${BOB_ROUTE}/bob-run`, { params: {
452
+ quota: "",
453
+ ...organizationId === void 0 ? {} : { organizationId: organizationId ?? "" }
454
+ } })).quota ?? {};
455
+ }
456
+ parseBobView(schema, body, what) {
457
+ const parsed = schema.safeParse(body);
458
+ if (!parsed.success) throw new Error(`the Bike4Mind server at ${this.baseURL} answered the Bob ${what} without its agent view; it likely predates these tools`);
459
+ return parsed.data;
460
+ }
296
461
  };
462
+ /** A 2xx `{ ok: false, error }` from Bob is a refusal (the daily cap, credits), not a transport error. */
463
+ function throwBobRefusal(body) {
464
+ const refusal = bobRefusalSchema.safeParse(body);
465
+ if (refusal.success) throw new Error(refusal.data.error);
466
+ }
297
467
  /**
298
468
  * Turn an API failure into an actionable, transport-agnostic message. `scope` is
299
469
  * the recommended API-key scope for the failing tool. A 403 can come from a
@@ -335,6 +505,20 @@ function mapApiError(error, baseURL, scope) {
335
505
  return error instanceof Error ? error.message : String(error);
336
506
  }
337
507
  /**
508
+ * {@link mapApiError} for Bob's routes, which do enforce their scopes. A scope 403 (Bob's own
509
+ * `missing_scope`, or the host gate's `required_scopes`) names the scope to grant; a 429 or 503 that
510
+ * carries `retryAfterMs` says how long to wait; a 404 with no error body means the server has no Bob.
511
+ */
512
+ function mapBobApiError(error, baseURL, scope) {
513
+ const failure = httpFailure(error);
514
+ const message = extractServerMessage(failure?.data);
515
+ const body = failure?.data && typeof failure.data === "object" ? failure.data : {};
516
+ if (failure?.status === 403 && (body.code === "missing_scope" || Array.isArray(body.required_scopes))) return `${message ?? "API key forbidden"} (grant the key ${scope})`;
517
+ if ((failure?.status === 429 || failure?.status === 503) && typeof body.retryAfterMs === "number") return `${message ?? "Bob is busy"} (retry after ${Math.ceil(body.retryAfterMs / 1e3)}s)`;
518
+ if (failure?.status === 404 && !message) return `Bob is not available on the Bike4Mind server at ${baseURL}`;
519
+ return mapApiError(error, baseURL, scope);
520
+ }
521
+ /**
338
522
  * Status and body of an HTTP failure from either transport: a `B4mApiError` from the SDK calls or an
339
523
  * AxiosError from the axios ones (with no status when the request never got a response). Undefined for
340
524
  * anything else.
@@ -493,6 +677,48 @@ const TOOL_META = [
493
677
  title: "Publish artifact",
494
678
  description: "Publish an artifact you own to a shareable /p/... link and return its absolute URL. Republishing an artifact updates its existing page and keeps that page's visibility unless you pass one; a first publish defaults to public.",
495
679
  scope: "files:write"
680
+ },
681
+ {
682
+ name: "bob_read_site",
683
+ title: "Read a site with Bob",
684
+ description: "Have Bob read a website: a panel of synthetic personas reads it and reports where visitors get lost. Spends the key owner's credits and publishes the report as them, as a run started in the app does. Waits for the report, reporting progress, and returns it as Markdown with links to the in-app and published reports; fails with Bob's reason when the read fails. If Bob is still reading when waitSeconds runs out, returns the runId to pass to get_bob_report later.",
685
+ scope: "bob:run"
686
+ },
687
+ {
688
+ name: "bob_compare_sites",
689
+ title: "Compare two sites with Bob",
690
+ description: "Have Bob read your site and another one side by side. Spends the key owner's credits for both reads and publishes each report as them. Waits for both, reporting progress, and returns the comparison summary as Markdown with each side's status and links, plus each side's full report when includeReports is true. If Bob is still reading when waitSeconds runs out, returns the comparisonId to pass to get_bob_comparison later.",
691
+ scope: "bob:run"
692
+ },
693
+ {
694
+ name: "bob_recheck",
695
+ title: "Re-check a Bob finding",
696
+ description: "Re-scan one check from a Bob report against the live site, e.g. after a fix, and return the new reading. Costs no credits and adds the reading to the existing report beside the original; no new report is published. checkId is the id the report shows in backticks beside the check. Re-checks are rate-limited, and a refusal says how long to wait.",
697
+ scope: "bob:run"
698
+ },
699
+ {
700
+ name: "get_bob_report",
701
+ title: "Get a Bob report",
702
+ description: "Fetch one Bob run by runId: its status (reading, ok or failed), its progress while reading, and once it is ok the report as Markdown with links to the in-app and published reports.",
703
+ scope: "bob:read"
704
+ },
705
+ {
706
+ name: "get_bob_comparison",
707
+ title: "Get a Bob comparison",
708
+ description: "Fetch one Bob comparison by comparisonId: its status, the summary as Markdown once both sides are read, and each side's status and links. Pass a side's runId to get_bob_report for its full report.",
709
+ scope: "bob:read"
710
+ },
711
+ {
712
+ name: "list_bob_reports",
713
+ title: "List Bob reports",
714
+ description: "List the key owner's recent finished Bob reports, newest first: single-site runs (kind 'run') and two-site comparisons (kind 'comparison'), each with its in-app link. Read a run with get_bob_report and its runId, and a comparison with get_bob_comparison and its comparisonId.",
715
+ scope: "bob:read"
716
+ },
717
+ {
718
+ name: "bob_runs_left",
719
+ title: "Bob runs left",
720
+ description: "How many Bob runs the key owner can start now under the daily limit, when the next one frees up, and whether the account's credits cover a full read. A comparison uses two runs.",
721
+ scope: "bob:read"
496
722
  }
497
723
  ];
498
724
  TOOL_META.map((t) => t.name);
@@ -586,6 +812,32 @@ const textToSpeechShape = {
586
812
  provider: ttsRequestSchema.shape.provider.describe(`Speech provider; defaults to ${DEFAULT_TTS_PROVIDER}`),
587
813
  preview: ttsRequestSchema.shape.preview.describe("Skip saving a copy to the file browser")
588
814
  };
815
+ const BOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
816
+ const BOB_WAIT_DEFAULT_SECONDS = 600;
817
+ const BOB_WAIT_MAX_SECONDS = 900;
818
+ const bobId = (description) => z$1.string().regex(BOB_ID_PATTERN, "must be a UUID").describe(description);
819
+ const bobOrganizationId = z$1.string().min(1).nullable().optional().describe("Organization whose credits pay for the run: an organization id, or null for the key owner's personal account; omit for their home organization");
820
+ const bobWaitSeconds = z$1.number().int().min(0).max(BOB_WAIT_MAX_SECONDS).default(BOB_WAIT_DEFAULT_SECONDS).describe(`How long to wait for the result, in seconds (0-${BOB_WAIT_MAX_SECONDS}, default ${BOB_WAIT_DEFAULT_SECONDS}); a read takes about 1 to 5 minutes and Bob keeps reading after the wait ends`);
821
+ const bobReadSiteShape = {
822
+ url: z$1.string().min(1).describe("The site to read, e.g. acme.io or acme.io/pricing"),
823
+ organizationId: bobOrganizationId,
824
+ waitSeconds: bobWaitSeconds
825
+ };
826
+ const bobCompareSitesShape = {
827
+ yourUrl: z$1.string().min(1).describe("Your site, e.g. acme.io"),
828
+ theirUrl: z$1.string().min(1).describe("The site to compare yours with"),
829
+ organizationId: bobOrganizationId,
830
+ waitSeconds: bobWaitSeconds,
831
+ includeReports: z$1.boolean().default(false).describe("Also return each side's full report as Markdown")
832
+ };
833
+ const bobRecheckShape = {
834
+ runId: bobId("The run the check belongs to"),
835
+ checkId: z$1.string().min(1).describe("The check id, as the report shows it in backticks beside the check")
836
+ };
837
+ const getBobReportShape = { runId: bobId("The run id") };
838
+ const getBobComparisonShape = { comparisonId: bobId("The comparison id") };
839
+ const listBobReportsShape = { limit: z$1.number().int().min(1).max(100).optional().describe("Maximum reports to return; the server caps the list") };
840
+ const bobRunsLeftShape = { organizationId: bobOrganizationId.describe("Account whose credits to check: an organization id, or null for the key owner's personal account; omit for their home organization") };
589
841
  function notebookSummary(n) {
590
842
  return {
591
843
  id: n.id,
@@ -746,30 +998,38 @@ const isPermanentApiError = (err) => {
746
998
  const status = apiErrorStatus(err);
747
999
  return status === 401 || status === 403 || status === 404;
748
1000
  };
749
- /** Poll a queued quest until `isFinished`, the timeout, a permanent poll failure, or an abort. */
750
- async function pollQuest(client, { questId, ref, task, scope, isFinished }, { interval, timeoutMs, sleep = defaultSleep, signal, onProgress, baseURL = "" }) {
1001
+ /** Poll `read` until `isFinished`, the timeout, a permanent poll failure, or an abort. */
1002
+ async function pollUntil({ read, ref, task, isFinished, describeError }, { interval, timeoutMs, sleep = defaultSleep, signal, onProgress }) {
751
1003
  const started = Date.now();
752
1004
  let failures = 0;
753
1005
  let retryAfterMs = 0;
1006
+ let latest;
754
1007
  for (;;) {
755
1008
  signal?.throwIfAborted();
756
- let quest;
1009
+ let value;
757
1010
  try {
758
- quest = await client.getQuest(questId);
1011
+ value = await read();
1012
+ latest = value;
759
1013
  failures = 0;
760
- if (isFinished(quest)) return quest;
1014
+ if (isFinished(value)) return {
1015
+ finished: true,
1016
+ value
1017
+ };
761
1018
  } catch (err) {
762
1019
  if (isRateLimited(err)) retryAfterMs = (apiErrorRetryAfterSeconds(err) ?? 0) * 1e3;
763
1020
  else {
764
1021
  failures += 1;
765
- if (isPermanentApiError(err) || failures >= MAX_CONSECUTIVE_POLL_FAILURES) throw new Error(`${mapApiError(err, baseURL, scope)} (${ref}; the ${task} may still complete)`);
1022
+ if (isPermanentApiError(err) || failures >= MAX_CONSECUTIVE_POLL_FAILURES) throw new Error(`${describeError(err)} (${ref}; the ${task} may still complete)`);
766
1023
  }
767
1024
  }
768
1025
  const elapsed = Date.now() - started;
769
- if (elapsed >= timeoutMs) throw new Error(`${task} did not finish within ${Math.round(timeoutMs / 1e3)}s (${ref})`);
1026
+ if (elapsed >= timeoutMs) return {
1027
+ finished: false,
1028
+ latest
1029
+ };
770
1030
  signal?.throwIfAborted();
771
1031
  try {
772
- await onProgress?.(elapsed, quest);
1032
+ await onProgress?.(elapsed, value);
773
1033
  } catch (err) {
774
1034
  logger.warn(`mcp: progress notification failed (${ref}): ${err instanceof Error ? err.message : String(err)}`);
775
1035
  }
@@ -778,6 +1038,18 @@ async function pollQuest(client, { questId, ref, task, scope, isFinished }, { in
778
1038
  retryAfterMs = 0;
779
1039
  }
780
1040
  }
1041
+ /** Poll a queued quest until `isFinished`, the timeout, a permanent poll failure, or an abort. */
1042
+ async function pollQuest(client, { questId, ref, task, scope, isFinished }, { baseURL = "", ...timing }) {
1043
+ const outcome = await pollUntil({
1044
+ read: () => client.getQuest(questId),
1045
+ ref,
1046
+ task,
1047
+ isFinished,
1048
+ describeError: (err) => mapApiError(err, baseURL, scope)
1049
+ }, timing);
1050
+ if (!outcome.finished) throw new Error(`${task} did not finish within ${Math.round(timing.timeoutMs / 1e3)}s (${ref})`);
1051
+ return outcome.value;
1052
+ }
781
1053
  /**
782
1054
  * Queue an image render and poll its quest to completion. A failed render still
783
1055
  * resolves `status: 'done'`, so `type: 'error'` (reason in `reply`) is the failure signal.
@@ -1016,19 +1288,19 @@ async function textToSpeech(client, args) {
1016
1288
  });
1017
1289
  }
1018
1290
  /**
1019
- * Turn a quest poll's progress callback into MCP `notifications/progress`, or nothing when the
1020
- * client sent no progressToken. Progress also lets a client that resets its request timeout on
1021
- * progress wait out a slow quest.
1291
+ * Turn a poll's progress callback into MCP `notifications/progress`, or nothing when the client
1292
+ * sent no progressToken. Progress counts elapsed seconds, so it rises on every poll, and that lets a
1293
+ * client that resets its request timeout on progress wait out slow work.
1022
1294
  */
1023
1295
  function progressReporter({ _meta, sendNotification }, describe) {
1024
1296
  const progressToken = _meta?.progressToken;
1025
1297
  if (progressToken === void 0) return void 0;
1026
- return (elapsedMs, quest) => sendNotification({
1298
+ return (elapsedMs, latest) => sendNotification({
1027
1299
  method: "notifications/progress",
1028
1300
  params: {
1029
1301
  progressToken,
1030
1302
  progress: Math.floor(elapsedMs / 1e3),
1031
- message: describe(quest)
1303
+ message: describe(latest)
1032
1304
  }
1033
1305
  });
1034
1306
  }
@@ -1038,6 +1310,188 @@ function chatProgress(quest) {
1038
1310
  return characters > 0 ? `generating reply (${characters} characters so far)` : "generating reply";
1039
1311
  }
1040
1312
  /**
1313
+ * Bob's own done/total count goes in the progress message, not in `progress`: a count can stay flat
1314
+ * for a while, and MCP needs `progress` to rise on every notification (see {@link progressReporter}).
1315
+ */
1316
+ function bobProgressMessage(what, count) {
1317
+ return count && count.total > 0 ? `${what} (${count.done} of ${count.total})` : what;
1318
+ }
1319
+ function bobComparisonCount(comparison) {
1320
+ const counts = [comparison.you?.progress, comparison.them?.progress].filter((c) => !!c);
1321
+ if (counts.length === 0) return null;
1322
+ return counts.reduce((sum, c) => ({
1323
+ done: sum.done + c.done,
1324
+ total: sum.total + c.total
1325
+ }));
1326
+ }
1327
+ /** send_message's cadence (fast for the first 30s, then slower), bounded by the caller's wait. */
1328
+ function bobPollTiming({ intervalMs = CHAT_POLL_INTERVAL_MS, ...poll }, waitSeconds) {
1329
+ return {
1330
+ ...poll,
1331
+ timeoutMs: waitSeconds * 1e3,
1332
+ interval: (elapsedMs) => chatPollInterval(elapsedMs, intervalMs)
1333
+ };
1334
+ }
1335
+ /** Bob's views carry app paths; a link is the API base URL plus one. */
1336
+ const bobLink = (baseURL, path) => path ? `${baseURL.replace(/\/+$/, "")}${path}` : null;
1337
+ function bobRunSummary(run, baseURL) {
1338
+ return {
1339
+ runId: run.runId,
1340
+ url: run.url,
1341
+ status: run.status,
1342
+ progress: run.progress ?? null,
1343
+ createdAt: run.createdAt,
1344
+ settledAt: run.settledAt ?? null,
1345
+ error: run.error ?? null,
1346
+ scanOnly: run.scanOnly ?? null,
1347
+ appUrl: bobLink(baseURL, run.appPath),
1348
+ publishedUrl: bobLink(baseURL, run.publishedPath),
1349
+ visibility: run.visibility ?? null
1350
+ };
1351
+ }
1352
+ function bobComparisonSummary(comparison, baseURL) {
1353
+ return {
1354
+ comparisonId: comparison.comparisonId,
1355
+ status: comparison.status,
1356
+ you: comparison.you && bobRunSummary(comparison.you, baseURL),
1357
+ them: comparison.them && bobRunSummary(comparison.them, baseURL),
1358
+ appUrl: bobLink(baseURL, comparison.appPath)
1359
+ };
1360
+ }
1361
+ /**
1362
+ * The summary as JSON (and as structuredContent), then each Markdown document as a text block of its
1363
+ * own. The documents stay out of structuredContent so a long report is not sent twice, as the audio
1364
+ * tools keep their bytes out.
1365
+ */
1366
+ function bobResult(summary, documents) {
1367
+ const result = toResult(summary);
1368
+ for (const text of documents) if (text) result.content.push({
1369
+ type: "text",
1370
+ text
1371
+ });
1372
+ return result;
1373
+ }
1374
+ function bobComparisonResult(comparison, baseURL, includeReports) {
1375
+ const reports = includeReports ? [comparison.you, comparison.them].map((side) => side?.reportMarkdown && `Report for ${side.url}:\n\n${side.reportMarkdown}`) : [];
1376
+ return bobResult(bobComparisonSummary(comparison, baseURL), [comparison.summaryMarkdown, ...reports]);
1377
+ }
1378
+ const bobStillReading = (waitSeconds, readBack) => `Bob is still reading after ${waitSeconds}s and carries on without this call; use ${readBack} later.`;
1379
+ /**
1380
+ * Start a Bob read of one site and poll it until it settles or `waitSeconds` passes. A refusal or a
1381
+ * failed read throws, so the tool reports isError in Bob's words. A read still going at the deadline
1382
+ * is not an error: the result carries the runId to read it back with.
1383
+ */
1384
+ async function bobReadSite(client, args, poll = {}) {
1385
+ const runId = randomUUID();
1386
+ await client.startBobRun({
1387
+ url: args.url,
1388
+ runId,
1389
+ organizationId: args.organizationId
1390
+ });
1391
+ const ref = `run ${runId}`;
1392
+ const outcome = await pollUntil({
1393
+ read: () => client.getBobRun(runId),
1394
+ ref,
1395
+ task: "site read",
1396
+ isFinished: (run) => run.status !== "reading",
1397
+ describeError: (err) => mapBobApiError(err, client.baseURL, "bob:run")
1398
+ }, bobPollTiming(poll, args.waitSeconds));
1399
+ if (!outcome.finished) return toResult({
1400
+ ...outcome.latest ? bobRunSummary(outcome.latest, client.baseURL) : {
1401
+ runId,
1402
+ url: args.url,
1403
+ status: "reading"
1404
+ },
1405
+ note: bobStillReading(args.waitSeconds, "get_bob_report with this runId")
1406
+ });
1407
+ const run = outcome.value;
1408
+ if (run.status === "failed") throw new Error(`Bob could not read ${run.url}: ${run.error || "the read failed"} (${ref})`);
1409
+ return bobResult(bobRunSummary(run, client.baseURL), [run.reportMarkdown]);
1410
+ }
1411
+ /** {@link bobReadSite} for two sites started as one comparison; fails only when neither side could be read. */
1412
+ async function bobCompareSites(client, args, poll = {}) {
1413
+ const comparisonId = randomUUID();
1414
+ const you = {
1415
+ url: args.yourUrl,
1416
+ runId: randomUUID()
1417
+ };
1418
+ const them = {
1419
+ url: args.theirUrl,
1420
+ runId: randomUUID()
1421
+ };
1422
+ await client.startBobComparison({
1423
+ comparisonId,
1424
+ you,
1425
+ them,
1426
+ organizationId: args.organizationId
1427
+ });
1428
+ const ref = `comparison ${comparisonId}`;
1429
+ const outcome = await pollUntil({
1430
+ read: () => client.getBobComparison(comparisonId),
1431
+ ref,
1432
+ task: "comparison",
1433
+ isFinished: (comparison) => comparison.status !== "reading",
1434
+ describeError: (err) => mapBobApiError(err, client.baseURL, "bob:run")
1435
+ }, bobPollTiming(poll, args.waitSeconds));
1436
+ if (!outcome.finished) return toResult({
1437
+ ...outcome.latest ? bobComparisonSummary(outcome.latest, client.baseURL) : {
1438
+ comparisonId,
1439
+ status: "reading",
1440
+ you,
1441
+ them
1442
+ },
1443
+ note: bobStillReading(args.waitSeconds, "get_bob_comparison with this comparisonId")
1444
+ });
1445
+ const comparison = outcome.value;
1446
+ if (comparison.status === "failed") {
1447
+ const reasons = [comparison.you, comparison.them].map((side) => side ? `${side.url}: ${side.error || "the read failed"}` : "one side has no run").join("; ");
1448
+ throw new Error(`Bob could not read either site: ${reasons} (${ref})`);
1449
+ }
1450
+ return bobComparisonResult(comparison, client.baseURL, args.includeReports === true);
1451
+ }
1452
+ async function bobRecheck(client, args) {
1453
+ return toResult({
1454
+ runId: args.runId,
1455
+ recheck: await client.recheckBob(args)
1456
+ });
1457
+ }
1458
+ async function getBobReport(client, args) {
1459
+ const run = await client.getBobRun(args.runId);
1460
+ return bobResult(bobRunSummary(run, client.baseURL), [run.reportMarkdown]);
1461
+ }
1462
+ async function getBobComparison(client, args) {
1463
+ return bobComparisonResult(await client.getBobComparison(args.comparisonId), client.baseURL, false);
1464
+ }
1465
+ /** A recent-list row in compact form, with the in-app link its kind opens at. */
1466
+ function bobReportRow(row, baseURL) {
1467
+ if (row.kind === "comparison") {
1468
+ const { comparisonId, you, them } = row;
1469
+ const appUrl = bobLink(baseURL, `/bob?compare=${encodeURIComponent(comparisonId)}`);
1470
+ return {
1471
+ kind: "comparison",
1472
+ comparisonId,
1473
+ you,
1474
+ them,
1475
+ createdAt: row.createdAt ?? null,
1476
+ appUrl
1477
+ };
1478
+ }
1479
+ const appUrl = bobLink(baseURL, `/bob?run=${encodeURIComponent(row.runId)}`);
1480
+ return {
1481
+ kind: "run",
1482
+ runId: row.runId,
1483
+ url: row.url,
1484
+ createdAt: row.createdAt ?? null,
1485
+ appUrl
1486
+ };
1487
+ }
1488
+ async function listBobReports(client, args) {
1489
+ return toResult({ reports: (await client.listBobReports(args.limit)).map((row) => bobReportRow(row, client.baseURL)) });
1490
+ }
1491
+ async function bobRunsLeft(client, args) {
1492
+ return toResult(await client.getBobQuota(args.organizationId));
1493
+ }
1494
+ /**
1041
1495
  * Register the Bike4Mind MCP tools on `server`. Each handler is wrapped so an
1042
1496
  * API failure becomes a structured `isError` result carrying a friendly message
1043
1497
  * (see {@link mapApiError}) rather than throwing across the transport.
@@ -1167,6 +1621,135 @@ function registerTools(server, client) {
1167
1621
  description: meta("publish_artifact").description,
1168
1622
  inputSchema: publishArtifactShape
1169
1623
  }, (args) => run("files:write", () => publishArtifact(client, args)));
1624
+ const runBob = async (scope, fn) => {
1625
+ try {
1626
+ return await fn();
1627
+ } catch (err) {
1628
+ return errorResult(mapBobApiError(err, baseURL, scope));
1629
+ }
1630
+ };
1631
+ server.registerTool("bob_read_site", {
1632
+ title: meta("bob_read_site").title,
1633
+ description: meta("bob_read_site").description,
1634
+ inputSchema: bobReadSiteShape
1635
+ }, (args, extra) => runBob("bob:run", () => bobReadSite(client, args, {
1636
+ signal: extra.signal,
1637
+ onProgress: progressReporter(extra, (run) => bobProgressMessage(`reading ${args.url}`, run?.progress))
1638
+ })));
1639
+ server.registerTool("bob_compare_sites", {
1640
+ title: meta("bob_compare_sites").title,
1641
+ description: meta("bob_compare_sites").description,
1642
+ inputSchema: bobCompareSitesShape
1643
+ }, (args, extra) => runBob("bob:run", () => bobCompareSites(client, args, {
1644
+ signal: extra.signal,
1645
+ onProgress: progressReporter(extra, (comparison) => bobProgressMessage("reading both sites", comparison && bobComparisonCount(comparison)))
1646
+ })));
1647
+ server.registerTool("bob_recheck", {
1648
+ title: meta("bob_recheck").title,
1649
+ description: meta("bob_recheck").description,
1650
+ inputSchema: bobRecheckShape
1651
+ }, (args) => runBob("bob:run", () => bobRecheck(client, args)));
1652
+ server.registerTool("get_bob_report", {
1653
+ title: meta("get_bob_report").title,
1654
+ description: meta("get_bob_report").description,
1655
+ inputSchema: getBobReportShape
1656
+ }, (args) => runBob("bob:read", () => getBobReport(client, args)));
1657
+ server.registerTool("get_bob_comparison", {
1658
+ title: meta("get_bob_comparison").title,
1659
+ description: meta("get_bob_comparison").description,
1660
+ inputSchema: getBobComparisonShape
1661
+ }, (args) => runBob("bob:read", () => getBobComparison(client, args)));
1662
+ server.registerTool("list_bob_reports", {
1663
+ title: meta("list_bob_reports").title,
1664
+ description: meta("list_bob_reports").description,
1665
+ inputSchema: listBobReportsShape
1666
+ }, (args) => runBob("bob:read", () => listBobReports(client, args)));
1667
+ server.registerTool("bob_runs_left", {
1668
+ title: meta("bob_runs_left").title,
1669
+ description: meta("bob_runs_left").description,
1670
+ inputSchema: bobRunsLeftShape
1671
+ }, (args) => runBob("bob:read", () => bobRunsLeft(client, args)));
1672
+ }
1673
+ //#endregion
1674
+ //#region src/mcp/solverTools.ts
1675
+ /** Both scopes the tools need; named in a 403 so callers know what to grant. */
1676
+ const REQUIRED_SCOPES = [ApiKeyScope.OPTIHASHI_READ, ApiKeyScope.OPTIHASHI_COMPUTE];
1677
+ const SOLVER_SCOPES = REQUIRED_SCOPES.join(" and ");
1678
+ const SOLVER_DISCOVERY_TIMEOUT_MS = 5e3;
1679
+ /**
1680
+ * The route's GET answer, trusted only as far as the tool schemas need. Everything beyond these
1681
+ * fields passes through to the agent untouched, so a roster or limit the server adds later needs
1682
+ * no CLI release.
1683
+ */
1684
+ const catalogSchema = z$1.looseObject({
1685
+ family: z$1.looseObject({
1686
+ familyIds: z$1.array(z$1.string()),
1687
+ solverIds: z$1.array(z$1.string())
1688
+ }),
1689
+ scheduling: z$1.looseObject({ solverIds: z$1.array(z$1.string()) }),
1690
+ defaultSolverIds: z$1.array(z$1.string()),
1691
+ limits: z$1.looseObject({
1692
+ maxSolversPerRequest: z$1.number().int().positive(),
1693
+ defaultTimeoutMs: z$1.number().int().positive(),
1694
+ maxTimeoutMs: z$1.number().int().positive()
1695
+ })
1696
+ });
1697
+ const oneOf = (ids) => ids.length > 0 ? [ids[0], ...ids.slice(1)] : void 0;
1698
+ function solveInputShape(catalog) {
1699
+ const solverIds = oneOf([.../* @__PURE__ */ new Set([...catalog.family.solverIds, ...catalog.scheduling.solverIds])]);
1700
+ const familyIds = oneOf(catalog.family.familyIds);
1701
+ const { limits } = catalog;
1702
+ return {
1703
+ familyId: (familyIds ? z$1.enum(familyIds) : z$1.string()).optional().describe("Problem family. Omit for a scheduling brief; the problem must then be a scheduling problem."),
1704
+ problem: z$1.record(z$1.string(), z$1.unknown()).describe("The problem instance. The server validates it and names what is wrong on a 400."),
1705
+ solverIds: z$1.array(solverIds ? z$1.enum(solverIds) : z$1.string()).min(1).max(limits.maxSolversPerRequest).optional().describe(`Solvers to race. Defaults to ${catalog.defaultSolverIds.join(", ")}.`),
1706
+ timeoutMs: z$1.number().int().min(100).max(limits.maxTimeoutMs).optional().describe(`Per-solver time budget in ms (default ${limits.defaultTimeoutMs}).`),
1707
+ includeTrace: z$1.boolean().optional().describe("Include a truncated convergence trace per solver.")
1708
+ };
1709
+ }
1710
+ /**
1711
+ * Register the solver tools an entitled caller may use, built from what the server reports at
1712
+ * startup. Stdio only: the HTTP transport has no per-request auth, so it never calls this.
1713
+ *
1714
+ * Offering nothing is the normal outcome for a caller without the feature, the scopes or the
1715
+ * entitlement (the route answers 403/404), so any failure here leaves the server's other tools
1716
+ * untouched instead of failing the session. Only the free classical race is exposed; no tool here
1717
+ * commissions paid compute.
1718
+ */
1719
+ async function registerSolverTools(server, client) {
1720
+ let catalog;
1721
+ let discoveryTimer;
1722
+ try {
1723
+ const [rawCatalog, scopes] = await Promise.race([Promise.all([client.getSolverCatalog(), client.getOwnKeyScopes()]), new Promise((_resolve, reject) => {
1724
+ discoveryTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("solver discovery timed out")), SOLVER_DISCOVERY_TIMEOUT_MS);
1725
+ })]);
1726
+ if (scopes && !REQUIRED_SCOPES.every((scope) => scopes.includes(scope))) {
1727
+ logger.debug(`mcp: solver tools not offered: key lacks ${SOLVER_SCOPES}`);
1728
+ return;
1729
+ }
1730
+ catalog = catalogSchema.parse(rawCatalog);
1731
+ } catch (err) {
1732
+ logger.debug(`mcp: solver tools not offered: ${mapApiError(err, client.baseURL, SOLVER_SCOPES)}`);
1733
+ return;
1734
+ } finally {
1735
+ clearTimeout(discoveryTimer);
1736
+ }
1737
+ const run = async (fn) => {
1738
+ try {
1739
+ return toResult(await fn());
1740
+ } catch (err) {
1741
+ return errorResult(mapApiError(err, client.baseURL, SOLVER_SCOPES));
1742
+ }
1743
+ };
1744
+ server.registerTool("optihashi_solvers", {
1745
+ title: "List OptiHashi solvers",
1746
+ description: "List the problem families, solvers, default race and limits available to the caller. Call before optihashi_solve."
1747
+ }, () => run(async () => catalogSchema.parse(await client.getSolverCatalog())));
1748
+ server.registerTool("optihashi_solve", {
1749
+ title: "Solve with OptiHashi",
1750
+ description: "Race classical solvers on an optimization problem and return the ranked results and the winner. Bounded and synchronous; no credits are spent.",
1751
+ inputSchema: solveInputShape(catalog)
1752
+ }, (args) => run(() => client.runSolverRace(args)));
1170
1753
  }
1171
1754
  //#endregion
1172
1755
  //#region src/mcp/resources.ts
@@ -1408,8 +1991,7 @@ function registerPrompts(server, client, now = () => /* @__PURE__ */ new Date())
1408
1991
  * network - only tool/resource *calls* and prompt listing/gets hit the API - so a
1409
1992
  * server built with an unreachable endpoint still advertises its full capability set.
1410
1993
  */
1411
- function buildMcpServer(options) {
1412
- const client = new B4mApiClient(options.baseURL, options.configStore, options.apiKey);
1994
+ function buildMcpServer(options, client = new B4mApiClient(options.baseURL, options.configStore, options.apiKey)) {
1413
1995
  const server = new McpServer({
1414
1996
  name: "bike4mind",
1415
1997
  version: options.version
@@ -1423,6 +2005,17 @@ function buildMcpServer(options) {
1423
2005
  registerPrompts(server, client);
1424
2006
  return server;
1425
2007
  }
2008
+ /**
2009
+ * {@link buildMcpServer} plus the tools only an authenticated, entitled caller gets, which the server
2010
+ * reports at startup. Stdio only: the HTTP transport trusts anything that can reach its socket, so
2011
+ * it uses the plain builder and never offers these.
2012
+ */
2013
+ async function buildStdioMcpServer(options) {
2014
+ const client = new B4mApiClient(options.baseURL, options.configStore, options.apiKey);
2015
+ const server = buildMcpServer(options, client);
2016
+ await registerSolverTools(server, client);
2017
+ return server;
2018
+ }
1426
2019
  //#endregion
1427
2020
  //#region src/mcp/serve.ts
1428
2021
  /**
@@ -1437,10 +2030,9 @@ function buildMcpServer(options) {
1437
2030
  * NOTHING else, so all diagnostics are forced to stderr before ANY other work
1438
2031
  * runs (mirrors the acp command's captureStdout - see src/commands/acpCommand.ts).
1439
2032
  *
1440
- * HTTP mode is deliberately loopback-only (binds 127.0.0.1) with no per-request
1441
- * auth of its own - it trusts anything that can reach the socket, so it must not
1442
- * be exposed on a routable interface. DNS-rebinding protection is on as defense
1443
- * in depth against a browser being tricked into posting to the local port.
2033
+ * HTTP mode binds loopback only (127.0.0.1) and requires `Authorization: Bearer
2034
+ * <token>` on every request. Token precedence is `--token` > `B4M_MCP_TOKEN` >
2035
+ * one generated at startup. DNS-rebinding protection (Host/Origin checks) stays on.
1444
2036
  */
1445
2037
  const HTTP_PATH = "/mcp";
1446
2038
  /**
@@ -1470,7 +2062,7 @@ function resolveBaseURL(options, configStore) {
1470
2062
  return configStore.getApiConfig().then((apiConfig) => requireApiUrl(apiConfig));
1471
2063
  }
1472
2064
  async function serveStdio(buildOptions, writeFrame) {
1473
- const server = buildMcpServer(buildOptions);
2065
+ const server = await buildStdioMcpServer(buildOptions);
1474
2066
  const stdout = new Writable({ write(chunk, _encoding, callback) {
1475
2067
  if (writeFrame(chunk)) {
1476
2068
  callback();
@@ -1485,6 +2077,30 @@ async function serveStdio(buildOptions, writeFrame) {
1485
2077
  process.stdin.once("close", resolve);
1486
2078
  });
1487
2079
  }
2080
+ function resolveHttpToken(flag, env) {
2081
+ if (flag) return {
2082
+ token: flag,
2083
+ source: "flag"
2084
+ };
2085
+ if (env) return {
2086
+ token: env,
2087
+ source: "env"
2088
+ };
2089
+ return {
2090
+ token: randomBytes(32).toString("base64url"),
2091
+ source: "generated"
2092
+ };
2093
+ }
2094
+ function isAuthorized(header, token) {
2095
+ const match = /^Bearer (.+)$/i.exec(header ?? "");
2096
+ if (!match) return false;
2097
+ const given = Buffer.from(match[1]);
2098
+ const expected = Buffer.from(token);
2099
+ return given.length === expected.length && timingSafeEqual(given, expected);
2100
+ }
2101
+ function maskToken(token) {
2102
+ return token.length > 8 ? `${token.slice(0, 4)}...` : "****";
2103
+ }
1488
2104
  async function handleHttpRequest(req, res, buildOptions, port) {
1489
2105
  const server = buildMcpServer(buildOptions);
1490
2106
  const transport = new StreamableHTTPServerTransport({
@@ -1506,13 +2122,21 @@ async function handleHttpRequest(req, res, buildOptions, port) {
1506
2122
  await server.connect(transport);
1507
2123
  await transport.handleRequest(req, res);
1508
2124
  }
1509
- async function serveHttp(buildOptions, port) {
1510
- const httpServer = createServer((req, res) => {
2125
+ function createHttpHandler(buildOptions, port, token) {
2126
+ return (req, res) => {
1511
2127
  if (new URL(req.url ?? "/", `http://localhost:${port}`).pathname !== HTTP_PATH) {
1512
2128
  res.writeHead(404, { "Content-Type": "application/json" });
1513
2129
  res.end(JSON.stringify({ error: `Not found. The MCP endpoint is ${HTTP_PATH}.` }));
1514
2130
  return;
1515
2131
  }
2132
+ if (!isAuthorized(req.headers.authorization, token)) {
2133
+ res.writeHead(401, {
2134
+ "Content-Type": "application/json",
2135
+ "WWW-Authenticate": "Bearer"
2136
+ });
2137
+ res.end(JSON.stringify({ error: "Unauthorized. Send an Authorization: Bearer <token> header." }));
2138
+ return;
2139
+ }
1516
2140
  handleHttpRequest(req, res, buildOptions, port).catch((err) => {
1517
2141
  logger.error("MCP HTTP request failed", err);
1518
2142
  if (!res.headersSent) {
@@ -1527,12 +2151,18 @@ async function serveHttp(buildOptions, port) {
1527
2151
  }));
1528
2152
  }
1529
2153
  });
1530
- });
2154
+ };
2155
+ }
2156
+ async function serveHttp(buildOptions, port, resolved) {
2157
+ const httpServer = createServer(createHttpHandler(buildOptions, port, resolved.token));
1531
2158
  await new Promise((resolve, reject) => {
1532
2159
  httpServer.once("error", reject);
1533
2160
  httpServer.listen(port, "127.0.0.1", () => resolve());
1534
2161
  });
1535
2162
  process.stderr.write(`Bike4Mind MCP server listening on http://127.0.0.1:${port}${HTTP_PATH}\n`);
2163
+ const shown = resolved.source === "generated" ? resolved.token : maskToken(resolved.token);
2164
+ process.stderr.write(`Authorization: Bearer ${shown}\n`);
2165
+ process.stderr.write("Set a fixed token with --token or B4M_MCP_TOKEN.\n");
1536
2166
  await new Promise((resolve) => {
1537
2167
  const shutdown = () => httpServer.close(() => resolve());
1538
2168
  process.once("SIGINT", shutdown);
@@ -1549,7 +2179,7 @@ async function handleMcpServeCommand(options) {
1549
2179
  version: options.version
1550
2180
  };
1551
2181
  if (writeFrame) await serveStdio(buildOptions, writeFrame);
1552
- else await serveHttp(buildOptions, options.port ?? 7e3);
2182
+ else await serveHttp(buildOptions, options.port ?? 7e3, resolveHttpToken(options.token, process.env.B4M_MCP_TOKEN));
1553
2183
  }
1554
2184
  //#endregion
1555
2185
  export { handleMcpServeCommand };