@bike4mind/cli 2.0.0 → 3.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.
- package/README.md +1 -1
- package/dist/{AgentHistoryStore-DIMzvf-y.mjs → AgentHistoryStore-DVxv1kSO.mjs} +7940 -7014
- package/dist/{ConfigStore-DIs-K9oD.mjs → ConfigStore-51UaP9pJ.mjs} +12333 -7008
- package/dist/{ImageStore-kVo-oHoS.mjs → ImageStore-DuI3tgwQ.mjs} +8 -6
- package/dist/SandboxRuntimeAdapter-Bt1wPpld.mjs +2 -0
- package/dist/{WorkItemsClient-Cow6nXx7.mjs → WorkItemsClient-BMY0Qq1s.mjs} +78 -9
- package/dist/{buildAgent-DOvEp92a.mjs → buildAgent-CrSRKYU_.mjs} +106 -43
- package/dist/commands/acpCommand.mjs +26 -35
- package/dist/commands/apiCommand.mjs +1 -1
- package/dist/commands/doctorCommand.mjs +3 -3
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +15 -24
- package/dist/commands/mcpCommand.mjs +3 -3
- package/dist/commands/pluginCommand.mjs +2 -2
- package/dist/commands/updateCommand.mjs +5 -15
- package/dist/dist-BsG2Hlp-.mjs +739 -0
- package/dist/index.mjs +670 -340
- package/dist/{package-Dl_F2Fah.mjs → package-DEsNSP54.mjs} +1 -1
- package/dist/serve-BaHzVbcm.mjs +1511 -0
- package/dist/store-BkMur6uk.mjs +2 -0
- package/dist/{store-CvjTpQPs.mjs → store-BxmPEI3v.mjs} +10 -0
- package/dist/types-I45lMh7Q.mjs +2 -0
- package/dist/{updateChecker-CQW8bxo6.mjs → updateChecker-CCLVVdH0.mjs} +36 -13
- package/package.json +27 -26
- package/dist/ApiClient-BTI0-m0g.mjs +0 -316
- package/dist/SandboxRuntimeAdapter-BmHELLuM.mjs +0 -3
- package/dist/serve-Y2zMbfGs.mjs +0 -938
- package/dist/store-BG3e54c8.mjs +0 -3
- package/dist/types-CdIKgWWe.mjs +0 -3
- package/dist/{bashExecute-CrdPpBqk-DCATrE-D.mjs → bashExecute-CrdPpBqk-CgK1gZa2.mjs} +1 -1
- package/dist/{grepSearch-BgoOOwGe-DtlV8Gn-.mjs → grepSearch-BgoOOwGe-B388GxRD.mjs} +2 -2
|
@@ -0,0 +1,1511 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { A as BRIEFCASE_CATALOG_QUERIES, At as ttsBase64ResponseSchema, B as ImageModels, C as AGENT_QUEST_ID, L as DEFAULT_TTS_PROVIDER, M as BriefcasePromptIdSchema, Mt as ttsResponseTooLargeSchema, Ot as supportedVoiceGenerationVendor, St as replacePromptVariables, T as AGENT_QUEST_MCP_URI, V as ImagePromptResolutionSchema, Y as PROMPT_TEXT_MAX, _ as logger, g as parseApiUrl, h as NotAuthenticatedError, jt as ttsRequestSchema, k as ArtifactTypeSchema, nt as VisibilitySchema, ot as buildPromptContext, t as ConfigStore, u as requireApiUrl, ut as generatedAudioResponseSchema, w as AGENT_QUEST_MANIFEST, xt as questReplyText, yt as joinReplySlots, z as GENERATED_IMAGE_EXTENSION_RE } from "./ConfigStore-51UaP9pJ.mjs";
|
|
3
|
+
import { a as ApiClient, i as parseRetryAfterSeconds, o as isProviderKeyFailure, r as createClient, t as B4mApiError } from "./dist-BsG2Hlp-.mjs";
|
|
4
|
+
import { z as z$1 } from "zod";
|
|
5
|
+
import { isAxiosError } from "axios";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { Writable } from "node:stream";
|
|
8
|
+
import { createServer } from "node:http";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
11
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
//#region src/mcp/b4mApiClient.ts
|
|
14
|
+
const NOTEBOOK_ID_PATTERN = /^[a-f0-9]{24}$/i;
|
|
15
|
+
const STOP_REPLY_TIMEOUT_MS = 1e4;
|
|
16
|
+
/**
|
|
17
|
+
* An empty or dot-segment id collapses `/api/sessions/{id}` to `/api/sessions`, whose DELETE wipes every
|
|
18
|
+
* notebook the caller owns, so write paths refuse anything that is not an ObjectId before any request.
|
|
19
|
+
*/
|
|
20
|
+
function writableNotebookId(notebookId) {
|
|
21
|
+
if (!NOTEBOOK_ID_PATTERN.test(notebookId)) throw new Error(`Invalid notebook id: ${JSON.stringify(notebookId)}`);
|
|
22
|
+
return notebookId;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Typed wrapper over {@link ApiClient} exposing exactly the Bike4Mind REST
|
|
26
|
+
* endpoints the MCP tools call. All routes are `baseApi()` routes that accept
|
|
27
|
+
* either an OAuth JWT or an instance API key, so a caller supplies whichever it
|
|
28
|
+
* has via the underlying ApiClient.
|
|
29
|
+
*
|
|
30
|
+
* Operations with a public contract go through `@bike4mind/sdk` (`this.sdk`), whose fetch is the
|
|
31
|
+
* ApiClient's, so auth and refresh are shared. The axios `this.client` calls are routes with no public
|
|
32
|
+
* contract, plus four that do have one but whose v1 shape would change the MCP output: the session and
|
|
33
|
+
* project lists (v1 pages by cursor, the tools by page number) and getFile/getProject (the tools return the
|
|
34
|
+
* whole document; v1 is a narrower snake_case resource).
|
|
35
|
+
*/
|
|
36
|
+
var B4mApiClient = class {
|
|
37
|
+
constructor(baseURL, configStore, apiKey) {
|
|
38
|
+
this.baseURL = baseURL;
|
|
39
|
+
this.client = new ApiClient(baseURL, configStore, apiKey);
|
|
40
|
+
this.sdk = createClient({
|
|
41
|
+
baseUrl: baseURL,
|
|
42
|
+
fetch: this.client.fetch
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
toList(result) {
|
|
46
|
+
if (Array.isArray(result)) return {
|
|
47
|
+
data: result,
|
|
48
|
+
hasMore: false
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
data: result.data ?? [],
|
|
52
|
+
hasMore: result.hasMore ?? false
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async listNotebooks(args) {
|
|
56
|
+
const result = await this.client.get("/api/sessions", { params: {
|
|
57
|
+
...args.search ? { search: args.search } : {},
|
|
58
|
+
pagination: {
|
|
59
|
+
page: args.page ?? 1,
|
|
60
|
+
limit: args.limit
|
|
61
|
+
}
|
|
62
|
+
} });
|
|
63
|
+
return this.toList(result);
|
|
64
|
+
}
|
|
65
|
+
async getNotebook(notebookId) {
|
|
66
|
+
return this.sdk.call("getSession", { params: { id: notebookId } });
|
|
67
|
+
}
|
|
68
|
+
async createNotebook(args) {
|
|
69
|
+
return this.sdk.call("createSession", { body: {
|
|
70
|
+
name: args.name,
|
|
71
|
+
...args.projectId ? { projectId: args.projectId } : {},
|
|
72
|
+
...args.dataLakeId ? { dataLakeId: args.dataLakeId } : {}
|
|
73
|
+
} });
|
|
74
|
+
}
|
|
75
|
+
async renameNotebook(notebookId, name) {
|
|
76
|
+
return this.sdk.call("updateSession", {
|
|
77
|
+
params: { id: writableNotebookId(notebookId) },
|
|
78
|
+
body: { name }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** Returns the new (cloned) notebook. */
|
|
82
|
+
async cloneNotebook(notebookId) {
|
|
83
|
+
return this.sdk.call("cloneSession", {
|
|
84
|
+
params: { id: writableNotebookId(notebookId) },
|
|
85
|
+
body: {}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async deleteNotebook(notebookId) {
|
|
89
|
+
return this.sdk.call("deleteSession", {
|
|
90
|
+
params: { id: writableNotebookId(notebookId) },
|
|
91
|
+
redirect: "manual"
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** GET /api/v1/data-lakes is cursor-paginated (`{ data, next_cursor }` body), so `toList` does not apply. */
|
|
95
|
+
async listDataLakes(args) {
|
|
96
|
+
const result = await this.sdk.call("listDataLakes", { query: {
|
|
97
|
+
limit: args.limit,
|
|
98
|
+
...args.cursor ? { cursor: args.cursor } : {}
|
|
99
|
+
} });
|
|
100
|
+
return {
|
|
101
|
+
data: result.data ?? [],
|
|
102
|
+
nextCursor: result.next_cursor ?? null
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async sendChat(args) {
|
|
106
|
+
const body = {
|
|
107
|
+
...args.notebookId ? { sessionId: args.notebookId } : { newConversation: true },
|
|
108
|
+
message: args.message,
|
|
109
|
+
...args.model ? { model: args.model } : {},
|
|
110
|
+
...args.systemPrompt ? { systemPrompt: args.systemPrompt } : {},
|
|
111
|
+
wait: false
|
|
112
|
+
};
|
|
113
|
+
return this.sdk.call("sendChatMessage", { body });
|
|
114
|
+
}
|
|
115
|
+
async getQuest(questId) {
|
|
116
|
+
return await this.sdk.call("getQuest", { params: { id: questId } });
|
|
117
|
+
}
|
|
118
|
+
/** Stop one generating turn. A server that predates `questId` stops the notebook's latest turn. */
|
|
119
|
+
async stopReply(notebookId, questId) {
|
|
120
|
+
return this.client.post(`/api/sessions/${writableNotebookId(notebookId)}/chat/stop-reply`, { questId }, { timeout: STOP_REPLY_TIMEOUT_MS });
|
|
121
|
+
}
|
|
122
|
+
async searchKnowledgeBase(args) {
|
|
123
|
+
return (await this.client.post("/api/sessions/semantic-search", {
|
|
124
|
+
query: args.query,
|
|
125
|
+
topK: args.limit,
|
|
126
|
+
...args.minSimilarity !== void 0 ? { minSimilarity: args.minSimilarity } : {}
|
|
127
|
+
})).scores ?? [];
|
|
128
|
+
}
|
|
129
|
+
async listFiles(args) {
|
|
130
|
+
const result = await this.client.get("/api/files/search", { params: {
|
|
131
|
+
...args.search ? { search: args.search } : {},
|
|
132
|
+
pagination: {
|
|
133
|
+
page: args.page ?? 1,
|
|
134
|
+
limit: args.limit
|
|
135
|
+
}
|
|
136
|
+
} });
|
|
137
|
+
return this.toList(result);
|
|
138
|
+
}
|
|
139
|
+
async getFile(fileId) {
|
|
140
|
+
return this.client.get(`/api/files/${encodeURIComponent(fileId)}`);
|
|
141
|
+
}
|
|
142
|
+
/** Generate a sound effect. The SDK requests base64 and rebuilds an old server's raw-bytes answer. */
|
|
143
|
+
async generateSoundEffect(args) {
|
|
144
|
+
const body = {
|
|
145
|
+
provider: args.provider,
|
|
146
|
+
text: args.text,
|
|
147
|
+
...args.durationSeconds !== void 0 ? { durationSeconds: args.durationSeconds } : {},
|
|
148
|
+
...args.promptInfluence !== void 0 ? { promptInfluence: args.promptInfluence } : {},
|
|
149
|
+
...args.format ? { format: args.format } : {}
|
|
150
|
+
};
|
|
151
|
+
return generatedAudioResponseSchema.parse(await this.sdk.soundEffects(body));
|
|
152
|
+
}
|
|
153
|
+
async synthesizeSpeech(args) {
|
|
154
|
+
const result = await this.sdk.tts(args);
|
|
155
|
+
if (result.kind === "audio") return {
|
|
156
|
+
kind: "audio",
|
|
157
|
+
data: ttsBase64ResponseSchema.parse(result.data)
|
|
158
|
+
};
|
|
159
|
+
const oversized = ttsResponseTooLargeSchema.parse(result.data);
|
|
160
|
+
const fallbackFrom = supportedVoiceGenerationVendor.safeParse(result.fallbackFrom);
|
|
161
|
+
return {
|
|
162
|
+
kind: "saved-too-large",
|
|
163
|
+
data: {
|
|
164
|
+
...oversized,
|
|
165
|
+
fabFileId: result.data.fabFileId
|
|
166
|
+
},
|
|
167
|
+
...fallbackFrom.success ? { fallbackFrom: fallbackFrom.data } : {}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
async generateImage(args) {
|
|
171
|
+
const body = {
|
|
172
|
+
prompt: args.prompt,
|
|
173
|
+
model: args.model,
|
|
174
|
+
...args.size ? { size: args.size } : {},
|
|
175
|
+
...args.notebookId ? { sessionId: args.notebookId } : {},
|
|
176
|
+
...args.projectId ? { projectId: args.projectId } : {},
|
|
177
|
+
...args.promptResolution ? { prompt_resolution: args.promptResolution } : {}
|
|
178
|
+
};
|
|
179
|
+
return await this.sdk.call("generateImage", { body });
|
|
180
|
+
}
|
|
181
|
+
async listProjects(args) {
|
|
182
|
+
const result = await this.client.get("/api/projects", { params: {
|
|
183
|
+
...args.search ? { search: args.search } : {},
|
|
184
|
+
pagination: {
|
|
185
|
+
page: args.page ?? 1,
|
|
186
|
+
limit: args.limit
|
|
187
|
+
}
|
|
188
|
+
} });
|
|
189
|
+
return this.toList(result);
|
|
190
|
+
}
|
|
191
|
+
async getProject(projectId) {
|
|
192
|
+
return this.client.get(`/api/projects/${encodeURIComponent(projectId)}`);
|
|
193
|
+
}
|
|
194
|
+
async createProject(args) {
|
|
195
|
+
const project = await this.sdk.call("createProject", { body: {
|
|
196
|
+
name: args.name,
|
|
197
|
+
description: args.description,
|
|
198
|
+
...args.sessionIds?.length ? { session_ids: args.sessionIds } : {},
|
|
199
|
+
...args.fileIds?.length ? { file_ids: args.fileIds } : {}
|
|
200
|
+
} });
|
|
201
|
+
return {
|
|
202
|
+
id: project.id,
|
|
203
|
+
name: project.name,
|
|
204
|
+
description: project.description,
|
|
205
|
+
sessionIds: project.session_ids,
|
|
206
|
+
fileIds: project.file_ids,
|
|
207
|
+
createdAt: project.created_at ?? void 0,
|
|
208
|
+
updatedAt: project.updated_at ?? void 0
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* GET /api/artifacts takes flat `limit`/`offset` params (not the nested
|
|
213
|
+
* `pagination` object the session/file/project routes use) and hard-caps
|
|
214
|
+
* `limit` at 100 via a strict zod parse, so callers must not exceed it.
|
|
215
|
+
*/
|
|
216
|
+
async listArtifacts(args) {
|
|
217
|
+
const result = await this.client.get("/api/artifacts", { params: {
|
|
218
|
+
...args.search ? { search: args.search } : {},
|
|
219
|
+
limit: args.limit,
|
|
220
|
+
offset: args.offset ?? 0
|
|
221
|
+
} });
|
|
222
|
+
return {
|
|
223
|
+
data: result.artifacts ?? [],
|
|
224
|
+
hasMore: result.pagination?.hasMore ?? false
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async getArtifact(artifactId) {
|
|
228
|
+
return this.client.get(`/api/artifacts/${encodeURIComponent(artifactId)}`, { params: { includeContent: "true" } });
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The caller's own most recent publication of an artifact, or null. The route is
|
|
232
|
+
* owner-scoped for `sourceArtifactId`, so another user's page never matches.
|
|
233
|
+
*/
|
|
234
|
+
async findPublishedArtifact(artifactId) {
|
|
235
|
+
return (await this.client.get("/api/publish/artifacts", { params: {
|
|
236
|
+
sourceArtifactId: artifactId,
|
|
237
|
+
limit: 1
|
|
238
|
+
} })).artifacts?.[0] ?? null;
|
|
239
|
+
}
|
|
240
|
+
/** POST /api/publish/artifact/upload-url: opens a publish draft and mints one upload URL per file. */
|
|
241
|
+
async requestPublishUpload(request) {
|
|
242
|
+
return this.client.post("/api/publish/artifact/upload-url", request);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* PUT one draft file to the URL upload-url minted. Plain fetch, never the app
|
|
246
|
+
* client: that client injects auth headers, which break an S3 presigned
|
|
247
|
+
* signature. Self-host mints a relative proxy URL, so resolve against baseURL.
|
|
248
|
+
*/
|
|
249
|
+
async uploadPublishDraftFile(uploadUrl, body, mimeType) {
|
|
250
|
+
const res = await fetch(new URL(uploadUrl, this.baseURL), {
|
|
251
|
+
method: "PUT",
|
|
252
|
+
headers: { "Content-Type": mimeType },
|
|
253
|
+
body
|
|
254
|
+
});
|
|
255
|
+
if (!res.ok) throw new Error(`publish upload failed (${res.status})`);
|
|
256
|
+
}
|
|
257
|
+
/** POST /api/publish/artifact/finalize: validates the uploaded draft and makes it live. */
|
|
258
|
+
async finalizePublish(draftId) {
|
|
259
|
+
return this.client.post("/api/publish/artifact/finalize", { draftId });
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* POST /api/briefcase/catalog: a key -> prompts map, one entry per query key.
|
|
263
|
+
*
|
|
264
|
+
* The route runs csrfProtection, which exempts API-key requests but rejects a
|
|
265
|
+
* login (JWT bearer) request that carries no Origin - and Node sends none. CSRF
|
|
266
|
+
* defends browsers, so a non-browser client naming the backend's own origin is
|
|
267
|
+
* the intended pass, not a bypass.
|
|
268
|
+
*/
|
|
269
|
+
async getBriefcaseCatalog(queries) {
|
|
270
|
+
return (await this.client.post("/api/briefcase/catalog", { queries }, { headers: { Origin: new URL(this.baseURL).origin } })).catalog;
|
|
271
|
+
}
|
|
272
|
+
async getBriefcasePrompt(promptId) {
|
|
273
|
+
return (await this.client.get(`/api/briefcase/prompts/${encodeURIComponent(promptId)}`)).prompt;
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* Turn an API failure into an actionable, transport-agnostic message. `scope` is
|
|
278
|
+
* the recommended API-key scope for the failing tool. A 403 can come from a
|
|
279
|
+
* missing scope OR from a route-level authorization check (CASL forbidden,
|
|
280
|
+
* suspended account), so the message stays broad rather than asserting a scope
|
|
281
|
+
* gap that may not be the cause.
|
|
282
|
+
*/
|
|
283
|
+
function mapApiError(error, baseURL, scope) {
|
|
284
|
+
if (error instanceof NotAuthenticatedError) return "not authenticated: no credential configured (set B4M_API_KEY or run `b4m login`)";
|
|
285
|
+
const failure = httpFailure(error);
|
|
286
|
+
if (!failure) return error instanceof Error ? error.message : String(error);
|
|
287
|
+
const { status, data } = failure;
|
|
288
|
+
if (status === 401) {
|
|
289
|
+
const providerKeyMessage = providerKeyFailureMessage(data);
|
|
290
|
+
if (providerKeyMessage) return providerKeyMessage;
|
|
291
|
+
return "authentication failed (run `b4m login` or set B4M_API_KEY)";
|
|
292
|
+
}
|
|
293
|
+
if (status === 403) {
|
|
294
|
+
if (data?.code === "FEATURE_DISABLED") return "feature disabled on this Bike4Mind instance (ask an admin to enable it)";
|
|
295
|
+
if (apiErrorCode(error) === "slug_owned_by_other") return extractServerMessage(data) ?? "this publish slug belongs to another publisher";
|
|
296
|
+
const serverMessage = extractServerMessage(data);
|
|
297
|
+
if (serverMessage && /CSRF|request origin/i.test(serverMessage)) return serverMessage;
|
|
298
|
+
if (serverMessage && /own user scope/i.test(serverMessage)) return `${serverMessage} (publish_artifact only publishes artifacts you own)`;
|
|
299
|
+
const base = "API key forbidden: check the key's scopes and account access";
|
|
300
|
+
return scope ? `${base} (recommended scope: ${scope})` : base;
|
|
301
|
+
}
|
|
302
|
+
if (status === 404 && error instanceof B4mApiError && !extractServerMessage(data)) return `the Bike4Mind server at ${baseURL} does not have this API route; it likely predates this CLI (upgrade the server or use an older CLI)`;
|
|
303
|
+
if (status === 429) {
|
|
304
|
+
const retryAfterSeconds = apiErrorRetryAfterSeconds(error);
|
|
305
|
+
const base = extractServerMessage(data) || "rate limit exceeded";
|
|
306
|
+
return retryAfterSeconds !== void 0 ? `${base} (retry after ${retryAfterSeconds}s)` : base;
|
|
307
|
+
}
|
|
308
|
+
if (isAxiosError(error)) {
|
|
309
|
+
if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT" || /timeout/i.test(error.message)) return `request to Bike4Mind at ${baseURL} timed out`;
|
|
310
|
+
if (error.code === "ECONNREFUSED" || error.message.includes("ECONNREFUSED")) return `cannot reach Bike4Mind at ${baseURL}`;
|
|
311
|
+
}
|
|
312
|
+
const serverMsg = extractServerMessage(data);
|
|
313
|
+
if (serverMsg) return serverMsg;
|
|
314
|
+
return error instanceof Error ? error.message : String(error);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Status and body of an HTTP failure from either transport: a `B4mApiError` from the SDK calls or an
|
|
318
|
+
* AxiosError from the axios ones (with no status when the request never got a response). Undefined for
|
|
319
|
+
* anything else.
|
|
320
|
+
*/
|
|
321
|
+
function httpFailure(error) {
|
|
322
|
+
if (error instanceof B4mApiError) return {
|
|
323
|
+
status: error.status,
|
|
324
|
+
data: error.body
|
|
325
|
+
};
|
|
326
|
+
if (isAxiosError(error)) return {
|
|
327
|
+
status: error.response?.status,
|
|
328
|
+
data: error.response?.data
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** The machine-readable `code` a route put on its error body, when it set one. */
|
|
332
|
+
function apiErrorCode(error) {
|
|
333
|
+
const data = httpFailure(error)?.data;
|
|
334
|
+
if (!data || typeof data !== "object") return void 0;
|
|
335
|
+
const code = data.code;
|
|
336
|
+
return typeof code === "string" ? code : void 0;
|
|
337
|
+
}
|
|
338
|
+
/** The HTTP status of a failed API call from either transport. */
|
|
339
|
+
function apiErrorStatus(error) {
|
|
340
|
+
return httpFailure(error)?.status;
|
|
341
|
+
}
|
|
342
|
+
/** The Retry-After of a failed API call from either transport, in seconds. */
|
|
343
|
+
function apiErrorRetryAfterSeconds(error) {
|
|
344
|
+
if (error instanceof B4mApiError) return error.retryAfterSeconds;
|
|
345
|
+
return isAxiosError(error) ? parseRetryAfterSeconds(error.response?.headers?.["retry-after"]) : void 0;
|
|
346
|
+
}
|
|
347
|
+
/** Pull a human-readable message out of a JSON error body (`error` or `message` field). */
|
|
348
|
+
function extractServerMessage(data) {
|
|
349
|
+
if (data && typeof data === "object") {
|
|
350
|
+
const record = data;
|
|
351
|
+
if (typeof record.error === "string") return record.error;
|
|
352
|
+
if (typeof record.message === "string") return record.message;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function providerKeyFailureMessage(data) {
|
|
356
|
+
if (!isProviderKeyFailure(data)) return void 0;
|
|
357
|
+
return `${extractServerMessage(data) ?? "the AI provider could not be used"} (configure or fix the provider API key in Bike4Mind; this is not a Bike4Mind login problem)`;
|
|
358
|
+
}
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/mcp/tools.ts
|
|
361
|
+
const TOOL_META = [
|
|
362
|
+
{
|
|
363
|
+
name: "list_notebooks",
|
|
364
|
+
title: "List notebooks",
|
|
365
|
+
description: "List the caller's Bike4Mind notebooks (sessions).",
|
|
366
|
+
scope: "notebooks:read"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: "get_notebook",
|
|
370
|
+
title: "Get notebook",
|
|
371
|
+
description: "Fetch a single notebook by id.",
|
|
372
|
+
scope: "notebooks:read"
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: "create_notebook",
|
|
376
|
+
title: "Create notebook",
|
|
377
|
+
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.",
|
|
378
|
+
scope: "notebooks:write"
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
name: "rename_notebook",
|
|
382
|
+
title: "Rename notebook",
|
|
383
|
+
description: "Rename a notebook.",
|
|
384
|
+
scope: "notebooks:write"
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: "clone_notebook",
|
|
388
|
+
title: "Clone notebook",
|
|
389
|
+
description: "Clone a notebook into a new one; returns the new notebook.",
|
|
390
|
+
scope: "notebooks:write"
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: "delete_notebook",
|
|
394
|
+
title: "Delete notebook",
|
|
395
|
+
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.",
|
|
396
|
+
scope: "notebooks:write"
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
name: "list_projects",
|
|
400
|
+
title: "List projects",
|
|
401
|
+
description: "List the Bike4Mind projects the caller can access, including ones shared with them.",
|
|
402
|
+
scope: "projects:read"
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
name: "get_project",
|
|
406
|
+
title: "Get project",
|
|
407
|
+
description: "Fetch a single project by id.",
|
|
408
|
+
scope: "projects:read"
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
name: "create_project",
|
|
412
|
+
title: "Create project",
|
|
413
|
+
description: "Create a new project. Pass the returned id as projectId to create_notebook to create notebooks inside it.",
|
|
414
|
+
scope: "projects:write"
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: "send_message",
|
|
418
|
+
title: "Send message",
|
|
419
|
+
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. Fails on an errored or stopped turn, and stops the turn when the call is cancelled.",
|
|
420
|
+
scope: "ai:chat"
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
name: "search_knowledge_base",
|
|
424
|
+
title: "Search knowledge base",
|
|
425
|
+
description: "Semantic search across the caller's notebooks.",
|
|
426
|
+
scope: "notebooks:read"
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
name: "list_lakes",
|
|
430
|
+
title: "List data lakes",
|
|
431
|
+
description: "List the data lakes the caller can reach. Pass a lake's id as dataLakeId to create_notebook to ground a notebook in it.",
|
|
432
|
+
scope: "datalake:read"
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
name: "list_files",
|
|
436
|
+
title: "List files",
|
|
437
|
+
description: "Search the caller's files.",
|
|
438
|
+
scope: "files:read"
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
name: "get_file",
|
|
442
|
+
title: "Get file",
|
|
443
|
+
description: "Fetch a file's metadata and a signed download URL.",
|
|
444
|
+
scope: "files:read"
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: "generate_sound_effect",
|
|
448
|
+
title: "Generate sound effect",
|
|
449
|
+
description: "Generate a sound effect from a text description. Returns the saved audio file (with a signed download URL) when the caller keeps generated audio, otherwise the audio inline.",
|
|
450
|
+
scope: "ai:generate"
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
name: "text_to_speech",
|
|
454
|
+
title: "Text to speech",
|
|
455
|
+
description: "Synthesize speech from text. Returns a saved audio file with a signed download URL when available, otherwise audio inline.",
|
|
456
|
+
scope: "ai:generate"
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
name: "generate_image",
|
|
460
|
+
title: "Generate image",
|
|
461
|
+
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.",
|
|
462
|
+
scope: "ai:generate"
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
name: "publish_artifact",
|
|
466
|
+
title: "Publish artifact",
|
|
467
|
+
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.",
|
|
468
|
+
scope: "files:write"
|
|
469
|
+
}
|
|
470
|
+
];
|
|
471
|
+
TOOL_META.map((t) => t.name);
|
|
472
|
+
const listNotebooksShape = {
|
|
473
|
+
search: z$1.string().optional().describe("Filter notebooks by name/content"),
|
|
474
|
+
limit: z$1.number().int().min(1).max(100).default(25).describe("Maximum notebooks to return"),
|
|
475
|
+
page: z$1.number().int().min(1).default(1).describe("1-based page number; request the next page when hasMore is true")
|
|
476
|
+
};
|
|
477
|
+
const notebookId = (description) => z$1.string().regex(NOTEBOOK_ID_PATTERN, "notebookId must be a 24-character hex ObjectId").describe(description);
|
|
478
|
+
const getNotebookShape = { notebookId: notebookId("The notebook (session) id") };
|
|
479
|
+
const createNotebookShape = {
|
|
480
|
+
name: z$1.string().optional().describe("Name for the new notebook"),
|
|
481
|
+
projectId: z$1.string().optional().describe("Project to create the notebook in"),
|
|
482
|
+
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")
|
|
483
|
+
};
|
|
484
|
+
const renameNotebookShape = {
|
|
485
|
+
notebookId: notebookId("The notebook (session) id"),
|
|
486
|
+
name: z$1.string().min(1).describe("The new name")
|
|
487
|
+
};
|
|
488
|
+
const cloneNotebookShape = { notebookId: notebookId("The notebook (session) id to clone") };
|
|
489
|
+
const deleteNotebookShape = {
|
|
490
|
+
notebookId: notebookId("The notebook (session) id to delete"),
|
|
491
|
+
confirm: z$1.literal(true).describe("Must be true; the delete is permanent")
|
|
492
|
+
};
|
|
493
|
+
const listProjectsShape = {
|
|
494
|
+
search: z$1.string().optional().describe("Filter projects by name"),
|
|
495
|
+
limit: z$1.number().int().min(1).max(100).default(25).describe("Maximum projects to return"),
|
|
496
|
+
page: z$1.number().int().min(1).default(1).describe("1-based page number; request the next page when hasMore is true")
|
|
497
|
+
};
|
|
498
|
+
const getProjectShape = { projectId: z$1.string().describe("The project id") };
|
|
499
|
+
const createProjectShape = {
|
|
500
|
+
name: z$1.string().min(1).describe("Name for the new project; must be unique among your projects"),
|
|
501
|
+
description: z$1.string().min(1).describe("Short description of the project"),
|
|
502
|
+
sessionIds: z$1.array(z$1.string()).optional().describe("Notebook (session) ids to add to the project"),
|
|
503
|
+
fileIds: z$1.array(z$1.string()).optional().describe("File ids to add to the project")
|
|
504
|
+
};
|
|
505
|
+
const sendMessageShape = {
|
|
506
|
+
message: z$1.string().describe("The message to send"),
|
|
507
|
+
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)"),
|
|
508
|
+
model: z$1.string().optional().describe("Model id to use; defaults to the instance default"),
|
|
509
|
+
systemPrompt: z$1.string().max(PROMPT_TEXT_MAX).optional().describe("Caller-supplied system-prompt text for this message only; refines but never overrides other guidance")
|
|
510
|
+
};
|
|
511
|
+
const searchKnowledgeBaseShape = {
|
|
512
|
+
query: z$1.string().describe("The search query"),
|
|
513
|
+
limit: z$1.number().int().min(1).max(100).default(10).describe("Maximum results to return"),
|
|
514
|
+
minSimilarity: z$1.number().min(0).max(1).optional().describe("Minimum cosine similarity threshold")
|
|
515
|
+
};
|
|
516
|
+
const listLakesShape = {
|
|
517
|
+
limit: z$1.number().int().min(1).max(100).default(25).describe("Maximum lakes to return"),
|
|
518
|
+
cursor: z$1.string().optional().describe("Pass back nextCursor from the previous page")
|
|
519
|
+
};
|
|
520
|
+
const listFilesShape = {
|
|
521
|
+
search: z$1.string().optional().describe("Filter files by name/content"),
|
|
522
|
+
limit: z$1.number().int().min(1).max(100).default(25).describe("Maximum files to return"),
|
|
523
|
+
page: z$1.number().int().min(1).default(1).describe("1-based page number; request the next page when hasMore is true")
|
|
524
|
+
};
|
|
525
|
+
const getFileShape = { fileId: z$1.string().describe("The file id") };
|
|
526
|
+
const generateSoundEffectShape = {
|
|
527
|
+
text: z$1.string().min(1).max(1e3).describe("Text description of the sound effect to generate"),
|
|
528
|
+
provider: z$1.enum(["elevenlabs"]).default("elevenlabs").describe("Sound-generation provider"),
|
|
529
|
+
durationSeconds: z$1.number().min(.5).max(30).optional().describe("Length of the sound in seconds (0.5-30); omit to let the provider choose"),
|
|
530
|
+
promptInfluence: z$1.number().min(0).max(1).optional().describe("How strictly to follow the prompt (0 = loose, 1 = strict)"),
|
|
531
|
+
format: z$1.string().optional().describe("Provider output encoding token, e.g. mp3_44100_128")
|
|
532
|
+
};
|
|
533
|
+
const generateImageShape = {
|
|
534
|
+
prompt: z$1.string().min(1).describe("Text description of the image to generate"),
|
|
535
|
+
model: z$1.string().default(ImageModels.GPT_IMAGE_2).describe("Image model id, e.g. gpt-image-2"),
|
|
536
|
+
size: z$1.string().optional().describe("Image size as 'widthxheight', e.g. 1024x1024; omit for the model default"),
|
|
537
|
+
notebookId: z$1.string().optional().describe("Notebook to add the image to; omit to create a new one"),
|
|
538
|
+
projectId: z$1.string().optional().describe("Project for the new notebook when notebookId is omitted"),
|
|
539
|
+
promptResolution: ImagePromptResolutionSchema.optional().describe("'auto' (default) rewrites the prompt against the notebook history; 'literal' sends it as written")
|
|
540
|
+
};
|
|
541
|
+
const publishVisibilitySchema = VisibilitySchema.extract(["public", "private"]);
|
|
542
|
+
const publishArtifactShape = {
|
|
543
|
+
artifactId: z$1.string().min(1).describe("The artifact id"),
|
|
544
|
+
visibility: publishVisibilitySchema.optional().describe("Who can open the link: 'public' (anyone) or 'private' (only you); omit to keep an existing page's visibility (a first publish is public)"),
|
|
545
|
+
title: z$1.string().min(1).max(200).optional().describe("Page title; defaults to the artifact's title")
|
|
546
|
+
};
|
|
547
|
+
const textToSpeechShape = {
|
|
548
|
+
...ttsRequestSchema.omit({ encoding: true }).shape,
|
|
549
|
+
text: ttsRequestSchema.shape.text.describe("Text to speak"),
|
|
550
|
+
provider: ttsRequestSchema.shape.provider.describe(`Speech provider; defaults to ${DEFAULT_TTS_PROVIDER}`),
|
|
551
|
+
preview: ttsRequestSchema.shape.preview.describe("Skip saving a copy to the file browser")
|
|
552
|
+
};
|
|
553
|
+
function notebookSummary(n) {
|
|
554
|
+
return {
|
|
555
|
+
id: n.id,
|
|
556
|
+
name: n.name,
|
|
557
|
+
model: n.lastUsedModel ?? void 0,
|
|
558
|
+
createdAt: n.createdAt ?? n.firstCreated,
|
|
559
|
+
updatedAt: n.updatedAt ?? n.lastUpdated
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function projectSummary(p) {
|
|
563
|
+
return {
|
|
564
|
+
id: p.id,
|
|
565
|
+
name: p.name,
|
|
566
|
+
createdAt: p.createdAt
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
function lakeSummary(l) {
|
|
570
|
+
return {
|
|
571
|
+
id: l.id,
|
|
572
|
+
name: l.name,
|
|
573
|
+
slug: l.slug,
|
|
574
|
+
description: l.description ?? void 0,
|
|
575
|
+
builtIn: l.built_in,
|
|
576
|
+
status: l.status,
|
|
577
|
+
fileCount: l.file_count
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
async function listNotebooks(client, args) {
|
|
581
|
+
const { data, hasMore } = await client.listNotebooks(args);
|
|
582
|
+
return {
|
|
583
|
+
notebooks: data.map(notebookSummary),
|
|
584
|
+
hasMore
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
async function getNotebook(client, args) {
|
|
588
|
+
return client.getNotebook(args.notebookId);
|
|
589
|
+
}
|
|
590
|
+
async function createNotebook(client, args) {
|
|
591
|
+
return client.createNotebook({
|
|
592
|
+
...args,
|
|
593
|
+
name: args.name ?? "New Notebook"
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
async function renameNotebook(client, args) {
|
|
597
|
+
return notebookSummary(await client.renameNotebook(args.notebookId, args.name));
|
|
598
|
+
}
|
|
599
|
+
async function cloneNotebook(client, args) {
|
|
600
|
+
return notebookSummary(await client.cloneNotebook(args.notebookId));
|
|
601
|
+
}
|
|
602
|
+
async function deleteNotebook(client, args) {
|
|
603
|
+
if (args.confirm !== true) throw new Error("delete_notebook requires confirm: true");
|
|
604
|
+
const { newLastNotebookId } = await client.deleteNotebook(args.notebookId);
|
|
605
|
+
return {
|
|
606
|
+
deleted: true,
|
|
607
|
+
notebookId: args.notebookId,
|
|
608
|
+
newLastNotebookId
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
async function listProjects(client, args) {
|
|
612
|
+
const { data, hasMore } = await client.listProjects(args);
|
|
613
|
+
return {
|
|
614
|
+
projects: data.map(projectSummary),
|
|
615
|
+
hasMore
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
async function getProject(client, args) {
|
|
619
|
+
return client.getProject(args.projectId);
|
|
620
|
+
}
|
|
621
|
+
async function createProject(client, args) {
|
|
622
|
+
return projectSummary(await client.createProject(args));
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Queue a chat turn and poll its quest to completion. A failed (`type: 'error'`) or stopped turn
|
|
626
|
+
* throws, so the tool reports `isError`; a cancelled call makes a best-effort stop of its quest.
|
|
627
|
+
*/
|
|
628
|
+
async function sendMessage(client, args, { intervalMs = CHAT_POLL_INTERVAL_MS, timeoutMs = CHAT_POLL_TIMEOUT_MS, ...poll } = {}) {
|
|
629
|
+
const ack = await client.sendChat(args);
|
|
630
|
+
const questId = ack.id;
|
|
631
|
+
const sessionId = args.notebookId ?? ack.sessionId;
|
|
632
|
+
const ref = questRef(questId, sessionId);
|
|
633
|
+
let quest;
|
|
634
|
+
try {
|
|
635
|
+
quest = await pollQuest(client, {
|
|
636
|
+
questId,
|
|
637
|
+
ref,
|
|
638
|
+
task: "chat completion",
|
|
639
|
+
scope: "ai:chat",
|
|
640
|
+
isFinished: isSettled
|
|
641
|
+
}, {
|
|
642
|
+
...poll,
|
|
643
|
+
timeoutMs,
|
|
644
|
+
interval: (elapsedMs) => chatPollInterval(elapsedMs, intervalMs)
|
|
645
|
+
});
|
|
646
|
+
} catch (err) {
|
|
647
|
+
if (poll.signal?.aborted && sessionId) await client.stopReply(sessionId, questId).catch((stopErr) => logger.warn(`mcp: stopping the cancelled turn failed (${ref}): ${stopErr instanceof Error ? stopErr.message : String(stopErr)}`));
|
|
648
|
+
throw err;
|
|
649
|
+
}
|
|
650
|
+
const reply = replyText(quest);
|
|
651
|
+
if (quest.status === "stopped") throw new Error(`chat completion was stopped (${ref})${reply ? `: ${reply}` : ""}`);
|
|
652
|
+
if (quest.type === "error") {
|
|
653
|
+
const code = quest.errorCode ? `${quest.errorCode}: ` : "";
|
|
654
|
+
throw new Error(`${code}${reply || "chat completion failed"} (${ref})`);
|
|
655
|
+
}
|
|
656
|
+
const notebookId = sessionId ?? quest.sessionId;
|
|
657
|
+
const citables = (quest.promptMeta?.citables ?? []).map((c) => ({
|
|
658
|
+
id: c.id,
|
|
659
|
+
type: c.type,
|
|
660
|
+
title: c.title,
|
|
661
|
+
url: c.url,
|
|
662
|
+
description: c.description
|
|
663
|
+
}));
|
|
664
|
+
return {
|
|
665
|
+
notebookId,
|
|
666
|
+
questId,
|
|
667
|
+
reply,
|
|
668
|
+
model: ack.model,
|
|
669
|
+
citables
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
async function searchKnowledgeBase(client, args) {
|
|
673
|
+
return { results: await client.searchKnowledgeBase(args) };
|
|
674
|
+
}
|
|
675
|
+
async function listLakes(client, args) {
|
|
676
|
+
const { data, nextCursor } = await client.listDataLakes(args);
|
|
677
|
+
return {
|
|
678
|
+
lakes: data.map(lakeSummary),
|
|
679
|
+
nextCursor
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
async function listFiles(client, args) {
|
|
683
|
+
const { data, hasMore } = await client.listFiles(args);
|
|
684
|
+
return {
|
|
685
|
+
files: data,
|
|
686
|
+
hasMore
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
async function getFile(client, args) {
|
|
690
|
+
return client.getFile(args.fileId);
|
|
691
|
+
}
|
|
692
|
+
async function generateSoundEffect(client, args) {
|
|
693
|
+
return generatedAudioResult(await client.generateSoundEffect(args), { provider: args.provider });
|
|
694
|
+
}
|
|
695
|
+
const IMAGE_POLL_INTERVAL_MS = 2e3;
|
|
696
|
+
const IMAGE_POLL_TIMEOUT_MS = 3e5;
|
|
697
|
+
const CHAT_POLL_INTERVAL_MS = 2e3;
|
|
698
|
+
const CHAT_POLL_FAST_WINDOW_MS = 3e4;
|
|
699
|
+
const CHAT_POLL_SLOW_INTERVAL_MS = 5e3;
|
|
700
|
+
const CHAT_POLL_TIMEOUT_MS = 9e5;
|
|
701
|
+
const MAX_CONSECUTIVE_POLL_FAILURES = 3;
|
|
702
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
703
|
+
const chatPollInterval = (elapsedMs, intervalMs) => elapsedMs < CHAT_POLL_FAST_WINDOW_MS ? intervalMs : Math.max(intervalMs, CHAT_POLL_SLOW_INTERVAL_MS);
|
|
704
|
+
const isSettled = (q) => q.status === "done" || q.status === "stopped" || q.type === "error";
|
|
705
|
+
const isImageSettled = (q) => isSettled(q) || !q.status && !!q.images?.length;
|
|
706
|
+
const replyText = (q) => questReplyText(q, joinReplySlots) ?? "";
|
|
707
|
+
const questRef = (questId, notebookId) => `quest ${questId}${notebookId ? `, notebook ${notebookId}` : ""}`;
|
|
708
|
+
const isRateLimited = (err) => apiErrorStatus(err) === 429;
|
|
709
|
+
const isPermanentApiError = (err) => {
|
|
710
|
+
const status = apiErrorStatus(err);
|
|
711
|
+
return status === 401 || status === 403 || status === 404;
|
|
712
|
+
};
|
|
713
|
+
/** Poll a queued quest until `isFinished`, the timeout, a permanent poll failure, or an abort. */
|
|
714
|
+
async function pollQuest(client, { questId, ref, task, scope, isFinished }, { interval, timeoutMs, sleep = defaultSleep, signal, onProgress, baseURL = "" }) {
|
|
715
|
+
const started = Date.now();
|
|
716
|
+
let failures = 0;
|
|
717
|
+
let retryAfterMs = 0;
|
|
718
|
+
for (;;) {
|
|
719
|
+
signal?.throwIfAborted();
|
|
720
|
+
let quest;
|
|
721
|
+
try {
|
|
722
|
+
quest = await client.getQuest(questId);
|
|
723
|
+
failures = 0;
|
|
724
|
+
if (isFinished(quest)) return quest;
|
|
725
|
+
} catch (err) {
|
|
726
|
+
if (isRateLimited(err)) retryAfterMs = (apiErrorRetryAfterSeconds(err) ?? 0) * 1e3;
|
|
727
|
+
else {
|
|
728
|
+
failures += 1;
|
|
729
|
+
if (isPermanentApiError(err) || failures >= MAX_CONSECUTIVE_POLL_FAILURES) throw new Error(`${mapApiError(err, baseURL, scope)} (${ref}; the ${task} may still complete)`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const elapsed = Date.now() - started;
|
|
733
|
+
if (elapsed >= timeoutMs) throw new Error(`${task} did not finish within ${Math.round(timeoutMs / 1e3)}s (${ref})`);
|
|
734
|
+
signal?.throwIfAborted();
|
|
735
|
+
try {
|
|
736
|
+
await onProgress?.(elapsed, quest);
|
|
737
|
+
} catch (err) {
|
|
738
|
+
logger.warn(`mcp: progress notification failed (${ref}): ${err instanceof Error ? err.message : String(err)}`);
|
|
739
|
+
}
|
|
740
|
+
const intervalMs = interval(elapsed);
|
|
741
|
+
await sleep(Math.min(Math.max(intervalMs, retryAfterMs), Math.max(timeoutMs - elapsed, intervalMs)));
|
|
742
|
+
retryAfterMs = 0;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Queue an image render and poll its quest to completion. A failed render still
|
|
747
|
+
* resolves `status: 'done'`, so `type: 'error'` (reason in `reply`) is the failure signal.
|
|
748
|
+
* Generated images are not FabFiles, so there is no file id: the quest id is the handle,
|
|
749
|
+
* and each image is its generated-file name plus the URL the quest poll resolves for it
|
|
750
|
+
* (`fileUrl` is a short-lived presigned URL, valid for about an hour).
|
|
751
|
+
*/
|
|
752
|
+
async function generateImage(client, args, { intervalMs = IMAGE_POLL_INTERVAL_MS, timeoutMs = IMAGE_POLL_TIMEOUT_MS, ...poll } = {}) {
|
|
753
|
+
const ack = await client.generateImage(args);
|
|
754
|
+
const questId = ack.quest.id;
|
|
755
|
+
const ref = questRef(questId, ack.quest.sessionId);
|
|
756
|
+
const quest = await pollQuest(client, {
|
|
757
|
+
questId,
|
|
758
|
+
ref,
|
|
759
|
+
task: "image generation",
|
|
760
|
+
scope: "ai:generate",
|
|
761
|
+
isFinished: isImageSettled
|
|
762
|
+
}, {
|
|
763
|
+
...poll,
|
|
764
|
+
timeoutMs,
|
|
765
|
+
interval: () => intervalMs
|
|
766
|
+
});
|
|
767
|
+
if (quest.status === "stopped") throw new Error(`image generation was stopped (${ref})`);
|
|
768
|
+
if (quest.type === "error") {
|
|
769
|
+
const code = quest.errorCode ? `${quest.errorCode}: ` : "";
|
|
770
|
+
throw new Error(`${code}${quest.reply || "image generation failed"} (${ref})`);
|
|
771
|
+
}
|
|
772
|
+
const urls = new Map((quest.files ?? []).map((f) => [f.name, f.url]));
|
|
773
|
+
const images = (quest.images ?? []).filter((name) => GENERATED_IMAGE_EXTENSION_RE.test(name)).map((name) => ({
|
|
774
|
+
fileName: name,
|
|
775
|
+
fileUrl: urls.get(name)
|
|
776
|
+
}));
|
|
777
|
+
if (images.length === 0) throw new Error(`${quest.reply || "image generation finished without producing an image"} (${ref})`);
|
|
778
|
+
return {
|
|
779
|
+
notebookId: quest.sessionId ?? ack.quest.sessionId,
|
|
780
|
+
questId,
|
|
781
|
+
model: args.model,
|
|
782
|
+
enhancedPrompt: ack.enhancedPrompt,
|
|
783
|
+
images
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
const PUBLISH_INDEX_PATH = "index.html";
|
|
787
|
+
const PUBLISH_INDEX_MIME = "text/html";
|
|
788
|
+
const PUBLISH_TITLE_MAX = 200;
|
|
789
|
+
const PUBLISH_FALLBACK_TITLE = "Shared artifact";
|
|
790
|
+
const PUBLISH_DEFAULT_VISIBILITY = "public";
|
|
791
|
+
const SLUG_TITLE_MAX = 48;
|
|
792
|
+
const SLUG_ARTIFACT_HASH_LENGTH = 8;
|
|
793
|
+
const SLUG_DISCRIMINATOR_LENGTH = 6;
|
|
794
|
+
function slugify(text) {
|
|
795
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, SLUG_TITLE_MAX).replace(/-+$/, "");
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Stable per artifact, so republishing updates the same page in place. Artifact
|
|
799
|
+
* ids all share an `artifact_` prefix, so the suffix hashes the whole id rather
|
|
800
|
+
* than taking a prefix of it.
|
|
801
|
+
*/
|
|
802
|
+
function artifactSlug(title, artifactId) {
|
|
803
|
+
const hash = createHash("sha256").update(artifactId).digest("hex").slice(0, SLUG_ARTIFACT_HASH_LENGTH);
|
|
804
|
+
return `${slugify(title) || "artifact"}-${hash}`;
|
|
805
|
+
}
|
|
806
|
+
/** The artifact-content doc carries the body as `content`; anything else has none to publish. */
|
|
807
|
+
function artifactContentText(content) {
|
|
808
|
+
if (!content || typeof content !== "object") return void 0;
|
|
809
|
+
const text = content.content;
|
|
810
|
+
return typeof text === "string" && text.trim() ? text : void 0;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Open the draft, retrying once under a discriminated slug when the derived slug
|
|
814
|
+
* already belongs to another publisher (the same fallback the web Share flow uses).
|
|
815
|
+
*/
|
|
816
|
+
async function openPublishDraft(client, request) {
|
|
817
|
+
try {
|
|
818
|
+
return await client.requestPublishUpload(request);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
if (apiErrorCode(err) !== "slug_owned_by_other") throw err;
|
|
821
|
+
const discriminator = randomUUID().replace(/-/g, "").slice(0, SLUG_DISCRIMINATOR_LENGTH);
|
|
822
|
+
return client.requestPublishUpload({
|
|
823
|
+
...request,
|
|
824
|
+
slug: `${request.slug}-${discriminator}`
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Publish an artifact: upload its RAW content and let finalize render it
|
|
830
|
+
* (source.artifactType), so the page matches an in-app publish byte for byte with
|
|
831
|
+
* no CLI-side renderer. An artifact the caller already published (from the app or
|
|
832
|
+
* here) is republished in place at its existing tier/scope/slug, as the in-app
|
|
833
|
+
* update flow does; otherwise it becomes a new user-tier page.
|
|
834
|
+
*/
|
|
835
|
+
async function publishArtifact(client, args) {
|
|
836
|
+
const { artifact, content } = await client.getArtifact(args.artifactId);
|
|
837
|
+
const body = artifactContentText(content);
|
|
838
|
+
if (!body) throw new Error(`artifact ${artifact.id} has no content to publish`);
|
|
839
|
+
const artifactType = ArtifactTypeSchema.safeParse(artifact.type);
|
|
840
|
+
if (!artifactType.success) throw new Error(`artifact ${artifact.id} has an unsupported type (${String(artifact.type)})`);
|
|
841
|
+
if (typeof artifact.userId !== "string") throw new Error(`artifact ${artifact.id} has no owner to publish under`);
|
|
842
|
+
const storedTitle = typeof artifact.title === "string" ? artifact.title : "";
|
|
843
|
+
const title = (args.title ?? storedTitle).trim().slice(0, PUBLISH_TITLE_MAX) || PUBLISH_FALLBACK_TITLE;
|
|
844
|
+
const existing = await client.findPublishedArtifact(artifact.id);
|
|
845
|
+
const request = {
|
|
846
|
+
tier: existing?.tier ?? "user",
|
|
847
|
+
scopeId: existing?.scopeId ?? artifact.userId,
|
|
848
|
+
slug: existing?.slug ?? artifactSlug(title, artifact.id),
|
|
849
|
+
title,
|
|
850
|
+
visibility: args.visibility ?? existing?.visibility ?? PUBLISH_DEFAULT_VISIBILITY,
|
|
851
|
+
...existing?.description !== void 0 && { description: existing.description },
|
|
852
|
+
...existing?.commentPolicy !== void 0 && { commentPolicy: existing.commentPolicy },
|
|
853
|
+
source: {
|
|
854
|
+
kind: "bundle",
|
|
855
|
+
artifactId: artifact.id,
|
|
856
|
+
artifactType: artifactType.data
|
|
857
|
+
},
|
|
858
|
+
files: [{
|
|
859
|
+
path: PUBLISH_INDEX_PATH,
|
|
860
|
+
size: Buffer.byteLength(body),
|
|
861
|
+
mimeType: PUBLISH_INDEX_MIME
|
|
862
|
+
}]
|
|
863
|
+
};
|
|
864
|
+
const draft = existing ? await client.requestPublishUpload(request) : await openPublishDraft(client, request);
|
|
865
|
+
const upload = draft.uploadUrls.find((u) => u.path === PUBLISH_INDEX_PATH);
|
|
866
|
+
if (!upload) throw new Error(`publish draft ${draft.draftId} returned no upload URL for ${PUBLISH_INDEX_PATH}`);
|
|
867
|
+
await client.uploadPublishDraftFile(upload.url, body, PUBLISH_INDEX_MIME);
|
|
868
|
+
const published = await client.finalizePublish(draft.draftId);
|
|
869
|
+
return {
|
|
870
|
+
publicId: published.publicId,
|
|
871
|
+
url: new URL(published.url, client.baseURL).toString(),
|
|
872
|
+
visibility: published.visibility
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
function toResult(value) {
|
|
876
|
+
const structuredContent = value && typeof value === "object" && !Array.isArray(value) ? value : { result: value };
|
|
877
|
+
return {
|
|
878
|
+
content: [{
|
|
879
|
+
type: "text",
|
|
880
|
+
text: JSON.stringify(value, null, 2)
|
|
881
|
+
}],
|
|
882
|
+
structuredContent
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
function errorResult(message) {
|
|
886
|
+
return {
|
|
887
|
+
content: [{
|
|
888
|
+
type: "text",
|
|
889
|
+
text: message
|
|
890
|
+
}],
|
|
891
|
+
isError: true
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Render a generated-audio response as an MCP result, shared by every audio tool.
|
|
896
|
+
* Oversized audio is reported by its signed URL; a saved copy with a usable URL is
|
|
897
|
+
* reported as a file (like get_file) with no inline bytes. Otherwise the audio rides
|
|
898
|
+
* inline as an `audio` block so a billed result is never lost, with any saved copy
|
|
899
|
+
* still named by id and a skipped save explained. The route forwards the signed URL
|
|
900
|
+
* it minted at upload, so no getFile re-fetch is needed (that fails closed until
|
|
901
|
+
* the async moderation scan runs).
|
|
902
|
+
*/
|
|
903
|
+
function generatedAudioResult(response, endpointMetadata) {
|
|
904
|
+
const metadata = {
|
|
905
|
+
...endpointMetadata,
|
|
906
|
+
contentType: response.contentType
|
|
907
|
+
};
|
|
908
|
+
const saveSkippedReason = response.saveSkippedReason ? { saveSkippedReason: response.saveSkippedReason } : {};
|
|
909
|
+
const file = response.fabFileId ? {
|
|
910
|
+
id: response.fabFileId,
|
|
911
|
+
...response.fileName ? { fileName: response.fileName } : {},
|
|
912
|
+
...response.fileUrl ? { fileUrl: response.fileUrl } : {}
|
|
913
|
+
} : void 0;
|
|
914
|
+
if (response.delivery === "url") return toResult({
|
|
915
|
+
...metadata,
|
|
916
|
+
byteLength: response.bytes,
|
|
917
|
+
url: response.url,
|
|
918
|
+
saved: response.saved === true,
|
|
919
|
+
...response.saved && file ? { file } : {},
|
|
920
|
+
...saveSkippedReason
|
|
921
|
+
});
|
|
922
|
+
const byteLength = Buffer.from(response.audio, "base64").length;
|
|
923
|
+
if (response.saved && file && file.fileUrl) return toResult({
|
|
924
|
+
...metadata,
|
|
925
|
+
byteLength,
|
|
926
|
+
saved: true,
|
|
927
|
+
file
|
|
928
|
+
});
|
|
929
|
+
return inlineAudioResult(response.saved && file ? {
|
|
930
|
+
...metadata,
|
|
931
|
+
byteLength,
|
|
932
|
+
saved: true,
|
|
933
|
+
file
|
|
934
|
+
} : {
|
|
935
|
+
...metadata,
|
|
936
|
+
byteLength,
|
|
937
|
+
saved: false,
|
|
938
|
+
...saveSkippedReason
|
|
939
|
+
}, response.audio, response.contentType);
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Metadata as JSON text plus the audio as an MCP `audio` block. The base64 stays
|
|
943
|
+
* out of structuredContent so a potentially large payload is not duplicated.
|
|
944
|
+
*/
|
|
945
|
+
function inlineAudioResult(meta, audioBase64, mimeType) {
|
|
946
|
+
return {
|
|
947
|
+
content: [{
|
|
948
|
+
type: "text",
|
|
949
|
+
text: JSON.stringify(meta, null, 2)
|
|
950
|
+
}, {
|
|
951
|
+
type: "audio",
|
|
952
|
+
data: audioBase64,
|
|
953
|
+
mimeType
|
|
954
|
+
}],
|
|
955
|
+
structuredContent: meta
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
async function textToSpeech(client, args) {
|
|
959
|
+
const response = await client.synthesizeSpeech(args);
|
|
960
|
+
if (response.kind === "saved-too-large") {
|
|
961
|
+
const { provider, fabFileId, fileUrl } = response.data;
|
|
962
|
+
return toResult({
|
|
963
|
+
saved: true,
|
|
964
|
+
provider,
|
|
965
|
+
...response.fallbackFrom ? { fallbackFrom: response.fallbackFrom } : {},
|
|
966
|
+
file: {
|
|
967
|
+
id: fabFileId,
|
|
968
|
+
...fileUrl ? { fileUrl } : {}
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
const result = response.data;
|
|
973
|
+
return generatedAudioResult(result, {
|
|
974
|
+
provider: result.provider ?? args.provider ?? "openai",
|
|
975
|
+
...result.fallbackFrom ? { fallbackFrom: result.fallbackFrom } : {},
|
|
976
|
+
format: result.format
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Turn a quest poll's progress callback into MCP `notifications/progress`, or nothing when the
|
|
981
|
+
* client sent no progressToken. Progress also lets a client that resets its request timeout on
|
|
982
|
+
* progress wait out a slow quest.
|
|
983
|
+
*/
|
|
984
|
+
function progressReporter({ _meta, sendNotification }, describe) {
|
|
985
|
+
const progressToken = _meta?.progressToken;
|
|
986
|
+
if (progressToken === void 0) return void 0;
|
|
987
|
+
return (elapsedMs, quest) => sendNotification({
|
|
988
|
+
method: "notifications/progress",
|
|
989
|
+
params: {
|
|
990
|
+
progressToken,
|
|
991
|
+
progress: Math.floor(elapsedMs / 1e3),
|
|
992
|
+
message: describe(quest)
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
function chatProgress(quest) {
|
|
997
|
+
if (quest?.status !== "running") return "waiting for the reply to start";
|
|
998
|
+
const characters = replyText(quest).length;
|
|
999
|
+
return characters > 0 ? `generating reply (${characters} characters so far)` : "generating reply";
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Register the Bike4Mind MCP tools on `server`. Each handler is wrapped so an
|
|
1003
|
+
* API failure becomes a structured `isError` result carrying a friendly message
|
|
1004
|
+
* (see {@link mapApiError}) rather than throwing across the transport.
|
|
1005
|
+
*/
|
|
1006
|
+
function registerTools(server, client) {
|
|
1007
|
+
const baseURL = client.baseURL;
|
|
1008
|
+
const meta = (name) => TOOL_META.find((t) => t.name === name);
|
|
1009
|
+
const run = async (scope, fn) => {
|
|
1010
|
+
try {
|
|
1011
|
+
return toResult(await fn());
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
return errorResult(mapApiError(err, baseURL, scope));
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
server.registerTool("list_notebooks", {
|
|
1017
|
+
title: meta("list_notebooks").title,
|
|
1018
|
+
description: meta("list_notebooks").description,
|
|
1019
|
+
inputSchema: listNotebooksShape
|
|
1020
|
+
}, (args) => run("notebooks:read", () => listNotebooks(client, args)));
|
|
1021
|
+
server.registerTool("get_notebook", {
|
|
1022
|
+
title: meta("get_notebook").title,
|
|
1023
|
+
description: meta("get_notebook").description,
|
|
1024
|
+
inputSchema: getNotebookShape
|
|
1025
|
+
}, (args) => run("notebooks:read", () => getNotebook(client, args)));
|
|
1026
|
+
server.registerTool("create_notebook", {
|
|
1027
|
+
title: meta("create_notebook").title,
|
|
1028
|
+
description: meta("create_notebook").description,
|
|
1029
|
+
inputSchema: createNotebookShape
|
|
1030
|
+
}, (args) => run("notebooks:write", () => createNotebook(client, args)));
|
|
1031
|
+
server.registerTool("rename_notebook", {
|
|
1032
|
+
title: meta("rename_notebook").title,
|
|
1033
|
+
description: meta("rename_notebook").description,
|
|
1034
|
+
inputSchema: renameNotebookShape
|
|
1035
|
+
}, (args) => run("notebooks:write", () => renameNotebook(client, args)));
|
|
1036
|
+
server.registerTool("clone_notebook", {
|
|
1037
|
+
title: meta("clone_notebook").title,
|
|
1038
|
+
description: meta("clone_notebook").description,
|
|
1039
|
+
inputSchema: cloneNotebookShape
|
|
1040
|
+
}, (args) => run("notebooks:write", () => cloneNotebook(client, args)));
|
|
1041
|
+
server.registerTool("delete_notebook", {
|
|
1042
|
+
title: meta("delete_notebook").title,
|
|
1043
|
+
description: meta("delete_notebook").description,
|
|
1044
|
+
inputSchema: deleteNotebookShape
|
|
1045
|
+
}, (args) => run("notebooks:write", () => deleteNotebook(client, args)));
|
|
1046
|
+
server.registerTool("list_projects", {
|
|
1047
|
+
title: meta("list_projects").title,
|
|
1048
|
+
description: meta("list_projects").description,
|
|
1049
|
+
inputSchema: listProjectsShape
|
|
1050
|
+
}, (args) => run("projects:read", () => listProjects(client, args)));
|
|
1051
|
+
server.registerTool("get_project", {
|
|
1052
|
+
title: meta("get_project").title,
|
|
1053
|
+
description: meta("get_project").description,
|
|
1054
|
+
inputSchema: getProjectShape
|
|
1055
|
+
}, (args) => run("projects:read", () => getProject(client, args)));
|
|
1056
|
+
server.registerTool("create_project", {
|
|
1057
|
+
title: meta("create_project").title,
|
|
1058
|
+
description: meta("create_project").description,
|
|
1059
|
+
inputSchema: createProjectShape
|
|
1060
|
+
}, (args) => run("projects:write", () => createProject(client, args)));
|
|
1061
|
+
server.registerTool("send_message", {
|
|
1062
|
+
title: meta("send_message").title,
|
|
1063
|
+
description: meta("send_message").description,
|
|
1064
|
+
inputSchema: sendMessageShape
|
|
1065
|
+
}, (args, extra) => run("ai:chat", () => sendMessage(client, args, {
|
|
1066
|
+
signal: extra.signal,
|
|
1067
|
+
baseURL,
|
|
1068
|
+
onProgress: progressReporter(extra, chatProgress)
|
|
1069
|
+
})));
|
|
1070
|
+
server.registerTool("search_knowledge_base", {
|
|
1071
|
+
title: meta("search_knowledge_base").title,
|
|
1072
|
+
description: meta("search_knowledge_base").description,
|
|
1073
|
+
inputSchema: searchKnowledgeBaseShape
|
|
1074
|
+
}, (args) => run("notebooks:read", () => searchKnowledgeBase(client, args)));
|
|
1075
|
+
server.registerTool("list_lakes", {
|
|
1076
|
+
title: meta("list_lakes").title,
|
|
1077
|
+
description: meta("list_lakes").description,
|
|
1078
|
+
inputSchema: listLakesShape
|
|
1079
|
+
}, (args) => run("datalake:read", () => listLakes(client, args)));
|
|
1080
|
+
server.registerTool("list_files", {
|
|
1081
|
+
title: meta("list_files").title,
|
|
1082
|
+
description: meta("list_files").description,
|
|
1083
|
+
inputSchema: listFilesShape
|
|
1084
|
+
}, (args) => run("files:read", () => listFiles(client, args)));
|
|
1085
|
+
server.registerTool("get_file", {
|
|
1086
|
+
title: meta("get_file").title,
|
|
1087
|
+
description: meta("get_file").description,
|
|
1088
|
+
inputSchema: getFileShape
|
|
1089
|
+
}, (args) => run("files:read", () => getFile(client, args)));
|
|
1090
|
+
server.registerTool("generate_sound_effect", {
|
|
1091
|
+
title: meta("generate_sound_effect").title,
|
|
1092
|
+
description: meta("generate_sound_effect").description,
|
|
1093
|
+
inputSchema: generateSoundEffectShape
|
|
1094
|
+
}, async (args) => {
|
|
1095
|
+
try {
|
|
1096
|
+
return await generateSoundEffect(client, args);
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
return errorResult(mapApiError(err, baseURL, "ai:generate"));
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
server.registerTool("text_to_speech", {
|
|
1102
|
+
title: meta("text_to_speech").title,
|
|
1103
|
+
description: meta("text_to_speech").description,
|
|
1104
|
+
inputSchema: textToSpeechShape
|
|
1105
|
+
}, async (args) => {
|
|
1106
|
+
try {
|
|
1107
|
+
return await textToSpeech(client, args);
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
return errorResult(mapApiError(err, baseURL, "ai:generate"));
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
server.registerTool("generate_image", {
|
|
1113
|
+
title: meta("generate_image").title,
|
|
1114
|
+
description: meta("generate_image").description,
|
|
1115
|
+
inputSchema: generateImageShape
|
|
1116
|
+
}, (args, extra) => run("ai:generate", () => generateImage(client, args, {
|
|
1117
|
+
signal: extra.signal,
|
|
1118
|
+
baseURL,
|
|
1119
|
+
onProgress: progressReporter(extra, () => "rendering image")
|
|
1120
|
+
})));
|
|
1121
|
+
server.registerTool("publish_artifact", {
|
|
1122
|
+
title: meta("publish_artifact").title,
|
|
1123
|
+
description: meta("publish_artifact").description,
|
|
1124
|
+
inputSchema: publishArtifactShape
|
|
1125
|
+
}, (args) => run("files:write", () => publishArtifact(client, args)));
|
|
1126
|
+
}
|
|
1127
|
+
//#endregion
|
|
1128
|
+
//#region src/mcp/resources.ts
|
|
1129
|
+
const LIST_LIMIT = 100;
|
|
1130
|
+
/**
|
|
1131
|
+
* Decode a URI-template variable, tolerating an id that was never encoded.
|
|
1132
|
+
*
|
|
1133
|
+
* The SDK reaches a read callback via `new URL(uri)` -> `uriTemplate.match(url.toString())`,
|
|
1134
|
+
* and `URL.toString()` percent-encodes, so an id we listed with `encodeURIComponent`
|
|
1135
|
+
* arrives already encoded and must be decoded back before it hits a REST path. A
|
|
1136
|
+
* hand-crafted uri with a malformed escape (e.g. `%zz`) would make `decodeURIComponent`
|
|
1137
|
+
* throw a bare `URIError` past `mapApiError`; fall back to the raw value instead.
|
|
1138
|
+
*/
|
|
1139
|
+
function safeDecode(value) {
|
|
1140
|
+
try {
|
|
1141
|
+
return decodeURIComponent(value);
|
|
1142
|
+
} catch {
|
|
1143
|
+
return value;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Register one `b4m://<name>/{id}` JSON resource.
|
|
1148
|
+
*
|
|
1149
|
+
* `list` degrades to an empty array on failure rather than throwing: the SDK's
|
|
1150
|
+
* resources/list handler awaits every registered template's list callback in one
|
|
1151
|
+
* unguarded loop, so a throw here would also blank every sibling template. `read`
|
|
1152
|
+
* still throws - a single read failure has no fan-out.
|
|
1153
|
+
*/
|
|
1154
|
+
function registerJsonResource(server, client, spec) {
|
|
1155
|
+
server.registerResource(spec.name, new ResourceTemplate(`b4m://${spec.name}/{id}`, { list: async () => {
|
|
1156
|
+
try {
|
|
1157
|
+
return { resources: (await spec.list()).map((item) => {
|
|
1158
|
+
const label = spec.label(item) ?? spec.id(item);
|
|
1159
|
+
return {
|
|
1160
|
+
uri: `b4m://${spec.name}/${encodeURIComponent(spec.id(item))}`,
|
|
1161
|
+
name: label,
|
|
1162
|
+
title: label,
|
|
1163
|
+
mimeType: "application/json"
|
|
1164
|
+
};
|
|
1165
|
+
}) };
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
logger.error(`mcp: listing ${spec.name} resources failed: ${mapApiError(err, client.baseURL, spec.scope)}`);
|
|
1168
|
+
return { resources: [] };
|
|
1169
|
+
}
|
|
1170
|
+
} }), {
|
|
1171
|
+
title: spec.title,
|
|
1172
|
+
description: spec.description,
|
|
1173
|
+
mimeType: "application/json"
|
|
1174
|
+
}, async (uri, variables) => {
|
|
1175
|
+
const id = safeDecode(String(variables.id));
|
|
1176
|
+
try {
|
|
1177
|
+
const record = await spec.read(id);
|
|
1178
|
+
return { contents: [{
|
|
1179
|
+
uri: uri.href,
|
|
1180
|
+
mimeType: "application/json",
|
|
1181
|
+
text: JSON.stringify(record, null, 2)
|
|
1182
|
+
}] };
|
|
1183
|
+
} catch (err) {
|
|
1184
|
+
throw new Error(mapApiError(err, client.baseURL, spec.scope));
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Register `b4m://agent-quest`: the machine-readable invitation to The Open Door.
|
|
1190
|
+
*
|
|
1191
|
+
* A fixed URI rather than a `ResourceTemplate` - there is exactly one manifest, so
|
|
1192
|
+
* there is nothing to list or address by id. It is also the only resource here that
|
|
1193
|
+
* needs no API call and no credentials: the document is a constant in
|
|
1194
|
+
* @bike4mind/common, shared with the public HTTP route that serves it
|
|
1195
|
+
* (apps/client/pages/api/agent-quest/manifest.ts). That means an agent can read the
|
|
1196
|
+
* invitation before it has an API key - which is the point, since the quest is how
|
|
1197
|
+
* it earns one.
|
|
1198
|
+
*/
|
|
1199
|
+
function registerAgentQuest(server) {
|
|
1200
|
+
server.registerResource(AGENT_QUEST_ID, AGENT_QUEST_MCP_URI, {
|
|
1201
|
+
title: AGENT_QUEST_MANIFEST.title,
|
|
1202
|
+
description: AGENT_QUEST_MANIFEST.summary,
|
|
1203
|
+
mimeType: "application/json"
|
|
1204
|
+
}, (uri) => ({ contents: [{
|
|
1205
|
+
uri: uri.href,
|
|
1206
|
+
mimeType: "application/json",
|
|
1207
|
+
text: JSON.stringify(AGENT_QUEST_MANIFEST, null, 2)
|
|
1208
|
+
}] }));
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Register the Bike4Mind MCP resources: the four templates below, each served as
|
|
1212
|
+
* application/json and backed by its REST list/read pair, plus the credential-free
|
|
1213
|
+
* `b4m://agent-quest` manifest.
|
|
1214
|
+
*/
|
|
1215
|
+
function registerResources(server, client) {
|
|
1216
|
+
registerAgentQuest(server);
|
|
1217
|
+
registerJsonResource(server, client, {
|
|
1218
|
+
name: "notebook",
|
|
1219
|
+
title: "Notebook",
|
|
1220
|
+
description: "A Bike4Mind notebook (session)",
|
|
1221
|
+
scope: "notebooks:read",
|
|
1222
|
+
list: async () => (await client.listNotebooks({ limit: LIST_LIMIT })).data,
|
|
1223
|
+
id: (n) => n.id,
|
|
1224
|
+
label: (n) => n.name,
|
|
1225
|
+
read: (id) => client.getNotebook(id)
|
|
1226
|
+
});
|
|
1227
|
+
registerJsonResource(server, client, {
|
|
1228
|
+
name: "file",
|
|
1229
|
+
title: "File",
|
|
1230
|
+
description: "A Bike4Mind file: metadata plus a signed download URL",
|
|
1231
|
+
scope: "files:read",
|
|
1232
|
+
list: async () => (await client.listFiles({ limit: LIST_LIMIT })).data,
|
|
1233
|
+
id: (f) => f.id,
|
|
1234
|
+
label: (f) => f.fileName,
|
|
1235
|
+
read: (id) => client.getFile(id)
|
|
1236
|
+
});
|
|
1237
|
+
registerJsonResource(server, client, {
|
|
1238
|
+
name: "project",
|
|
1239
|
+
title: "Project",
|
|
1240
|
+
description: "A Bike4Mind project",
|
|
1241
|
+
scope: "projects:read",
|
|
1242
|
+
list: async () => (await client.listProjects({ limit: LIST_LIMIT })).data,
|
|
1243
|
+
id: (p) => p.id,
|
|
1244
|
+
label: (p) => p.name,
|
|
1245
|
+
read: (id) => client.getProject(id)
|
|
1246
|
+
});
|
|
1247
|
+
registerJsonResource(server, client, {
|
|
1248
|
+
name: "artifact",
|
|
1249
|
+
title: "Artifact",
|
|
1250
|
+
description: "A Bike4Mind artifact: metadata plus its current content",
|
|
1251
|
+
list: async () => (await client.listArtifacts({ limit: LIST_LIMIT })).data,
|
|
1252
|
+
id: (a) => a.id,
|
|
1253
|
+
label: (a) => a.title,
|
|
1254
|
+
read: (id) => client.getArtifact(id)
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
//#endregion
|
|
1258
|
+
//#region src/mcp/prompts.ts
|
|
1259
|
+
/**
|
|
1260
|
+
* The `{{placeholder}}` keys a client supplies as prompt arguments. The clock keys
|
|
1261
|
+
* (currentDate, currentTime, ...) are not arguments: they are filled at get time,
|
|
1262
|
+
* exactly as the web launcher does at click time.
|
|
1263
|
+
*/
|
|
1264
|
+
const PROMPT_ARGUMENTS = [
|
|
1265
|
+
{
|
|
1266
|
+
name: "userName",
|
|
1267
|
+
description: "Your name, substituted for {{userName}}"
|
|
1268
|
+
},
|
|
1269
|
+
{
|
|
1270
|
+
name: "userEmail",
|
|
1271
|
+
description: "Your email, substituted for {{userEmail}}"
|
|
1272
|
+
},
|
|
1273
|
+
{
|
|
1274
|
+
name: "userRole",
|
|
1275
|
+
description: "Your role, substituted for {{userRole}}"
|
|
1276
|
+
},
|
|
1277
|
+
{
|
|
1278
|
+
name: "organization",
|
|
1279
|
+
description: "Your organization, substituted for {{organization}}"
|
|
1280
|
+
}
|
|
1281
|
+
];
|
|
1282
|
+
const PROMPT_ARGUMENT_NAMES = new Set(PROMPT_ARGUMENTS.map((arg) => arg.name));
|
|
1283
|
+
/** Flatten the catalog in category order; a prompt listed under two keys appears once. */
|
|
1284
|
+
function flattenCatalog(catalog) {
|
|
1285
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1286
|
+
for (const { key } of BRIEFCASE_CATALOG_QUERIES) for (const prompt of catalog[key] ?? []) if (!byId.has(prompt.id)) byId.set(prompt.id, prompt);
|
|
1287
|
+
return [...byId.values()];
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* List the caller's Briefcase catalog as MCP prompts, named by prompt id (display
|
|
1291
|
+
* names are not unique). Degrades to an empty list on failure, like the resource
|
|
1292
|
+
* lists: EnableBriefcase is off by default, so a hard error would fail every
|
|
1293
|
+
* client's startup listing on most instances.
|
|
1294
|
+
*/
|
|
1295
|
+
async function listPrompts(client) {
|
|
1296
|
+
try {
|
|
1297
|
+
return { prompts: flattenCatalog(await client.getBriefcaseCatalog(BRIEFCASE_CATALOG_QUERIES)).map((prompt) => ({
|
|
1298
|
+
name: prompt.id,
|
|
1299
|
+
title: prompt.name,
|
|
1300
|
+
description: prompt.description,
|
|
1301
|
+
arguments: PROMPT_ARGUMENTS.map((arg) => ({
|
|
1302
|
+
...arg,
|
|
1303
|
+
required: false
|
|
1304
|
+
}))
|
|
1305
|
+
})) };
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
logger.error(`mcp: listing briefcase prompts failed: ${mapApiError(err, client.baseURL)}`);
|
|
1308
|
+
return { prompts: [] };
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
function toPromptContext(args, now) {
|
|
1312
|
+
const supplied = Object.entries(args ?? {}).filter(([name, value]) => PROMPT_ARGUMENT_NAMES.has(name) && value !== "");
|
|
1313
|
+
return {
|
|
1314
|
+
...buildPromptContext(null, null, now),
|
|
1315
|
+
...Object.fromEntries(supplied)
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
/** Resolve one prompt's template against the supplied arguments plus the clock. */
|
|
1319
|
+
async function getPrompt(client, name, args, now) {
|
|
1320
|
+
const notFound = new McpError(ErrorCode.InvalidParams, `Prompt ${name} not found`);
|
|
1321
|
+
const id = BriefcasePromptIdSchema.safeParse(name);
|
|
1322
|
+
if (!id.success) throw notFound;
|
|
1323
|
+
let prompt;
|
|
1324
|
+
try {
|
|
1325
|
+
prompt = await client.getBriefcasePrompt(id.data);
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
if (isAxiosError(err) && err.response?.status === 404) throw notFound;
|
|
1328
|
+
throw new Error(mapApiError(err, client.baseURL));
|
|
1329
|
+
}
|
|
1330
|
+
if (!prompt.promptText) throw new Error(`Prompt ${name} has no text`);
|
|
1331
|
+
const text = replacePromptVariables(prompt.promptText, toPromptContext(args, now));
|
|
1332
|
+
return {
|
|
1333
|
+
description: prompt.description ?? prompt.name,
|
|
1334
|
+
messages: [{
|
|
1335
|
+
role: "user",
|
|
1336
|
+
content: {
|
|
1337
|
+
type: "text",
|
|
1338
|
+
text
|
|
1339
|
+
}
|
|
1340
|
+
}]
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Expose the Briefcase prompt catalog as MCP prompts. Wired on the low-level
|
|
1345
|
+
* server rather than through `registerPrompt`: the catalog is per-caller server
|
|
1346
|
+
* data that changes at runtime, so it is fetched on every prompts/list instead
|
|
1347
|
+
* of being frozen at build time. Requires the `prompts` capability to be declared
|
|
1348
|
+
* on the server (see buildMcpServer). Claims prompts/list and prompts/get on the
|
|
1349
|
+
* low-level server, so do not also call McpServer.registerPrompt on this server
|
|
1350
|
+
* (a claimed method makes it throw "already exists" at startup).
|
|
1351
|
+
*/
|
|
1352
|
+
function registerPrompts(server, client, now = () => /* @__PURE__ */ new Date()) {
|
|
1353
|
+
server.server.setRequestHandler(ListPromptsRequestSchema, () => listPrompts(client));
|
|
1354
|
+
server.server.setRequestHandler(GetPromptRequestSchema, (request) => getPrompt(client, request.params.name, request.params.arguments, now()));
|
|
1355
|
+
}
|
|
1356
|
+
//#endregion
|
|
1357
|
+
//#region src/mcp/server.ts
|
|
1358
|
+
/**
|
|
1359
|
+
* Build a fully-configured Bike4Mind MCP server: the tools, the four resource
|
|
1360
|
+
* templates (`b4m://notebook/{id}`, `b4m://file/{id}`, `b4m://project/{id}`,
|
|
1361
|
+
* `b4m://artifact/{id}`) backed by a {@link B4mApiClient} bound to the given
|
|
1362
|
+
* endpoint and credentials, the credential-free `b4m://agent-quest` manifest, and
|
|
1363
|
+
* the Briefcase prompt catalog as MCP prompts. Tool listing never touches the
|
|
1364
|
+
* network - only tool/resource *calls* and prompt listing/gets hit the API - so a
|
|
1365
|
+
* server built with an unreachable endpoint still advertises its full capability set.
|
|
1366
|
+
*/
|
|
1367
|
+
function buildMcpServer(options) {
|
|
1368
|
+
const client = new B4mApiClient(options.baseURL, options.configStore, options.apiKey);
|
|
1369
|
+
const server = new McpServer({
|
|
1370
|
+
name: "bike4mind",
|
|
1371
|
+
version: options.version
|
|
1372
|
+
}, { capabilities: {
|
|
1373
|
+
tools: {},
|
|
1374
|
+
resources: {},
|
|
1375
|
+
prompts: {}
|
|
1376
|
+
} });
|
|
1377
|
+
registerTools(server, client);
|
|
1378
|
+
registerResources(server, client);
|
|
1379
|
+
registerPrompts(server, client);
|
|
1380
|
+
return server;
|
|
1381
|
+
}
|
|
1382
|
+
//#endregion
|
|
1383
|
+
//#region src/mcp/serve.ts
|
|
1384
|
+
/**
|
|
1385
|
+
* `b4m mcp serve` - expose Bike4Mind as an MCP server.
|
|
1386
|
+
*
|
|
1387
|
+
* Two transports: stdio (default, for Claude Desktop and other local clients)
|
|
1388
|
+
* and stateless streamable HTTP (`--http`). Auth precedence is
|
|
1389
|
+
* `--api-key` > `B4M_API_KEY` > the stored OAuth JWT; the endpoint is
|
|
1390
|
+
* `B4M_API_URL` > `--api-url` > the CLI's configured backend.
|
|
1391
|
+
*
|
|
1392
|
+
* Transport contract for stdio: stdout carries the JSON-RPC frame stream and
|
|
1393
|
+
* NOTHING else, so all diagnostics are forced to stderr before ANY other work
|
|
1394
|
+
* runs (mirrors the acp command's captureStdout - see src/commands/acpCommand.ts).
|
|
1395
|
+
*
|
|
1396
|
+
* HTTP mode is deliberately loopback-only (binds 127.0.0.1) with no per-request
|
|
1397
|
+
* auth of its own - it trusts anything that can reach the socket, so it must not
|
|
1398
|
+
* be exposed on a routable interface. DNS-rebinding protection is on as defense
|
|
1399
|
+
* in depth against a browser being tricked into posting to the local port.
|
|
1400
|
+
*/
|
|
1401
|
+
const HTTP_PATH = "/mcp";
|
|
1402
|
+
/**
|
|
1403
|
+
* Capture the real stdout for the JSON-RPC frame stream, then redirect
|
|
1404
|
+
* everything else - `console.*` and any stray `process.stdout.write` deep in the
|
|
1405
|
+
* stack - to stderr. A single unrelated byte on stdout corrupts a frame, so the
|
|
1406
|
+
* whole channel is closed rather than trusting no dependency ever prints.
|
|
1407
|
+
*/
|
|
1408
|
+
function captureStdout() {
|
|
1409
|
+
const writeToRealStdout = process.stdout.write.bind(process.stdout);
|
|
1410
|
+
const toStderr = (...args) => {
|
|
1411
|
+
process.stderr.write(args.map(String).join(" ") + "\n");
|
|
1412
|
+
};
|
|
1413
|
+
console.log = toStderr;
|
|
1414
|
+
console.info = toStderr;
|
|
1415
|
+
console.debug = toStderr;
|
|
1416
|
+
process.stdout.write = process.stderr.write.bind(process.stderr);
|
|
1417
|
+
return writeToRealStdout;
|
|
1418
|
+
}
|
|
1419
|
+
function resolveBaseURL(options, configStore) {
|
|
1420
|
+
const explicit = process.env.B4M_API_URL ?? options.apiUrl;
|
|
1421
|
+
if (explicit) {
|
|
1422
|
+
const parsed = parseApiUrl(explicit);
|
|
1423
|
+
if ("error" in parsed) throw new Error(`Invalid API URL: ${parsed.error}`);
|
|
1424
|
+
return Promise.resolve(parsed.url);
|
|
1425
|
+
}
|
|
1426
|
+
return configStore.getApiConfig().then((apiConfig) => requireApiUrl(apiConfig));
|
|
1427
|
+
}
|
|
1428
|
+
async function serveStdio(buildOptions, writeFrame) {
|
|
1429
|
+
const server = buildMcpServer(buildOptions);
|
|
1430
|
+
const stdout = new Writable({ write(chunk, _encoding, callback) {
|
|
1431
|
+
if (writeFrame(chunk)) {
|
|
1432
|
+
callback();
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
process.stdout.once("drain", () => callback());
|
|
1436
|
+
} });
|
|
1437
|
+
const transport = new StdioServerTransport(process.stdin, stdout);
|
|
1438
|
+
await server.connect(transport);
|
|
1439
|
+
await new Promise((resolve) => {
|
|
1440
|
+
process.stdin.once("end", resolve);
|
|
1441
|
+
process.stdin.once("close", resolve);
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
async function handleHttpRequest(req, res, buildOptions, port) {
|
|
1445
|
+
const server = buildMcpServer(buildOptions);
|
|
1446
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1447
|
+
sessionIdGenerator: void 0,
|
|
1448
|
+
enableJsonResponse: true,
|
|
1449
|
+
enableDnsRebindingProtection: true,
|
|
1450
|
+
allowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
|
|
1451
|
+
allowedOrigins: [`http://127.0.0.1:${port}`, `http://localhost:${port}`]
|
|
1452
|
+
});
|
|
1453
|
+
let released = false;
|
|
1454
|
+
const release = () => {
|
|
1455
|
+
if (released) return;
|
|
1456
|
+
released = true;
|
|
1457
|
+
transport.close();
|
|
1458
|
+
server.close();
|
|
1459
|
+
};
|
|
1460
|
+
res.on("finish", release);
|
|
1461
|
+
res.on("close", release);
|
|
1462
|
+
await server.connect(transport);
|
|
1463
|
+
await transport.handleRequest(req, res);
|
|
1464
|
+
}
|
|
1465
|
+
async function serveHttp(buildOptions, port) {
|
|
1466
|
+
const httpServer = createServer((req, res) => {
|
|
1467
|
+
if (new URL(req.url ?? "/", `http://localhost:${port}`).pathname !== HTTP_PATH) {
|
|
1468
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1469
|
+
res.end(JSON.stringify({ error: `Not found. The MCP endpoint is ${HTTP_PATH}.` }));
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
handleHttpRequest(req, res, buildOptions, port).catch((err) => {
|
|
1473
|
+
logger.error("MCP HTTP request failed", err);
|
|
1474
|
+
if (!res.headersSent) {
|
|
1475
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1476
|
+
res.end(JSON.stringify({
|
|
1477
|
+
jsonrpc: "2.0",
|
|
1478
|
+
error: {
|
|
1479
|
+
code: -32603,
|
|
1480
|
+
message: "Internal server error"
|
|
1481
|
+
},
|
|
1482
|
+
id: null
|
|
1483
|
+
}));
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
});
|
|
1487
|
+
await new Promise((resolve, reject) => {
|
|
1488
|
+
httpServer.once("error", reject);
|
|
1489
|
+
httpServer.listen(port, "127.0.0.1", () => resolve());
|
|
1490
|
+
});
|
|
1491
|
+
process.stderr.write(`Bike4Mind MCP server listening on http://127.0.0.1:${port}${HTTP_PATH}\n`);
|
|
1492
|
+
await new Promise((resolve) => {
|
|
1493
|
+
const shutdown = () => httpServer.close(() => resolve());
|
|
1494
|
+
process.once("SIGINT", shutdown);
|
|
1495
|
+
process.once("SIGTERM", shutdown);
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
async function handleMcpServeCommand(options) {
|
|
1499
|
+
const writeFrame = options.http ? void 0 : captureStdout();
|
|
1500
|
+
const configStore = new ConfigStore();
|
|
1501
|
+
const buildOptions = {
|
|
1502
|
+
baseURL: await resolveBaseURL(options, configStore),
|
|
1503
|
+
apiKey: options.apiKey ?? process.env.B4M_API_KEY,
|
|
1504
|
+
configStore,
|
|
1505
|
+
version: options.version
|
|
1506
|
+
};
|
|
1507
|
+
if (writeFrame) await serveStdio(buildOptions, writeFrame);
|
|
1508
|
+
else await serveHttp(buildOptions, options.port ?? 7e3);
|
|
1509
|
+
}
|
|
1510
|
+
//#endregion
|
|
1511
|
+
export { handleMcpServeCommand };
|