@bike4mind/cli 0.18.5 → 0.20.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/LICENSE +1 -1
- package/README.md +204 -35
- package/bin/bike4mind-cli.mjs +137 -24
- package/bin/hearth-hook.mjs +292 -0
- package/dist/AgentHistoryStore-C8uUKjjC.mjs +35512 -0
- package/dist/ApiClient-B_CQrUiF.mjs +277 -0
- package/dist/{ConfigStore-D39UqFnY.mjs → ConfigStore-DD3DcC3-.mjs} +6199 -3954
- package/dist/{ImageStore-BVmEG1xc.mjs → ImageStore-kVo-oHoS.mjs} +2 -2
- package/dist/PluginStore-DwvOJ-G3.mjs +206 -0
- package/dist/ProxyManager-Bqr7Lmsd.mjs +3 -0
- package/dist/{ProxyManager-CV94yZUW.mjs → ProxyManager-C5H0pUyK.mjs} +2 -2
- package/dist/{SandboxOrchestrator-BS6gALNq.mjs → SandboxOrchestrator-BFPVpmB5.mjs} +1 -1
- package/dist/{SandboxOrchestrator-BoINxbX4.mjs → SandboxOrchestrator-C8uleDn2.mjs} +7 -7
- package/dist/ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs +252 -0
- package/dist/{ViolationLogStore-B-plqJfn.mjs → ViolationLogStore-byEhxa2A.mjs} +1 -1
- package/dist/WorkItemsClient-Cow6nXx7.mjs +382 -0
- package/dist/{bashExecute-B1N1lMOS-TZVDbcQ4.mjs → bashExecute-CrdPpBqk-DCATrE-D.mjs} +116 -16
- package/dist/buildAgent-mVuXU_H4.mjs +824 -0
- package/dist/commands/acpCommand.mjs +798 -0
- package/dist/commands/apiCommand.mjs +14 -16
- package/dist/commands/doctorCommand.mjs +5 -5
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +272 -76
- package/dist/commands/mcpCommand.mjs +14 -1
- package/dist/commands/pluginCommand.mjs +232 -0
- package/dist/commands/updateCommand.mjs +10 -9
- package/dist/{grepSearch-DJs-cubo-Bm0Y8oS3.mjs → grepSearch-BaYUfIYs-C-fxWc9G.mjs} +3 -3
- package/dist/index.mjs +3281 -2322
- package/dist/{package-I_v_WFUn.mjs → package-BqKSCbso.mjs} +1 -1
- package/dist/serve-CuF0I5en.mjs +772 -0
- package/dist/store-BG3e54c8.mjs +3 -0
- package/dist/{store-DV5s-qni.mjs → store-CvjTpQPs.mjs} +70 -3
- package/dist/{terminalSetup-BbJt04ZG.mjs → terminalSetup-DjXAwpDy.mjs} +2 -3
- package/dist/{treeSitterEngine-BRbQ9b7I.mjs → treeSitterEngine-QBE3YkmG.mjs} +51 -1
- package/dist/{updateChecker-C8xsNY2L.mjs → updateChecker-CQW8bxo6.mjs} +10 -10
- package/package.json +48 -43
- package/dist/BackgroundAgentManager-D-xsWd3C.mjs +0 -27303
- package/dist/ProxyManager-ByuAHFMq.mjs +0 -3
- package/dist/store-DgzCTRkN.mjs +0 -3
- package/dist/utils-Cdktpk_k.mjs +0 -158
- package/dist/utils-DEizxshI.mjs +0 -3
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { c as requireApiUrl, d as AGENT_QUEST_MANIFEST, f as AGENT_QUEST_MCP_URI, n as logger, s as parseApiUrl, t as ConfigStore, u as AGENT_QUEST_ID } from "./ConfigStore-DD3DcC3-.mjs";
|
|
3
|
+
import { t as ApiClient } from "./ApiClient-B_CQrUiF.mjs";
|
|
4
|
+
import { z as z$1 } from "zod";
|
|
5
|
+
import { isAxiosError } from "axios";
|
|
6
|
+
import { Writable } from "node:stream";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
10
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
//#region src/mcp/b4mApiClient.ts
|
|
12
|
+
/**
|
|
13
|
+
* Typed wrapper over {@link ApiClient} exposing exactly the Bike4Mind REST
|
|
14
|
+
* endpoints the MCP tools call. All routes are `baseApi()` routes that accept
|
|
15
|
+
* either an OAuth JWT or an instance API key, so a caller supplies whichever it
|
|
16
|
+
* has via the underlying ApiClient.
|
|
17
|
+
*/
|
|
18
|
+
var B4mApiClient = class {
|
|
19
|
+
constructor(baseURL, configStore, apiKey) {
|
|
20
|
+
this.baseURL = baseURL;
|
|
21
|
+
this.client = new ApiClient(baseURL, configStore, apiKey);
|
|
22
|
+
}
|
|
23
|
+
toList(result) {
|
|
24
|
+
if (Array.isArray(result)) return {
|
|
25
|
+
data: result,
|
|
26
|
+
hasMore: false
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
data: result.data ?? [],
|
|
30
|
+
hasMore: result.hasMore ?? false
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async listNotebooks(args) {
|
|
34
|
+
const result = await this.client.get("/api/sessions", { params: {
|
|
35
|
+
...args.search ? { search: args.search } : {},
|
|
36
|
+
pagination: {
|
|
37
|
+
page: args.page ?? 1,
|
|
38
|
+
limit: args.limit
|
|
39
|
+
}
|
|
40
|
+
} });
|
|
41
|
+
return this.toList(result);
|
|
42
|
+
}
|
|
43
|
+
async getNotebook(notebookId) {
|
|
44
|
+
return this.client.get(`/api/sessions/${encodeURIComponent(notebookId)}`);
|
|
45
|
+
}
|
|
46
|
+
async createNotebook(args) {
|
|
47
|
+
return this.client.post("/api/sessions/create", {
|
|
48
|
+
...args.name ? { name: args.name } : {},
|
|
49
|
+
...args.projectId ? { projectId: args.projectId } : {}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async sendChat(args) {
|
|
53
|
+
return this.client.post("/api/chat", {
|
|
54
|
+
...args.notebookId ? { sessionId: args.notebookId } : {},
|
|
55
|
+
message: args.message,
|
|
56
|
+
...args.model ? { model: args.model } : {},
|
|
57
|
+
wait: true
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async getQuest(questId) {
|
|
61
|
+
return this.client.get(`/api/quests/${encodeURIComponent(questId)}`);
|
|
62
|
+
}
|
|
63
|
+
async searchKnowledgeBase(args) {
|
|
64
|
+
return (await this.client.post("/api/sessions/semantic-search", {
|
|
65
|
+
query: args.query,
|
|
66
|
+
topK: args.limit,
|
|
67
|
+
...args.minSimilarity !== void 0 ? { minSimilarity: args.minSimilarity } : {}
|
|
68
|
+
})).scores ?? [];
|
|
69
|
+
}
|
|
70
|
+
async listFiles(args) {
|
|
71
|
+
const result = await this.client.get("/api/files/search", { params: {
|
|
72
|
+
...args.search ? { search: args.search } : {},
|
|
73
|
+
pagination: {
|
|
74
|
+
page: args.page ?? 1,
|
|
75
|
+
limit: args.limit
|
|
76
|
+
}
|
|
77
|
+
} });
|
|
78
|
+
return this.toList(result);
|
|
79
|
+
}
|
|
80
|
+
async getFile(fileId) {
|
|
81
|
+
return this.client.get(`/api/files/${encodeURIComponent(fileId)}`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Generate a sound effect. Unlike the JSON routes, this one streams raw audio
|
|
85
|
+
* bytes, so it goes through the axios instance directly to read the response
|
|
86
|
+
* headers (content type + the persisted-FabFile side channel). On failure the
|
|
87
|
+
* error body arrives as bytes; {@link decodeArrayBufferErrorBody} restores the
|
|
88
|
+
* JSON shape so {@link mapApiError} can surface the server's message.
|
|
89
|
+
*/
|
|
90
|
+
async generateSoundEffect(args) {
|
|
91
|
+
try {
|
|
92
|
+
const response = await this.client.getAxiosInstance().post("/api/ai/sound-effects", {
|
|
93
|
+
provider: args.provider,
|
|
94
|
+
text: args.text,
|
|
95
|
+
...args.durationSeconds !== void 0 ? { durationSeconds: args.durationSeconds } : {},
|
|
96
|
+
...args.promptInfluence !== void 0 ? { promptInfluence: args.promptInfluence } : {},
|
|
97
|
+
...args.format ? { format: args.format } : {}
|
|
98
|
+
}, { responseType: "arraybuffer" });
|
|
99
|
+
const headerString = (value) => typeof value === "string" ? value : void 0;
|
|
100
|
+
const saved = String(response.headers["x-b4m-audio-saved"] ?? "") === "true";
|
|
101
|
+
return {
|
|
102
|
+
audio: Buffer.from(response.data),
|
|
103
|
+
contentType: String(response.headers["content-type"] ?? "application/octet-stream"),
|
|
104
|
+
saved,
|
|
105
|
+
fabFileId: saved ? headerString(response.headers["x-b4m-audio-fab-file-id"]) : void 0,
|
|
106
|
+
fileName: saved ? headerString(response.headers["x-b4m-audio-file-name"]) : void 0,
|
|
107
|
+
fileUrl: saved ? headerString(response.headers["x-b4m-audio-file-url"]) : void 0
|
|
108
|
+
};
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw decodeArrayBufferErrorBody(error);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async listProjects(args) {
|
|
114
|
+
const result = await this.client.get("/api/projects", { params: {
|
|
115
|
+
...args.search ? { search: args.search } : {},
|
|
116
|
+
pagination: {
|
|
117
|
+
page: args.page ?? 1,
|
|
118
|
+
limit: args.limit
|
|
119
|
+
}
|
|
120
|
+
} });
|
|
121
|
+
return this.toList(result);
|
|
122
|
+
}
|
|
123
|
+
async getProject(projectId) {
|
|
124
|
+
return this.client.get(`/api/projects/${encodeURIComponent(projectId)}`);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* GET /api/artifacts takes flat `limit`/`offset` params (not the nested
|
|
128
|
+
* `pagination` object the session/file/project routes use) and hard-caps
|
|
129
|
+
* `limit` at 100 via a strict zod parse, so callers must not exceed it.
|
|
130
|
+
*/
|
|
131
|
+
async listArtifacts(args) {
|
|
132
|
+
const result = await this.client.get("/api/artifacts", { params: {
|
|
133
|
+
...args.search ? { search: args.search } : {},
|
|
134
|
+
limit: args.limit,
|
|
135
|
+
offset: args.offset ?? 0
|
|
136
|
+
} });
|
|
137
|
+
return {
|
|
138
|
+
data: result.artifacts ?? [],
|
|
139
|
+
hasMore: result.pagination?.hasMore ?? false
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async getArtifact(artifactId) {
|
|
143
|
+
return this.client.get(`/api/artifacts/${encodeURIComponent(artifactId)}`, { params: { includeContent: "true" } });
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Turn an API failure into an actionable, transport-agnostic message. `scope` is
|
|
148
|
+
* the recommended API-key scope for the failing tool. A 403 can come from a
|
|
149
|
+
* missing scope OR from a route-level authorization check (CASL forbidden,
|
|
150
|
+
* suspended account), so the message stays broad rather than asserting a scope
|
|
151
|
+
* gap that may not be the cause.
|
|
152
|
+
*/
|
|
153
|
+
function mapApiError(error, baseURL, scope) {
|
|
154
|
+
if (isAxiosError(error)) {
|
|
155
|
+
const status = error.response?.status;
|
|
156
|
+
if (status === 401) return "authentication failed (run `b4m login` or set B4M_API_KEY)";
|
|
157
|
+
if (status === 403) {
|
|
158
|
+
const base = "API key forbidden: check the key's scopes and account access";
|
|
159
|
+
return scope ? `${base} (recommended scope: ${scope})` : base;
|
|
160
|
+
}
|
|
161
|
+
if (status === 429) {
|
|
162
|
+
const retryAfterSeconds = parseRetryAfterSeconds(error.response?.headers?.["retry-after"]);
|
|
163
|
+
const base = extractServerMessage(error.response?.data) || "rate limit exceeded";
|
|
164
|
+
return retryAfterSeconds !== void 0 ? `${base} (retry after ${retryAfterSeconds}s)` : base;
|
|
165
|
+
}
|
|
166
|
+
if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT" || /timeout/i.test(error.message)) return `request to Bike4Mind at ${baseURL} timed out`;
|
|
167
|
+
if (error.code === "ECONNREFUSED" || error.message.includes("ECONNREFUSED")) return `cannot reach Bike4Mind at ${baseURL}`;
|
|
168
|
+
const serverMsg = extractServerMessage(error.response?.data);
|
|
169
|
+
if (serverMsg) return serverMsg;
|
|
170
|
+
return error.message;
|
|
171
|
+
}
|
|
172
|
+
return error instanceof Error ? error.message : String(error);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* A request made with `responseType: 'arraybuffer'` also decodes its error body
|
|
176
|
+
* as bytes, so a JSON `{ error }` payload reaches us as a Buffer that
|
|
177
|
+
* {@link mapApiError} can't read. Decode it back to a parsed object in place so
|
|
178
|
+
* the server's message survives; leave a non-JSON body untouched.
|
|
179
|
+
*/
|
|
180
|
+
function decodeArrayBufferErrorBody(error) {
|
|
181
|
+
if (!isAxiosError(error) || !error.response) return error;
|
|
182
|
+
const { data } = error.response;
|
|
183
|
+
if (typeof data === "string") {
|
|
184
|
+
try {
|
|
185
|
+
error.response.data = JSON.parse(data);
|
|
186
|
+
} catch {}
|
|
187
|
+
return error;
|
|
188
|
+
}
|
|
189
|
+
const bytes = Buffer.isBuffer(data) ? data : data instanceof ArrayBuffer ? Buffer.from(data) : ArrayBuffer.isView(data) ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : void 0;
|
|
190
|
+
if (!bytes) return error;
|
|
191
|
+
try {
|
|
192
|
+
error.response.data = JSON.parse(bytes.toString("utf8"));
|
|
193
|
+
} catch {}
|
|
194
|
+
return error;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Normalize a Retry-After header (RFC 7231: either delta-seconds or an HTTP-date)
|
|
198
|
+
* to a whole, non-negative number of seconds. Returns undefined when the header is
|
|
199
|
+
* absent or parses as neither, so callers can omit the retry hint entirely.
|
|
200
|
+
*/
|
|
201
|
+
function parseRetryAfterSeconds(value) {
|
|
202
|
+
if (value === void 0 || value === null) return void 0;
|
|
203
|
+
const raw = String(value).trim();
|
|
204
|
+
if (/^\d+$/.test(raw)) return Number(raw);
|
|
205
|
+
const dateMs = Date.parse(raw);
|
|
206
|
+
if (Number.isNaN(dateMs)) return void 0;
|
|
207
|
+
return Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
|
|
208
|
+
}
|
|
209
|
+
/** Pull a human-readable message out of a JSON error body (`error` or `message` field). */
|
|
210
|
+
function extractServerMessage(data) {
|
|
211
|
+
if (data && typeof data === "object") {
|
|
212
|
+
const record = data;
|
|
213
|
+
if (typeof record.error === "string") return record.error;
|
|
214
|
+
if (typeof record.message === "string") return record.message;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/mcp/tools.ts
|
|
219
|
+
const TOOL_META = [
|
|
220
|
+
{
|
|
221
|
+
name: "list_notebooks",
|
|
222
|
+
title: "List notebooks",
|
|
223
|
+
description: "List the caller's Bike4Mind notebooks (sessions).",
|
|
224
|
+
scope: "notebooks:read"
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "get_notebook",
|
|
228
|
+
title: "Get notebook",
|
|
229
|
+
description: "Fetch a single notebook by id.",
|
|
230
|
+
scope: "notebooks:read"
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "create_notebook",
|
|
234
|
+
title: "Create notebook",
|
|
235
|
+
description: "Create a new notebook, optionally inside a project. Defaults the name to \"New Notebook\" when omitted.",
|
|
236
|
+
scope: "notebooks:write"
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "send_message",
|
|
240
|
+
title: "Send message",
|
|
241
|
+
description: "Send a chat message and wait for the assistant reply.",
|
|
242
|
+
scope: "ai:chat"
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: "search_knowledge_base",
|
|
246
|
+
title: "Search knowledge base",
|
|
247
|
+
description: "Semantic search across the caller's notebooks.",
|
|
248
|
+
scope: "notebooks:read"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "list_files",
|
|
252
|
+
title: "List files",
|
|
253
|
+
description: "Search the caller's files.",
|
|
254
|
+
scope: "files:read"
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "get_file",
|
|
258
|
+
title: "Get file",
|
|
259
|
+
description: "Fetch a file's metadata and a signed download URL.",
|
|
260
|
+
scope: "files:read"
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: "generate_sound_effect",
|
|
264
|
+
title: "Generate sound effect",
|
|
265
|
+
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.",
|
|
266
|
+
scope: "ai:generate"
|
|
267
|
+
}
|
|
268
|
+
];
|
|
269
|
+
TOOL_META.map((t) => t.name);
|
|
270
|
+
const listNotebooksShape = {
|
|
271
|
+
search: z$1.string().optional().describe("Filter notebooks by name/content"),
|
|
272
|
+
limit: z$1.number().int().min(1).max(100).default(25).describe("Maximum notebooks to return"),
|
|
273
|
+
page: z$1.number().int().min(1).default(1).describe("1-based page number; request the next page when hasMore is true")
|
|
274
|
+
};
|
|
275
|
+
const getNotebookShape = { notebookId: z$1.string().describe("The notebook (session) id") };
|
|
276
|
+
const createNotebookShape = {
|
|
277
|
+
name: z$1.string().optional().describe("Name for the new notebook"),
|
|
278
|
+
projectId: z$1.string().optional().describe("Project to create the notebook in")
|
|
279
|
+
};
|
|
280
|
+
const sendMessageShape = {
|
|
281
|
+
message: z$1.string().describe("The message to send"),
|
|
282
|
+
notebookId: z$1.string().optional().describe("Notebook to send to; defaults to the most recent"),
|
|
283
|
+
model: z$1.string().optional().describe("Model id to use; defaults to the instance default")
|
|
284
|
+
};
|
|
285
|
+
const searchKnowledgeBaseShape = {
|
|
286
|
+
query: z$1.string().describe("The search query"),
|
|
287
|
+
limit: z$1.number().int().min(1).max(100).default(10).describe("Maximum results to return"),
|
|
288
|
+
minSimilarity: z$1.number().min(0).max(1).optional().describe("Minimum cosine similarity threshold")
|
|
289
|
+
};
|
|
290
|
+
const listFilesShape = {
|
|
291
|
+
search: z$1.string().optional().describe("Filter files by name/content"),
|
|
292
|
+
limit: z$1.number().int().min(1).max(100).default(25).describe("Maximum files to return"),
|
|
293
|
+
page: z$1.number().int().min(1).default(1).describe("1-based page number; request the next page when hasMore is true")
|
|
294
|
+
};
|
|
295
|
+
const getFileShape = { fileId: z$1.string().describe("The file id") };
|
|
296
|
+
const generateSoundEffectShape = {
|
|
297
|
+
text: z$1.string().min(1).max(1e3).describe("Text description of the sound effect to generate"),
|
|
298
|
+
provider: z$1.enum(["elevenlabs"]).default("elevenlabs").describe("Sound-generation provider"),
|
|
299
|
+
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"),
|
|
300
|
+
promptInfluence: z$1.number().min(0).max(1).optional().describe("How strictly to follow the prompt (0 = loose, 1 = strict)"),
|
|
301
|
+
format: z$1.string().optional().describe("Provider output encoding token, e.g. mp3_44100_128")
|
|
302
|
+
};
|
|
303
|
+
function notebookSummary(n) {
|
|
304
|
+
return {
|
|
305
|
+
id: n.id,
|
|
306
|
+
name: n.name,
|
|
307
|
+
model: n.lastUsedModel ?? void 0,
|
|
308
|
+
createdAt: n.createdAt ?? n.firstCreated,
|
|
309
|
+
updatedAt: n.updatedAt ?? n.lastUpdated
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
async function listNotebooks(client, args) {
|
|
313
|
+
const { data, hasMore } = await client.listNotebooks(args);
|
|
314
|
+
return {
|
|
315
|
+
notebooks: data.map(notebookSummary),
|
|
316
|
+
hasMore
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
async function getNotebook(client, args) {
|
|
320
|
+
return client.getNotebook(args.notebookId);
|
|
321
|
+
}
|
|
322
|
+
async function createNotebook(client, args) {
|
|
323
|
+
return client.createNotebook({
|
|
324
|
+
...args,
|
|
325
|
+
name: args.name ?? "New Notebook"
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
async function sendMessage(client, args) {
|
|
329
|
+
const res = await client.sendChat(args);
|
|
330
|
+
const questId = res.id;
|
|
331
|
+
let notebookId = args.notebookId;
|
|
332
|
+
if (!notebookId) try {
|
|
333
|
+
notebookId = (await client.getQuest(questId)).sessionId;
|
|
334
|
+
} catch {
|
|
335
|
+
notebookId = void 0;
|
|
336
|
+
}
|
|
337
|
+
const reply = res.responses && res.responses.length > 0 ? res.responses.join("\n\n") : res.response ?? "";
|
|
338
|
+
return {
|
|
339
|
+
notebookId,
|
|
340
|
+
questId,
|
|
341
|
+
reply,
|
|
342
|
+
model: res.model
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
async function searchKnowledgeBase(client, args) {
|
|
346
|
+
return { results: await client.searchKnowledgeBase(args) };
|
|
347
|
+
}
|
|
348
|
+
async function listFiles(client, args) {
|
|
349
|
+
const { data, hasMore } = await client.listFiles(args);
|
|
350
|
+
return {
|
|
351
|
+
files: data,
|
|
352
|
+
hasMore
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
async function getFile(client, args) {
|
|
356
|
+
return client.getFile(args.fileId);
|
|
357
|
+
}
|
|
358
|
+
async function generateSoundEffect(client, args) {
|
|
359
|
+
const { audio, contentType, saved, fabFileId, fileName, fileUrl } = await client.generateSoundEffect(args);
|
|
360
|
+
const base = {
|
|
361
|
+
provider: args.provider,
|
|
362
|
+
contentType,
|
|
363
|
+
byteLength: audio.length
|
|
364
|
+
};
|
|
365
|
+
if (saved && fabFileId && fileUrl) return {
|
|
366
|
+
...base,
|
|
367
|
+
saved: true,
|
|
368
|
+
file: {
|
|
369
|
+
id: fabFileId,
|
|
370
|
+
fileName,
|
|
371
|
+
fileUrl
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
return {
|
|
375
|
+
...base,
|
|
376
|
+
saved: false,
|
|
377
|
+
audioBase64: audio.toString("base64")
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function toResult(value) {
|
|
381
|
+
const structuredContent = value && typeof value === "object" && !Array.isArray(value) ? value : { result: value };
|
|
382
|
+
return {
|
|
383
|
+
content: [{
|
|
384
|
+
type: "text",
|
|
385
|
+
text: JSON.stringify(value, null, 2)
|
|
386
|
+
}],
|
|
387
|
+
structuredContent
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function errorResult(message) {
|
|
391
|
+
return {
|
|
392
|
+
content: [{
|
|
393
|
+
type: "text",
|
|
394
|
+
text: message
|
|
395
|
+
}],
|
|
396
|
+
isError: true
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Render a {@link SoundEffectOutcome} as an MCP result. A persisted file becomes
|
|
401
|
+
* a JSON metadata result (carrying the signed URL), exactly like get_file. When
|
|
402
|
+
* the audio was not persisted, it is returned inline as an `audio` content block
|
|
403
|
+
* so the bytes are not lost; the base64 is kept out of structuredContent to avoid
|
|
404
|
+
* duplicating a potentially large payload.
|
|
405
|
+
*/
|
|
406
|
+
function soundEffectResult(outcome) {
|
|
407
|
+
if (outcome.saved) return toResult({
|
|
408
|
+
saved: true,
|
|
409
|
+
provider: outcome.provider,
|
|
410
|
+
contentType: outcome.contentType,
|
|
411
|
+
byteLength: outcome.byteLength,
|
|
412
|
+
file: outcome.file
|
|
413
|
+
});
|
|
414
|
+
const { audioBase64, ...meta } = outcome;
|
|
415
|
+
return {
|
|
416
|
+
content: [{
|
|
417
|
+
type: "text",
|
|
418
|
+
text: JSON.stringify(meta, null, 2)
|
|
419
|
+
}, {
|
|
420
|
+
type: "audio",
|
|
421
|
+
data: audioBase64,
|
|
422
|
+
mimeType: outcome.contentType
|
|
423
|
+
}],
|
|
424
|
+
structuredContent: meta
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Register the Bike4Mind MCP tools on `server`. Each handler is wrapped so an
|
|
429
|
+
* API failure becomes a structured `isError` result carrying a friendly message
|
|
430
|
+
* (see {@link mapApiError}) rather than throwing across the transport.
|
|
431
|
+
*/
|
|
432
|
+
function registerTools(server, client) {
|
|
433
|
+
const baseURL = client.baseURL;
|
|
434
|
+
const meta = (name) => TOOL_META.find((t) => t.name === name);
|
|
435
|
+
const run = async (scope, fn) => {
|
|
436
|
+
try {
|
|
437
|
+
return toResult(await fn());
|
|
438
|
+
} catch (err) {
|
|
439
|
+
return errorResult(mapApiError(err, baseURL, scope));
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
server.registerTool("list_notebooks", {
|
|
443
|
+
title: meta("list_notebooks").title,
|
|
444
|
+
description: meta("list_notebooks").description,
|
|
445
|
+
inputSchema: listNotebooksShape
|
|
446
|
+
}, (args) => run("notebooks:read", () => listNotebooks(client, args)));
|
|
447
|
+
server.registerTool("get_notebook", {
|
|
448
|
+
title: meta("get_notebook").title,
|
|
449
|
+
description: meta("get_notebook").description,
|
|
450
|
+
inputSchema: getNotebookShape
|
|
451
|
+
}, (args) => run("notebooks:read", () => getNotebook(client, args)));
|
|
452
|
+
server.registerTool("create_notebook", {
|
|
453
|
+
title: meta("create_notebook").title,
|
|
454
|
+
description: meta("create_notebook").description,
|
|
455
|
+
inputSchema: createNotebookShape
|
|
456
|
+
}, (args) => run("notebooks:write", () => createNotebook(client, args)));
|
|
457
|
+
server.registerTool("send_message", {
|
|
458
|
+
title: meta("send_message").title,
|
|
459
|
+
description: meta("send_message").description,
|
|
460
|
+
inputSchema: sendMessageShape
|
|
461
|
+
}, (args) => run("ai:chat", () => sendMessage(client, args)));
|
|
462
|
+
server.registerTool("search_knowledge_base", {
|
|
463
|
+
title: meta("search_knowledge_base").title,
|
|
464
|
+
description: meta("search_knowledge_base").description,
|
|
465
|
+
inputSchema: searchKnowledgeBaseShape
|
|
466
|
+
}, (args) => run("notebooks:read", () => searchKnowledgeBase(client, args)));
|
|
467
|
+
server.registerTool("list_files", {
|
|
468
|
+
title: meta("list_files").title,
|
|
469
|
+
description: meta("list_files").description,
|
|
470
|
+
inputSchema: listFilesShape
|
|
471
|
+
}, (args) => run("files:read", () => listFiles(client, args)));
|
|
472
|
+
server.registerTool("get_file", {
|
|
473
|
+
title: meta("get_file").title,
|
|
474
|
+
description: meta("get_file").description,
|
|
475
|
+
inputSchema: getFileShape
|
|
476
|
+
}, (args) => run("files:read", () => getFile(client, args)));
|
|
477
|
+
server.registerTool("generate_sound_effect", {
|
|
478
|
+
title: meta("generate_sound_effect").title,
|
|
479
|
+
description: meta("generate_sound_effect").description,
|
|
480
|
+
inputSchema: generateSoundEffectShape
|
|
481
|
+
}, async (args) => {
|
|
482
|
+
try {
|
|
483
|
+
return soundEffectResult(await generateSoundEffect(client, args));
|
|
484
|
+
} catch (err) {
|
|
485
|
+
return errorResult(mapApiError(err, baseURL, "ai:generate"));
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
//#endregion
|
|
490
|
+
//#region src/mcp/resources.ts
|
|
491
|
+
const LIST_LIMIT = 100;
|
|
492
|
+
/**
|
|
493
|
+
* Decode a URI-template variable, tolerating an id that was never encoded.
|
|
494
|
+
*
|
|
495
|
+
* The SDK reaches a read callback via `new URL(uri)` -> `uriTemplate.match(url.toString())`,
|
|
496
|
+
* and `URL.toString()` percent-encodes, so an id we listed with `encodeURIComponent`
|
|
497
|
+
* arrives already encoded and must be decoded back before it hits a REST path. A
|
|
498
|
+
* hand-crafted uri with a malformed escape (e.g. `%zz`) would make `decodeURIComponent`
|
|
499
|
+
* throw a bare `URIError` past `mapApiError`; fall back to the raw value instead.
|
|
500
|
+
*/
|
|
501
|
+
function safeDecode(value) {
|
|
502
|
+
try {
|
|
503
|
+
return decodeURIComponent(value);
|
|
504
|
+
} catch {
|
|
505
|
+
return value;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Register one `b4m://<name>/{id}` JSON resource.
|
|
510
|
+
*
|
|
511
|
+
* `list` degrades to an empty array on failure rather than throwing: the SDK's
|
|
512
|
+
* resources/list handler awaits every registered template's list callback in one
|
|
513
|
+
* unguarded loop, so a throw here would also blank every sibling template. `read`
|
|
514
|
+
* still throws - a single read failure has no fan-out.
|
|
515
|
+
*/
|
|
516
|
+
function registerJsonResource(server, client, spec) {
|
|
517
|
+
server.registerResource(spec.name, new ResourceTemplate(`b4m://${spec.name}/{id}`, { list: async () => {
|
|
518
|
+
try {
|
|
519
|
+
return { resources: (await spec.list()).map((item) => {
|
|
520
|
+
const label = spec.label(item) ?? spec.id(item);
|
|
521
|
+
return {
|
|
522
|
+
uri: `b4m://${spec.name}/${encodeURIComponent(spec.id(item))}`,
|
|
523
|
+
name: label,
|
|
524
|
+
title: label,
|
|
525
|
+
mimeType: "application/json"
|
|
526
|
+
};
|
|
527
|
+
}) };
|
|
528
|
+
} catch (err) {
|
|
529
|
+
logger.error(`mcp: listing ${spec.name} resources failed: ${mapApiError(err, client.baseURL, spec.scope)}`);
|
|
530
|
+
return { resources: [] };
|
|
531
|
+
}
|
|
532
|
+
} }), {
|
|
533
|
+
title: spec.title,
|
|
534
|
+
description: spec.description,
|
|
535
|
+
mimeType: "application/json"
|
|
536
|
+
}, async (uri, variables) => {
|
|
537
|
+
const id = safeDecode(String(variables.id));
|
|
538
|
+
try {
|
|
539
|
+
const record = await spec.read(id);
|
|
540
|
+
return { contents: [{
|
|
541
|
+
uri: uri.href,
|
|
542
|
+
mimeType: "application/json",
|
|
543
|
+
text: JSON.stringify(record, null, 2)
|
|
544
|
+
}] };
|
|
545
|
+
} catch (err) {
|
|
546
|
+
throw new Error(mapApiError(err, client.baseURL, spec.scope));
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Register `b4m://agent-quest`: the machine-readable invitation to The Open Door.
|
|
552
|
+
*
|
|
553
|
+
* A fixed URI rather than a `ResourceTemplate` - there is exactly one manifest, so
|
|
554
|
+
* there is nothing to list or address by id. It is also the only resource here that
|
|
555
|
+
* needs no API call and no credentials: the document is a constant in
|
|
556
|
+
* @bike4mind/common, shared with the public HTTP route that serves it
|
|
557
|
+
* (apps/client/pages/api/agent-quest/manifest.ts). That means an agent can read the
|
|
558
|
+
* invitation before it has an API key - which is the point, since the quest is how
|
|
559
|
+
* it earns one.
|
|
560
|
+
*/
|
|
561
|
+
function registerAgentQuest(server) {
|
|
562
|
+
server.registerResource(AGENT_QUEST_ID, AGENT_QUEST_MCP_URI, {
|
|
563
|
+
title: AGENT_QUEST_MANIFEST.title,
|
|
564
|
+
description: AGENT_QUEST_MANIFEST.summary,
|
|
565
|
+
mimeType: "application/json"
|
|
566
|
+
}, (uri) => ({ contents: [{
|
|
567
|
+
uri: uri.href,
|
|
568
|
+
mimeType: "application/json",
|
|
569
|
+
text: JSON.stringify(AGENT_QUEST_MANIFEST, null, 2)
|
|
570
|
+
}] }));
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Register the Bike4Mind MCP resources: the four templates below, each served as
|
|
574
|
+
* application/json and backed by its REST list/read pair, plus the credential-free
|
|
575
|
+
* `b4m://agent-quest` manifest.
|
|
576
|
+
*/
|
|
577
|
+
function registerResources(server, client) {
|
|
578
|
+
registerAgentQuest(server);
|
|
579
|
+
registerJsonResource(server, client, {
|
|
580
|
+
name: "notebook",
|
|
581
|
+
title: "Notebook",
|
|
582
|
+
description: "A Bike4Mind notebook (session)",
|
|
583
|
+
scope: "notebooks:read",
|
|
584
|
+
list: async () => (await client.listNotebooks({ limit: LIST_LIMIT })).data,
|
|
585
|
+
id: (n) => n.id,
|
|
586
|
+
label: (n) => n.name,
|
|
587
|
+
read: (id) => client.getNotebook(id)
|
|
588
|
+
});
|
|
589
|
+
registerJsonResource(server, client, {
|
|
590
|
+
name: "file",
|
|
591
|
+
title: "File",
|
|
592
|
+
description: "A Bike4Mind file: metadata plus a signed download URL",
|
|
593
|
+
scope: "files:read",
|
|
594
|
+
list: async () => (await client.listFiles({ limit: LIST_LIMIT })).data,
|
|
595
|
+
id: (f) => f.id,
|
|
596
|
+
label: (f) => f.fileName,
|
|
597
|
+
read: (id) => client.getFile(id)
|
|
598
|
+
});
|
|
599
|
+
registerJsonResource(server, client, {
|
|
600
|
+
name: "project",
|
|
601
|
+
title: "Project",
|
|
602
|
+
description: "A Bike4Mind project",
|
|
603
|
+
scope: "projects:read",
|
|
604
|
+
list: async () => (await client.listProjects({ limit: LIST_LIMIT })).data,
|
|
605
|
+
id: (p) => p.id,
|
|
606
|
+
label: (p) => p.name,
|
|
607
|
+
read: (id) => client.getProject(id)
|
|
608
|
+
});
|
|
609
|
+
registerJsonResource(server, client, {
|
|
610
|
+
name: "artifact",
|
|
611
|
+
title: "Artifact",
|
|
612
|
+
description: "A Bike4Mind artifact: metadata plus its current content",
|
|
613
|
+
list: async () => (await client.listArtifacts({ limit: LIST_LIMIT })).data,
|
|
614
|
+
id: (a) => a.id,
|
|
615
|
+
label: (a) => a.title,
|
|
616
|
+
read: (id) => client.getArtifact(id)
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region src/mcp/server.ts
|
|
621
|
+
/**
|
|
622
|
+
* Build a fully-configured Bike4Mind MCP server: the tools, the four resource
|
|
623
|
+
* templates (`b4m://notebook/{id}`, `b4m://file/{id}`, `b4m://project/{id}`,
|
|
624
|
+
* `b4m://artifact/{id}`) backed by a {@link B4mApiClient} bound to the given
|
|
625
|
+
* endpoint and credentials, and the credential-free `b4m://agent-quest` manifest.
|
|
626
|
+
* Tool listing never touches the network - only tool/resource *calls* hit the API -
|
|
627
|
+
* so a server built with an unreachable endpoint still advertises its full
|
|
628
|
+
* capability set.
|
|
629
|
+
*/
|
|
630
|
+
function buildMcpServer(options) {
|
|
631
|
+
const client = new B4mApiClient(options.baseURL, options.configStore, options.apiKey);
|
|
632
|
+
const server = new McpServer({
|
|
633
|
+
name: "bike4mind",
|
|
634
|
+
version: options.version
|
|
635
|
+
}, { capabilities: {
|
|
636
|
+
tools: {},
|
|
637
|
+
resources: {}
|
|
638
|
+
} });
|
|
639
|
+
registerTools(server, client);
|
|
640
|
+
registerResources(server, client);
|
|
641
|
+
return server;
|
|
642
|
+
}
|
|
643
|
+
//#endregion
|
|
644
|
+
//#region src/mcp/serve.ts
|
|
645
|
+
/**
|
|
646
|
+
* `b4m mcp serve` - expose Bike4Mind as an MCP server.
|
|
647
|
+
*
|
|
648
|
+
* Two transports: stdio (default, for Claude Desktop and other local clients)
|
|
649
|
+
* and stateless streamable HTTP (`--http`). Auth precedence is
|
|
650
|
+
* `--api-key` > `B4M_API_KEY` > the stored OAuth JWT; the endpoint is
|
|
651
|
+
* `B4M_API_URL` > `--api-url` > the CLI's configured backend.
|
|
652
|
+
*
|
|
653
|
+
* Transport contract for stdio: stdout carries the JSON-RPC frame stream and
|
|
654
|
+
* NOTHING else, so all diagnostics are forced to stderr before ANY other work
|
|
655
|
+
* runs (mirrors the acp command's captureStdout - see src/commands/acpCommand.ts).
|
|
656
|
+
*
|
|
657
|
+
* HTTP mode is deliberately loopback-only (binds 127.0.0.1) with no per-request
|
|
658
|
+
* auth of its own - it trusts anything that can reach the socket, so it must not
|
|
659
|
+
* be exposed on a routable interface. DNS-rebinding protection is on as defense
|
|
660
|
+
* in depth against a browser being tricked into posting to the local port.
|
|
661
|
+
*/
|
|
662
|
+
const HTTP_PATH = "/mcp";
|
|
663
|
+
/**
|
|
664
|
+
* Capture the real stdout for the JSON-RPC frame stream, then redirect
|
|
665
|
+
* everything else - `console.*` and any stray `process.stdout.write` deep in the
|
|
666
|
+
* stack - to stderr. A single unrelated byte on stdout corrupts a frame, so the
|
|
667
|
+
* whole channel is closed rather than trusting no dependency ever prints.
|
|
668
|
+
*/
|
|
669
|
+
function captureStdout() {
|
|
670
|
+
const writeToRealStdout = process.stdout.write.bind(process.stdout);
|
|
671
|
+
const toStderr = (...args) => {
|
|
672
|
+
process.stderr.write(args.map(String).join(" ") + "\n");
|
|
673
|
+
};
|
|
674
|
+
console.log = toStderr;
|
|
675
|
+
console.info = toStderr;
|
|
676
|
+
console.debug = toStderr;
|
|
677
|
+
process.stdout.write = process.stderr.write.bind(process.stderr);
|
|
678
|
+
return writeToRealStdout;
|
|
679
|
+
}
|
|
680
|
+
function resolveBaseURL(options, configStore) {
|
|
681
|
+
const explicit = process.env.B4M_API_URL ?? options.apiUrl;
|
|
682
|
+
if (explicit) {
|
|
683
|
+
const parsed = parseApiUrl(explicit);
|
|
684
|
+
if ("error" in parsed) throw new Error(`Invalid API URL: ${parsed.error}`);
|
|
685
|
+
return Promise.resolve(parsed.url);
|
|
686
|
+
}
|
|
687
|
+
return configStore.getApiConfig().then((apiConfig) => requireApiUrl(apiConfig));
|
|
688
|
+
}
|
|
689
|
+
async function serveStdio(buildOptions, writeFrame) {
|
|
690
|
+
const server = buildMcpServer(buildOptions);
|
|
691
|
+
const stdout = new Writable({ write(chunk, _encoding, callback) {
|
|
692
|
+
if (writeFrame(chunk)) {
|
|
693
|
+
callback();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
process.stdout.once("drain", () => callback());
|
|
697
|
+
} });
|
|
698
|
+
const transport = new StdioServerTransport(process.stdin, stdout);
|
|
699
|
+
await server.connect(transport);
|
|
700
|
+
await new Promise((resolve) => {
|
|
701
|
+
process.stdin.once("end", resolve);
|
|
702
|
+
process.stdin.once("close", resolve);
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
async function handleHttpRequest(req, res, buildOptions, port) {
|
|
706
|
+
const server = buildMcpServer(buildOptions);
|
|
707
|
+
const transport = new StreamableHTTPServerTransport({
|
|
708
|
+
sessionIdGenerator: void 0,
|
|
709
|
+
enableJsonResponse: true,
|
|
710
|
+
enableDnsRebindingProtection: true,
|
|
711
|
+
allowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
|
|
712
|
+
allowedOrigins: [`http://127.0.0.1:${port}`, `http://localhost:${port}`]
|
|
713
|
+
});
|
|
714
|
+
let released = false;
|
|
715
|
+
const release = () => {
|
|
716
|
+
if (released) return;
|
|
717
|
+
released = true;
|
|
718
|
+
transport.close();
|
|
719
|
+
server.close();
|
|
720
|
+
};
|
|
721
|
+
res.on("finish", release);
|
|
722
|
+
res.on("close", release);
|
|
723
|
+
await server.connect(transport);
|
|
724
|
+
await transport.handleRequest(req, res);
|
|
725
|
+
}
|
|
726
|
+
async function serveHttp(buildOptions, port) {
|
|
727
|
+
const httpServer = createServer((req, res) => {
|
|
728
|
+
if (new URL(req.url ?? "/", `http://localhost:${port}`).pathname !== HTTP_PATH) {
|
|
729
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
730
|
+
res.end(JSON.stringify({ error: `Not found. The MCP endpoint is ${HTTP_PATH}.` }));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
handleHttpRequest(req, res, buildOptions, port).catch((err) => {
|
|
734
|
+
logger.error("MCP HTTP request failed", err);
|
|
735
|
+
if (!res.headersSent) {
|
|
736
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
737
|
+
res.end(JSON.stringify({
|
|
738
|
+
jsonrpc: "2.0",
|
|
739
|
+
error: {
|
|
740
|
+
code: -32603,
|
|
741
|
+
message: "Internal server error"
|
|
742
|
+
},
|
|
743
|
+
id: null
|
|
744
|
+
}));
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
await new Promise((resolve, reject) => {
|
|
749
|
+
httpServer.once("error", reject);
|
|
750
|
+
httpServer.listen(port, "127.0.0.1", () => resolve());
|
|
751
|
+
});
|
|
752
|
+
process.stderr.write(`Bike4Mind MCP server listening on http://127.0.0.1:${port}${HTTP_PATH}\n`);
|
|
753
|
+
await new Promise((resolve) => {
|
|
754
|
+
const shutdown = () => httpServer.close(() => resolve());
|
|
755
|
+
process.once("SIGINT", shutdown);
|
|
756
|
+
process.once("SIGTERM", shutdown);
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
async function handleMcpServeCommand(options) {
|
|
760
|
+
const writeFrame = options.http ? void 0 : captureStdout();
|
|
761
|
+
const configStore = new ConfigStore();
|
|
762
|
+
const buildOptions = {
|
|
763
|
+
baseURL: await resolveBaseURL(options, configStore),
|
|
764
|
+
apiKey: options.apiKey ?? process.env.B4M_API_KEY,
|
|
765
|
+
configStore,
|
|
766
|
+
version: options.version
|
|
767
|
+
};
|
|
768
|
+
if (writeFrame) await serveStdio(buildOptions, writeFrame);
|
|
769
|
+
else await serveHttp(buildOptions, options.port ?? 7e3);
|
|
770
|
+
}
|
|
771
|
+
//#endregion
|
|
772
|
+
export { handleMcpServeCommand };
|