@bike4mind/cli 2.0.0 → 2.1.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,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { R as PROMPT_TEXT_MAX, Y as generatedAudioResponseSchema, _ as requireApiUrl, b as AGENT_QUEST_MANIFEST, dt as ttsBase64ResponseSchema, ft as ttsRequestSchema, g as parseApiUrl, k as DEFAULT_TTS_PROVIDER, n as logger, pt as ttsResponseTooLargeSchema, t as ConfigStore, ut as supportedVoiceGenerationVendor, x as AGENT_QUEST_MCP_URI, y as AGENT_QUEST_ID } from "./ConfigStore-DIs-K9oD.mjs";
3
- import { n as NotAuthenticatedError, r as isProviderKeyFailure, t as ApiClient } from "./ApiClient-BTI0-m0g.mjs";
2
+ import { Ct as ttsResponseTooLargeSchema, D as BriefcasePromptIdSchema, F as ImagePromptResolutionSchema, N as GENERATED_IMAGE_EXTENSION_RE, P as ImageModels, St as ttsRequestSchema, T as BRIEFCASE_CATALOG_QUERIES, U as PROMPT_TEXT_MAX, Z as buildPromptContext, _ as requireApiUrl, b as AGENT_QUEST_MANIFEST, ft as questReplyText, g as parseApiUrl, j as DEFAULT_TTS_PROVIDER, n as logger, pt as replacePromptVariables, t as ConfigStore, tt as generatedAudioResponseSchema, ut as joinReplySlots, x as AGENT_QUEST_MCP_URI, xt as ttsBase64ResponseSchema, y as AGENT_QUEST_ID, yt as supportedVoiceGenerationVendor } from "./ConfigStore-BygzEI6H.mjs";
3
+ import { n as NotAuthenticatedError, r as isProviderKeyFailure, t as ApiClient } from "./ApiClient-dIB3YQc2.mjs";
4
4
  import { z as z$1 } from "zod";
5
5
  import { isAxiosError } from "axios";
6
6
  import { Writable } from "node:stream";
@@ -8,7 +8,17 @@ import { createServer } from "node:http";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
10
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
11
12
  //#region src/mcp/b4mApiClient.ts
