@olares/lares-core 0.15.6 → 0.15.9
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 +2 -1
- package/brand/identity.js +33 -3
- package/drive/cat.js +62 -0
- package/drive/download.js +3 -3
- package/drive/execute.js +8 -2
- package/drive/files-path.js +33 -0
- package/drive/flowstudio-files.js +87 -0
- package/drive/ls.js +3 -3
- package/drive/paths.js +23 -13
- package/drive/tools.js +4 -2
- package/entrance/surface.js +96 -0
- package/files/app-link.js +64 -0
- package/files/backend-client.js +266 -0
- package/files/deliverables.js +55 -1
- package/files/intake.js +0 -63
- package/files/mention.js +1 -51
- package/files/preview-groups.js +71 -3
- package/files/preview-local.js +69 -0
- package/files/preview-workspace.js +114 -13
- package/files/preview.js +171 -16
- package/files/produced-gate.js +197 -0
- package/files/produced-mentions.js +19 -0
- package/files/published-tools.js +69 -0
- package/files/published-turn-data.js +84 -0
- package/i18n/chat-model.js +5 -5
- package/i18n/mcp.js +83 -0
- package/i18n/mobile.js +100 -2
- package/i18n/preview.js +10 -0
- package/i18n/search.js +13 -7
- package/i18n/skills.js +31 -0
- package/i18n/voice.js +88 -19
- package/larepass/chat.js +9 -0
- package/larepass/host.js +7 -2
- package/larepass/mux.js +210 -2
- package/larepass/process-fold.js +239 -0
- package/larepass/queue.js +44 -0
- package/larepass/rpc.js +50 -3
- package/larepass/runtime.js +215 -83
- package/larepass/session-cache.js +6 -0
- package/larepass/settings.js +15 -7
- package/larepass/transcript.js +49 -11
- package/mcp/approval.js +152 -0
- package/mcp/client.js +38 -0
- package/mcp/config.js +139 -0
- package/media/catalog-row.js +244 -0
- package/media/generation.js +707 -0
- package/media/tool.js +92 -0
- package/olares/entrance.js +67 -15
- package/olares/identity.js +13 -136
- package/olares/session-identity.js +12 -12
- package/olares/trusted-host.js +0 -29
- package/package.json +48 -4
- package/router/catalog-cache.d.ts +1 -1
- package/router/catalog-cache.js +25 -14
- package/router/catalog-sync.js +8 -3
- package/router/catalog.d.ts +6 -1
- package/router/catalog.js +18 -2
- package/router/gateway.d.ts +9 -1
- package/router/gateway.js +40 -4
- package/router/gemini-schema.js +125 -0
- package/router/generation-guard.js +82 -0
- package/router/models.d.ts +6 -0
- package/router/models.js +22 -3
- package/router/search.js +10 -2
- package/router/session-model.js +53 -0
- package/router/shim.js +104 -3
- package/router/sref.js +126 -0
- package/router/stt.js +43 -12
- package/router/tts.js +118 -0
- package/search/client.js +5 -3
- package/search/config.js +53 -39
- package/search/menu.js +45 -10
- package/skills/client.js +42 -0
- package/skills/packs.js +40 -0
- package/skills/state.d.ts +22 -0
- package/skills/state.js +142 -0
- package/tools/crypto-uuid.js +3 -6
- package/tools/head-script.js +15 -0
- package/voice/agent.js +50 -0
- package/voice/capture.js +250 -0
- package/voice/client.js +126 -24
- package/voice/config.js +11 -1
- package/voice/duplex.js +44 -0
- package/voice/endpoint.js +57 -0
- package/voice/host/bluetooth-ready.py +1196 -0
- package/voice/host/install-host.sh +102 -0
- package/voice/input.js +4 -0
- package/voice/install.js +146 -0
- package/voice/languages.js +11 -1
- package/voice/link.js +86 -0
- package/voice/pcm.js +100 -0
- package/voice/playback.js +7 -0
- package/voice/problem.js +46 -0
- package/voice/recorder.js +79 -4
- package/voice/script.js +35 -0
- package/voice/service.js +253 -10
- package/voice/session.js +128 -0
- package/voice/speech.js +93 -0
- package/files/preview-cache.js +0 -100
package/larepass/transcript.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { producedPathsFromView } from "../files/deliverables.js";
|
|
1
|
+
import { producedPathsFromView, turnMediaPathsFromEvent } from "../files/deliverables.js";
|
|
2
|
+
import { durablePathFromToolCall, parseToolArguments } from "../files/published-tools.js";
|
|
3
|
+
import { concludingReply } from "../voice/speech.js";
|
|
2
4
|
import { contextForm, contextProvenance, isHumanUserSource } from "./context-provenance.js";
|
|
3
5
|
import { toolRowModel } from "./tool-row.js";
|
|
4
6
|
import { createTurnMetrics } from "./turn-metrics.js";
|
|
@@ -99,6 +101,7 @@ export function foldTranscript(entries) {
|
|
|
99
101
|
const items = [];
|
|
100
102
|
const blocks = new Map();
|
|
101
103
|
const tools = new Map();
|
|
104
|
+
const durableCalls = new Map();
|
|
102
105
|
const metrics = createTurnMetrics();
|
|
103
106
|
const lastReply = new Map();
|
|
104
107
|
const retries = new Map();
|
|
@@ -106,8 +109,17 @@ export function foldTranscript(entries) {
|
|
|
106
109
|
let files = [];
|
|
107
110
|
let filesAt = -1;
|
|
108
111
|
let running = false;
|
|
112
|
+
let runningSince = 0;
|
|
109
113
|
let error = "";
|
|
114
|
+
let reply = null;
|
|
110
115
|
let streamCursor = { loc: "", kind: "", index: 0 };
|
|
116
|
+
let activeTurn = 0;
|
|
117
|
+
const ended = new Map();
|
|
118
|
+
|
|
119
|
+
const tag = (row) => {
|
|
120
|
+
row.turn = activeTurn;
|
|
121
|
+
return row;
|
|
122
|
+
};
|
|
111
123
|
|
|
112
124
|
const rememberFile = (path) => {
|
|
113
125
|
if (!path || fileSeen.has(path)) return;
|
|
@@ -121,7 +133,7 @@ export function foldTranscript(entries) {
|
|
|
121
133
|
items[filesAt].paths = [...files];
|
|
122
134
|
return;
|
|
123
135
|
}
|
|
124
|
-
items.push({ type: "files", paths: [...files] });
|
|
136
|
+
items.push(tag({ type: "files", paths: [...files] }));
|
|
125
137
|
filesAt = items.length - 1;
|
|
126
138
|
};
|
|
127
139
|
|
|
@@ -135,7 +147,7 @@ export function foldTranscript(entries) {
|
|
|
135
147
|
};
|
|
136
148
|
|
|
137
149
|
const putBlock = (turn, step, index, row) => {
|
|
138
|
-
const stored = upsert(items, blocks, blockKey(turn, step, index), row);
|
|
150
|
+
const stored = upsert(items, blocks, blockKey(turn, step, index), tag(row));
|
|
139
151
|
// The turn's readings belong under its closing reply, the row whose action
|
|
140
152
|
// bar ends the turn on screen.
|
|
141
153
|
if (stored.type === "assistant") lastReply.set(Number(turn) || 0, stored);
|
|
@@ -229,15 +241,19 @@ export function foldTranscript(entries) {
|
|
|
229
241
|
|
|
230
242
|
if (event.type === "turn/start") {
|
|
231
243
|
closeTurn();
|
|
244
|
+
if (Number.isInteger(data.turn)) activeTurn = data.turn;
|
|
232
245
|
files = [];
|
|
233
246
|
fileSeen.clear();
|
|
234
247
|
filesAt = -1;
|
|
235
248
|
running = true;
|
|
249
|
+
runningSince = Number(event.time) || 0;
|
|
236
250
|
error = "";
|
|
251
|
+
reply = null;
|
|
237
252
|
continue;
|
|
238
253
|
}
|
|
239
254
|
|
|
240
255
|
if (event.type === "turn/end") {
|
|
256
|
+
ended.set(activeTurn, data.reason?.kind || "completed");
|
|
241
257
|
if (data.reason?.kind === "error") {
|
|
242
258
|
error = data.reason.error?.message || "turn failed";
|
|
243
259
|
}
|
|
@@ -248,22 +264,24 @@ export function foldTranscript(entries) {
|
|
|
248
264
|
if (event.type === "user/message") {
|
|
249
265
|
const text = textFromBlocks(data.content);
|
|
250
266
|
if (isHumanUserSource(data.source)) {
|
|
251
|
-
if (text) items.push({ type: "user", text });
|
|
267
|
+
if (text) items.push(tag({ type: "user", text }));
|
|
252
268
|
continue;
|
|
253
269
|
}
|
|
254
270
|
const provenance = contextProvenance(data.source);
|
|
255
|
-
items.push({
|
|
271
|
+
items.push(tag({
|
|
256
272
|
type: "context",
|
|
257
273
|
role: provenance.role,
|
|
258
274
|
label: provenance.label,
|
|
259
275
|
form: contextForm(data.source),
|
|
260
276
|
text,
|
|
261
|
-
});
|
|
277
|
+
}));
|
|
262
278
|
continue;
|
|
263
279
|
}
|
|
264
280
|
|
|
265
281
|
if (event.type === "assistant/message") {
|
|
266
282
|
applyAssistantMessage(data);
|
|
283
|
+
const text = concludingReply(event);
|
|
284
|
+
if (text) reply = { seq: event.seq, text };
|
|
267
285
|
continue;
|
|
268
286
|
}
|
|
269
287
|
|
|
@@ -276,7 +294,7 @@ export function foldTranscript(entries) {
|
|
|
276
294
|
const callView = callViewOf(view);
|
|
277
295
|
const argsRaw = typeof data.arguments === "string" ? data.arguments : "";
|
|
278
296
|
const model = toolRowModel(data.name, argsRaw);
|
|
279
|
-
const row = {
|
|
297
|
+
const row = tag({
|
|
280
298
|
type: "tool",
|
|
281
299
|
callId: data.callId,
|
|
282
300
|
name: data.name,
|
|
@@ -284,9 +302,19 @@ export function foldTranscript(entries) {
|
|
|
284
302
|
argsRaw,
|
|
285
303
|
paths: producedPathsFromView(callView),
|
|
286
304
|
...model,
|
|
287
|
-
};
|
|
305
|
+
});
|
|
288
306
|
items.push(row);
|
|
289
|
-
if (data.callId)
|
|
307
|
+
if (data.callId) {
|
|
308
|
+
tools.set(data.callId, items.length - 1);
|
|
309
|
+
const path = durablePathFromToolCall(data.name, parseToolArguments(data.arguments));
|
|
310
|
+
if (path) durableCalls.set(data.callId, path);
|
|
311
|
+
}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const publishedMedia = turnMediaPathsFromEvent(event);
|
|
316
|
+
if (publishedMedia.length > 0) {
|
|
317
|
+
for (const path of publishedMedia) rememberFile(path);
|
|
290
318
|
continue;
|
|
291
319
|
}
|
|
292
320
|
|
|
@@ -300,6 +328,7 @@ export function foldTranscript(entries) {
|
|
|
300
328
|
if (resultView?.title) tool.title = resultView.title;
|
|
301
329
|
if (tool.status === "done") {
|
|
302
330
|
for (const path of tool.paths) rememberFile(path);
|
|
331
|
+
rememberFile(durableCalls.get(id));
|
|
303
332
|
}
|
|
304
333
|
}
|
|
305
334
|
continue;
|
|
@@ -307,7 +336,7 @@ export function foldTranscript(entries) {
|
|
|
307
336
|
|
|
308
337
|
if (event.type === "llm/retry") {
|
|
309
338
|
const retryId = data.retryId || String(event.seq ?? items.length);
|
|
310
|
-
upsert(items, retries, retryId, {
|
|
339
|
+
upsert(items, retries, retryId, tag({
|
|
311
340
|
type: "retry",
|
|
312
341
|
retryId,
|
|
313
342
|
retry: data.retry,
|
|
@@ -317,7 +346,7 @@ export function foldTranscript(entries) {
|
|
|
317
346
|
seconds: retrySeconds(data.delayMs),
|
|
318
347
|
failure: data.failure,
|
|
319
348
|
retryState: "scheduled",
|
|
320
|
-
});
|
|
349
|
+
}));
|
|
321
350
|
continue;
|
|
322
351
|
}
|
|
323
352
|
|
|
@@ -333,11 +362,20 @@ export function foldTranscript(entries) {
|
|
|
333
362
|
const value = metrics.value(turn);
|
|
334
363
|
if (value) row.metrics = value;
|
|
335
364
|
}
|
|
365
|
+
for (const [turn, reason] of ended) {
|
|
366
|
+
const row = items.find((item) => item.turn === turn);
|
|
367
|
+
if (!row) continue;
|
|
368
|
+
row.turnReason = reason;
|
|
369
|
+
const value = metrics.value(turn);
|
|
370
|
+
if (value) row.turnRunMs = value.runMs;
|
|
371
|
+
}
|
|
336
372
|
if (running) syncFiles();
|
|
337
373
|
return {
|
|
338
374
|
items,
|
|
339
375
|
messages: messagesFromItems(items),
|
|
340
376
|
running,
|
|
377
|
+
runningSince: running ? runningSince : 0,
|
|
341
378
|
error,
|
|
379
|
+
reply,
|
|
342
380
|
};
|
|
343
381
|
}
|
package/mcp/approval.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP calls follow the session permission preset.
|
|
5
|
+
*
|
|
6
|
+
* Full access (`danger-full-access`) runs every MCP tool. Read-only and
|
|
7
|
+
* workspace-write allow a tool that declares `readOnlyHint`, and ask before
|
|
8
|
+
* any other MCP tool — the same approval seam a local write uses when the
|
|
9
|
+
* sandbox does not already allow the effect. Public names must match
|
|
10
|
+
* `@deepseek-ai/dsh-mcp-client` (`mcp__<server>__<tool>`, hashed when lossy).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MAX_PUBLIC_NAME_LENGTH = 64;
|
|
14
|
+
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
|
|
15
|
+
const HASH_LENGTH = 12;
|
|
16
|
+
const CAPTURE = Symbol.for("lares.mcp.client-capture");
|
|
17
|
+
|
|
18
|
+
export function mcpPublicToolName(serverName, rawName) {
|
|
19
|
+
const joined = `mcp__${serverName}__${rawName}`;
|
|
20
|
+
const normalized = joined.replace(INVALID_NAME_CHARS, "_");
|
|
21
|
+
if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized;
|
|
22
|
+
const hash = createHash("sha256").update(`${serverName}\0${rawName}`).digest("hex").slice(0, HASH_LENGTH);
|
|
23
|
+
return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** MCP's default is that a tool may modify state unless it says otherwise. */
|
|
27
|
+
export function mcpToolMutates(annotations) {
|
|
28
|
+
return annotations?.readOnlyHint !== true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function decideMcpToolGate({ name, annotation, sandboxMode, prior }) {
|
|
32
|
+
if (!prior || prior.kind !== "allow") return prior;
|
|
33
|
+
if (typeof name !== "string" || !name.startsWith("mcp__")) return prior;
|
|
34
|
+
if (sandboxMode === "danger-full-access") return prior;
|
|
35
|
+
if (annotation !== undefined && !mcpToolMutates(annotation)) return prior;
|
|
36
|
+
return { kind: "ask" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readOnlyAnnotation(annotations) {
|
|
40
|
+
return annotations?.readOnlyHint === true ? { readOnlyHint: true } : {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createMcpAnnotationIndex() {
|
|
44
|
+
const pendingByServer = new Map();
|
|
45
|
+
const live = new Map();
|
|
46
|
+
const liveByServer = new Map();
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
stage(serverName, { first, tools }) {
|
|
50
|
+
const pending = first || !pendingByServer.has(serverName) ? new Map() : pendingByServer.get(serverName);
|
|
51
|
+
pendingByServer.set(serverName, pending);
|
|
52
|
+
for (const tool of tools) {
|
|
53
|
+
if (!tool || typeof tool.name !== "string" || tool.name === "") continue;
|
|
54
|
+
pending.set(mcpPublicToolName(serverName, tool.name), readOnlyAnnotation(tool.annotations));
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
commit(publicName) {
|
|
58
|
+
for (const [serverName, pending] of pendingByServer) {
|
|
59
|
+
if (!pending.has(publicName)) continue;
|
|
60
|
+
live.set(publicName, pending.get(publicName));
|
|
61
|
+
let names = liveByServer.get(serverName);
|
|
62
|
+
if (!names) {
|
|
63
|
+
names = new Set();
|
|
64
|
+
liveByServer.set(serverName, names);
|
|
65
|
+
}
|
|
66
|
+
names.add(publicName);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
drop(serverName) {
|
|
71
|
+
pendingByServer.delete(serverName);
|
|
72
|
+
const names = liveByServer.get(serverName);
|
|
73
|
+
liveByServer.delete(serverName);
|
|
74
|
+
if (!names) return;
|
|
75
|
+
for (const publicName of names) live.delete(publicName);
|
|
76
|
+
},
|
|
77
|
+
get(publicName) {
|
|
78
|
+
return live.has(publicName) ? live.get(publicName) : undefined;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function recordMcpToolPage(index, serverName, request, result) {
|
|
84
|
+
const tools = Array.isArray(result?.tools) ? result.tools : [];
|
|
85
|
+
index.stage(serverName, { first: !request?.params?.cursor, tools });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sameList(left, right) {
|
|
89
|
+
const a = Array.isArray(left) ? left : [];
|
|
90
|
+
const b = Array.isArray(right) ? right : [];
|
|
91
|
+
return a.length === b.length && a.every((item, index) => item === b[index]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sameUrl(configured, actual) {
|
|
95
|
+
try {
|
|
96
|
+
return new URL(configured).href === new URL(actual).href;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function transportUrl(transport) {
|
|
103
|
+
const url = transport?._url;
|
|
104
|
+
if (typeof url === "string") return url;
|
|
105
|
+
return typeof url?.href === "string" ? url.href : "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function mcpServerNameForTransport(transport, servers) {
|
|
109
|
+
if (!transport || !Array.isArray(servers)) return undefined;
|
|
110
|
+
const url = transportUrl(transport);
|
|
111
|
+
if (url) {
|
|
112
|
+
return servers.find((server) =>
|
|
113
|
+
server?.enabled !== false
|
|
114
|
+
&& server.transport === "streamable-http"
|
|
115
|
+
&& sameUrl(server.url, url)
|
|
116
|
+
)?.serverName;
|
|
117
|
+
}
|
|
118
|
+
const params = transport._serverParams;
|
|
119
|
+
if (!params || typeof params.command !== "string") return undefined;
|
|
120
|
+
return servers.find((server) =>
|
|
121
|
+
server?.enabled !== false
|
|
122
|
+
&& server.transport === "stdio"
|
|
123
|
+
&& server.command === params.command
|
|
124
|
+
&& sameList(server.args, params.args)
|
|
125
|
+
&& String(server.cwd ?? "") === String(params.cwd ?? "")
|
|
126
|
+
)?.serverName;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Observe `tools/list` on an MCP `Client` prototype and stage annotations. */
|
|
130
|
+
export function bindMcpClientCapture(proto, gate) {
|
|
131
|
+
if (proto[CAPTURE]) {
|
|
132
|
+
proto[CAPTURE].gate = gate;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const connect = proto.connect;
|
|
136
|
+
const request = proto.request;
|
|
137
|
+
const state = { gate, clients: new WeakMap() };
|
|
138
|
+
proto.connect = async function connectCaptured(transport, ...rest) {
|
|
139
|
+
const serverName = mcpServerNameForTransport(transport, state.gate.servers());
|
|
140
|
+
if (serverName) state.clients.set(this, serverName);
|
|
141
|
+
return connect.call(this, transport, ...rest);
|
|
142
|
+
};
|
|
143
|
+
proto.request = async function requestCaptured(req, ...rest) {
|
|
144
|
+
const result = await request.call(this, req, ...rest);
|
|
145
|
+
const serverName = state.clients.get(this);
|
|
146
|
+
if (serverName && req?.method === "tools/list") {
|
|
147
|
+
recordMcpToolPage(state.gate.annotations, serverName, req, result);
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
};
|
|
151
|
+
proto[CAPTURE] = state;
|
|
152
|
+
}
|
package/mcp/client.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createSnapshotStore } from "../tools/async.js";
|
|
2
|
+
|
|
3
|
+
export const API = "/api/lares/mcp";
|
|
4
|
+
|
|
5
|
+
const settings = createSnapshotStore();
|
|
6
|
+
|
|
7
|
+
async function readPayload(res, path) {
|
|
8
|
+
const payload = await res.json().catch(() => ({}));
|
|
9
|
+
if (!res.ok) {
|
|
10
|
+
throw new Error(payload?.error?.message ?? payload?.error?.code ?? `${path} → ${res.status}`);
|
|
11
|
+
}
|
|
12
|
+
return settings.remember(payload);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function post(path, body) {
|
|
16
|
+
const res = await fetch(`${API}${path}`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "content-type": "application/json" },
|
|
19
|
+
body: JSON.stringify(body),
|
|
20
|
+
});
|
|
21
|
+
return readPayload(res, path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function rememberedMcpSettings() {
|
|
25
|
+
return settings.peek();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadMcpSettings(options = {}) {
|
|
29
|
+
return settings.load(async () => readPayload(await fetch(API), "/"), options);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveMcpServer(server, previousName = "") {
|
|
33
|
+
return post("/save", { server, previousName });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function removeMcpServer(serverName) {
|
|
37
|
+
return post("/remove", { serverName });
|
|
38
|
+
}
|
package/mcp/config.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writeJsonFile } from "../tools/json-file.js";
|
|
4
|
+
|
|
5
|
+
const SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/;
|
|
6
|
+
const MAX_SERVERS = 32;
|
|
7
|
+
const MAX_ENTRIES = 64;
|
|
8
|
+
|
|
9
|
+
export class McpConfigError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = "invalid_mcp_server";
|
|
13
|
+
this.status = 400;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function mcpConfigPath(dataDir) {
|
|
18
|
+
return join(dataDir, "mcp", "servers.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function requiredString(value, field, max = 2048) {
|
|
22
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
23
|
+
if (!text) throw new McpConfigError(`${field} is required`);
|
|
24
|
+
if (text.length > max) throw new McpConfigError(`${field} is too long`);
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stringRecord(value, field) {
|
|
29
|
+
if (value === undefined) return {};
|
|
30
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
31
|
+
throw new McpConfigError(`${field} must be an object`);
|
|
32
|
+
}
|
|
33
|
+
const entries = Object.entries(value);
|
|
34
|
+
if (entries.length > MAX_ENTRIES) throw new McpConfigError(`${field} has too many entries`);
|
|
35
|
+
return Object.fromEntries(entries.map(([key, item]) => {
|
|
36
|
+
const name = requiredString(key, `${field} key`, 256);
|
|
37
|
+
if (typeof item !== "string" || item.length > 8192) {
|
|
38
|
+
throw new McpConfigError(`${field}.${name} must be a string`);
|
|
39
|
+
}
|
|
40
|
+
return [name, item];
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stringArray(value, field) {
|
|
45
|
+
if (value === undefined) return [];
|
|
46
|
+
if (!Array.isArray(value) || value.length > MAX_ENTRIES) {
|
|
47
|
+
throw new McpConfigError(`${field} must be an array with at most ${MAX_ENTRIES} entries`);
|
|
48
|
+
}
|
|
49
|
+
return value.map((item, index) => {
|
|
50
|
+
if (typeof item !== "string" || item.length > 8192) {
|
|
51
|
+
throw new McpConfigError(`${field}[${index}] must be a string`);
|
|
52
|
+
}
|
|
53
|
+
return item;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeMcpServer(value) {
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
59
|
+
throw new McpConfigError("server must be an object");
|
|
60
|
+
}
|
|
61
|
+
const serverName = requiredString(value.serverName, "serverName", 32);
|
|
62
|
+
if (!SERVER_NAME.test(serverName)) {
|
|
63
|
+
throw new McpConfigError("serverName may contain only letters, numbers, underscores, and hyphens");
|
|
64
|
+
}
|
|
65
|
+
const enabled = value.enabled !== false;
|
|
66
|
+
if (value.transport === "streamable-http") {
|
|
67
|
+
const url = requiredString(value.url, "url");
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = new URL(url);
|
|
71
|
+
} catch {
|
|
72
|
+
throw new McpConfigError("url must be valid");
|
|
73
|
+
}
|
|
74
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
75
|
+
throw new McpConfigError("url must use http or https");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
serverName,
|
|
79
|
+
enabled,
|
|
80
|
+
transport: "streamable-http",
|
|
81
|
+
url: parsed.toString(),
|
|
82
|
+
headers: stringRecord(value.headers, "headers"),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (value.transport === "stdio") {
|
|
86
|
+
return {
|
|
87
|
+
serverName,
|
|
88
|
+
enabled,
|
|
89
|
+
transport: "stdio",
|
|
90
|
+
command: requiredString(value.command, "command", 1024),
|
|
91
|
+
args: stringArray(value.args, "args"),
|
|
92
|
+
env: stringRecord(value.env, "env"),
|
|
93
|
+
cwd: typeof value.cwd === "string" ? value.cwd.trim() : "",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
throw new McpConfigError("transport must be streamable-http or stdio");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function loadMcpServers(dataDir) {
|
|
100
|
+
const path = mcpConfigPath(dataDir);
|
|
101
|
+
if (!existsSync(path)) return [];
|
|
102
|
+
let document;
|
|
103
|
+
try {
|
|
104
|
+
document = JSON.parse(readFileSync(path, "utf8"));
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error(`invalid MCP configuration: ${path}`);
|
|
107
|
+
}
|
|
108
|
+
if (!document || document.version !== 1 || !Array.isArray(document.servers)) {
|
|
109
|
+
throw new Error(`invalid MCP configuration: ${path}`);
|
|
110
|
+
}
|
|
111
|
+
if (document.servers.length > MAX_SERVERS) {
|
|
112
|
+
throw new Error(`MCP configuration exceeds ${MAX_SERVERS} servers`);
|
|
113
|
+
}
|
|
114
|
+
const servers = document.servers.map(normalizeMcpServer);
|
|
115
|
+
const names = new Set();
|
|
116
|
+
for (const server of servers) {
|
|
117
|
+
if (names.has(server.serverName)) {
|
|
118
|
+
throw new Error(`duplicate MCP serverName: ${server.serverName}`);
|
|
119
|
+
}
|
|
120
|
+
names.add(server.serverName);
|
|
121
|
+
}
|
|
122
|
+
return servers;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function saveMcpServers(dataDir, servers) {
|
|
126
|
+
if (!Array.isArray(servers) || servers.length > MAX_SERVERS) {
|
|
127
|
+
throw new McpConfigError(`servers must contain at most ${MAX_SERVERS} entries`);
|
|
128
|
+
}
|
|
129
|
+
const normalized = servers.map(normalizeMcpServer);
|
|
130
|
+
const names = new Set();
|
|
131
|
+
for (const server of normalized) {
|
|
132
|
+
if (names.has(server.serverName)) {
|
|
133
|
+
throw new McpConfigError(`duplicate serverName: ${server.serverName}`);
|
|
134
|
+
}
|
|
135
|
+
names.add(server.serverName);
|
|
136
|
+
}
|
|
137
|
+
writeJsonFile(mcpConfigPath(dataDir), { version: 1, servers: normalized });
|
|
138
|
+
return normalized;
|
|
139
|
+
}
|