specrails-desktop 2.35.0 → 2.36.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/client/dist/assets/{ActivityFeedPage-LCBJ5WiD.js → ActivityFeedPage-tAQcWs1R.js} +1 -1
- package/client/dist/assets/{AgentBrowserCapture-B-xQpyZr.js → AgentBrowserCapture-BX8BNae5.js} +1 -1
- package/client/dist/assets/{AgentModeAnalyticsPane-BI1Vcf3T.js → AgentModeAnalyticsPane-BVAqCL0K.js} +2 -2
- package/client/dist/assets/{AgentModeCodePane-BTRwCl9h.js → AgentModeCodePane-9XfqQc9c.js} +2 -2
- package/client/dist/assets/{AgentModeJobsPane-VzeBJLqP.js → AgentModeJobsPane-BCjLsPhc.js} +1 -1
- package/client/dist/assets/{AgentsPage-CJj1K24O.js → AgentsPage-C8J_42sm.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-CQpV5_fX.js → AnalyticsPage-BtQuyfJm.js} +1 -1
- package/client/dist/assets/{CodePage-9-tFh0jB.js → CodePage-BZ2MwdCu.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-BcZ0_O_C.js → DesktopAnalyticsPage-Bh_29V4g.js} +1 -1
- package/client/dist/assets/{DocsDialog-BPAzDICW.js → DocsDialog-C76VdBJC.js} +1 -1
- package/client/dist/assets/{DocsPage-Dqo4Hn6G.js → DocsPage-ziMlj6B6.js} +1 -1
- package/client/dist/assets/{InteractiveJobComposer-D36fZWai.js → InteractiveJobComposer-BCZiSfzc.js} +1 -1
- package/client/dist/assets/{JobDetailModal-BxsktbkQ.js → JobDetailModal-NAJmuYIT.js} +1 -1
- package/client/dist/assets/{JobDetailPage-WWyCy6Sp.js → JobDetailPage-fE0ZycUX.js} +1 -1
- package/client/dist/assets/{JobsPage-CEbM7uxU.js → JobsPage-z5M_HVJG.js} +1 -1
- package/client/dist/assets/{LoopBuilderPage-CYsSN8io.js → LoopBuilderPage-j1FQS47c.js} +1 -1
- package/client/dist/assets/{LoopPreviewModal-BJhgX5wY.js → LoopPreviewModal-DhDJjdCe.js} +1 -1
- package/client/dist/assets/{LoopsPage-DB4OqMot.js → LoopsPage-Ll7zORZb.js} +1 -1
- package/client/dist/assets/{MinimizedChatsContext-CcjBHMHd.js → MinimizedChatsContext-Bd3OqR_S.js} +1 -1
- package/client/dist/assets/{PluginsPage-CR8K4nM_.js → PluginsPage-DN4kGvJt.js} +1 -1
- package/client/dist/assets/{ProjectSettingsDialog-C45-qhF4.js → ProjectSettingsDialog-OgsgxjzF.js} +1 -1
- package/client/dist/assets/{ReviewPacketPage-Cz_QQeC3.js → ReviewPacketPage-DFog4sXn.js} +1 -1
- package/client/dist/assets/{TemplatePreviewModal-wFBd2vQP.js → TemplatePreviewModal-L2XxqiOD.js} +1 -1
- package/client/dist/assets/{TicketDetailModal-BvZoyDAL.js → TicketDetailModal-CsF-AWZX.js} +1 -1
- package/client/dist/assets/{Trans-DXZ-BLXb.js → Trans-Bsz9_gyq.js} +1 -1
- package/client/dist/assets/{formatDistanceToNow-C0ImhVKp.js → formatDistanceToNow-DQCvMgUc.js} +1 -1
- package/client/dist/assets/{getTimezoneOffsetInMilliseconds-owhDdTXs.js → getTimezoneOffsetInMilliseconds-C16cxSe1.js} +1 -1
- package/client/dist/assets/{i18n-DwgvwTnx.js → i18n-hnX2DPmo.js} +2 -2
- package/client/dist/assets/{index-DhjjFyU5.js → index-DIhMEAVw.js} +14 -14
- package/client/dist/assets/index-DwygcU7s.css +2 -0
- package/client/dist/assets/{jira-api-CcZMcEN5.js → jira-api-B1-MUehg.js} +1 -1
- package/client/dist/assets/{mcp-DsTwmjST.js → mcp-B8X8wBLu.js} +1 -1
- package/client/dist/assets/{mcp-SMkn8boG.js → mcp-C2AojZZN.js} +1 -1
- package/client/dist/assets/mcp-D8BT78_L.js +1 -0
- package/client/dist/assets/{mcp-D4ZjtIx3.js → mcp-Df5RWBNk.js} +1 -1
- package/client/dist/assets/{mcp-DIfBUv2D.js → mcp-Dj7BgMQW.js} +1 -1
- package/client/dist/assets/{mcp-D2pc5GGx.js → mcp-Q7iT7rcz.js} +1 -1
- package/client/dist/assets/{mcp-fSfiVYAc.js → mcp-YlvNvJbW.js} +1 -1
- package/client/dist/assets/{mcp-Czd_iyp0.js → mcp-mKFpTSRb.js} +1 -1
- package/client/dist/assets/{spending-BQX3P7Cq.js → spending-EC9xqio-.js} +1 -1
- package/client/dist/assets/{useDesktop-BzX769yH.js → useDesktop-BQVt8lFd.js} +1 -1
- package/client/dist/index.html +12 -12
- package/docs/mcp.md +16 -0
- package/package.json +1 -1
- package/server/dist/agent-chat-manager.js +2 -0
- package/server/dist/agent-mcp-config.js +94 -2
- package/server/dist/agent-operator-prompt.js +4 -0
- package/server/dist/desktop-router.js +22 -0
- package/server/dist/external-mcp.js +370 -0
- package/client/dist/assets/index-xBFsOEiJ.css +0 -2
- package/client/dist/assets/mcp-uDkXYu4L.js +0 -1
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ExternalMcpValidationError = exports.DISCOVERY_SOURCE_PROVIDERS = exports.RESERVED_SERVER_NAME = exports.EXTERNAL_MCP_SETTING_KEY = void 0;
|
|
7
|
+
exports.isExternalMcpEnabled = isExternalMcpEnabled;
|
|
8
|
+
exports.readNativeMcpServers = readNativeMcpServers;
|
|
9
|
+
exports.readCodexNativeServerNames = readCodexNativeServerNames;
|
|
10
|
+
exports.discoveredId = discoveredId;
|
|
11
|
+
exports.customId = customId;
|
|
12
|
+
exports.discoverExternalMcp = discoverExternalMcp;
|
|
13
|
+
exports.readExternalMcpSettings = readExternalMcpSettings;
|
|
14
|
+
exports.applyExternalMcpPatch = applyExternalMcpPatch;
|
|
15
|
+
exports.resolveExternalEntries = resolveExternalEntries;
|
|
16
|
+
exports.isCodexInjectable = isCodexInjectable;
|
|
17
|
+
/**
|
|
18
|
+
* External MCP servers registry — app-level (per-machine) configuration of the
|
|
19
|
+
* user's OWN MCP servers for the MISSION agent (Desktop agent chat), with a
|
|
20
|
+
* per-provider activation matrix.
|
|
21
|
+
*
|
|
22
|
+
* Storage is one JSON blob under `desktop_settings['external_mcp_servers']`
|
|
23
|
+
* (no migration — plain k/v, precedent: `specrails_agent_defaults`).
|
|
24
|
+
*
|
|
25
|
+
* Two entry kinds:
|
|
26
|
+
* - `discovered` (`d:<sourceProvider>:<name>`): the app persists SELECTION
|
|
27
|
+
* ONLY — the transport is re-resolved LIVE from the source provider's
|
|
28
|
+
* native user config at spawn time, so user edits flow through and a
|
|
29
|
+
* removed server degrades to an orphan (skipped at spawn), never a broken
|
|
30
|
+
* mission turn.
|
|
31
|
+
* - `custom` (`c:<name>`): full stdio transport stored in the blob.
|
|
32
|
+
*
|
|
33
|
+
* Discovery reads provider native configs READ-ONLY and never writes them:
|
|
34
|
+
* claude → ~/.claude.json (top-level mcpServers)
|
|
35
|
+
* gemini → ~/.gemini/settings.json (mcpServers)
|
|
36
|
+
* kimi → ~/.kimi-code/mcp.json (mcpServers)
|
|
37
|
+
* codex → ~/.codex/config.toml (names only — codex mission spawns keep
|
|
38
|
+
* the user's real CODEX_HOME and load these natively; display-only)
|
|
39
|
+
*
|
|
40
|
+
* Consent-first: discovery only lists; every entry starts fully unticked and
|
|
41
|
+
* the per-provider tick in Settings is the consent act (mission spawns run
|
|
42
|
+
* with --dangerously-skip-permissions and external tools live OUTSIDE the
|
|
43
|
+
* Shift+Tab tier ladder).
|
|
44
|
+
*
|
|
45
|
+
* Kill switch: SPECRAILS_EXTERNAL_MCP=false|0|off (case-insensitive) bypasses
|
|
46
|
+
* all resolution — mission wiring stays byte-identical to the pre-change app.
|
|
47
|
+
*/
|
|
48
|
+
const fs_1 = __importDefault(require("fs"));
|
|
49
|
+
const os_1 = __importDefault(require("os"));
|
|
50
|
+
const path_1 = __importDefault(require("path"));
|
|
51
|
+
const desktop_db_1 = require("./desktop-db");
|
|
52
|
+
const providers_1 = require("./providers");
|
|
53
|
+
exports.EXTERNAL_MCP_SETTING_KEY = 'external_mcp_servers';
|
|
54
|
+
/** Server name the embedded Specrails bridge always owns — never injectable. */
|
|
55
|
+
exports.RESERVED_SERVER_NAME = 'specrails';
|
|
56
|
+
/** Providers whose native config is parsed as a full JSON mcpServers map. */
|
|
57
|
+
exports.DISCOVERY_SOURCE_PROVIDERS = ['claude', 'gemini', 'kimi'];
|
|
58
|
+
const SERVER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
59
|
+
/** TOML/`-c` override keys tolerate a narrower charset than JSON keys. */
|
|
60
|
+
const CODEX_SAFE_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
61
|
+
class ExternalMcpValidationError extends Error {
|
|
62
|
+
code;
|
|
63
|
+
constructor(code, message) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.code = code;
|
|
66
|
+
this.name = 'ExternalMcpValidationError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
exports.ExternalMcpValidationError = ExternalMcpValidationError;
|
|
70
|
+
const EMPTY_SETTINGS = { version: 1, servers: {} };
|
|
71
|
+
function isExternalMcpEnabled() {
|
|
72
|
+
const raw = (process.env.SPECRAILS_EXTERNAL_MCP ?? '').trim().toLowerCase();
|
|
73
|
+
return !(raw === 'false' || raw === '0' || raw === 'off');
|
|
74
|
+
}
|
|
75
|
+
function homeDir() {
|
|
76
|
+
return process.env.SPECRAILS_REGISTRY_HOME || os_1.default.homedir();
|
|
77
|
+
}
|
|
78
|
+
function isRecord(value) {
|
|
79
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
80
|
+
}
|
|
81
|
+
// ─── Native-config discovery (read-only, tolerant) ───────────────────────────
|
|
82
|
+
function readJsonFile(file) {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
|
|
85
|
+
return isRecord(parsed) ? parsed : null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** The `mcpServers` map from a native JSON config; `{}` on any failure. */
|
|
92
|
+
function readMcpServersMap(file, key = 'mcpServers') {
|
|
93
|
+
const parsed = readJsonFile(file);
|
|
94
|
+
const map = parsed?.[key];
|
|
95
|
+
if (!isRecord(map))
|
|
96
|
+
return {};
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const [name, entry] of Object.entries(map)) {
|
|
99
|
+
if (isRecord(entry))
|
|
100
|
+
out[name] = entry;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function nativeConfigPath(provider) {
|
|
105
|
+
const home = homeDir();
|
|
106
|
+
switch (provider) {
|
|
107
|
+
case 'claude':
|
|
108
|
+
return path_1.default.join(home, '.claude.json');
|
|
109
|
+
case 'gemini':
|
|
110
|
+
return path_1.default.join(home, '.gemini', 'settings.json');
|
|
111
|
+
case 'kimi':
|
|
112
|
+
return path_1.default.join(home, '.kimi-code', 'mcp.json');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Read one source provider's native user-scope mcpServers map. Never throws. */
|
|
116
|
+
function readNativeMcpServers(provider) {
|
|
117
|
+
return readMcpServersMap(nativeConfigPath(provider));
|
|
118
|
+
}
|
|
119
|
+
/** Codex `[mcp_servers.<name>]` table names from config.toml. Names only —
|
|
120
|
+
* codex loads these natively in missions; no transport parsing (no TOML dep). */
|
|
121
|
+
function readCodexNativeServerNames() {
|
|
122
|
+
const file = path_1.default.join(homeDir(), '.codex', 'config.toml');
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = fs_1.default.readFileSync(file, 'utf-8');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const names = new Set();
|
|
131
|
+
for (const line of raw.split('\n')) {
|
|
132
|
+
const m = /^\s*\[mcp_servers\.([^\][.]+)\]\s*(?:#.*)?$/.exec(line);
|
|
133
|
+
if (m) {
|
|
134
|
+
const name = m[1].trim().replace(/^["']|["']$/g, '');
|
|
135
|
+
if (name)
|
|
136
|
+
names.add(name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return [...names].sort();
|
|
140
|
+
}
|
|
141
|
+
function discoveredId(provider, name) {
|
|
142
|
+
return `d:${provider}:${name}`;
|
|
143
|
+
}
|
|
144
|
+
function customId(name) {
|
|
145
|
+
return `c:${name}`;
|
|
146
|
+
}
|
|
147
|
+
/** Discovery snapshot + orphan flags against the stored selections. */
|
|
148
|
+
function discoverExternalMcp(settings) {
|
|
149
|
+
const byProvider = {};
|
|
150
|
+
const presentIds = new Set();
|
|
151
|
+
for (const provider of exports.DISCOVERY_SOURCE_PROVIDERS) {
|
|
152
|
+
const names = Object.keys(readNativeMcpServers(provider)).sort();
|
|
153
|
+
byProvider[provider] = names.map((name) => {
|
|
154
|
+
const id = discoveredId(provider, name);
|
|
155
|
+
presentIds.add(id);
|
|
156
|
+
return { id, name };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const orphanIds = Object.entries(settings.servers)
|
|
160
|
+
.filter(([id, entry]) => entry.source === 'discovered' && !presentIds.has(id))
|
|
161
|
+
.map(([id]) => id)
|
|
162
|
+
.sort();
|
|
163
|
+
return {
|
|
164
|
+
claude: byProvider.claude,
|
|
165
|
+
gemini: byProvider.gemini,
|
|
166
|
+
kimi: byProvider.kimi,
|
|
167
|
+
codexNative: readCodexNativeServerNames(),
|
|
168
|
+
orphanIds,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// ─── Settings read / validate / patch ────────────────────────────────────────
|
|
172
|
+
function sanitizeProvidersMatrix(raw, adapterIds) {
|
|
173
|
+
const out = {};
|
|
174
|
+
if (!isRecord(raw))
|
|
175
|
+
return out;
|
|
176
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
177
|
+
if (adapterIds.has(id) && value === true)
|
|
178
|
+
out[id] = true;
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
/** Structural sanitize for values read from disk — never throws, drops junk. */
|
|
183
|
+
function sanitizeStored(raw) {
|
|
184
|
+
if (!isRecord(raw) || !isRecord(raw.servers))
|
|
185
|
+
return { version: 1, servers: {} };
|
|
186
|
+
const adapterIds = new Set((0, providers_1.listAdapters)().map((a) => a.id));
|
|
187
|
+
const servers = {};
|
|
188
|
+
for (const [id, value] of Object.entries(raw.servers)) {
|
|
189
|
+
const entry = tryParseEntry(id, value, adapterIds);
|
|
190
|
+
if (entry)
|
|
191
|
+
servers[id] = entry;
|
|
192
|
+
}
|
|
193
|
+
return { version: 1, servers };
|
|
194
|
+
}
|
|
195
|
+
/** Parse one stored entry; null on any structural problem (read path). */
|
|
196
|
+
function tryParseEntry(id, value, adapterIds) {
|
|
197
|
+
if (!isRecord(value))
|
|
198
|
+
return null;
|
|
199
|
+
const discovered = /^d:([a-z0-9-]+):(.+)$/.exec(id);
|
|
200
|
+
const custom = /^c:(.+)$/.exec(id);
|
|
201
|
+
if (discovered) {
|
|
202
|
+
const provider = discovered[1];
|
|
203
|
+
const name = discovered[2];
|
|
204
|
+
if (!exports.DISCOVERY_SOURCE_PROVIDERS.includes(provider))
|
|
205
|
+
return null;
|
|
206
|
+
if (!SERVER_NAME_PATTERN.test(name) || name === exports.RESERVED_SERVER_NAME)
|
|
207
|
+
return null;
|
|
208
|
+
return {
|
|
209
|
+
source: 'discovered',
|
|
210
|
+
sourceProvider: provider,
|
|
211
|
+
name,
|
|
212
|
+
providers: sanitizeProvidersMatrix(value.providers, adapterIds),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (custom) {
|
|
216
|
+
const name = custom[1];
|
|
217
|
+
if (!SERVER_NAME_PATTERN.test(name) || name === exports.RESERVED_SERVER_NAME)
|
|
218
|
+
return null;
|
|
219
|
+
const transport = value.transport;
|
|
220
|
+
if (!isRecord(transport) || typeof transport.command !== 'string' || !transport.command.trim())
|
|
221
|
+
return null;
|
|
222
|
+
const args = Array.isArray(transport.args) ? transport.args.filter((a) => typeof a === 'string') : [];
|
|
223
|
+
const env = {};
|
|
224
|
+
if (isRecord(transport.env)) {
|
|
225
|
+
for (const [k, v] of Object.entries(transport.env)) {
|
|
226
|
+
if (typeof v === 'string')
|
|
227
|
+
env[k] = v;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
source: 'custom',
|
|
232
|
+
name,
|
|
233
|
+
providers: sanitizeProvidersMatrix(value.providers, adapterIds),
|
|
234
|
+
transport: { command: transport.command, args, env },
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
/** Read + sanitize the stored settings. Never throws. */
|
|
240
|
+
function readExternalMcpSettings(db) {
|
|
241
|
+
try {
|
|
242
|
+
const raw = (0, desktop_db_1.getDesktopSetting)(db, exports.EXTERNAL_MCP_SETTING_KEY);
|
|
243
|
+
if (!raw)
|
|
244
|
+
return { ...EMPTY_SETTINGS, servers: {} };
|
|
245
|
+
return sanitizeStored(JSON.parse(raw));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return { ...EMPTY_SETTINGS, servers: {} };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Validate an untrusted PATCH body and persist it. The blob is replaced
|
|
253
|
+
* wholesale (the client always PATCHes the full settings object). Throws
|
|
254
|
+
* `ExternalMcpValidationError` with an API-stable `code`.
|
|
255
|
+
*/
|
|
256
|
+
function applyExternalMcpPatch(db, body) {
|
|
257
|
+
if (!isRecord(body) || !isRecord(body.servers)) {
|
|
258
|
+
throw new ExternalMcpValidationError('invalid_body', 'body must be { servers: { … } }');
|
|
259
|
+
}
|
|
260
|
+
const adapterIds = new Set((0, providers_1.listAdapters)().map((a) => a.id));
|
|
261
|
+
const servers = {};
|
|
262
|
+
for (const [id, value] of Object.entries(body.servers)) {
|
|
263
|
+
if (!isRecord(value)) {
|
|
264
|
+
throw new ExternalMcpValidationError('invalid_entry', `entry ${JSON.stringify(id)} must be an object`);
|
|
265
|
+
}
|
|
266
|
+
const discovered = /^d:([a-z0-9-]+):(.+)$/.exec(id);
|
|
267
|
+
const custom = /^c:(.+)$/.exec(id);
|
|
268
|
+
if (!discovered && !custom) {
|
|
269
|
+
throw new ExternalMcpValidationError('invalid_entry', `entry id ${JSON.stringify(id)} must be d:<provider>:<name> or c:<name>`);
|
|
270
|
+
}
|
|
271
|
+
const name = discovered ? discovered[2] : custom[1];
|
|
272
|
+
if (name === exports.RESERVED_SERVER_NAME) {
|
|
273
|
+
throw new ExternalMcpValidationError('reserved_name', `server name ${JSON.stringify(exports.RESERVED_SERVER_NAME)} is reserved`);
|
|
274
|
+
}
|
|
275
|
+
if (!SERVER_NAME_PATTERN.test(name)) {
|
|
276
|
+
throw new ExternalMcpValidationError('invalid_entry', `server name ${JSON.stringify(name)} is not a valid MCP server name`);
|
|
277
|
+
}
|
|
278
|
+
if (discovered && !exports.DISCOVERY_SOURCE_PROVIDERS.includes(discovered[1])) {
|
|
279
|
+
throw new ExternalMcpValidationError('unknown_provider', `unknown discovery source provider ${JSON.stringify(discovered[1])}`);
|
|
280
|
+
}
|
|
281
|
+
if (isRecord(value.providers)) {
|
|
282
|
+
for (const key of Object.keys(value.providers)) {
|
|
283
|
+
if (!adapterIds.has(key)) {
|
|
284
|
+
throw new ExternalMcpValidationError('unknown_provider', `unknown provider ${JSON.stringify(key)} in activation matrix`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (custom) {
|
|
289
|
+
const transport = value.transport;
|
|
290
|
+
if (!isRecord(transport) || typeof transport.command !== 'string' || !transport.command.trim()) {
|
|
291
|
+
throw new ExternalMcpValidationError('invalid_transport', `custom entry ${JSON.stringify(name)} requires a non-empty command`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const entry = tryParseEntry(id, value, adapterIds);
|
|
295
|
+
if (!entry) {
|
|
296
|
+
throw new ExternalMcpValidationError('invalid_entry', `entry ${JSON.stringify(id)} is malformed`);
|
|
297
|
+
}
|
|
298
|
+
servers[id] = entry;
|
|
299
|
+
}
|
|
300
|
+
// One injected name per provider: two enabled entries sharing a name would
|
|
301
|
+
// collide in the target CLI's mcpServers map.
|
|
302
|
+
for (const adapterId of adapterIds) {
|
|
303
|
+
const seen = new Map();
|
|
304
|
+
for (const [id, entry] of Object.entries(servers)) {
|
|
305
|
+
if (entry.providers[adapterId] !== true)
|
|
306
|
+
continue;
|
|
307
|
+
const prior = seen.get(entry.name);
|
|
308
|
+
if (prior) {
|
|
309
|
+
throw new ExternalMcpValidationError('duplicate_server_name', `entries ${JSON.stringify(prior)} and ${JSON.stringify(id)} both inject ${JSON.stringify(entry.name)} for provider ${JSON.stringify(adapterId)}`);
|
|
310
|
+
}
|
|
311
|
+
seen.set(entry.name, id);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const next = { version: 1, servers };
|
|
315
|
+
(0, desktop_db_1.setDesktopSetting)(db, exports.EXTERNAL_MCP_SETTING_KEY, JSON.stringify(next));
|
|
316
|
+
return next;
|
|
317
|
+
}
|
|
318
|
+
// ─── Spawn-time resolution ────────────────────────────────────────────────────
|
|
319
|
+
/**
|
|
320
|
+
* The external servers to inject into a mission spawn for `adapterId`.
|
|
321
|
+
* Discovered entries re-read their source native config LIVE — an absent
|
|
322
|
+
* server is skipped (logged) so a stale selection can never break the turn.
|
|
323
|
+
* Kill switch off ⇒ `[]` (byte-identical legacy wiring). Never throws.
|
|
324
|
+
*/
|
|
325
|
+
function resolveExternalEntries(adapterId, db) {
|
|
326
|
+
if (!isExternalMcpEnabled())
|
|
327
|
+
return [];
|
|
328
|
+
let settings;
|
|
329
|
+
try {
|
|
330
|
+
settings = readExternalMcpSettings(db);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
const out = [];
|
|
336
|
+
const nativeCache = new Map();
|
|
337
|
+
for (const [id, entry] of Object.entries(settings.servers)) {
|
|
338
|
+
if (entry.providers[adapterId] !== true)
|
|
339
|
+
continue;
|
|
340
|
+
if (entry.name === exports.RESERVED_SERVER_NAME)
|
|
341
|
+
continue;
|
|
342
|
+
if (entry.source === 'custom') {
|
|
343
|
+
const t = entry.transport;
|
|
344
|
+
if (!t)
|
|
345
|
+
continue;
|
|
346
|
+
out.push({ id, name: entry.name, config: { command: t.command, args: t.args, env: t.env } });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const provider = entry.sourceProvider;
|
|
350
|
+
if (!provider)
|
|
351
|
+
continue;
|
|
352
|
+
let map = nativeCache.get(provider);
|
|
353
|
+
if (!map) {
|
|
354
|
+
map = readNativeMcpServers(provider);
|
|
355
|
+
nativeCache.set(provider, map);
|
|
356
|
+
}
|
|
357
|
+
const config = map[entry.name];
|
|
358
|
+
if (!config) {
|
|
359
|
+
console.warn(`[external-mcp] skipping orphaned entry ${id} (not found in ${provider} config)`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
out.push({ id, name: entry.name, config });
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
/** Whether a resolved server can ride codex `-c mcp_servers.<name>.*` overrides
|
|
367
|
+
* (needs a TOML-safe name and a string command — http-shaped configs cannot). */
|
|
368
|
+
function isCodexInjectable(server) {
|
|
369
|
+
return CODEX_SAFE_NAME_PATTERN.test(server.name) && typeof server.config.command === 'string' && !!server.config.command;
|
|
370
|
+
}
|