mouaif 0.3.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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
package/src/mcp.js ADDED
@@ -0,0 +1,1517 @@
1
+ 'use strict';
2
+
3
+ // MCP — Model Context Protocol client.
4
+ //
5
+ // Implements docs/decisions.md §18: per-project MCP server registry,
6
+ // stdio JSON-RPC transport, tool discovery, and tool execution. The
7
+ // AI client (src/ai.js) intercepts tool_call events whose name matches
8
+ // `mcp__<serverSlug>__<toolName>` and dispatches them through the
9
+ // `callTool()` surface below; the rest of the server stays plain
10
+ // JSON-RPC and Node, with the @modelcontextprotocol/sdk scoped to this
11
+ // module.
12
+ //
13
+ // Scope: MCP servers can be configured per project (in
14
+ // <projectDir>/.mcp.json under servers; legacy <projectDir>/.mouaif.json
15
+ // mcp.servers is read as a fallback) or app-wide (in the app SQLite
16
+ // store under mcp.servers). A project sees the union — app entries
17
+ // first, then project entries — with project entries winning on
18
+ // duplicate slugs (the settings resolution order, decisions §2).
19
+ // App-scoped entries carry scope: 'app' in API responses; project
20
+ // entries are scope: 'project'.
21
+ // The runtime state (child processes, live tool lists) is in-memory
22
+ // only; the last-known tool list per server is persisted in the app
23
+ // SQLite store (settings.getMcpToolCache), not in the project file.
24
+ // Servers are stopped on `process.exit`.
25
+ //
26
+ // Public surface:
27
+ //
28
+ // listServers(projectDir) -> [{ id, name, command, args, env, cwd, scope, status, tools? }]
29
+ // getServer(projectDir, serverId) -> the server record or null
30
+ // addServer(projectDir, opts) -> the new server record (opts.scope: 'project'|'app')
31
+ // updateServer(projectDir, id, patch) -> the updated record or null
32
+ // removeServer(projectDir, serverId) -> boolean
33
+ //
34
+ // startServer(projectDir, serverId) -> the running session or throws
35
+ // stopServer(projectDir, serverId) -> boolean
36
+ // stopAll() -> void (used on shutdown)
37
+ //
38
+ // listDiscoveredTools(projectDir, serverId) -> [{ name, description, inputSchema }]
39
+ // callTool(projectDir, serverSlug, toolName, args) -> { ok, content, isError? }
40
+ //
41
+ // Errors are typed: EMCP_NOTFOUND, EMCP_DUPLICATE,
42
+ // EMCP_START, EMCP_TRANSPORT, EMCP_RPC, EMCP_TIMEOUT, EMCP_NOSESSION.
43
+ //
44
+ // The SDK is loaded lazily so the rest of the server boots even when
45
+ // the SDK fails to load for any reason. The require() result is cached
46
+ // in `sdk` so a per-server call costs nothing.
47
+
48
+ const fs = require('fs');
49
+ const path = require('path');
50
+ const crypto = require('crypto');
51
+ const { URL, pathToFileURL } = require('url');
52
+ const { err } = require('./util.js');
53
+ const settings = require('./settings.js');
54
+ const mcpOAuth = require('./oauth-mcp.js');
55
+
56
+ // ---- SDK lazy load ------------------------------------------------------
57
+
58
+ let sdk = null;
59
+ let sdkLoadError = null;
60
+ function getSdk() {
61
+ if (sdk) return sdk;
62
+ if (sdkLoadError) throw sdkLoadError;
63
+ try {
64
+ const clientMod = require('@modelcontextprotocol/sdk/client/index.js');
65
+ const stdioMod = require('@modelcontextprotocol/sdk/client/stdio.js');
66
+ const httpMod = require('@modelcontextprotocol/sdk/client/streamableHttp.js');
67
+ sdk = {
68
+ Client: clientMod.Client,
69
+ StdioClientTransport: stdioMod.StdioClientTransport,
70
+ StreamableHTTPClientTransport: httpMod.StreamableHTTPClientTransport
71
+ };
72
+ return sdk;
73
+ } catch (e) {
74
+ sdkLoadError = new Error('Failed to load @modelcontextprotocol/sdk: ' + (e.message || e));
75
+ sdkLoadError.code = 'EMODULE';
76
+ throw sdkLoadError;
77
+ }
78
+ }
79
+
80
+ // ---- Helpers ------------------------------------------------------------
81
+
82
+ function newServerId() {
83
+ // Short hex id; uniqueness is project-scoped.
84
+ return crypto.randomBytes(4).toString('hex');
85
+ }
86
+
87
+ // Slug used in tool names: lowercased, non-alnum collapsed to underscores,
88
+ // leading/trailing underscores stripped, capped at 48 chars. Matches the
89
+ // standard MCP convention so model output stays unambiguous.
90
+ function slugify(s) {
91
+ if (typeof s !== 'string') return '';
92
+ return s.toLowerCase()
93
+ .replace(/[^a-z0-9]+/g, '_')
94
+ .replace(/^_+|_+$/g, '')
95
+ .slice(0, 48) || 'srv';
96
+ }
97
+
98
+ // Compose the model-facing tool name. Anything <serverSlug>__<toolName>
99
+ // is reserved for MCP-discovered tools so the AI client can route them
100
+ // without colliding with built-in tool names like `shell`.
101
+ function composedToolName(serverSlug, toolName) {
102
+ return 'mcp__' + serverSlug + '__' + toolName;
103
+ }
104
+
105
+ function parseServerSlugAndToolName(composedName) {
106
+ if (typeof composedName !== 'string') return null;
107
+ if (!composedName.startsWith('mcp__')) return null;
108
+ const rest = composedName.slice('mcp__'.length);
109
+ const sep = rest.indexOf('__');
110
+ if (sep <= 0 || sep === rest.length - 2) return null;
111
+ const serverSlug = rest.slice(0, sep);
112
+ const toolName = rest.slice(sep + 2);
113
+ if (!serverSlug || !toolName) return null;
114
+ return { serverSlug, toolName };
115
+ }
116
+
117
+ // Per-server denylist (decision §16's runtime env policy) plus a few
118
+ // extra vars an MCP server must not be allowed to override. We strip
119
+ // these from both the inherited env and the per-server map.
120
+ const ENV_DENYLIST = new Set([
121
+ 'LD_PRELOAD',
122
+ 'LD_LIBRARY_PATH',
123
+ 'DYLD_INSERT_LIBRARIES',
124
+ 'NODE_OPTIONS',
125
+ 'NODE_DEBUG',
126
+ 'NODE_DISABLE_COLORS',
127
+ 'ELECTRON_RUN_AS_NODE'
128
+ ]);
129
+
130
+ function normalizeHeaders(raw) {
131
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
132
+ const out = {};
133
+ for (const [k, v] of Object.entries(raw)) {
134
+ if (typeof k !== 'string' || !k.trim()) continue;
135
+ if (typeof v === 'string') out[k.trim()] = v;
136
+ }
137
+ return out;
138
+ }
139
+
140
+ function redactHeaders(headers) {
141
+ const out = {};
142
+ for (const key of Object.keys(headers || {})) out[key] = { configured: true };
143
+ return out;
144
+ }
145
+
146
+ function buildChildEnv(perServerEnv) {
147
+ const env = Object.assign({}, process.env);
148
+ // Strip denylisted keys from the parent env.
149
+ for (const k of Object.keys(env)) {
150
+ if (ENV_DENYLIST.has(k)) delete env[k];
151
+ }
152
+ // Per-server env overrides. We do not strip denylist keys from the
153
+ // explicit per-server env — if the user wants to set NODE_OPTIONS
154
+ // for a specific MCP server, that's their call. Built-in tooling
155
+ // (like our own spawn here) still works correctly.
156
+ if (perServerEnv && typeof perServerEnv === 'object') {
157
+ for (const [k, v] of Object.entries(perServerEnv)) {
158
+ if (ENV_DENYLIST.has(k)) continue;
159
+ if (typeof v === 'string') env[k] = v;
160
+ else if (v == null) delete env[k];
161
+ }
162
+ }
163
+ return env;
164
+ }
165
+
166
+ // ---- Project + app registry ---------------------------------------------
167
+
168
+ const MCP_FILE = '.mcp.json';
169
+ const APP_SCOPE = 'app';
170
+ const PROJECT_SCOPE = 'project';
171
+
172
+ function getMcpPath(projectDir) {
173
+ if (!projectDir || typeof projectDir !== 'string') {
174
+ throw new TypeError('projectDir must be a non-empty string');
175
+ }
176
+ return path.join(projectDir, MCP_FILE);
177
+ }
178
+
179
+ function readMcpFile(projectDir) {
180
+ const file = getMcpPath(projectDir);
181
+ if (!fs.existsSync(file)) return null;
182
+ try {
183
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
184
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
185
+ } catch (e) {
186
+ const error = new Error(`Failed to parse ${file}: ${e.message}`);
187
+ error.code = 'MCP_PROJECT_PARSE_ERROR';
188
+ error.cause = e;
189
+ throw error;
190
+ }
191
+ }
192
+
193
+ function writeMcpFile(projectDir, obj) {
194
+ const file = getMcpPath(projectDir);
195
+ fs.mkdirSync(path.dirname(file), { recursive: true });
196
+ fs.writeFileSync(file, JSON.stringify(obj || {}, null, 2) + '\n', 'utf8');
197
+ }
198
+
199
+ function readProjectConfig(projectDir) {
200
+ const config = readMcpFile(projectDir);
201
+ if (config) {
202
+ const list = Array.isArray(config.servers) ? config.servers : [];
203
+ return { mcp: config, list, source: MCP_FILE };
204
+ }
205
+
206
+ // Back-compat: older projects stored MCP under .mouaif.json -> mcp.servers.
207
+ const project = settings.getProject(projectDir);
208
+ const mcp = (project && typeof project === 'object' && project.mcp && typeof project.mcp === 'object')
209
+ ? project.mcp
210
+ : {};
211
+ const list = Array.isArray(mcp.servers) ? mcp.servers : [];
212
+ return { mcp, list, source: settings.PROJECT_FILE };
213
+ }
214
+
215
+ function writeProjectConfig(projectDir, mcp) {
216
+ // Merge into the existing file instead of replacing it: .mcp.json also
217
+ // carries the authorization block written by tools/authorization.js
218
+ // (setAuthorization), and a blanket overwrite would silently drop the
219
+ // user's MCP allow/allowlist policy on every server CRUD. A corrupt or
220
+ // missing file falls back to the incoming object.
221
+ let base = {};
222
+ try { base = readMcpFile(projectDir) || {}; } catch { /* replace corrupt file */ }
223
+ const out = Object.assign({}, base, mcp || { servers: [] });
224
+ // Strip any legacy inline toolCache keys: the cache lives in the app
225
+ // SQLite store now, and leaving a copy here would both bloat the
226
+ // project file and go stale.
227
+ if (Array.isArray(out.servers)) {
228
+ out.servers = out.servers.map((s) => {
229
+ if (!s || typeof s !== 'object') return s;
230
+ if (!Object.prototype.hasOwnProperty.call(s, 'toolCache')) return s;
231
+ const clone = Object.assign({}, s);
232
+ delete clone.toolCache;
233
+ return clone;
234
+ });
235
+ }
236
+ writeMcpFile(projectDir, out);
237
+ }
238
+
239
+ // ---- App-level registry ---------------------------------------------------
240
+ // App-scoped servers live in the app SQLite store (settings.setApp) under
241
+ // `mcp.servers` — same entry shape as project entries. They are visible to
242
+ // every project (the project entries win on a slug collision, matching the
243
+ // settings resolution order in decisions §2). A project can shadow an app
244
+ // entry with a same-named project entry; there is no other per-project
245
+ // filtering of app entries.
246
+
247
+ function readAppConfig() {
248
+ let app = {};
249
+ try { app = settings.getApp() || {}; } catch { app = {}; }
250
+ const mcp = (app.mcp && typeof app.mcp === 'object' && !Array.isArray(app.mcp)) ? app.mcp : {};
251
+ const list = Array.isArray(mcp.servers) ? mcp.servers : [];
252
+ return { mcp, list, source: APP_SCOPE };
253
+ }
254
+
255
+ function writeAppConfig(mcp) {
256
+ // Merge into the existing app.mcp block: it also carries the app-level
257
+ // authorization fallback (tools/authorization.js), and a blanket
258
+ // overwrite would drop the user's MCP policy on every server CRUD.
259
+ let app = {};
260
+ try { app = settings.getApp() || {}; } catch { app = {}; }
261
+ const base = (app.mcp && typeof app.mcp === 'object' && !Array.isArray(app.mcp)) ? app.mcp : {};
262
+ const out = Object.assign({}, base, mcp || { servers: [] });
263
+ if (Array.isArray(out.servers)) {
264
+ out.servers = out.servers.map((s) => {
265
+ if (!s || typeof s !== 'object') return s;
266
+ if (!Object.prototype.hasOwnProperty.call(s, 'toolCache')) return s;
267
+ const clone = Object.assign({}, s);
268
+ delete clone.toolCache;
269
+ return clone;
270
+ });
271
+ }
272
+ settings.setApp({ mcp: out });
273
+ }
274
+
275
+ // readAllConfigs(projectDir) -> { entries: [{ scope, raw }], byId: Map }
276
+ //
277
+ // The merged view every read path uses. Entries are the raw config objects
278
+ // (not normalized) so the write-back for update/remove can land on the
279
+ // right file. Project entries override app entries on slug collision — the
280
+ // project file is the user's most specific intent.
281
+ function readAllConfigs(projectDir) {
282
+ // App entries are always in scope, with or without a project — a null
283
+ // projectDir means "app-only view" (the Settings App tab), not "no
284
+ // config at all".
285
+ const appCfg = readAppConfig();
286
+ let projectList = [];
287
+ if (projectDir) {
288
+ try { projectList = readProjectConfig(projectDir).list; } catch (e) { throw e; }
289
+ }
290
+ const entries = [];
291
+ for (const raw of appCfg.list) {
292
+ if (raw && typeof raw === 'object') entries.push({ scope: APP_SCOPE, raw });
293
+ }
294
+ for (const raw of projectList) {
295
+ if (raw && typeof raw === 'object') entries.push({ scope: PROJECT_SCOPE, raw });
296
+ }
297
+ return { entries };
298
+ }
299
+
300
+ // Write back the full server list for one scope. `scope` is APP_SCOPE or
301
+ // PROJECT_SCOPE; `servers` is the normalized entry list to persist.
302
+ function writeConfigForScope(projectDir, scope, servers) {
303
+ if (scope === APP_SCOPE) {
304
+ writeAppConfig({ servers });
305
+ } else {
306
+ writeProjectConfig(projectDir, { servers });
307
+ }
308
+ }
309
+
310
+ function normalizeServerEntry(raw, usedSlugs) {
311
+ if (!raw || typeof raw !== 'object') return null;
312
+ if (typeof raw.name !== 'string' || !raw.name.trim()) return null;
313
+ const transport = raw.transport === 'http' ? 'http' : 'stdio';
314
+ if (transport === 'stdio' && (typeof raw.command !== 'string' || !raw.command.trim())) return null;
315
+ if (transport === 'http' && (typeof raw.url !== 'string' || !raw.url.trim())) return null;
316
+ const name = raw.name.trim();
317
+ let slug = typeof raw.slug === 'string' && raw.slug ? slugify(raw.slug) : slugify(name);
318
+ if (!slug) slug = 'srv';
319
+ // Ensure slug uniqueness within the project; auto-suffix on collision.
320
+ let candidate = slug;
321
+ let n = 2;
322
+ while (usedSlugs.has(candidate)) {
323
+ candidate = slug + '_' + n;
324
+ n++;
325
+ }
326
+ usedSlugs.add(candidate);
327
+
328
+ const args = Array.isArray(raw.args) ? raw.args.filter(a => typeof a === 'string') : [];
329
+ const env = (raw.env && typeof raw.env === 'object' && !Array.isArray(raw.env))
330
+ ? Object.fromEntries(Object.entries(raw.env).filter(([, v]) => typeof v === 'string' || v == null))
331
+ : {};
332
+ const cwd = typeof raw.cwd === 'string' && raw.cwd.trim() ? raw.cwd.trim() : '';
333
+ const out = {
334
+ id: typeof raw.id === 'string' && raw.id ? raw.id : newServerId(),
335
+ name,
336
+ slug: candidate,
337
+ command: transport === 'stdio' ? raw.command.trim() : '',
338
+ url: transport === 'http' ? raw.url.trim() : '',
339
+ headers: normalizeHeaders(raw.headers),
340
+ args,
341
+ env,
342
+ cwd,
343
+ createdAt: raw.createdAt || new Date().toISOString()
344
+ };
345
+ // Only persist the transport field for HTTP — stdio is the implicit
346
+ // default. The read path defaults to 'stdio' when the field is absent.
347
+ if (transport === 'http') out.transport = 'http';
348
+ if (transport === 'http' && raw.oauth && raw.oauth.enabled === true) {
349
+ mcpOAuth.safeUrl(out.url);
350
+ out.oauth = {
351
+ enabled: true,
352
+ clientId: typeof raw.oauth.clientId === 'string' ? raw.oauth.clientId.trim() : '',
353
+ scope: typeof raw.oauth.scope === 'string' ? raw.oauth.scope.trim() : ''
354
+ };
355
+ }
356
+ return out;
357
+ }
358
+
359
+ function normalizeAll(rawList) {
360
+ const usedSlugs = new Set();
361
+ const out = [];
362
+ for (const r of rawList) {
363
+ const n = normalizeServerEntry(r, usedSlugs);
364
+ if (n) out.push(n);
365
+ }
366
+ return out;
367
+ }
368
+
369
+ // The discovered tool list is persisted in the app SQLite store (see
370
+ // settings.getMcpToolCache/setMcpToolCache) so a stopped server still
371
+ // shows what it advertised the last time it ran (and the model can
372
+ // still see its surface in the tools catalog). The cache is refreshed
373
+ // on every successful start / tools/list refresh, and cleared when the
374
+ // server is removed. The runtime state (child process, live session)
375
+ // stays in-memory; only the last-known tool descriptors are persisted.
376
+ //
377
+ // ── Schema shrinking ────────────────────────────────────────────────────
378
+ // MCP server inputSchema definitions are often extremely verbose: full
379
+ // property descriptions, $defs blocks, examples, titles, etc. These get
380
+ // serialized into the `tools` array on every upstream API turn, costing
381
+ // 10 K – 50 K+ prompt tokens per request. We shrink aggressively:
382
+ // keep property names, types, enums, required, items, and simple numeric
383
+ // constraints; drop descriptions, $defs, examples, titles, and defaults.
384
+ function shrinkMcpSchema(schema) {
385
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
386
+ // Recursion guard – deep but not infinite; stop at 8 levels.
387
+ const _shrink = (node, depth) => {
388
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
389
+ if (depth > 8) return { type: typeof node.type === 'string' ? node.type : 'object' };
390
+
391
+ const out = {};
392
+
393
+ // Always preserve type.
394
+ if (typeof node.type === 'string') out.type = node.type;
395
+
396
+ // Preserve enum — the model needs the exact values.
397
+ if (Array.isArray(node.enum) && node.enum.length) out.enum = node.enum;
398
+
399
+ // Preserve const — same reason.
400
+ if (node.const !== undefined) out.const = node.const;
401
+
402
+ // Preserve required (list of property names).
403
+ if (Array.isArray(node.required) && node.required.length) out.required = node.required;
404
+
405
+ // Preserve simple numeric/string constraints — they guide the model.
406
+ for (const k of ['minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems', 'pattern']) {
407
+ if (node[k] !== undefined) out[k] = node[k];
408
+ }
409
+
410
+ // Recurse into properties, dropping descriptions.
411
+ if (node.properties && typeof node.properties === 'object' && !Array.isArray(node.properties)) {
412
+ out.properties = {};
413
+ for (const [key, val] of Object.entries(node.properties)) {
414
+ out.properties[key] = _shrink(val, depth + 1);
415
+ }
416
+ }
417
+
418
+ // Recurse into items (array element schema).
419
+ if (node.items && typeof node.items === 'object') {
420
+ out.items = _shrink(node.items, depth + 1);
421
+ }
422
+
423
+ // Recurse into additionalProperties — keep only the bool/object form.
424
+ if (node.additionalProperties === true || node.additionalProperties === false) {
425
+ out.additionalProperties = node.additionalProperties;
426
+ } else if (node.additionalProperties && typeof node.additionalProperties === 'object' && !Array.isArray(node.additionalProperties)) {
427
+ out.additionalProperties = _shrink(node.additionalProperties, depth + 1);
428
+ }
429
+
430
+ // Keep oneOf / anyOf — common pattern in MCP schemas — recursed.
431
+ for (const comb of ['oneOf', 'anyOf']) {
432
+ if (Array.isArray(node[comb])) {
433
+ out[comb] = node[comb].map((item) => _shrink(item, depth + 1));
434
+ }
435
+ }
436
+
437
+ // Preserve allOf if present.
438
+ if (Array.isArray(node.allOf)) {
439
+ out.allOf = node.allOf.map((item) => _shrink(item, depth + 1));
440
+ }
441
+
442
+ return out;
443
+ };
444
+
445
+ return _shrink(schema || {}, 0);
446
+ }
447
+
448
+ // Minimal JSON Schema validator for outputSchema checking (spec 2025-06-18:
449
+ // "Clients SHOULD validate structured results against this schema"). Covers
450
+ // the subset shrinkMcpSchema preserves: type, enum, const, required,
451
+ // properties, items, additionalProperties, oneOf/anyOf/allOf, and the simple
452
+ // numeric/string constraints. Returns an error string on mismatch, null on
453
+ // success. Deliberately not a full validator — a miss just means we skip
454
+ // the warning, never that we reject a valid result.
455
+ function validateAgainstSchema(value, schema, path) {
456
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return null;
457
+ const at = path || '$';
458
+
459
+ // const / enum first — they pin the value exactly.
460
+ if (schema.const !== undefined && value !== schema.const) {
461
+ return at + ': expected const ' + JSON.stringify(schema.const);
462
+ }
463
+ if (Array.isArray(schema.enum) && schema.enum.length && !schema.enum.some(v => deepEqual(v, value))) {
464
+ return at + ': not in enum';
465
+ }
466
+
467
+ if (typeof schema.type === 'string') {
468
+ const t = schema.type;
469
+ const ok =
470
+ (t === 'string' && typeof value === 'string') ||
471
+ (t === 'number' && typeof value === 'number' && Number.isFinite(value)) ||
472
+ (t === 'integer' && typeof value === 'number' && Number.isInteger(value)) ||
473
+ (t === 'boolean' && typeof value === 'boolean') ||
474
+ (t === 'null' && value === null) ||
475
+ (t === 'array' && Array.isArray(value)) ||
476
+ (t === 'object' && value !== null && typeof value === 'object' && !Array.isArray(value));
477
+ if (!ok) return at + ': expected ' + t + ', got ' + (Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value);
478
+ }
479
+
480
+ if (typeof value === 'number') {
481
+ if (schema.minimum !== undefined && value < schema.minimum) return at + ': below minimum ' + schema.minimum;
482
+ if (schema.maximum !== undefined && value > schema.maximum) return at + ': above maximum ' + schema.maximum;
483
+ }
484
+ if (typeof value === 'string') {
485
+ if (schema.minLength !== undefined && value.length < schema.minLength) return at + ': shorter than minLength';
486
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) return at + ': longer than maxLength';
487
+ if (typeof schema.pattern === 'string') {
488
+ try { if (!new RegExp(schema.pattern).test(value)) return at + ': does not match pattern'; } catch { /* bad pattern: skip */ }
489
+ }
490
+ }
491
+ if (Array.isArray(value)) {
492
+ if (schema.minItems !== undefined && value.length < schema.minItems) return at + ': fewer than minItems';
493
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) return at + ': more than maxItems';
494
+ if (schema.items && typeof schema.items === 'object') {
495
+ for (let i = 0; i < value.length; i++) {
496
+ const e = validateAgainstSchema(value[i], schema.items, at + '[' + i + ']');
497
+ if (e) return e;
498
+ }
499
+ }
500
+ }
501
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
502
+ if (Array.isArray(schema.required)) {
503
+ for (const key of schema.required) {
504
+ if (!Object.prototype.hasOwnProperty.call(value, key)) return at + ': missing required "' + key + '"';
505
+ }
506
+ }
507
+ if (schema.properties && typeof schema.properties === 'object') {
508
+ for (const key of Object.keys(schema.properties)) {
509
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
510
+ const e = validateAgainstSchema(value[key], schema.properties[key], at + '.' + key);
511
+ if (e) return e;
512
+ }
513
+ }
514
+ }
515
+ if (schema.additionalProperties === false && schema.properties) {
516
+ for (const key of Object.keys(value)) {
517
+ if (!Object.prototype.hasOwnProperty.call(schema.properties, key)) return at + ': additional property "' + key + '"';
518
+ }
519
+ } else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
520
+ for (const key of Object.keys(value)) {
521
+ if (!schema.properties || !Object.prototype.hasOwnProperty.call(schema.properties, key)) {
522
+ const e = validateAgainstSchema(value[key], schema.additionalProperties, at + '.' + key);
523
+ if (e) return e;
524
+ }
525
+ }
526
+ }
527
+ }
528
+ // Combinators: value must satisfy at least one of oneOf/anyOf, all of allOf.
529
+ if (Array.isArray(schema.allOf)) {
530
+ for (const sub of schema.allOf) {
531
+ const e = validateAgainstSchema(value, sub, at);
532
+ if (e) return e;
533
+ }
534
+ }
535
+ for (const comb of ['oneOf', 'anyOf']) {
536
+ if (Array.isArray(schema[comb]) && schema[comb].length) {
537
+ const ok = schema[comb].some(sub => validateAgainstSchema(value, sub, at) === null);
538
+ if (!ok) return at + ': no ' + comb + ' branch matched';
539
+ }
540
+ }
541
+ return null;
542
+ }
543
+
544
+ function deepEqual(a, b) {
545
+ if (a === b) return true;
546
+ if (typeof a !== typeof b || a === null || b === null) return false;
547
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
548
+ if (Array.isArray(a)) {
549
+ return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
550
+ }
551
+ if (typeof a === 'object') {
552
+ const ka = Object.keys(a), kb = Object.keys(b);
553
+ return ka.length === kb.length && ka.every(k => deepEqual(a[k], b[k]));
554
+ }
555
+ return false;
556
+ }
557
+
558
+ // Older builds stored the cache inline in .mcp.json under each server
559
+ // entry's `toolCache` key. loadToolCache migrates those rows into the
560
+ // DB on first read and strips the key the next time the config file is
561
+ // written, so the project file shrinks back to just the server config.
562
+ function normalizeToolCache(raw) {
563
+ if (!Array.isArray(raw)) return [];
564
+ return raw
565
+ .filter(t => t && typeof t.name === 'string' && t.name)
566
+ .map(t => {
567
+ const out = {
568
+ name: t.name,
569
+ description: typeof t.description === 'string' ? t.description : '',
570
+ inputSchema: (t.inputSchema && typeof t.inputSchema === 'object')
571
+ ? shrinkMcpSchema(t.inputSchema)
572
+ : { type: 'object', properties: {} }
573
+ };
574
+ // outputSchema (spec 2025-06-18+): optional JSON Schema describing the
575
+ // shape of structuredContent results. Preserved (shrunk) so clients can
576
+ // validate structured results against it; omitted when the tool does
577
+ // not declare one.
578
+ if (t.outputSchema && typeof t.outputSchema === 'object' && !Array.isArray(t.outputSchema)) {
579
+ out.outputSchema = shrinkMcpSchema(t.outputSchema);
580
+ }
581
+ return out;
582
+ });
583
+ }
584
+
585
+ // Returns the persisted cache for a server. Migration path: if the DB
586
+ // has no row but the raw config entry still carries an inline
587
+ // `toolCache`, move it into the DB (best-effort) and mark the source
588
+ // entry for stripping on the next config write.
589
+ function loadToolCache(projectDir, rawEntry) {
590
+ if (!rawEntry || !rawEntry.id) return [];
591
+ const fromDb = settings.getMcpToolCache(projectDir, rawEntry.id);
592
+ if (Array.isArray(fromDb)) return normalizeToolCache(fromDb);
593
+ const legacy = normalizeToolCache(rawEntry.toolCache);
594
+ if (legacy.length) {
595
+ rawEntry._stripToolCache = true;
596
+ try { settings.setMcpToolCache(projectDir, rawEntry.id, legacy); } catch { /* ignore */ }
597
+ }
598
+ return legacy;
599
+ }
600
+
601
+ function persistToolCache(projectDir, serverId, tools) {
602
+ try { settings.setMcpToolCache(projectDir, serverId, normalizeToolCache(tools)); } catch { /* ignore */ }
603
+ }
604
+
605
+ function clearToolCache(projectDir, serverId) {
606
+ try { settings.deleteMcpToolCache(projectDir, serverId); } catch { /* ignore */ }
607
+ }
608
+
609
+ // ---- In-memory runtime state -------------------------------------------
610
+
611
+ // Per-project, per-server session: child process, MCP client, discovered
612
+ // tool list, status. Cleared on stop() and on process exit. The key is
613
+ // `${projectDir}::${serverId}`. We also keep a projectDir -> serverId
614
+ // index for quick fan-out (e.g. "all sessions for a project").
615
+ const _sessions = new Map();
616
+ const _byProject = new Map(); // scopeKey -> Set<serverId>
617
+
618
+ // Sessions are keyed by the *context* the server runs in: a project dir
619
+ // when there is one, the string 'app' when the server was started without
620
+ // a project (app-scope start from the Settings UI). The same app-scoped
621
+ // server started from two different projects gets two sessions — each
622
+ // project's chat dispatches to its own child.
623
+ function scopeKey(projectDir) {
624
+ return (projectDir && typeof projectDir === 'string' && projectDir.trim()) ? projectDir : 'app';
625
+ }
626
+
627
+ function keyOf(projectDir, serverId) { return scopeKey(projectDir) + '::' + serverId; }
628
+
629
+ function trackSession(projectDir, serverId, session) {
630
+ _sessions.set(keyOf(projectDir, serverId), session);
631
+ const key = scopeKey(projectDir);
632
+ if (!_byProject.has(key)) _byProject.set(key, new Set());
633
+ _byProject.get(key).add(serverId);
634
+ }
635
+
636
+ function untrackSession(projectDir, serverId) {
637
+ _sessions.delete(keyOf(projectDir, serverId));
638
+ const set = _byProject.get(scopeKey(projectDir));
639
+ if (set) {
640
+ set.delete(serverId);
641
+ if (set.size === 0) _byProject.delete(scopeKey(projectDir));
642
+ }
643
+ }
644
+
645
+ function getSession(projectDir, serverId) {
646
+ return _sessions.get(keyOf(projectDir, serverId)) || null;
647
+ }
648
+
649
+ // findSessionBySlug stays inside the requested runtime context. In
650
+ // particular, a project chat must not reuse an app-context process:
651
+ // that process was initialized without the project's roots/list entry,
652
+ // so artifact tools would reject valid project paths as out of scope.
653
+ // App-scoped configuration is still available to every project; its
654
+ // process is simply started once per project when first called.
655
+ function findSessionBySlug(projectDir, serverSlug) {
656
+ const key = scopeKey(projectDir);
657
+ const set = _byProject.get(key);
658
+ if (!set) return null;
659
+ for (const id of set) {
660
+ const s = _sessions.get(key + '::' + id);
661
+ if (s && s.entry && s.entry.slug === serverSlug) return { id, session: s };
662
+ }
663
+ return null;
664
+ }
665
+
666
+ // ---- CRUD ---------------------------------------------------------------
667
+
668
+ // resolveMerged(projectDir) -> [{ entry, scope, raw }]
669
+ //
670
+ // The single read path every public surface uses. App entries come first,
671
+ // project entries second; a project entry with the same slug as an app
672
+ // entry shadows it (the app entry is dropped from the merged view).
673
+ // Normalization assigns ids + unique slugs, so the merged list is stable
674
+ // across calls within a boot.
675
+ function resolveMerged(projectDir) {
676
+ const { entries } = readAllConfigs(projectDir);
677
+ const appRaw = entries.filter(e => e.scope === APP_SCOPE).map(e => e.raw);
678
+ const projectRaw = entries.filter(e => e.scope === PROJECT_SCOPE).map(e => e.raw);
679
+ const appNorm = normalizeAll(appRaw);
680
+ const projectNorm = normalizeAll(projectRaw);
681
+ const projectSlugs = new Set(projectNorm.map(s => s.slug));
682
+ const out = [];
683
+ for (let i = 0; i < appNorm.length; i++) {
684
+ if (projectSlugs.has(appNorm[i].slug)) continue; // project wins
685
+ out.push({ entry: appNorm[i], scope: APP_SCOPE, raw: appRaw[i] });
686
+ }
687
+ for (let i = 0; i < projectNorm.length; i++) {
688
+ out.push({ entry: projectNorm[i], scope: PROJECT_SCOPE, raw: projectRaw[i] });
689
+ }
690
+ return out;
691
+ }
692
+
693
+ function listServers(projectDir) {
694
+ // Stable alphabetical sort by display name within the existing
695
+ // app-before-project scope grouping (scope wins, then name), so the
696
+ // listing is deterministic and friendly to scan regardless of storage
697
+ // order. Case-insensitive, tie-broken by the raw name then storage
698
+ // order (Array.prototype.sort is stable).
699
+ return resolveMerged(projectDir)
700
+ .sort((a, b) => {
701
+ // Scope wins (app before project)
702
+ if (a.scope !== b.scope) return a.scope === APP_SCOPE ? -1 : 1;
703
+ const na = (a.entry.name || '').toLowerCase();
704
+ const nb = (b.entry.name || '').toLowerCase();
705
+ if (na !== nb) return na < nb ? -1 : 1;
706
+ return (a.entry.name || '') < (b.entry.name || '') ? -1
707
+ : (a.entry.name || '') > (b.entry.name || '') ? 1 : 0;
708
+ })
709
+ .map(({ entry, scope, raw }) =>
710
+ decorate(Object.assign({}, entry, { scope }), projectDir, raw));
711
+ }
712
+
713
+ function getServer(projectDir, serverId) {
714
+ const found = resolveMerged(projectDir).find(r => r.entry.id === serverId);
715
+ if (!found) return null;
716
+ return decorate(Object.assign({}, found.entry, { scope: found.scope }), projectDir, found.raw);
717
+ }
718
+
719
+ function decorate(entry, projectDir, rawEntry) {
720
+ const session = getSession(projectDir, entry.id);
721
+ const status = session ? session.status : 'stopped';
722
+ // Live tools win; the persisted cache (app DB) is the fallback so a
723
+ // stopped server still shows what it advertised the last time it ran.
724
+ const cache = loadToolCache(projectDir, rawEntry || entry);
725
+ const tools = session ? session.tools.slice() : cache;
726
+ const error = session && session.error ? session.error : null;
727
+ // `enabled` reflects the per-server authorization gate (not `off`).
728
+ // A disabled server surfaces its tools from cache but can never be
729
+ // started on demand; a stopped-but-enabled one can. Absent auth data
730
+ // (decorate also runs without a project) it defaults to enabled so
731
+ // the Settings list is unchanged.
732
+ const enabled = entry.enabled !== undefined ? entry.enabled : serverEnabled(projectDir, entry);
733
+ const decorated = Object.assign({}, entry, { env: redactEnv(entry.env), headers: redactHeaders(entry.headers), status, tools, enabled });
734
+ if (error) decorated.error = error;
735
+ return decorated;
736
+ }
737
+
738
+ function redactEnv(env) {
739
+ const out = {};
740
+ for (const [key, value] of Object.entries(env || {})) {
741
+ out[key] = { configured: typeof value === 'string' };
742
+ }
743
+ return out;
744
+ }
745
+
746
+ function addServer(projectDir, opts) {
747
+ if (!opts || typeof opts !== 'object') throw err('EBADINPUT', 'opts required');
748
+ if (typeof opts.name !== 'string' || !opts.name.trim()) throw err('EBADINPUT', 'name is required');
749
+ const transport = opts.transport === 'http' ? 'http' : 'stdio';
750
+ if (transport === 'stdio' && (typeof opts.command !== 'string' || !opts.command.trim())) throw err('EBADINPUT', 'command is required');
751
+ if (transport === 'http' && (typeof opts.url !== 'string' || !opts.url.trim())) throw err('EBADINPUT', 'url is required');
752
+ const scope = opts.scope === APP_SCOPE ? APP_SCOPE : PROJECT_SCOPE;
753
+ if (scope === PROJECT_SCOPE && (!projectDir || typeof projectDir !== 'string' || !projectDir.trim())) {
754
+ throw err('EBADINPUT', 'projectDir is required for a project-scoped server');
755
+ }
756
+ // Uniqueness is scope-local: an app entry and a project entry may share
757
+ // a slug (the project one shadows the app one at merge time), but two
758
+ // entries inside the same scope never collide.
759
+ const scopeList = scope === APP_SCOPE ? readAppConfig().list : readProjectConfig(projectDir).list;
760
+ const usedIds = new Set(scopeList.map(s => s && s.id));
761
+ let id = newServerId();
762
+ while (usedIds.has(id)) id = newServerId();
763
+ const usedSlugs = new Set(normalizeAll(scopeList).map(s => s.slug));
764
+ const entry = normalizeServerEntry(Object.assign({}, opts, { id }), usedSlugs);
765
+ if (!entry) throw err('EBADINPUT', 'invalid server entry');
766
+ const next = scopeList.concat([entry]);
767
+ writeConfigForScope(projectDir, scope, next);
768
+ return decorate(Object.assign({}, entry, { scope }), projectDir, entry);
769
+ }
770
+
771
+ // findInScope(projectDir, scope, serverId) -> { list, normalized, idx, scope } | null
772
+ //
773
+ // Update/remove must search each scope's *own* list, not the merged view:
774
+ // the merged view drops a shadowed app entry, but the entry still exists in
775
+ // the app store and the user can legitimately want to edit or delete it
776
+ // (the Settings App tab shows exactly that un-merged list).
777
+ function findInScope(projectDir, scope, serverId) {
778
+ const list = scope === APP_SCOPE ? readAppConfig().list : readProjectConfig(projectDir).list;
779
+ const normalized = normalizeAll(list);
780
+ const idx = normalized.findIndex(s => s.id === serverId);
781
+ if (idx < 0) return null;
782
+ return { list, normalized, idx, scope };
783
+ }
784
+
785
+ function findServerAnyScope(projectDir, serverId) {
786
+ // Project scope first: for a chat-facing lookup the project entry is
787
+ // the one the model can actually see (it shadows an app entry on a
788
+ // slug collision).
789
+ if (projectDir) {
790
+ const p = findInScope(projectDir, PROJECT_SCOPE, serverId);
791
+ if (p) return p;
792
+ }
793
+ return findInScope(projectDir, APP_SCOPE, serverId);
794
+ }
795
+
796
+ // Raw, scope-aware identity for OAuth only; never returned through REST.
797
+ function getOAuthContext(projectDir, serverId) {
798
+ const found = findServerAnyScope(projectDir, serverId);
799
+ return found ? { entry: found.normalized[found.idx], scope: found.scope, projectDir: projectDir || '' } : null;
800
+ }
801
+
802
+ async function stopOAuthSessions(context) {
803
+ for (const [key, session] of [..._sessions]) {
804
+ if (session.oauthKey !== mcpOAuth.identity(context)) continue;
805
+ const split = key.lastIndexOf('::');
806
+ untrackSession(key.slice(0, split), key.slice(split + 2));
807
+ await session.shutdown();
808
+ }
809
+ }
810
+
811
+ function updateServer(projectDir, serverId, patch) {
812
+ if (!serverId) return null;
813
+ const found = findServerAnyScope(projectDir, serverId);
814
+ if (!found) return null;
815
+ const scope = found.scope;
816
+ const normalized = found.normalized;
817
+ const idx = found.idx;
818
+ // Stop the running session synchronously (don't await — the caller
819
+ // wants a fast PATCH) so the next start reflects the new config.
820
+ // untrackSession runs inside stopServer, so the entry is removed
821
+ // from the in-memory map by the time this function returns even
822
+ // though the child is still being torn down. The untrack is
823
+ // synchronous; the shutdown is async.
824
+ const session = getSession(projectDir, serverId);
825
+ if (session) {
826
+ // Fire-and-forget the actual shutdown; the bookkeeping is sync.
827
+ Promise.resolve(session.shutdown()).catch(() => {});
828
+ untrackSession(projectDir, serverId);
829
+ }
830
+ const cleanPatch = Object.assign({}, patch || {});
831
+ delete cleanPatch.scope; // scope is fixed at creation; use delete+add to move
832
+ delete cleanPatch.projectDir; // transport detail, never persisted
833
+ if (!Object.prototype.hasOwnProperty.call(cleanPatch, 'env')) cleanPatch.env = normalized[idx].env;
834
+ if (!Object.prototype.hasOwnProperty.call(cleanPatch, 'headers')) cleanPatch.headers = normalized[idx].headers;
835
+ const merged = Object.assign({}, normalized[idx], cleanPatch, { id: serverId });
836
+ // Re-slug only if the name changed and the user did not pin a slug.
837
+ if (patch && typeof patch.name === 'string' && !patch.slug) {
838
+ const others = normalized.filter((_, i) => i !== idx);
839
+ const used = new Set(others.map(o => o.slug));
840
+ let candidate = slugify(merged.name);
841
+ if (!candidate) candidate = 'srv';
842
+ let n = 2;
843
+ while (used.has(candidate)) { candidate = slugify(merged.name) + '_' + n; n++; }
844
+ merged.slug = candidate;
845
+ }
846
+ const renormalized = normalizeServerEntry(merged, new Set(normalized.filter((_, i) => i !== idx).map(o => o.slug)));
847
+ if (!renormalized) return null;
848
+ const oldContext = { entry: normalized[idx], scope, projectDir };
849
+ if (normalized[idx].oauth?.enabled && mcpOAuth.identity(oldContext) !== mcpOAuth.identity({ entry: renormalized, scope, projectDir })) {
850
+ mcpOAuth.clear(oldContext);
851
+ stopOAuthSessions(oldContext).catch(() => {});
852
+ }
853
+ normalized[idx] = renormalized;
854
+ writeConfigForScope(projectDir, scope, normalized);
855
+ return decorate(Object.assign({}, renormalized, { scope }), projectDir, renormalized);
856
+ }
857
+
858
+ function removeServer(projectDir, serverId) {
859
+ if (!serverId) return false;
860
+ stopServer(projectDir, serverId).catch(() => {});
861
+ const found = findServerAnyScope(projectDir, serverId);
862
+ if (!found) return false;
863
+ const context = { entry: found.normalized[found.idx], scope: found.scope, projectDir };
864
+ if (context.entry.oauth?.enabled) {
865
+ mcpOAuth.clear(context);
866
+ stopOAuthSessions(context).catch(() => {});
867
+ }
868
+ const scopeList = found.list;
869
+ const before = scopeList.length;
870
+ const next = scopeList.filter(s => s && s.id !== serverId);
871
+ if (next.length === before) return false;
872
+ writeConfigForScope(projectDir, found.scope, next);
873
+ // The tool cache is keyed by server id; drop it with the server.
874
+ clearToolCache(projectDir, serverId);
875
+ return true;
876
+ }
877
+
878
+ // ---- Lifecycle ----------------------------------------------------------
879
+
880
+ // startServer: spawn the child, open a stdio transport, run the MCP
881
+ // `initialize` handshake, discover the server's tool list, and store
882
+ // the session. Returns the decorated server record. Throws on any
883
+ // failure with a typed code so the HTTP layer can branch.
884
+ async function startServer(projectDir, serverId) {
885
+ // Per-scope lookup (not the merged view) so a shadowed app entry can
886
+ // still be started from the Settings App tab.
887
+ const found = findServerAnyScope(projectDir, serverId);
888
+ if (!found) throw err('EMCP_NOTFOUND', 'Server not found', { serverId });
889
+ const entry = found.normalized[found.idx];
890
+
891
+ // Reject overlapping starts.
892
+ const existing = getSession(projectDir, serverId);
893
+ if (existing && existing.status !== 'errored' && existing.status !== 'stopped') {
894
+ return decorate(entry, projectDir);
895
+ }
896
+ if (existing) {
897
+ // Clean up a dead session before re-spawning.
898
+ try { await existing.shutdown(); } catch { /* ignore */ }
899
+ untrackSession(projectDir, serverId);
900
+ }
901
+
902
+ const { Client, StdioClientTransport, StreamableHTTPClientTransport } = getSdk();
903
+ const hasProject = !!(projectDir && typeof projectDir === 'string' && projectDir.trim());
904
+ let transport;
905
+ if (entry.transport === 'http') {
906
+ let endpoint;
907
+ try { endpoint = new URL(entry.url); } catch { throw err('EBADINPUT', 'HTTP MCP URL is invalid', { serverId }); }
908
+ if (endpoint.protocol !== 'http:' && endpoint.protocol !== 'https:') {
909
+ throw err('EBADINPUT', 'HTTP MCP URL must start with http:// or https://', { serverId });
910
+ }
911
+ const oauthContext = { entry, scope: found.scope, projectDir };
912
+ const oauthEnabled = entry.oauth?.enabled === true;
913
+ // Explicit OAuth owns Authorization; don't let a stale manual header
914
+ // override SDK bearer tokens or leak it into discovery/token requests.
915
+ const headers = Object.fromEntries(Object.entries(entry.headers || {}).filter(([key]) => !oauthEnabled || key.toLowerCase() !== 'authorization'));
916
+ transport = new StreamableHTTPClientTransport(endpoint, oauthEnabled ? {
917
+ authProvider: mcpOAuth.provider(oauthContext),
918
+ fetch: (input, init = {}) => {
919
+ // SDK discovery and token requests share this fetch implementation.
920
+ // Custom MCP headers must never follow them to an authorization server.
921
+ const target = String(input instanceof Request ? input.url : input);
922
+ const scopedHeaders = new Headers(init.headers);
923
+ if (target === endpoint.href) {
924
+ for (const [key, value] of Object.entries(headers)) scopedHeaders.set(key, value);
925
+ }
926
+ return mcpOAuth.oauthFetch(input, { ...init, headers: scopedHeaders });
927
+ }
928
+ } : { requestInit: { headers } });
929
+ } else {
930
+ // Canonicalize an existing project root before comparing cwd values.
931
+ // Projects opened through a symlink otherwise compare their real cwd
932
+ // (for example /mnt/work/app) with the lexical projectDir alias
933
+ // (/home/me/app) and incorrectly look outside the project.
934
+ const projectResolved = hasProject ? canonicalProjectRoot(projectDir) : null;
935
+ // cwd anchor: relative entry.cwd resolves against the canonical project.
936
+ // Without a project context (app-scope start from the Settings UI) a
937
+ // relative cwd has no anchor — fall back to the process cwd; absolute
938
+ // cwd values still work. The project-containment check only applies
939
+ // when there is a project to be contained in.
940
+ const cwd = entry.cwd
941
+ ? (path.isAbsolute(entry.cwd) ? entry.cwd : path.resolve(projectResolved || process.cwd(), entry.cwd))
942
+ : (projectResolved || process.cwd());
943
+ const cwdResolved = canonicalFilesystemPath(cwd);
944
+ if (projectResolved) {
945
+ // Sanity check: cwd must be inside projectDir (decision §4's
946
+ // "outside project" rule, applied to the spawn directory).
947
+ const rel = path.relative(projectResolved, cwdResolved);
948
+ if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) {
949
+ throw err('EOUTSIDE_PROJECT', 'Server cwd must be inside the project directory', { cwd: cwdResolved });
950
+ }
951
+ }
952
+ const env = buildChildEnv(entry.env);
953
+ transport = new StdioClientTransport({
954
+ command: entry.command,
955
+ args: entry.args,
956
+ env,
957
+ cwd: cwdResolved,
958
+ stderr: 'pipe'
959
+ });
960
+ }
961
+
962
+ // Track stderr so a misbehaving server's logs are visible from
963
+ // /api/mcp/servers/:id for debugging. Cap the buffer to avoid
964
+ // unbounded memory growth in a long-lived server.
965
+ const stderrBuf = [];
966
+ const STDERR_CAP = 16 * 1024;
967
+ if (transport.stderr && typeof transport.stderr.on === 'function') {
968
+ transport.stderr.on('data', (chunk) => {
969
+ const s = chunk.toString('utf8');
970
+ if (stderrBuf.length < STDERR_CAP) {
971
+ const remaining = STDERR_CAP - stderrBuf.reduce((n, c) => n + c.length, 0);
972
+ if (remaining > 0) stderrBuf.push(s.slice(0, Math.max(0, remaining)));
973
+ }
974
+ });
975
+ }
976
+
977
+ const client = new Client({
978
+ name: 'mouaif',
979
+ version: require('./package-version.js')
980
+ }, {
981
+ capabilities: {
982
+ // roots: expose the active project as the MCP server's writable workspace.
983
+ roots: { listChanged: false }
984
+ // sampling: not supported — mouaif is a thin client, not an LLM host
985
+ // elicitation: not supported — no UI for server-initiated user prompts
986
+ }
987
+ });
988
+
989
+ // Advertising the roots capability is not enough: servers such as Chrome
990
+ // DevTools MCP call roots/list before allowing an artifact write. Return the
991
+ // active project as a file URL so their canonical path boundary matches ours.
992
+ if (hasProject && typeof client.setRequestHandler === 'function') {
993
+ const { ListRootsRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
994
+ const projectRoot = canonicalProjectRoot(projectDir);
995
+ client.setRequestHandler(ListRootsRequestSchema, () => ({
996
+ roots: [{ uri: pathToFileURL(projectRoot).href, name: path.basename(projectRoot) }]
997
+ }));
998
+ }
999
+
1000
+ const session = {
1001
+ entry,
1002
+ oauthKey: entry.oauth?.enabled ? mcpOAuth.identity({ entry, scope: found.scope, projectDir }) : null,
1003
+ client,
1004
+ transport,
1005
+ status: 'starting',
1006
+ tools: [],
1007
+ error: null,
1008
+ stderr: stderrBuf,
1009
+ async shutdown() {
1010
+ try { await client.close(); } catch { /* ignore */ }
1011
+ try { await transport.close(); } catch { /* ignore */ }
1012
+ }
1013
+ };
1014
+ trackSession(projectDir, serverId, session);
1015
+
1016
+ try {
1017
+ await client.connect(transport, { timeout: 30000 });
1018
+ } catch (e) {
1019
+ session.status = 'errored';
1020
+ session.error = { code: 'EMCP_START', message: entry.oauth?.enabled ? 'MCP OAuth connection failed. Sign in again in Settings.' : ((e && e.message) || String(e)) };
1021
+ try { await session.shutdown(); } catch { /* ignore */ }
1022
+ untrackSession(projectDir, serverId);
1023
+ if (entry.oauth?.enabled) throw err('EMCP_AUTH', 'MCP connection failed. Check the endpoint and sign in again in Settings.', { serverId });
1024
+ throw err('EMCP_START', 'Failed to start MCP server: ' + (e && e.message || e), { serverId });
1025
+ }
1026
+
1027
+ // Discover tools. Some servers return a paginated list; walk cursors
1028
+ // until exhausted. A failure here is non-fatal — the server may be
1029
+ // a "resources only" server that exposes no tools.
1030
+ let discovered = [];
1031
+ try {
1032
+ let cursor;
1033
+ for (let i = 0; i < 16; i++) { // 16 pages * 100 = 1600 tools; more than any real server
1034
+ const page = await client.listTools(cursor ? { cursor } : undefined, { timeout: 10000 });
1035
+ const list = (page && page.tools) || [];
1036
+ discovered = discovered.concat(list);
1037
+ cursor = page && page.nextCursor;
1038
+ if (!cursor) break;
1039
+ }
1040
+ } catch (e) {
1041
+ session.status = 'errored';
1042
+ session.error = { code: 'EMCP_RPC', message: entry.oauth?.enabled ? 'MCP tool discovery failed. Check sign-in in Settings.' : 'tools/list failed: ' + (e && e.message || e) };
1043
+ try { await session.shutdown(); } catch { /* ignore */ }
1044
+ untrackSession(projectDir, serverId);
1045
+ throw err('EMCP_RPC', entry.oauth?.enabled ? session.error.message : 'MCP server failed to list tools: ' + (e && e.message || e), { serverId });
1046
+ }
1047
+
1048
+ // Normalize tool descriptors: name (required), description, inputSchema.
1049
+ // The shape stored here is what the AI client turns into the
1050
+ // model-facing tool spec.
1051
+ session.tools = normalizeToolCache(discovered);
1052
+ // Persist the last-known tool list so a stopped server still shows
1053
+ // what it advertised (and the model can still see its surface in
1054
+ // the tools catalog). The write is best-effort — a disk failure
1055
+ // should not abort the start.
1056
+ try { persistToolCache(projectDir, serverId, session.tools); } catch { /* ignore */ }
1057
+
1058
+ // Wire transport-close -> errored status so the next call surfaces
1059
+ // EMCP_TRANSPORT instead of a hung connection.
1060
+ try {
1061
+ transport.onclose = () => {
1062
+ if (_sessions.get(keyOf(projectDir, serverId)) === session) {
1063
+ session.status = 'errored';
1064
+ session.error = { code: 'EMCP_TRANSPORT', message: 'Server process exited' };
1065
+ }
1066
+ };
1067
+ } catch { /* transport may not expose onclose */ }
1068
+
1069
+ // Handle notifications/tools/list_changed (spec: servers that declared
1070
+ // the listChanged capability SHOULD send it when tools change). Refresh
1071
+ // the live tool list + persisted cache in place so the next
1072
+ // listComposedToolSpecs / callTool sees the new surface without a
1073
+ // restart. Debounced — a server that adds tools in a burst sends one
1074
+ // notification per change and we only need one refresh.
1075
+ try {
1076
+ const { ToolListChangedNotificationSchema } = require('@modelcontextprotocol/sdk/types.js');
1077
+ if (ToolListChangedNotificationSchema && typeof client.setNotificationHandler === 'function') {
1078
+ let refreshTimer = null;
1079
+ client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
1080
+ if (refreshTimer) return;
1081
+ refreshTimer = setTimeout(async () => {
1082
+ refreshTimer = null;
1083
+ if (_sessions.get(keyOf(projectDir, serverId)) !== session) return;
1084
+ try {
1085
+ let refreshed = [];
1086
+ let cursor;
1087
+ for (let i = 0; i < 16; i++) {
1088
+ const page = await client.listTools(cursor ? { cursor } : undefined, { timeout: 10000 });
1089
+ refreshed = refreshed.concat((page && page.tools) || []);
1090
+ cursor = page && page.nextCursor;
1091
+ if (!cursor) break;
1092
+ }
1093
+ session.tools = normalizeToolCache(refreshed);
1094
+ try { persistToolCache(projectDir, serverId, session.tools); } catch { /* best-effort */ }
1095
+ } catch { /* a failed refresh keeps the last-known list */ }
1096
+ }, 250);
1097
+ });
1098
+ }
1099
+ } catch { /* SDK without notification support; tool list stays start-time */ }
1100
+
1101
+ session.status = 'ready';
1102
+ return decorate(Object.assign({}, entry, { scope: found.scope }), projectDir);
1103
+ }
1104
+
1105
+ async function stopServer(projectDir, serverId) {
1106
+ if (!serverId) return false;
1107
+ const session = getSession(projectDir, serverId);
1108
+ if (!session) return false;
1109
+ try { await session.shutdown(); } catch { /* ignore */ }
1110
+ untrackSession(projectDir, serverId);
1111
+ return true;
1112
+ }
1113
+
1114
+ async function stopAll() {
1115
+ const all = Array.from(_sessions.entries());
1116
+ await Promise.allSettled(all.map(([, s]) => s.shutdown()));
1117
+ _sessions.clear();
1118
+ _byProject.clear();
1119
+ }
1120
+
1121
+ // Re-discover tools without restarting the process. Used by the
1122
+ // "Refresh" UI button. Returns the discovered tool list.
1123
+ async function listDiscoveredTools(projectDir, serverId) {
1124
+ const session = getSession(projectDir, serverId);
1125
+ if (!session) throw err('EMCP_NOSESSION', 'Server is not running', { serverId });
1126
+ if (session.status !== 'ready') throw err('EMCP_NOSESSION', 'Server is not ready', { serverId });
1127
+ let discovered = [];
1128
+ try {
1129
+ let cursor;
1130
+ for (let i = 0; i < 16; i++) {
1131
+ const page = await session.client.listTools(cursor ? { cursor } : undefined, { timeout: 10000 });
1132
+ discovered = discovered.concat((page && page.tools) || []);
1133
+ cursor = page && page.nextCursor;
1134
+ if (!cursor) break;
1135
+ }
1136
+ } catch (e) {
1137
+ throw err('EMCP_RPC', session.oauthKey ? 'MCP tool discovery failed. Check sign-in in Settings.' : 'tools/list failed: ' + (e && e.message || e), { serverId });
1138
+ }
1139
+ session.tools = normalizeToolCache(discovered);
1140
+ // Keep the persisted cache in sync when the user taps Refresh.
1141
+ try { persistToolCache(projectDir, serverId, session.tools); } catch { /* ignore */ }
1142
+ return session.tools.slice();
1143
+ }
1144
+
1145
+ // ---- Tool dispatch ------------------------------------------------------
1146
+
1147
+ // MCP servers commonly require absolute filesystem paths even though mouaif's
1148
+ // model-facing convention is project-relative. Resolve standard output path
1149
+ // arguments for every MCP server and keep them confined to the active project.
1150
+ const MCP_OUTPUT_PATH_KEYS = new Set([
1151
+ 'filePath',
1152
+ 'outputPath',
1153
+ 'outputDirPath',
1154
+ 'requestFilePath',
1155
+ 'responseFilePath'
1156
+ ]);
1157
+
1158
+ function canonicalFilesystemPath(value) {
1159
+ const absolute = path.resolve(value);
1160
+ let probe = absolute;
1161
+ const suffix = [];
1162
+ while (true) {
1163
+ try {
1164
+ const real = fs.realpathSync(probe);
1165
+ return path.resolve(real, ...suffix.reverse());
1166
+ } catch {
1167
+ const parent = path.dirname(probe);
1168
+ if (parent === probe) return absolute;
1169
+ suffix.push(path.basename(probe));
1170
+ probe = parent;
1171
+ }
1172
+ }
1173
+ }
1174
+
1175
+ function canonicalProjectRoot(projectDir) {
1176
+ return canonicalFilesystemPath(projectDir);
1177
+ }
1178
+
1179
+ function isPathInside(root, candidate) {
1180
+ const relative = path.relative(root, candidate);
1181
+ return relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
1182
+ }
1183
+
1184
+ function resolveMcpOutputPaths(projectDir, args) {
1185
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return args;
1186
+ const lexicalRoot = path.resolve(projectDir);
1187
+ const root = canonicalProjectRoot(projectDir);
1188
+ const next = Object.assign({}, args);
1189
+ for (const key of MCP_OUTPUT_PATH_KEYS) {
1190
+ const value = next[key];
1191
+ if (typeof value !== 'string' || !value.trim()) continue;
1192
+
1193
+ let resolved;
1194
+ if (path.isAbsolute(value)) {
1195
+ const absolute = path.resolve(value);
1196
+ // An absolute path may use the symlink alias through which the project
1197
+ // was opened. Preserve its project-relative suffix while mapping it to
1198
+ // the canonical root advertised through MCP roots/list.
1199
+ const lexicalRelative = path.relative(lexicalRoot, absolute);
1200
+ resolved = isPathInside(lexicalRoot, absolute)
1201
+ ? path.resolve(root, lexicalRelative)
1202
+ : absolute;
1203
+ } else {
1204
+ resolved = path.resolve(root, value);
1205
+ }
1206
+
1207
+ resolved = canonicalFilesystemPath(resolved);
1208
+ if (!isPathInside(root, resolved)) {
1209
+ throw err('EBADINPUT', key + ' must be inside the project directory', { key });
1210
+ }
1211
+ next[key] = resolved;
1212
+ }
1213
+ return next;
1214
+ }
1215
+
1216
+ // callTool: route a model tool_call to the right server and return the
1217
+ // normalized result. `serverSlug` is the slug the model saw in the
1218
+ // tool name; `toolName` is the bare tool name from the server. Used
1219
+ // directly by src/ai.js when intercepting a tool_call.
1220
+ async function callTool(projectDir, serverSlug, toolName, args) {
1221
+ let found = findSessionBySlug(projectDir, serverSlug);
1222
+ // On-demand start: the model called a tool on a server that is
1223
+ // *enabled* (auth mode not `off`) but not currently running. Start it
1224
+ // transparently here, within the same awaited call, so the model just
1225
+ // sees a (possibly slower) result instead of an EMCP_NOSESSION
1226
+ // dead-end that forces the user to go start it by hand. A disabled
1227
+ // (`off`) server is never auto-started — only a server the user has
1228
+ // checked on can come up on demand.
1229
+ if (!found || found.session.status !== 'ready') {
1230
+ // Resolve the server by slug so we can (a) refuse to auto-start a
1231
+ // disabled server and (b) know the server's id for startServer.
1232
+ const entry = resolveMerged(projectDir).find(({ entry }) =>
1233
+ (entry.slug === serverSlug) || (slugify(entry.id || entry.name || '') === serverSlug));
1234
+ if (!entry) throw err('EMCP_NOSESSION', 'MCP server not running: ' + serverSlug, { serverSlug });
1235
+ if (!serverEnabled(projectDir, entry.entry)) {
1236
+ throw err('ETOOL_DISABLED', 'MCP server is disabled: ' + serverSlug, { serverSlug });
1237
+ }
1238
+ try {
1239
+ await startServer(projectDir, entry.entry.id);
1240
+ } catch (e) {
1241
+ // Surface the start failure as the tool result so the model sees a
1242
+ // typed error instead of a generic transport error.
1243
+ throw err((e && e.code) || 'EMCP_START', 'Failed to start MCP server: ' + ((e && e.message) || String(e)), { serverSlug });
1244
+ }
1245
+ found = findSessionBySlug(projectDir, serverSlug);
1246
+ }
1247
+ const { session } = found;
1248
+ if (!session || session.status !== 'ready') {
1249
+ throw err('EMCP_NOSESSION', 'MCP server not ready after start: ' + serverSlug, { serverSlug, status: session && session.status });
1250
+ }
1251
+ // Confirm the tool is in the discovered list. The MCP spec allows
1252
+ // the client to call any tool the server has; this is a defensive
1253
+ // check against a stale slug or a tool that disappeared after start.
1254
+ const tool = session.tools.find(t => t.name === toolName);
1255
+ if (!tool) throw err('EMCP_NOTFOUND', 'Tool not found on MCP server: ' + toolName, { serverSlug, toolName });
1256
+
1257
+ // args must be a JSON object; the MCP spec requires an object even
1258
+ // when empty. Defensive: a string or null from upstream gets coerced
1259
+ // to {} so we never send `null` over the wire.
1260
+ let callArgs = args;
1261
+ if (callArgs == null) callArgs = {};
1262
+ else if (typeof callArgs !== 'object' || Array.isArray(callArgs)) {
1263
+ throw err('EBADINPUT', 'tool args must be a JSON object', { toolName });
1264
+ }
1265
+
1266
+ callArgs = resolveMcpOutputPaths(projectDir, callArgs);
1267
+
1268
+ let result;
1269
+ try {
1270
+ result = await session.client.callTool({ name: toolName, arguments: callArgs }, undefined, { timeout: 60000 });
1271
+ } catch (e) {
1272
+ throw err('EMCP_RPC', session.oauthKey ? 'MCP tool call failed. Check server availability and sign-in in Settings.' : 'tools/call failed: ' + (e && e.message || e), { serverSlug, toolName });
1273
+ }
1274
+ if (!result || typeof result !== 'object') {
1275
+ return { ok: false, content: [{ type: 'text', text: 'MCP server returned no result' }], isError: true };
1276
+ }
1277
+ // MCP tool results are { content: [...], structuredContent?, isError?: bool }.
1278
+ // content is an array of typed blocks (text, image, resource, etc).
1279
+ // structuredContent (spec 2025-06-18+) is JSON-typed result data for
1280
+ // programmatic use. The model-facing shape we forward is the same —
1281
+ // the chat UI renders each block in order.
1282
+ const out = {
1283
+ ok: result.isError !== true,
1284
+ content: Array.isArray(result.content) ? result.content : [],
1285
+ isError: result.isError === true
1286
+ };
1287
+ // Forward structuredContent when present (spec 2025-06-18). This is
1288
+ // server-produced result data, not LLM "structured outputs" — it lets
1289
+ // callers consume typed JSON without parsing text blocks.
1290
+ if (result.structuredContent !== undefined) {
1291
+ out.structuredContent = result.structuredContent;
1292
+ // Spec: "Clients SHOULD validate structured results against this schema."
1293
+ // The check is advisory — a mismatch is surfaced as a warning on the
1294
+ // result, never a rejection: the server may legitimately outrun its own
1295
+ // schema, and dropping real data would be worse than flagging it.
1296
+ if (tool.outputSchema) {
1297
+ const vErr = validateAgainstSchema(result.structuredContent, tool.outputSchema);
1298
+ if (vErr) {
1299
+ out.schemaWarning = 'structuredContent does not match outputSchema: ' + vErr;
1300
+ }
1301
+ }
1302
+ }
1303
+ return out;
1304
+ }
1305
+
1306
+ // composedToolNameFor and parseComposedToolName are exported so
1307
+ // src/ai.js can match the model-facing tool name without duplicating
1308
+ // the convention.
1309
+ function composedToolNameFor(serverEntry, tool) {
1310
+ return composedToolName(serverEntry.slug, tool.name);
1311
+ }
1312
+
1313
+ // serverEnabled(projectDir, entry) -> boolean
1314
+ //
1315
+ // Decide whether a configured MCP server is "enabled" — i.e. its tools
1316
+ // are exposed to the model and it is eligible for start. The per-server
1317
+ // authorization mode is the enable signal (decisions §18): `off` means
1318
+ // the user has disabled the checkbox and the server must NEVER
1319
+ // auto-start nor be surfaced; any other mode (`ask`/`allow`/`allowlist`,
1320
+ // default `ask`) means it is enabled. Falling back to the shared MCP
1321
+ // gate when there is no per-server override matches how authorization
1322
+ // resolves the server's effective mode everywhere else. Loading the
1323
+ // authorization module here keeps mcp.js free of a hard dependency on
1324
+ // it (the layering lives in authorization.js, which already loads
1325
+ // mcp.js for slug resolution — so we stay one-directional and never
1326
+ // import-cycle).
1327
+ // serverEnabled(projectDir, entry) -> boolean
1328
+ //
1329
+ // Decide whether a configured MCP server is "enabled" — i.e. its tools
1330
+ // are exposed to the model and it is eligible for start. The
1331
+ // per-server authorization mode is the enable signal (decisions §18):
1332
+ // `off` means the user has disabled the checkbox and the server must
1333
+ // NEVER auto-start nor be surfaced; any other mode (`ask`/`allow`/
1334
+ // `allowlist`, default `ask`) means it is enabled.
1335
+ //
1336
+ // This reads the authorization block DIRECTLY from the merged config
1337
+ // (the same `readAllConfigs` inputs `resolveMerged` uses) instead of
1338
+ // going through tools/authorization.getAuthorization. That's
1339
+ // deliberate: getAuthorization resolves slugs by calling
1340
+ // mcp.listServers, which calls decorate -> serverEnabled, which would
1341
+ // recurse into getAuthorization — a module-init deadlock when
1342
+ // authorization.js first loads mcp.js. Reading the raw authorization
1343
+ // block here keeps the layering identical (per-server override >
1344
+ // project gate > app gate) with no cycle.
1345
+ function serverEnabled(projectDir, entry) {
1346
+ try {
1347
+ const slug = entry.slug || slugify(entry.id || entry.name || '');
1348
+ // Layering order, lowest precedence first: default 'ask' -> app gate
1349
+ // -> project gate -> per-server override. A lower layer that is
1350
+ // *present* always wins over the default, including an `off`; only a
1351
+ // missing layer falls through to the next.
1352
+ let mode = 'ask';
1353
+ try {
1354
+ const app = settings.getApp() || {};
1355
+ const appAuth = app && app.mcp && typeof app.mcp.authorization === 'object'
1356
+ ? app.mcp.authorization : {};
1357
+ if (appAuth && typeof appAuth.mode === 'string') {
1358
+ mode = appAuth.mode;
1359
+ }
1360
+ } catch { /* ignore app-fallback errors */ }
1361
+ // Project gate + per-server override live in the merged config files.
1362
+ const cfg = readProjectConfig(projectDir);
1363
+ const auth = cfg && cfg.mcp && typeof cfg.mcp.authorization === 'object'
1364
+ ? cfg.mcp.authorization : {};
1365
+ if (auth && typeof auth.mode === 'string') mode = auth.mode;
1366
+ if (auth.servers && typeof auth.servers === 'object' && !Array.isArray(auth.servers)) {
1367
+ const over = auth.servers[slug]
1368
+ || auth.servers[entry.id];
1369
+ if (over && typeof over === 'object' && typeof over.mode === 'string') mode = over.mode;
1370
+ }
1371
+ return mode !== 'off';
1372
+ } catch {
1373
+ // Authorization unavailable (module missing, config unreadable):
1374
+ // fall back to enabled so a configured server still works the way
1375
+ // it always has rather than silently disappearing.
1376
+ return true;
1377
+ }
1378
+ }
1379
+
1380
+ // ensureServersRunning(projectDir) -> Promise<[{ id, name, status, tools }]>
1381
+ //
1382
+ // Called when the user opens a chat (via /api/tools/list). It is now a
1383
+ // pure *status reporter*: it never spawns a configured MCP server. The
1384
+ // user's explicit intent drives lifecycle:
1385
+ //
1386
+ // - `off` (auth mode) -> disabled: reported as such, never
1387
+ // surfaced nor startable from here.
1388
+ // - enabled-but-stopped -> reported stopped with its persisted
1389
+ // tool cache (listComposedToolSpecs
1390
+ // falls back to it); the model's first
1391
+ // tool-call starts it on demand via
1392
+ // callTool, or the user taps the reload
1393
+ // control.
1394
+ // - enabled-and-running -> reported ready.
1395
+ //
1396
+ // No child process is spawned here. On-demand start lives in callTool,
1397
+ // and the explicit /api/mcp/servers/:id/start endpoint drives the reload
1398
+ // button. This is what stops opening a chat from cold-starting every
1399
+ // configured MCP server the user never explicitly asked to run.
1400
+ function ensureServersRunning(projectDir) {
1401
+ const configured = resolveMerged(projectDir);
1402
+ if (!configured.length) return Promise.resolve([]);
1403
+ const results = [];
1404
+ for (const { entry, scope } of configured) {
1405
+ const enabled = serverEnabled(projectDir, entry);
1406
+ results.push(decorate(Object.assign({}, entry, { scope, enabled }), projectDir));
1407
+ }
1408
+ return Promise.resolve(results);
1409
+ }
1410
+
1411
+ // listComposedToolSpecs(projectDir) -> the model-facing tool spec list.
1412
+ // Each entry is { name, description, parameters, serverSlug, toolName }.
1413
+ // The AI client merges these into the upstream tools array.
1414
+ function listComposedToolSpecs(projectDir) {
1415
+ const out = [];
1416
+ const seen = new Set();
1417
+ // 1) Live sessions first — the running process is the source of truth.
1418
+ // Both the project context and the 'app' context are scanned so an
1419
+ // app-scoped server started from Settings (no project) still
1420
+ // advertises its tools to a chat.
1421
+ for (const ctxKey of [scopeKey(projectDir), 'app']) {
1422
+ for (const serverId of (_byProject.get(ctxKey) || new Set())) {
1423
+ if (seen.has(serverId)) continue;
1424
+ const session = _sessions.get(ctxKey + '::' + serverId);
1425
+ if (!session || session.status !== 'ready') continue;
1426
+ seen.add(serverId);
1427
+ const entry = session.entry;
1428
+ for (const tool of session.tools) {
1429
+ out.push({
1430
+ name: composedToolName(entry.slug, tool.name),
1431
+ description: tool.description || ('MCP tool: ' + entry.name + '/' + tool.name),
1432
+ parameters: shrinkMcpSchema(tool.inputSchema) || { type: 'object', properties: {} },
1433
+ serverId: entry.id,
1434
+ serverSlug: entry.slug,
1435
+ toolName: tool.name
1436
+ });
1437
+ }
1438
+ }
1439
+ }
1440
+ // 2) Enabled-but-stopped servers fall back to the persisted tool
1441
+ // cache. The model sees the same surface it saw the last time
1442
+ // the server ran; a call will surface EMCP_NOSESSION until the
1443
+ // user starts it again, which is the honest signal.
1444
+ try {
1445
+ for (const { entry, raw } of resolveMerged(projectDir)) {
1446
+ if (!entry || seen.has(entry.id)) continue;
1447
+ const cache = loadToolCache(projectDir, raw || entry);
1448
+ if (!cache.length) continue;
1449
+ for (const tool of cache) {
1450
+ out.push({
1451
+ name: composedToolName(entry.slug, tool.name),
1452
+ description: tool.description || ('MCP tool: ' + entry.name + '/' + tool.name),
1453
+ parameters: tool.inputSchema || { type: 'object', properties: {} },
1454
+ serverId: entry.id,
1455
+ serverSlug: entry.slug,
1456
+ toolName: tool.name
1457
+ });
1458
+ }
1459
+ }
1460
+ } catch { /* config unreadable; live sessions still advertised */ }
1461
+ return out;
1462
+ }
1463
+
1464
+ // ---- Shutdown wiring ---------------------------------------------------
1465
+
1466
+ let _shuttingDown = false;
1467
+ function installShutdown() {
1468
+ if (_shuttingDown) return;
1469
+ _shuttingDown = true;
1470
+ const handler = () => { stopAll().catch(() => {}); };
1471
+ process.once('exit', handler);
1472
+ process.once('SIGINT', () => { handler(); process.exit(0); });
1473
+ process.once('SIGTERM', () => { handler(); process.exit(0); });
1474
+ }
1475
+
1476
+ module.exports = {
1477
+ // constants
1478
+ ENV_DENYLIST,
1479
+ MCP_FILE,
1480
+ APP_SCOPE,
1481
+ PROJECT_SCOPE,
1482
+ getMcpPath,
1483
+ // helpers (exported for tests)
1484
+ slugify,
1485
+ composedToolName,
1486
+ parseServerSlugAndToolName,
1487
+ normalizeHeaders,
1488
+ buildChildEnv,
1489
+ resolveMcpOutputPaths,
1490
+ // CRUD
1491
+ listServers,
1492
+ getServer,
1493
+ addServer,
1494
+ updateServer,
1495
+ removeServer,
1496
+ resolveMerged,
1497
+ getOAuthContext,
1498
+ stopOAuthSessions,
1499
+ // lifecycle
1500
+ startServer,
1501
+ stopServer,
1502
+ stopAll,
1503
+ installShutdown,
1504
+ ensureServersRunning,
1505
+ // discovery + dispatch
1506
+ listDiscoveredTools,
1507
+ callTool,
1508
+ composedToolNameFor,
1509
+ listComposedToolSpecs,
1510
+ normalizeToolCache,
1511
+ loadToolCache,
1512
+ persistToolCache,
1513
+ clearToolCache,
1514
+ // for tests + diagnostics
1515
+ _sessions,
1516
+ _byProject
1517
+ };