@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.
- package/README.md +1 -1
- package/dist/{AgentHistoryStore-DIMzvf-y.mjs → AgentHistoryStore-DdYKfx7C.mjs} +145 -95
- package/dist/{ApiClient-BTI0-m0g.mjs → ApiClient-dIB3YQc2.mjs} +2 -2
- package/dist/{ConfigStore-DIs-K9oD.mjs → ConfigStore-BygzEI6H.mjs} +2756 -1316
- package/dist/{WorkItemsClient-Cow6nXx7.mjs → WorkItemsClient-ClBC49y0.mjs} +52 -8
- package/dist/{buildAgent-DOvEp92a.mjs → buildAgent-B7cMwRAy.mjs} +33 -2
- package/dist/commands/acpCommand.mjs +4 -4
- package/dist/commands/apiCommand.mjs +1 -1
- package/dist/commands/doctorCommand.mjs +1 -1
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +4 -4
- package/dist/commands/mcpCommand.mjs +3 -3
- package/dist/commands/pluginCommand.mjs +1 -1
- package/dist/commands/updateCommand.mjs +1 -1
- package/dist/index.mjs +75 -23
- package/dist/{package-Dl_F2Fah.mjs → package-CRNnNXxq.mjs} +1 -1
- package/dist/{serve-Y2zMbfGs.mjs → serve-DM7ld3-o.mjs} +452 -25
- package/package.json +11 -11
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { n as NotAuthenticatedError, r as isProviderKeyFailure, t as ApiClient } from "./ApiClient-
|
|
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:
|
|
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
|
|
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
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
}))
|
|
611
|
+
}));
|
|
442
612
|
return {
|
|
443
613
|
notebookId,
|
|
444
614
|
questId,
|
|
445
|
-
reply:
|
|
446
|
-
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,
|
|
792
|
-
*
|
|
793
|
-
*
|
|
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
|