@olares/lares-core 0.15.7 → 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.
Files changed (99) hide show
  1. package/README.md +2 -1
  2. package/brand/identity.js +33 -3
  3. package/drive/cat.js +62 -0
  4. package/drive/download.js +3 -3
  5. package/drive/execute.js +8 -2
  6. package/drive/files-path.js +33 -0
  7. package/drive/flowstudio-files.js +87 -0
  8. package/drive/ls.js +3 -3
  9. package/drive/paths.js +23 -13
  10. package/drive/tools.js +4 -2
  11. package/entrance/surface.js +96 -0
  12. package/files/app-link.js +64 -0
  13. package/files/backend-client.js +266 -0
  14. package/files/deliverables.js +55 -1
  15. package/files/intake.js +0 -63
  16. package/files/mention.js +1 -51
  17. package/files/preview-groups.js +71 -3
  18. package/files/preview-local.js +69 -0
  19. package/files/preview-workspace.js +114 -13
  20. package/files/preview.js +171 -16
  21. package/files/produced-gate.js +197 -0
  22. package/files/produced-mentions.js +19 -0
  23. package/files/published-tools.js +69 -0
  24. package/files/published-turn-data.js +84 -0
  25. package/i18n/chat-model.js +5 -5
  26. package/i18n/mcp.js +83 -0
  27. package/i18n/mobile.js +96 -2
  28. package/i18n/preview.js +10 -0
  29. package/i18n/search.js +13 -7
  30. package/i18n/skills.js +31 -0
  31. package/i18n/voice.js +88 -19
  32. package/larepass/chat.js +9 -0
  33. package/larepass/host.js +7 -2
  34. package/larepass/mux.js +210 -2
  35. package/larepass/process-fold.js +239 -0
  36. package/larepass/queue.js +44 -0
  37. package/larepass/rpc.js +50 -3
  38. package/larepass/runtime.js +215 -83
  39. package/larepass/session-cache.js +6 -0
  40. package/larepass/settings.js +15 -7
  41. package/larepass/transcript.js +49 -11
  42. package/mcp/approval.js +152 -0
  43. package/mcp/client.js +38 -0
  44. package/mcp/config.js +139 -0
  45. package/media/catalog-row.js +244 -0
  46. package/media/generation.js +707 -0
  47. package/media/tool.js +92 -0
  48. package/olares/entrance.js +67 -15
  49. package/olares/identity.js +13 -136
  50. package/olares/session-identity.js +12 -12
  51. package/olares/trusted-host.js +0 -29
  52. package/package.json +48 -4
  53. package/router/catalog-cache.d.ts +1 -1
  54. package/router/catalog-cache.js +25 -14
  55. package/router/catalog-sync.js +8 -3
  56. package/router/catalog.d.ts +6 -1
  57. package/router/catalog.js +18 -2
  58. package/router/gateway.d.ts +9 -1
  59. package/router/gateway.js +40 -4
  60. package/router/gemini-schema.js +125 -0
  61. package/router/generation-guard.js +82 -0
  62. package/router/models.d.ts +6 -0
  63. package/router/models.js +22 -3
  64. package/router/search.js +10 -2
  65. package/router/session-model.js +53 -0
  66. package/router/shim.js +104 -3
  67. package/router/sref.js +126 -0
  68. package/router/stt.js +43 -12
  69. package/router/tts.js +118 -0
  70. package/search/client.js +5 -3
  71. package/search/config.js +53 -39
  72. package/search/menu.js +45 -10
  73. package/skills/client.js +42 -0
  74. package/skills/packs.js +40 -0
  75. package/skills/state.d.ts +22 -0
  76. package/skills/state.js +142 -0
  77. package/tools/crypto-uuid.js +3 -6
  78. package/tools/head-script.js +15 -0
  79. package/voice/agent.js +50 -0
  80. package/voice/capture.js +250 -0
  81. package/voice/client.js +126 -24
  82. package/voice/config.js +11 -1
  83. package/voice/duplex.js +44 -0
  84. package/voice/endpoint.js +57 -0
  85. package/voice/host/bluetooth-ready.py +1196 -0
  86. package/voice/host/install-host.sh +102 -0
  87. package/voice/input.js +4 -0
  88. package/voice/install.js +146 -0
  89. package/voice/languages.js +11 -1
  90. package/voice/link.js +86 -0
  91. package/voice/pcm.js +100 -0
  92. package/voice/playback.js +7 -0
  93. package/voice/problem.js +46 -0
  94. package/voice/recorder.js +79 -4
  95. package/voice/script.js +35 -0
  96. package/voice/service.js +253 -10
  97. package/voice/session.js +128 -0
  98. package/voice/speech.js +93 -0
  99. package/files/preview-cache.js +0 -100
@@ -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) tools.set(data.callId, items.length - 1);
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
  }
@@ -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
+ }