13
+ const NOTEBOOK_ID_PATTERN = /^[a-f0-9]{24}$/i;
14
+ /**
15
+ * An empty or dot-segment id collapses `/api/sessions/{id}` to `/api/sessions`, whose DELETE wipes every
16
+ * notebook the caller owns, so write paths refuse anything that is not an ObjectId before any request.
17
+ */
18
+ function notebookPath(notebookId) {
19
+ if (!NOTEBOOK_ID_PATTERN.test(notebookId)) throw new Error(`Invalid notebook id: ${JSON.stringify(notebookId)}`);
20
+ return `/api/sessions/${notebookId}`;
21
+ }
12
22
  /**
13
23
  * Typed wrapper over {@link ApiClient} exposing exactly the Bike4Mind REST
14
24
  * endpoints the MCP tools call. All routes are `baseApi()` routes that accept
@@ -50,6 +60,16 @@ var B4mApiClient = class {
50
60
  ...args.dataLakeId ? { dataLakeId: args.dataLakeId } : {}
51
61
  });
52
62
  }
63
+ async renameNotebook(notebookId, name) {
64
+ return this.client.put(notebookPath(notebookId), { name });
65
+ }
66
+ /** Returns the new (cloned) notebook. */
67
+ async cloneNotebook(notebookId) {
68
+ return this.client.post(`${notebookPath(notebookId)}/clone`, {});
69
+ }
70
+ async deleteNotebook(notebookId) {
71
+ return this.client.delete(notebookPath(notebookId), { maxRedirects: 0 });
72
+ }
53
73
  /**
54
74
  * GET /api/v1/data-lakes is cursor-paginated (flat `limit`/`cursor` params,
55
75
  * `{ data, next_cursor }` body), so `toList` does not apply.
@@ -70,7 +90,7 @@ var B4mApiClient = class {
70
90
  message: args.message,
71
91
  ...args.model ? { model: args.model } : {},
72
92
  ...args.systemPrompt ? { systemPrompt: args.systemPrompt } : {},
73
- wait: true
93
+ wait: false
74
94
  });
75
95
  }
76
96
  async getQuest(questId) {
@@ -130,6 +150,16 @@ var B4mApiClient = class {
130
150
  throw error;
131
151
  }
132
152
  }
153
+ async generateImage(args) {
154
+ return this.client.post("/api/ai/generate-image", {
155
+ prompt: args.prompt,
156
+ model: args.model,
157
+ ...args.size ? { size: args.size } : {},
158
+ ...args.notebookId ? { sessionId: args.notebookId } : {},
159
+ ...args.projectId ? { projectId: args.projectId } : {},
160
+ ...args.promptResolution ? { prompt_resolution: args.promptResolution } : {}
161
+ });
162
+ }
133
163
  /**
134
164
  * POST a generated-audio request with `encoding: 'base64'` and normalize any
135
165
  * server's answer into the JSON shape `schema` describes. Base64 (never binary)
@@ -175,6 +205,14 @@ var B4mApiClient = class {
175
205
  async getProject(projectId) {
176
206
  return this.client.get(`/api/projects/${encodeURIComponent(projectId)}`);
177
207
  }
208
+ async createProject(args) {
209
+ return this.client.post("/api/projects", {
210
+ name: args.name,
211
+ description: args.description,
212
+ ...args.sessionIds?.length ? { sessionIds: args.sessionIds } : {},
213
+ ...args.fileIds?.length ? { fileIds: args.fileIds } : {}
214
+ });
215
+ }
178
216
  /**
179
217
  * GET /api/artifacts takes flat `limit`/`offset` params (not the nested
180
218
  * `pagination` object the session/file/project routes use) and hard-caps
@@ -194,6 +232,20 @@ var B4mApiClient = class {
194
232
  async getArtifact(artifactId) {
195
233
  return this.client.get(`/api/artifacts/${encodeURIComponent(artifactId)}`, { params: { includeContent: "true" } });
196
234
  }
235
+ /**
236
+ * POST /api/briefcase/catalog: a key -> prompts map, one entry per query key.
237
+ *
238
+ * The route runs csrfProtection, which exempts API-key requests but rejects a
239
+ * login (JWT bearer) request that carries no Origin - and Node sends none. CSRF
240
+ * defends browsers, so a non-browser client naming the backend's own origin is
241
+ * the intended pass, not a bypass.
242
+ */
243
+ async getBriefcaseCatalog(queries) {
244
+ return (await this.client.post("/api/briefcase/catalog", { queries }, { headers: { Origin: new URL(this.baseURL).origin } })).catalog;
245
+ }
246
+ async getBriefcasePrompt(promptId) {
247
+ return (await this.client.get(`/api/briefcase/prompts/${encodeURIComponent(promptId)}`)).prompt;
248
+ }
197
249
  };
