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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- 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
|
+
};
|