198
250
  /**
199
251
  * Turn an API failure into an actionable, transport-agnostic message. `scope` is
@@ -213,6 +265,8 @@ function mapApiError(error, baseURL, scope) {
213
265
  }
214
266
  if (status === 403) {
215
267
  if ((error.response?.data)?.code === "FEATURE_DISABLED") return "feature disabled on this Bike4Mind instance (ask an admin to enable it)";
268
+ const csrfMessage = extractServerMessage(error.response?.data);
269
+ if (csrfMessage && /CSRF|request origin/i.test(csrfMessage)) return csrfMessage;
216
270
  const base = "API key forbidden: check the key's scopes and account access";
217
271
  return scope ? `${base} (recommended scope: ${scope})` : base;
218
272
  }
@@ -297,10 +351,46 @@ const TOOL_META = [
297
351
  description: "Create a new notebook, optionally inside a project or grounded in a data lake (dataLakeId, see list_lakes). Defaults the name to \"New Notebook\" when omitted.",
298
352
  scope: "notebooks:write"
299
353
  },
354
+ {
355
+ name: "rename_notebook",
356
+ title: "Rename notebook",
357
+ description: "Rename a notebook.",
358
+ scope: "notebooks:write"
359
+ },
360
+ {
361
+ name: "clone_notebook",
362
+ title: "Clone notebook",
363
+ description: "Clone a notebook into a new one; returns the new notebook.",
364
+ scope: "notebooks:write"
365
+ },
366
+ {
367
+ name: "delete_notebook",
368
+ title: "Delete notebook",
369
+ description: "Permanently delete a notebook and the files its owner uploaded to it; this cannot be undone through the API. Requires confirm: true. On a conflict (409) nothing was deleted, so retry.",
370
+ scope: "notebooks:write"
371
+ },
372
+ {
373
+ name: "list_projects",
374
+ title: "List projects",
375
+ description: "List the Bike4Mind projects the caller can access, including ones shared with them.",
376
+ scope: "projects:read"
377
+ },
378
+ {
379
+ name: "get_project",
380
+ title: "Get project",
381
+ description: "Fetch a single project by id.",
382
+ scope: "projects:read"
383
+ },
384
+ {
385
+ name: "create_project",
386
+ title: "Create project",
387
+ description: "Create a new project. Pass the returned id as projectId to create_notebook to create notebooks inside it.",
388
+ scope: "projects:write"
389
+ },
300
390
  {
301
391
  name: "send_message",
302
392
  title: "Send message",
303
- description: "Send a chat message and wait for the assistant reply; returns the cited sources (citables) the answer was grounded in.",
393
+ description: "Send a chat message and wait for the assistant reply, reporting progress while it is generated; returns the cited sources (citables) the answer was grounded in.",
304
394
  scope: "ai:chat"
305
395
  },
306
396
  {
@@ -338,6 +428,12 @@ const TOOL_META = [
338
428
  title: "Text to speech",
339
429
  description: "Synthesize speech from text. Returns a saved audio file with a signed download URL when available, otherwise audio inline.",
340
430
  scope: "ai:generate"
431
+ },
432
+ {
433
+ name: "generate_image",
434
+ title: "Generate image",
435
+ description: "Generate an image from a text prompt and wait for the render. Returns the quest and notebook ids plus each image's file name and download URL. Image names are not file ids and do not work with get_file.",
436
+ scope: "ai:generate"
341
437
  }
342
438
  ];
343
439
  TOOL_META.map((t) => t.name);
@@ -346,12 +442,34 @@ const listNotebooksShape = {
346
442
  limit: z$1.number().int().min(1).max(100).default(25).describe("Maximum notebooks to return"),
347
443
  page: z$1.number().int().min(1).default(1).describe("1-based page number; request the next page when hasMore is true")
348
444
  };
349
- const getNotebookShape = { notebookId: z$1.string().describe("The notebook (session) id") };
445
+ const notebookId = (description) => z$1.string().regex(NOTEBOOK_ID_PATTERN, "notebookId must be a 24-character hex ObjectId").describe(description);
446
+ const getNotebookShape = { notebookId: notebookId("The notebook (session) id") };
350
447
  const createNotebookShape = {
351
448
  name: z$1.string().optional().describe("Name for the new notebook"),
352
449
  projectId: z$1.string().optional().describe("Project to create the notebook in"),
353
450
  dataLakeId: z$1.string().optional().describe("Data lake id or slug to ground the notebook in (see list_lakes); seeds the lake's retrieval defaults")
354
451
  };
452
+ const renameNotebookShape = {
453
+ notebookId: notebookId("The notebook (session) id"),
454
+ name: z$1.string().min(1).describe("The new name")
455
+ };
456
+ const cloneNotebookShape = { notebookId: notebookId("The notebook (session) id to clone") };
457
+ const deleteNotebookShape = {
458
+ notebookId: notebookId("The notebook (session) id to delete"),
459
+ confirm: z$1.literal(true).describe("Must be true; the delete is permanent")
460
+ };
461
+ const listProjectsShape = {
462
+ search: z$1.string().optional().describe("Filter projects by name"),
463
+ limit: z$1.number().int().min(1).max(100).default(25).describe("Maximum projects to return"),
464
+ page: z$1.number().int().min(1).default(1).describe("1-based page number; request the next page when hasMore is true")
465
+ };
466
+ const getProjectShape = { projectId: z$1.string().describe("The project id") };
467
+ const createProjectShape = {
468
+ name: z$1.string().min(1).describe("Name for the new project; must be unique among your projects"),
469
+ description: z$1.string().min(1).describe("Short description of the project"),
470
+ sessionIds: z$1.array(z$1.string()).optional().describe("Notebook (session) ids to add to the project"),
471
+ fileIds: z$1.array(z$1.string()).optional().describe("File ids to add to the project")
472
+ };
355
473
  const sendMessageShape = {
356
474
  message: z$1.string().describe("The message to send"),
357
475
  notebookId: z$1.string().optional().describe("Notebook to send to; omit to start a new notebook (its id is returned as notebookId, pass it back to continue the thread)"),
@@ -380,6 +498,14 @@ const generateSoundEffectShape = {
380
498
  promptInfluence: z$1.number().min(0).max(1).optional().describe("How strictly to follow the prompt (0 = loose, 1 = strict)"),
381
499
  format: z$1.string().optional().describe("Provider output encoding token, e.g. mp3_44100_128")
382
500
  };
501
+ const generateImageShape = {
502
+ prompt: z$1.string().min(1).describe("Text description of the image to generate"),
503
+ model: z$1.string().default(ImageModels.GPT_IMAGE_2).describe("Image model id, e.g. gpt-image-2"),
504
+ size: z$1.string().optional().describe("Image size as 'widthxheight', e.g. 1024x1024; omit for the model default"),
505
+ notebookId: z$1.string().optional().describe("Notebook to add the image to; omit to create a new one"),
506
+ projectId: z$1.string().optional().describe("Project for the new notebook when notebookId is omitted"),
507
+ promptResolution: ImagePromptResolutionSchema.optional().describe("'auto' (default) rewrites the prompt against the notebook history; 'literal' sends it as written")
508
+ };
383
509
  const textToSpeechShape = {
384
510
  ...ttsRequestSchema.omit({ encoding: true }).shape,
385
511
  text: ttsRequestSchema.shape.text.describe("Text to speak"),
@@ -395,6 +521,13 @@ function notebookSummary(n) {
395
521
  updatedAt: n.updatedAt ?? n.lastUpdated
396
522
  };
397
523
  }
524
+ function projectSummary(p) {
525
+ return {
526
+ id: p.id,
527
+ name: p.name,
528
+ createdAt: p.createdAt
529
+ };
530
+ }
398
531
  function lakeSummary(l) {
399
532
  return {
400
533
  id: l.id,
@@ -422,28 +555,65 @@ async function createNotebook(client, args) {
422
555
  name: args.name ?? "New Notebook"
423
556
  });
424
557
  }
425
- async function sendMessage(client, args) {
426
- const res = await client.sendChat(args);
427
- const questId = res.id;
428
- let quest;
429
- try {
430
- quest = await client.getQuest(questId);
431
- } catch {
432
- quest = void 0;
433
- }
434
- const notebookId = args.notebookId ?? res.sessionId ?? quest?.sessionId;
435
- const citables = quest ? (quest.promptMeta?.citables ?? []).map((c) => ({
558
+ async function renameNotebook(client, args) {
559
+ return notebookSummary(await client.renameNotebook(args.notebookId, args.name));
560
+ }
561
+ async function cloneNotebook(client, args) {
562
+ return notebookSummary(await client.cloneNotebook(args.notebookId));
563
+ }
564
+ async function deleteNotebook(client, args) {
565
+ if (args.confirm !== true) throw new Error("delete_notebook requires confirm: true");
566
+ const { newLastNotebookId } = await client.deleteNotebook(args.notebookId);
567
+ return {
568
+ deleted: true,
569
+ notebookId: args.notebookId,
570
+ newLastNotebookId
571
+ };
572
+ }
573
+ async function listProjects(client, args) {
574
+ const { data, hasMore } = await client.listProjects(args);
575
+ return {
576
+ projects: data.map(projectSummary),
577
+ hasMore
578
+ };
579
+ }
580
+ async function getProject(client, args) {
581
+ return client.getProject(args.projectId);
582
+ }
583
+ async function createProject(client, args) {
584
+ return projectSummary(await client.createProject(args));
585
+ }
586
+ /**
587
+ * Queue a chat turn and poll its quest to completion. A failed turn ends `type: 'error'` with its
588
+ * explanation as the reply, which is returned as-is like any other reply.
589
+ */
590
+ async function sendMessage(client, args, { intervalMs = CHAT_POLL_INTERVAL_MS, timeoutMs = CHAT_POLL_TIMEOUT_MS, ...poll } = {}) {
591
+ const ack = await client.sendChat(args);
592
+ const questId = ack.id;
593
+ const quest = await pollQuest(client, {
594
+ questId,
595
+ ref: questRef(questId, ack.sessionId),
596
+ task: "chat completion",
597
+ scope: "ai:chat",
598
+ isFinished: isSettled
599
+ }, {
600
+ ...poll,
601
+ timeoutMs,
602
+ interval: (elapsedMs) => chatPollInterval(elapsedMs, intervalMs)
603
+ });
604
+ const notebookId = args.notebookId ?? ack.sessionId ?? quest.sessionId;
605
+ const citables = (quest.promptMeta?.citables ?? []).map((c) => ({
436
606
  id: c.id,
437
607
  type: c.type,
438
608
  title: c.title,
439
609
  url: c.url,
440
610
  description: c.description
441
- })) : void 0;
611
+ }));
442
612
  return {
443
613
  notebookId,
444
614
  questId,
445
- reply: res.responses && res.responses.length > 0 ? res.responses.join("\n\n") : res.response ?? "",
446
- model: res.model,
615
+ reply: replyText(quest),
616
+ model: ack.model,
447
617
  citables
448
618
  };
449
619
  }
@@ -470,6 +640,97 @@ async function getFile(client, args) {
470
640
  async function generateSoundEffect(client, args) {
471
641
  return generatedAudioResult(await client.generateSoundEffect(args), { provider: args.provider });
472
642
  }
643
+ const IMAGE_POLL_INTERVAL_MS = 2e3;
644
+ const IMAGE_POLL_TIMEOUT_MS = 3e5;
645
+ const CHAT_POLL_INTERVAL_MS = 2e3;
646
+ const CHAT_POLL_FAST_WINDOW_MS = 3e4;
647
+ const CHAT_POLL_SLOW_INTERVAL_MS = 5e3;
648
+ const CHAT_POLL_TIMEOUT_MS = 9e5;
649
+ const MAX_CONSECUTIVE_POLL_FAILURES = 3;
650
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
651
+ const chatPollInterval = (elapsedMs, intervalMs) => elapsedMs < CHAT_POLL_FAST_WINDOW_MS ? intervalMs : Math.max(intervalMs, CHAT_POLL_SLOW_INTERVAL_MS);
652
+ const isSettled = (q) => q.status === "done" || q.status === "stopped" || q.type === "error";
653
+ const isImageSettled = (q) => isSettled(q) || !q.status && !!q.images?.length;
654
+ const replyText = (q) => questReplyText(q, joinReplySlots) ?? "";
655
+ const questRef = (questId, notebookId) => `quest ${questId}${notebookId ? `, notebook ${notebookId}` : ""}`;
656
+ const isRateLimited = (err) => isAxiosError(err) && err.response?.status === 429;
657
+ const isPermanentApiError = (err) => {
658
+ const status = isAxiosError(err) ? err.response?.status : void 0;
659
+ return status === 401 || status === 403 || status === 404;
660
+ };
661
+ /** Poll a queued quest until `isFinished`, the timeout, a permanent poll failure, or an abort. */
662
+ async function pollQuest(client, { questId, ref, task, scope, isFinished }, { interval, timeoutMs, sleep = defaultSleep, signal, onProgress, baseURL = "" }) {
663
+ const started = Date.now();
664
+ let failures = 0;
665
+ let retryAfterMs = 0;
666
+ for (;;) {
667
+ signal?.throwIfAborted();
668
+ let quest;
669
+ try {
670
+ quest = await client.getQuest(questId);
671
+ failures = 0;
672
+ if (isFinished(quest)) return quest;
673
+ } catch (err) {
674
+ if (isRateLimited(err)) retryAfterMs = (parseRetryAfterSeconds(isAxiosError(err) && err.response?.headers?.["retry-after"]) ?? 0) * 1e3;
675
+ else {
676
+ failures += 1;
677
+ if (isPermanentApiError(err) || failures >= MAX_CONSECUTIVE_POLL_FAILURES) throw new Error(`${mapApiError(err, baseURL, scope)} (${ref}; the ${task} may still complete)`);
678
+ }
679
+ }
680
+ const elapsed = Date.now() - started;
681
+ if (elapsed >= timeoutMs) throw new Error(`${task} did not finish within ${Math.round(timeoutMs / 1e3)}s (${ref})`);
682
+ signal?.throwIfAborted();
683
+ try {
684
+ await onProgress?.(elapsed, quest);
685
+ } catch (err) {
686
+ logger.warn(`mcp: progress notification failed (${ref}): ${err instanceof Error ? err.message : String(err)}`);
687
+ }
688
+ const intervalMs = interval(elapsed);
689
+ await sleep(Math.min(Math.max(intervalMs, retryAfterMs), Math.max(timeoutMs - elapsed, intervalMs)));
690
+ retryAfterMs = 0;
691
+ }
692
+ }
693
+ /**
694
+ * Queue an image render and poll its quest to completion. A failed render still
695
+ * resolves `status: 'done'`, so `type: 'error'` (reason in `reply`) is the failure signal.
696
+ * Generated images are not FabFiles, so there is no file id: the quest id is the handle,
697
+ * and each image is its generated-file name plus the URL the quest poll resolves for it
698
+ * (`fileUrl` is absent when the server has no CDN configured).
699
+ */
700
+ async function generateImage(client, args, { intervalMs = IMAGE_POLL_INTERVAL_MS, timeoutMs = IMAGE_POLL_TIMEOUT_MS, ...poll } = {}) {
701
+ const ack = await client.generateImage(args);
702
+ const questId = ack.quest.id;
703
+ const ref = questRef(questId, ack.quest.sessionId);
704
+ const quest = await pollQuest(client, {
705
+ questId,
706
+ ref,
707
+ task: "image generation",
708
+ scope: "ai:generate",
709
+ isFinished: isImageSettled
710
+ }, {
711
+ ...poll,
712
+ timeoutMs,
713
+ interval: () => intervalMs
714
+ });
715
+ if (quest.status === "stopped") throw new Error(`image generation was stopped (${ref})`);
716
+ if (quest.type === "error") {
717
+ const code = quest.errorCode ? `${quest.errorCode}: ` : "";
718
+ throw new Error(`${code}${quest.reply || "image generation failed"} (${ref})`);
719
+ }
720
+ const urls = new Map((quest.files ?? []).map((f) => [f.name, f.url]));
721
+ const images = (quest.images ?? []).filter((name) => GENERATED_IMAGE_EXTENSION_RE.test(name)).map((name) => ({
722
+ fileName: name,
723
+ fileUrl: urls.get(name)
724
+ }));
725
+ if (images.length === 0) throw new Error(`${quest.reply || "image generation finished without producing an image"} (${ref})`);
726
+ return {
727
+ notebookId: quest.sessionId ?? ack.quest.sessionId,
728
+ questId,
729
+ model: args.model,
730
+ enhancedPrompt: ack.enhancedPrompt,
731
+ images
732
+ };
733
+ }
473
734
  function toResult(value) {
474
735
  const structuredContent = value && typeof value === "object" && !Array.isArray(value) ? value : { result: value };
475
736
  return {
@@ -575,6 +836,28 @@ async function textToSpeech(client, args) {
575
836
  });
576
837
  }
577
838
  /**
839
+ * Turn a quest poll's progress callback into MCP `notifications/progress`, or nothing when the
840
+ * client sent no progressToken. Progress also lets a client that resets its request timeout on
841
+ * progress wait out a slow quest.
842
+ */
843
+ function progressReporter({ _meta, sendNotification }, describe) {
844
+ const progressToken = _meta?.progressToken;
845
+ if (progressToken === void 0) return void 0;
846
+ return (elapsedMs, quest) => sendNotification({
847
+ method: "notifications/progress",
848
+ params: {
849
+ progressToken,
850
+ progress: Math.floor(elapsedMs / 1e3),
851
+ message: describe(quest)
852
+ }
853
+ });
854
+ }
855
+ function chatProgress(quest) {
856
+ if (quest?.status !== "running") return "waiting for the reply to start";
857
+ const characters = replyText(quest).length;
858
+ return characters > 0 ? `generating reply (${characters} characters so far)` : "generating reply";
859
+ }
860
+ /**
578
861
  * Register the Bike4Mind MCP tools on `server`. Each handler is wrapped so an
579
862
  * API failure becomes a structured `isError` result carrying a friendly message
580
863
  * (see {@link mapApiError}) rather than throwing across the transport.
@@ -604,11 +887,45 @@ function registerTools(server, client) {
604
887
  description: meta("create_notebook").description,
605
888
  inputSchema: createNotebookShape
606
889
  }, (args) => run("notebooks:write", () => createNotebook(client, args)));
890
+ server.registerTool("rename_notebook", {
891
+ title: meta("rename_notebook").title,
892
+ description: meta("rename_notebook").description,
893
+ inputSchema: renameNotebookShape
894
+ }, (args) => run("notebooks:write", () => renameNotebook(client, args)));
895
+ server.registerTool("clone_notebook", {
896
+ title: meta("clone_notebook").title,
897
+ description: meta("clone_notebook").description,
898
+ inputSchema: cloneNotebookShape
899
+ }, (args) => run("notebooks:write", () => cloneNotebook(client, args)));
900
+ server.registerTool("delete_notebook", {
901
+ title: meta("delete_notebook").title,
902
+ description: meta("delete_notebook").description,
903
+ inputSchema: deleteNotebookShape
904
+ }, (args) => run("notebooks:write", () => deleteNotebook(client, args)));
905
+ server.registerTool("list_projects", {
906
+ title: meta("list_projects").title,
907
+ description: meta("list_projects").description,
908
+ inputSchema: listProjectsShape
909
+ }, (args) => run("projects:read", () => listProjects(client, args)));
910
+ server.registerTool("get_project", {
911
+ title: meta("get_project").title,
912
+ description: meta("get_project").description,
913
+ inputSchema: getProjectShape
914
+ }, (args) => run("projects:read", () => getProject(client, args)));
915
+ server.registerTool("create_project", {
916
+ title: meta("create_project").title,
917
+ description: meta("create_project").description,
918
+ inputSchema: createProjectShape
919
+ }, (args) => run("projects:write", () => createProject(client, args)));
607
920
  server.registerTool("send_message", {
608
921
  title: meta("send_message").title,
609
922
  description: meta("send_message").description,
610
923
  inputSchema: sendMessageShape
611
- }, (args) => run("ai:chat", () => sendMessage(client, args)));
924
+ }, (args, extra) => run("ai:chat", () => sendMessage(client, args, {
925
+ signal: extra.signal,
926
+ baseURL,
927
+ onProgress: progressReporter(extra, chatProgress)
928
+ })));
612
929
  server.registerTool("search_knowledge_base", {
613
930
  title: meta("search_knowledge_base").title,
614
931
  description: meta("search_knowledge_base").description,
@@ -651,6 +968,15 @@ function registerTools(server, client) {
651
968
  return errorResult(mapApiError(err, baseURL, "ai:generate"));
652
969
  }
653
970
  });
971
+ server.registerTool("generate_image", {
972
+ title: meta("generate_image").title,
973
+ description: meta("generate_image").description,
974
+ inputSchema: generateImageShape
975
+ }, (args, extra) => run("ai:generate", () => generateImage(client, args, {
976
+ signal: extra.signal,
977
+ baseURL,
978
+ onProgress: progressReporter(extra, () => "rendering image")
979
+ })));
654
980
  }
655
981
  //#endregion
656
982
  //#region src/mcp/resources.ts
@@ -783,15 +1109,114 @@ function registerResources(server, client) {
783
1109
  });
784
1110
  }
785
1111
  //#endregion
1112
+ //#region src/mcp/prompts.ts
1113
+ /**
1114
+ * The `{{placeholder}}` keys a client supplies as prompt arguments. The clock keys
1115
+ * (currentDate, currentTime, ...) are not arguments: they are filled at get time,
1116
+ * exactly as the web launcher does at click time.
1117
+ */
1118
+ const PROMPT_ARGUMENTS = [
1119
+ {
1120
+ name: "userName",
1121
+ description: "Your name, substituted for {{userName}}"
1122
+ },
1123
+ {
1124
+ name: "userEmail",
1125
+ description: "Your email, substituted for {{userEmail}}"
1126
+ },
1127
+ {
1128
+ name: "userRole",
1129
+ description: "Your role, substituted for {{userRole}}"
1130
+ },
1131
+ {
1132
+ name: "organization",
1133
+ description: "Your organization, substituted for {{organization}}"
1134
+ }
1135
+ ];
1136
+ const PROMPT_ARGUMENT_NAMES = new Set(PROMPT_ARGUMENTS.map((arg) => arg.name));
1137
+ /** Flatten the catalog in category order; a prompt listed under two keys appears once. */
1138
+ function flattenCatalog(catalog) {
1139
+ const byId = /* @__PURE__ */ new Map();
1140
+ for (const { key } of BRIEFCASE_CATALOG_QUERIES) for (const prompt of catalog[key] ?? []) if (!byId.has(prompt.id)) byId.set(prompt.id, prompt);
1141
+ return [...byId.values()];
1142
+ }
1143
+ /**
1144
+ * List the caller's Briefcase catalog as MCP prompts, named by prompt id (display
1145
+ * names are not unique). Degrades to an empty list on failure, like the resource
1146
+ * lists: EnableBriefcase is off by default, so a hard error would fail every
1147
+ * client's startup listing on most instances.
1148
+ */
1149
+ async function listPrompts(client) {
1150
+ try {
1151
+ return { prompts: flattenCatalog(await client.getBriefcaseCatalog(BRIEFCASE_CATALOG_QUERIES)).map((prompt) => ({
1152
+ name: prompt.id,
1153
+ title: prompt.name,
1154
+ description: prompt.description,
1155
+ arguments: PROMPT_ARGUMENTS.map((arg) => ({
1156
+ ...arg,
1157
+ required: false
1158
+ }))
1159
+ })) };
1160
+ } catch (err) {
1161
+ logger.error(`mcp: listing briefcase prompts failed: ${mapApiError(err, client.baseURL)}`);
1162
+ return { prompts: [] };
1163
+ }
1164
+ }
1165
+ function toPromptContext(args, now) {
1166
+ const supplied = Object.entries(args ?? {}).filter(([name, value]) => PROMPT_ARGUMENT_NAMES.has(name) && value !== "");
1167
+ return {
1168
+ ...buildPromptContext(null, null, now),
1169
+ ...Object.fromEntries(supplied)
1170
+ };
1171
+ }
1172
+ /** Resolve one prompt's template against the supplied arguments plus the clock. */
1173
+ async function getPrompt(client, name, args, now) {
1174
+ const notFound = new McpError(ErrorCode.InvalidParams, `Prompt ${name} not found`);
1175
+ const id = BriefcasePromptIdSchema.safeParse(name);
1176
+ if (!id.success) throw notFound;
1177
+ let prompt;
1178
+ try {
1179
+ prompt = await client.getBriefcasePrompt(id.data);
1180
+ } catch (err) {
1181
+ if (isAxiosError(err) && err.response?.status === 404) throw notFound;
1182
+ throw new Error(mapApiError(err, client.baseURL));
1183
+ }
1184
+ if (!prompt.promptText) throw new Error(`Prompt ${name} has no text`);
1185
+ const text = replacePromptVariables(prompt.promptText, toPromptContext(args, now));
1186
+ return {
1187
+ description: prompt.description ?? prompt.name,
1188
+ messages: [{
1189
+ role: "user",
1190
+ content: {
1191
+ type: "text",
1192
+ text
1193
+ }
1194
+ }]
1195
+ };
1196
+ }
1197
+ /**
1198
+ * Expose the Briefcase prompt catalog as MCP prompts. Wired on the low-level
1199
+ * server rather than through `registerPrompt`: the catalog is per-caller server
1200
+ * data that changes at runtime, so it is fetched on every prompts/list instead
1201
+ * of being frozen at build time. Requires the `prompts` capability to be declared
1202
+ * on the server (see buildMcpServer). Claims prompts/list and prompts/get on the
1203
+ * low-level server, so do not also call McpServer.registerPrompt on this server
1204
+ * (a claimed method makes it throw "already exists" at startup).
1205
+ */
1206
+ function registerPrompts(server, client, now = () => /* @__PURE__ */ new Date()) {
1207
+ server.server.setRequestHandler(ListPromptsRequestSchema, () => listPrompts(client));
1208
+ server.server.setRequestHandler(GetPromptRequestSchema, (request) => getPrompt(client, request.params.name, request.params.arguments, now()));
1209
+ }
1210
+ //#endregion
786
1211
  //#region src/mcp/server.ts
787
1212
  /**
788
1213
  * Build a fully-configured Bike4Mind MCP server: the tools, the four resource
789
1214
  * templates (`b4m://notebook/{id}`, `b4m://file/{id}`, `b4m://project/{id}`,
790
1215
  * `b4m://artifact/{id}`) backed by a {@link B4mApiClient} bound to the given
791
- * endpoint and credentials, and the credential-free `b4m://agent-quest` manifest.
792
- * Tool listing never touches the network - only tool/resource *calls* hit the API -
793
- * so a server built with an unreachable endpoint still advertises its full
794
- * capability set.
1216
+ * endpoint and credentials, the credential-free `b4m://agent-quest` manifest, and
1217
+ * the Briefcase prompt catalog as MCP prompts. Tool listing never touches the
1218
+ * network - only tool/resource *calls* and prompt listing/gets hit the API - so a
1219
+ * server built with an unreachable endpoint still advertises its full capability set.
795
1220
  */
796
1221
  function buildMcpServer(options) {
797
1222
  const client = new B4mApiClient(options.baseURL, options.configStore, options.apiKey);
@@ -800,10 +1225,12 @@ function buildMcpServer(options) {
800
1225
  version: options.version
801
1226
  }, { capabilities: {
802
1227
  tools: {},
803
- resources: {}
1228
+ resources: {},
1229
+ prompts: {}
804
1230
  } });
805
1231
  registerTools(server, client);
806
1232
  registerResources(server, client);
1233
+ registerPrompts(server, client);
807
1234
  return server;
808
1235
  }
809
1236
  //#endregion