@kwonye/mcpx 0.1.68 → 0.1.74
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/dist/adapters/claude-desktop.d.ts +1 -0
- package/dist/adapters/claude-desktop.d.ts.map +1 -0
- package/dist/adapters/claude.d.ts +1 -0
- package/dist/adapters/claude.d.ts.map +1 -0
- package/dist/adapters/cline.d.ts +1 -0
- package/dist/adapters/cline.d.ts.map +1 -0
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.d.ts.map +1 -0
- package/dist/adapters/cursor.d.ts +1 -0
- package/dist/adapters/cursor.d.ts.map +1 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/kiro.d.ts +1 -0
- package/dist/adapters/kiro.d.ts.map +1 -0
- package/dist/adapters/opencode.d.ts +1 -0
- package/dist/adapters/opencode.d.ts.map +1 -0
- package/dist/adapters/qwen.d.ts +1 -0
- package/dist/adapters/qwen.d.ts.map +1 -0
- package/dist/adapters/utils/index.d.ts +1 -0
- package/dist/adapters/utils/index.d.ts.map +1 -0
- package/dist/adapters/vscode.d.ts +1 -0
- package/dist/adapters/vscode.d.ts.map +1 -0
- package/dist/cli-33pamqwn.js +3292 -0
- package/dist/cli-ezvj44tr.js +3292 -0
- package/dist/cli-q8de7zb0.js +12 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1880 -1131
- package/dist/compat/claude.d.ts +1 -0
- package/dist/compat/claude.d.ts.map +1 -0
- package/dist/compat/codex.d.ts +1 -0
- package/dist/compat/codex.d.ts.map +1 -0
- package/dist/compat/index.d.ts +1 -0
- package/dist/compat/index.d.ts.map +1 -0
- package/dist/compat/qwen.d.ts +1 -0
- package/dist/compat/qwen.d.ts.map +1 -0
- package/dist/compat/unsupported.d.ts +1 -0
- package/dist/compat/unsupported.d.ts.map +1 -0
- package/dist/compat/vscode.d.ts +1 -0
- package/dist/compat/vscode.d.ts.map +1 -0
- package/dist/core/auth-probe.d.ts +1 -0
- package/dist/core/auth-probe.d.ts.map +1 -0
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/daemon.d.ts +1 -0
- package/dist/core/daemon.d.ts.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +81 -24
- package/dist/core/managed-index.d.ts +1 -0
- package/dist/core/managed-index.d.ts.map +1 -0
- package/dist/core/paths.d.ts +2 -0
- package/dist/core/paths.d.ts.map +1 -0
- package/dist/core/registry.d.ts +1 -0
- package/dist/core/registry.d.ts.map +1 -0
- package/dist/core/secrets.d.ts +1 -0
- package/dist/core/secrets.d.ts.map +1 -0
- package/dist/core/server-auth.d.ts +1 -0
- package/dist/core/server-auth.d.ts.map +1 -0
- package/dist/core/status.d.ts +1 -0
- package/dist/core/status.d.ts.map +1 -0
- package/dist/core/sync.d.ts +1 -0
- package/dist/core/sync.d.ts.map +1 -0
- package/dist/core/update-manager.d.ts +1 -0
- package/dist/core/update-manager.d.ts.map +1 -0
- package/dist/core/update.d.ts +1 -0
- package/dist/core/update.d.ts.map +1 -0
- package/dist/gateway/server.d.ts +1 -0
- package/dist/gateway/server.d.ts.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -10
- package/dist/util/fs.d.ts +1 -0
- package/dist/util/fs.d.ts.map +1 -0
- package/dist/version.d.ts +2 -1
- package/dist/version.d.ts.map +1 -0
- package/package.json +10 -11
- package/dist/adapters/claude-desktop.js +0 -118
- package/dist/adapters/claude-desktop.js.map +0 -1
- package/dist/adapters/claude.js +0 -131
- package/dist/adapters/claude.js.map +0 -1
- package/dist/adapters/cline.js +0 -135
- package/dist/adapters/cline.js.map +0 -1
- package/dist/adapters/codex.js +0 -125
- package/dist/adapters/codex.js.map +0 -1
- package/dist/adapters/cursor.js +0 -125
- package/dist/adapters/cursor.js.map +0 -1
- package/dist/adapters/index.js +0 -23
- package/dist/adapters/index.js.map +0 -1
- package/dist/adapters/kiro.js +0 -120
- package/dist/adapters/kiro.js.map +0 -1
- package/dist/adapters/opencode.js +0 -122
- package/dist/adapters/opencode.js.map +0 -1
- package/dist/adapters/qwen.js +0 -117
- package/dist/adapters/qwen.js.map +0 -1
- package/dist/adapters/utils/index.js +0 -144
- package/dist/adapters/utils/index.js.map +0 -1
- package/dist/adapters/vscode.js +0 -125
- package/dist/adapters/vscode.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/compat/claude.js +0 -252
- package/dist/compat/claude.js.map +0 -1
- package/dist/compat/codex.js +0 -179
- package/dist/compat/codex.js.map +0 -1
- package/dist/compat/index.js +0 -108
- package/dist/compat/index.js.map +0 -1
- package/dist/compat/qwen.js +0 -316
- package/dist/compat/qwen.js.map +0 -1
- package/dist/compat/unsupported.js +0 -47
- package/dist/compat/unsupported.js.map +0 -1
- package/dist/compat/vscode.js +0 -164
- package/dist/compat/vscode.js.map +0 -1
- package/dist/core/auth-probe.js +0 -54
- package/dist/core/auth-probe.js.map +0 -1
- package/dist/core/config.js +0 -76
- package/dist/core/config.js.map +0 -1
- package/dist/core/daemon.js +0 -176
- package/dist/core/daemon.js.map +0 -1
- package/dist/core/index.js.map +0 -1
- package/dist/core/managed-index.js +0 -22
- package/dist/core/managed-index.js.map +0 -1
- package/dist/core/paths.js +0 -55
- package/dist/core/paths.js.map +0 -1
- package/dist/core/registry.js +0 -51
- package/dist/core/registry.js.map +0 -1
- package/dist/core/secrets.js +0 -108
- package/dist/core/secrets.js.map +0 -1
- package/dist/core/server-auth.js +0 -112
- package/dist/core/server-auth.js.map +0 -1
- package/dist/core/status.js +0 -65
- package/dist/core/status.js.map +0 -1
- package/dist/core/sync.js +0 -128
- package/dist/core/sync.js.map +0 -1
- package/dist/core/update-manager.js +0 -170
- package/dist/core/update-manager.js.map +0 -1
- package/dist/core/update.js +0 -111
- package/dist/core/update.js.map +0 -1
- package/dist/gateway/server.js +0 -995
- package/dist/gateway/server.js.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/util/fs.js +0 -34
- package/dist/util/fs.js.map +0 -1
- package/dist/version.js +0 -2
- package/dist/version.js.map +0 -1
|
@@ -0,0 +1,3292 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isServerEnabled,
|
|
3
|
+
normalizeServerSpecEnabled
|
|
4
|
+
} from "./cli-q8de7zb0.js";
|
|
5
|
+
|
|
6
|
+
// src/core/paths.ts
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
function envOrDefault(value, fallback) {
|
|
11
|
+
if (value && value.trim().length > 0) {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
function homeDir() {
|
|
17
|
+
return process.env.HOME ?? os.homedir();
|
|
18
|
+
}
|
|
19
|
+
function getConfigRoot() {
|
|
20
|
+
return envOrDefault(process.env.MCPX_CONFIG_HOME ?? process.env.XDG_CONFIG_HOME, path.join(homeDir(), ".config"));
|
|
21
|
+
}
|
|
22
|
+
function getDataRoot() {
|
|
23
|
+
return envOrDefault(process.env.MCPX_DATA_HOME ?? process.env.XDG_DATA_HOME, path.join(homeDir(), ".local", "share"));
|
|
24
|
+
}
|
|
25
|
+
function getStateRoot() {
|
|
26
|
+
return envOrDefault(process.env.MCPX_STATE_HOME ?? process.env.XDG_STATE_HOME, path.join(homeDir(), ".local", "state"));
|
|
27
|
+
}
|
|
28
|
+
function getConfigPath() {
|
|
29
|
+
return path.join(getConfigRoot(), "mcpx", "config.json");
|
|
30
|
+
}
|
|
31
|
+
function getManagedIndexPath() {
|
|
32
|
+
return path.join(getDataRoot(), "mcpx", "managed-index.json");
|
|
33
|
+
}
|
|
34
|
+
function getSecretNamesPath() {
|
|
35
|
+
return path.join(getDataRoot(), "mcpx", "secret-names.json");
|
|
36
|
+
}
|
|
37
|
+
function getRuntimeDir() {
|
|
38
|
+
return path.join(getStateRoot(), "mcpx", "runtime");
|
|
39
|
+
}
|
|
40
|
+
function getLogDir() {
|
|
41
|
+
return path.join(getStateRoot(), "mcpx", "logs");
|
|
42
|
+
}
|
|
43
|
+
function getPidPath() {
|
|
44
|
+
return path.join(getRuntimeDir(), "daemon.pid");
|
|
45
|
+
}
|
|
46
|
+
function getLogPath() {
|
|
47
|
+
return path.join(getLogDir(), "daemon.log");
|
|
48
|
+
}
|
|
49
|
+
function getUpdatesDir() {
|
|
50
|
+
return path.join(getDataRoot(), "mcpx", "updates");
|
|
51
|
+
}
|
|
52
|
+
function getStagedVersionPath() {
|
|
53
|
+
return path.join(getUpdatesDir(), "staged-version.json");
|
|
54
|
+
}
|
|
55
|
+
function getUpdateLockPath() {
|
|
56
|
+
return path.join(getUpdatesDir(), ".update.lock");
|
|
57
|
+
}
|
|
58
|
+
function ensureDir(dirPath) {
|
|
59
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
function ensureParentDir(filePath) {
|
|
62
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/core/config.ts
|
|
66
|
+
import { z } from "zod";
|
|
67
|
+
|
|
68
|
+
// src/util/fs.ts
|
|
69
|
+
import fs2 from "node:fs";
|
|
70
|
+
import path2 from "node:path";
|
|
71
|
+
import crypto from "node:crypto";
|
|
72
|
+
function readJsonFile(filePath, fallback) {
|
|
73
|
+
if (!fs2.existsSync(filePath)) {
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const raw = fs2.readFileSync(filePath, "utf8");
|
|
78
|
+
return JSON.parse(raw);
|
|
79
|
+
} catch {
|
|
80
|
+
return fallback;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function writeJsonAtomic(filePath, data) {
|
|
84
|
+
fs2.mkdirSync(path2.dirname(filePath), { recursive: true });
|
|
85
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
86
|
+
const content = `${JSON.stringify(data, null, 2)}
|
|
87
|
+
`;
|
|
88
|
+
fs2.writeFileSync(tempPath, content, { mode: 384 });
|
|
89
|
+
fs2.renameSync(tempPath, filePath);
|
|
90
|
+
}
|
|
91
|
+
function sha256(value) {
|
|
92
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/core/config.ts
|
|
96
|
+
var clientStateSchema = z.object({
|
|
97
|
+
status: z.enum(["SYNCED", "UNSUPPORTED_HTTP", "ERROR", "SKIPPED"]),
|
|
98
|
+
lastSyncAt: z.string().optional(),
|
|
99
|
+
message: z.string().optional(),
|
|
100
|
+
configPath: z.string().optional()
|
|
101
|
+
});
|
|
102
|
+
var httpServerSchema = z.object({
|
|
103
|
+
transport: z.literal("http"),
|
|
104
|
+
url: z.url(),
|
|
105
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
106
|
+
enabled: z.boolean().default(true)
|
|
107
|
+
});
|
|
108
|
+
var stdioServerSchema = z.object({
|
|
109
|
+
transport: z.literal("stdio"),
|
|
110
|
+
command: z.string().min(1),
|
|
111
|
+
args: z.array(z.string()).optional(),
|
|
112
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
113
|
+
cwd: z.string().min(1).optional(),
|
|
114
|
+
enabled: z.boolean().default(true)
|
|
115
|
+
});
|
|
116
|
+
var serverSchema = z.discriminatedUnion("transport", [httpServerSchema, stdioServerSchema]);
|
|
117
|
+
var configSchema = z.object({
|
|
118
|
+
schemaVersion: z.literal(1).default(1),
|
|
119
|
+
gateway: z.object({
|
|
120
|
+
port: z.number().int().min(1).max(65535).default(37373),
|
|
121
|
+
tokenRef: z.string().default("secret://local_gateway_token"),
|
|
122
|
+
autoStart: z.boolean().default(true)
|
|
123
|
+
}).default({
|
|
124
|
+
port: 37373,
|
|
125
|
+
tokenRef: "secret://local_gateway_token",
|
|
126
|
+
autoStart: true
|
|
127
|
+
}),
|
|
128
|
+
servers: z.record(z.string(), serverSchema).default({}),
|
|
129
|
+
clients: z.record(z.string(), clientStateSchema).default({})
|
|
130
|
+
});
|
|
131
|
+
function defaultConfig() {
|
|
132
|
+
return {
|
|
133
|
+
schemaVersion: 1,
|
|
134
|
+
gateway: {
|
|
135
|
+
port: 37373,
|
|
136
|
+
tokenRef: "secret://local_gateway_token",
|
|
137
|
+
autoStart: true
|
|
138
|
+
},
|
|
139
|
+
servers: {},
|
|
140
|
+
clients: {}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function loadConfig(configPath = getConfigPath()) {
|
|
144
|
+
const raw = readJsonFile(configPath, defaultConfig());
|
|
145
|
+
const parsed = configSchema.safeParse(raw);
|
|
146
|
+
if (!parsed.success) {
|
|
147
|
+
return defaultConfig();
|
|
148
|
+
}
|
|
149
|
+
const clientEntries = {};
|
|
150
|
+
for (const [key, value] of Object.entries(parsed.data.clients)) {
|
|
151
|
+
if (["claude", "claude-desktop", "codex", "cursor", "cline", "opencode", "kiro", "vscode", "qwen"].includes(key)) {
|
|
152
|
+
clientEntries[key] = value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const servers = Object.fromEntries(Object.entries(parsed.data.servers).map(([name, spec]) => [name, normalizeServerSpecEnabled(spec)]));
|
|
156
|
+
return {
|
|
157
|
+
schemaVersion: 1,
|
|
158
|
+
gateway: parsed.data.gateway,
|
|
159
|
+
servers,
|
|
160
|
+
clients: clientEntries
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function saveConfig(config, configPath = getConfigPath()) {
|
|
164
|
+
config.servers = Object.fromEntries(Object.entries(config.servers).map(([name, spec]) => [name, normalizeServerSpecEnabled(spec)]));
|
|
165
|
+
writeJsonAtomic(configPath, config);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/core/registry.ts
|
|
169
|
+
function validateServerName(name) {
|
|
170
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$/.test(name)) {
|
|
171
|
+
throw new Error("Invalid server name. Use 1-63 chars: letters, numbers, '.', '-', '_' and start with alphanumeric.");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function addServer(config, name, spec, force = false) {
|
|
175
|
+
validateServerName(name);
|
|
176
|
+
if (config.servers[name] && !force) {
|
|
177
|
+
throw new Error(`Server "${name}" already exists. Use --force to overwrite.`);
|
|
178
|
+
}
|
|
179
|
+
config.servers[name] = normalizeServerSpecEnabled(spec);
|
|
180
|
+
}
|
|
181
|
+
function removeServer(config, name, force = false) {
|
|
182
|
+
if (!config.servers[name] && !force) {
|
|
183
|
+
throw new Error(`Server "${name}" does not exist.`);
|
|
184
|
+
}
|
|
185
|
+
delete config.servers[name];
|
|
186
|
+
}
|
|
187
|
+
function updateServer(config, name, spec) {
|
|
188
|
+
validateServerName(name);
|
|
189
|
+
if (!config.servers[name]) {
|
|
190
|
+
throw new Error(`Server "${name}" does not exist.`);
|
|
191
|
+
}
|
|
192
|
+
config.servers[name] = normalizeServerSpecEnabled(spec);
|
|
193
|
+
}
|
|
194
|
+
function setServerEnabled(config, name, enabled) {
|
|
195
|
+
const existing = config.servers[name];
|
|
196
|
+
if (!existing) {
|
|
197
|
+
throw new Error(`Server "${name}" does not exist.`);
|
|
198
|
+
}
|
|
199
|
+
config.servers[name] = {
|
|
200
|
+
...existing,
|
|
201
|
+
enabled
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function getGatewayTokenSecretName(config) {
|
|
205
|
+
if (!config.gateway.tokenRef.startsWith("secret://")) {
|
|
206
|
+
return "local_gateway_token";
|
|
207
|
+
}
|
|
208
|
+
return config.gateway.tokenRef.slice("secret://".length) || "local_gateway_token";
|
|
209
|
+
}
|
|
210
|
+
function ensureGatewayToken(config, secrets) {
|
|
211
|
+
const secretName = getGatewayTokenSecretName(config);
|
|
212
|
+
const existing = secrets.getSecret(secretName);
|
|
213
|
+
if (existing) {
|
|
214
|
+
return existing;
|
|
215
|
+
}
|
|
216
|
+
return secrets.rotateLocalToken(secretName);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/core/secrets.ts
|
|
220
|
+
import { execFileSync } from "node:child_process";
|
|
221
|
+
import crypto2 from "node:crypto";
|
|
222
|
+
import fs3 from "node:fs";
|
|
223
|
+
function parseSecretRef(ref) {
|
|
224
|
+
if (!ref.startsWith("secret://")) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const name = ref.slice("secret://".length);
|
|
228
|
+
return name.length > 0 ? name : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
class SecretsManager {
|
|
232
|
+
service;
|
|
233
|
+
nameIndexPath;
|
|
234
|
+
constructor(service = "mcpx", nameIndexPath = getSecretNamesPath()) {
|
|
235
|
+
this.service = service;
|
|
236
|
+
this.nameIndexPath = nameIndexPath;
|
|
237
|
+
}
|
|
238
|
+
loadNameIndex() {
|
|
239
|
+
return readJsonFile(this.nameIndexPath, { names: [] });
|
|
240
|
+
}
|
|
241
|
+
saveNameIndex(index) {
|
|
242
|
+
const deduped = Array.from(new Set(index.names)).sort();
|
|
243
|
+
writeJsonAtomic(this.nameIndexPath, { names: deduped });
|
|
244
|
+
}
|
|
245
|
+
trackSecretName(name) {
|
|
246
|
+
const index = this.loadNameIndex();
|
|
247
|
+
if (!index.names.includes(name)) {
|
|
248
|
+
index.names.push(name);
|
|
249
|
+
this.saveNameIndex(index);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
untrackSecretName(name) {
|
|
253
|
+
const index = this.loadNameIndex();
|
|
254
|
+
index.names = index.names.filter((entry) => entry !== name);
|
|
255
|
+
this.saveNameIndex(index);
|
|
256
|
+
}
|
|
257
|
+
listSecretNames() {
|
|
258
|
+
return this.loadNameIndex().names;
|
|
259
|
+
}
|
|
260
|
+
setSecret(name, value) {
|
|
261
|
+
if (process.platform !== "darwin") {
|
|
262
|
+
throw new Error("OS keychain integration is currently implemented for macOS only.");
|
|
263
|
+
}
|
|
264
|
+
execFileSync("security", ["add-generic-password", "-U", "-a", name, "-s", this.service, "-w", value], {
|
|
265
|
+
stdio: "ignore"
|
|
266
|
+
});
|
|
267
|
+
this.trackSecretName(name);
|
|
268
|
+
}
|
|
269
|
+
removeSecret(name) {
|
|
270
|
+
if (process.platform === "darwin") {
|
|
271
|
+
try {
|
|
272
|
+
execFileSync("security", ["delete-generic-password", "-a", name, "-s", this.service], {
|
|
273
|
+
stdio: "ignore"
|
|
274
|
+
});
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
this.untrackSecretName(name);
|
|
278
|
+
}
|
|
279
|
+
getSecret(name) {
|
|
280
|
+
const envOverride = process.env[`MCPX_SECRET_${name}`];
|
|
281
|
+
if (envOverride && envOverride.length > 0) {
|
|
282
|
+
return envOverride;
|
|
283
|
+
}
|
|
284
|
+
if (process.platform !== "darwin") {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
return execFileSync("security", ["find-generic-password", "-w", "-a", name, "-s", this.service], {
|
|
289
|
+
encoding: "utf8"
|
|
290
|
+
}).trim();
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
resolveMaybeSecret(value) {
|
|
296
|
+
const secretName = parseSecretRef(value);
|
|
297
|
+
if (!secretName) {
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
const secret = this.getSecret(secretName);
|
|
301
|
+
if (!secret) {
|
|
302
|
+
throw new Error(`Secret not found: ${secretName}`);
|
|
303
|
+
}
|
|
304
|
+
return secret;
|
|
305
|
+
}
|
|
306
|
+
rotateLocalToken(secretName = "local_gateway_token") {
|
|
307
|
+
const token = crypto2.randomBytes(32).toString("base64url");
|
|
308
|
+
this.setSecret(secretName, token);
|
|
309
|
+
return token;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function readSecretValueFromStdin() {
|
|
313
|
+
if (process.stdin.isTTY) {
|
|
314
|
+
throw new Error("No value provided. Use --value or pipe a value via stdin.");
|
|
315
|
+
}
|
|
316
|
+
const content = fs3.readFileSync(process.stdin.fd, "utf8").trim();
|
|
317
|
+
if (content.length === 0) {
|
|
318
|
+
throw new Error("Empty secret value provided.");
|
|
319
|
+
}
|
|
320
|
+
return content;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/core/auth-probe.ts
|
|
324
|
+
var JSON_RPC_VERSION = "2.0";
|
|
325
|
+
var DEFAULT_AUTH_PROBE_TIMEOUT_MS = 8000;
|
|
326
|
+
function resolveHeaders(spec, secrets) {
|
|
327
|
+
const headers = {
|
|
328
|
+
"content-type": "application/json",
|
|
329
|
+
accept: "application/json, text/event-stream"
|
|
330
|
+
};
|
|
331
|
+
for (const [key, value] of Object.entries(spec.headers ?? {})) {
|
|
332
|
+
headers[key] = secrets.resolveMaybeSecret(value);
|
|
333
|
+
}
|
|
334
|
+
return headers;
|
|
335
|
+
}
|
|
336
|
+
async function probeHttpAuthRequirement(spec, secrets, timeoutMs = DEFAULT_AUTH_PROBE_TIMEOUT_MS) {
|
|
337
|
+
const timeoutController = new AbortController;
|
|
338
|
+
const timeoutHandle = setTimeout(() => timeoutController.abort(), timeoutMs);
|
|
339
|
+
try {
|
|
340
|
+
const headers = resolveHeaders(spec, secrets);
|
|
341
|
+
const response = await fetch(spec.url, {
|
|
342
|
+
method: "POST",
|
|
343
|
+
headers,
|
|
344
|
+
body: JSON.stringify({
|
|
345
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
346
|
+
id: "mcpx-auth-probe",
|
|
347
|
+
method: "tools/list",
|
|
348
|
+
params: {}
|
|
349
|
+
}),
|
|
350
|
+
signal: timeoutController.signal
|
|
351
|
+
});
|
|
352
|
+
if (response.status === 401 || response.status === 403) {
|
|
353
|
+
return {
|
|
354
|
+
authRequired: true,
|
|
355
|
+
status: response.status,
|
|
356
|
+
wwwAuthenticate: response.headers.get("www-authenticate") ?? undefined
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
authRequired: false,
|
|
361
|
+
status: response.status
|
|
362
|
+
};
|
|
363
|
+
} catch (error) {
|
|
364
|
+
const isAbort = error.name === "AbortError";
|
|
365
|
+
return {
|
|
366
|
+
authRequired: false,
|
|
367
|
+
error: isAbort ? `Auth probe timed out after ${timeoutMs}ms.` : error.message
|
|
368
|
+
};
|
|
369
|
+
} finally {
|
|
370
|
+
clearTimeout(timeoutHandle);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/adapters/claude.ts
|
|
375
|
+
import path3 from "node:path";
|
|
376
|
+
import { z as z2 } from "zod";
|
|
377
|
+
|
|
378
|
+
// src/adapters/utils/index.ts
|
|
379
|
+
function getManagedEntryNames(managedIndex, clientId) {
|
|
380
|
+
return Object.keys(managedIndex.managed[clientId]?.entries ?? {});
|
|
381
|
+
}
|
|
382
|
+
function isManagedEntry(managedIndex, clientId, entryName) {
|
|
383
|
+
return Boolean(managedIndex.managed[clientId]?.entries?.[entryName]);
|
|
384
|
+
}
|
|
385
|
+
function pruneStaleManagedEntries(managedIndex, clientId, servers, keepEntryNames) {
|
|
386
|
+
const keep = new Set(keepEntryNames);
|
|
387
|
+
const managedNames = getManagedEntryNames(managedIndex, clientId);
|
|
388
|
+
for (const name of managedNames) {
|
|
389
|
+
if (!keep.has(name)) {
|
|
390
|
+
delete servers[name];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function ensureManagedEntryWritable(managedIndex, clientId, entryName, existingValue) {
|
|
395
|
+
if (existingValue === undefined || existingValue === null) {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
if (isManagedEntry(managedIndex, clientId, entryName)) {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
return `Cannot sync managed entry "${entryName}" because an unmanaged entry already exists.`;
|
|
402
|
+
}
|
|
403
|
+
function setManagedEntries(managedIndex, clientId, configPath, entries) {
|
|
404
|
+
if (!managedIndex.managed[clientId]) {
|
|
405
|
+
managedIndex.managed[clientId] = {
|
|
406
|
+
configPath,
|
|
407
|
+
entries: {}
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
managedIndex.managed[clientId].configPath = configPath;
|
|
411
|
+
managedIndex.managed[clientId].entries = Object.fromEntries(Object.entries(entries).map(([entryName, serializedEntry]) => [entryName, {
|
|
412
|
+
fingerprint: sha256(serializedEntry),
|
|
413
|
+
lastSyncedAt: new Date().toISOString()
|
|
414
|
+
}]));
|
|
415
|
+
}
|
|
416
|
+
function okResult(clientId, configPath, message) {
|
|
417
|
+
return {
|
|
418
|
+
clientId,
|
|
419
|
+
status: "SYNCED",
|
|
420
|
+
configPath,
|
|
421
|
+
message
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
function errorResult(clientId, configPath, message) {
|
|
425
|
+
return {
|
|
426
|
+
clientId,
|
|
427
|
+
status: "ERROR",
|
|
428
|
+
configPath,
|
|
429
|
+
message
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function isManagedGatewayProjection(entryName) {
|
|
433
|
+
return entryName.endsWith(" (mcpx)");
|
|
434
|
+
}
|
|
435
|
+
function sortRecord(record) {
|
|
436
|
+
if (!record || Object.keys(record).length === 0) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right)));
|
|
440
|
+
}
|
|
441
|
+
function normalizeServerSpec(spec) {
|
|
442
|
+
if (spec.transport === "http") {
|
|
443
|
+
return {
|
|
444
|
+
transport: "http",
|
|
445
|
+
url: spec.url,
|
|
446
|
+
headers: sortRecord(spec.headers)
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
transport: "stdio",
|
|
451
|
+
command: spec.command,
|
|
452
|
+
args: spec.args && spec.args.length > 0 ? [...spec.args] : undefined,
|
|
453
|
+
env: sortRecord(spec.env),
|
|
454
|
+
cwd: spec.cwd
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function serializeServerSpec(spec) {
|
|
458
|
+
return JSON.stringify(normalizeServerSpec(spec));
|
|
459
|
+
}
|
|
460
|
+
function serverSpecsEqual(left, right) {
|
|
461
|
+
return serializeServerSpec(left) === serializeServerSpec(right);
|
|
462
|
+
}
|
|
463
|
+
function emptyImportScan(clientId, configPath) {
|
|
464
|
+
return {
|
|
465
|
+
clientId,
|
|
466
|
+
configPath,
|
|
467
|
+
candidates: [],
|
|
468
|
+
skipped: []
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
function buildImportSkip(clientId, sourceEntryName, reason, configPath, serverName = sourceEntryName) {
|
|
472
|
+
return {
|
|
473
|
+
clientId,
|
|
474
|
+
configPath,
|
|
475
|
+
sourceEntryName,
|
|
476
|
+
serverName,
|
|
477
|
+
reason
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function removeSourceEntries(entries, names) {
|
|
481
|
+
for (const name of names ?? []) {
|
|
482
|
+
delete entries[name];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function syncDisabledMcpServersArray(raw, managedNames, managedEntries) {
|
|
486
|
+
const existingDisabled = (raw.disabledMcpServers ?? []).filter((name) => !managedNames.includes(name));
|
|
487
|
+
const disabledManaged = managedEntries.filter((entry) => !entry.enabled).map((entry) => entry.name);
|
|
488
|
+
raw.disabledMcpServers = [...existingDisabled, ...disabledManaged];
|
|
489
|
+
}
|
|
490
|
+
function syncMcpExcludedArray(raw, managedNames, managedEntries) {
|
|
491
|
+
const mcp = raw.mcp ?? {};
|
|
492
|
+
const existingExcluded = (mcp.excluded ?? []).filter((name) => !managedNames.includes(name));
|
|
493
|
+
const disabledManaged = managedEntries.filter((entry) => !entry.enabled).map((entry) => entry.name);
|
|
494
|
+
mcp.excluded = [...existingExcluded, ...disabledManaged];
|
|
495
|
+
raw.mcp = mcp;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/adapters/claude.ts
|
|
499
|
+
var stringMapSchema = z2.record(z2.string(), z2.string());
|
|
500
|
+
var claudeEntrySchema = z2.object({
|
|
501
|
+
type: z2.string().optional(),
|
|
502
|
+
url: z2.string().min(1).optional(),
|
|
503
|
+
headers: stringMapSchema.optional(),
|
|
504
|
+
command: z2.string().min(1).optional(),
|
|
505
|
+
args: z2.array(z2.string()).optional(),
|
|
506
|
+
env: stringMapSchema.optional(),
|
|
507
|
+
cwd: z2.string().min(1).optional(),
|
|
508
|
+
disabled: z2.boolean().optional()
|
|
509
|
+
}).passthrough();
|
|
510
|
+
|
|
511
|
+
class ClaudeAdapter {
|
|
512
|
+
id = "claude";
|
|
513
|
+
detectConfigPath() {
|
|
514
|
+
return path3.join(homeDir(), ".claude.json");
|
|
515
|
+
}
|
|
516
|
+
supportsHttp() {
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
scanForImports(_config, managedIndex) {
|
|
520
|
+
const configPath = this.detectConfigPath();
|
|
521
|
+
const result = emptyImportScan(this.id, configPath ?? undefined);
|
|
522
|
+
if (!configPath) {
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
const raw = readJsonFile(configPath, {});
|
|
526
|
+
const topLevelServers = raw.mcpServers ?? {};
|
|
527
|
+
const disabledMcpServers = new Set(raw.disabledMcpServers ?? []);
|
|
528
|
+
const projects = raw.projects ?? {};
|
|
529
|
+
for (const project of Object.values(projects)) {
|
|
530
|
+
const projectDisabled = project?.disabledMcpServers ?? [];
|
|
531
|
+
for (const name of projectDisabled) {
|
|
532
|
+
disabledMcpServers.add(name);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
for (const [name, rawEntry] of Object.entries(topLevelServers)) {
|
|
536
|
+
if (isManagedGatewayProjection(name) || managedIndex.managed[this.id]?.entries?.[name]) {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const parsed = claudeEntrySchema.safeParse(rawEntry);
|
|
540
|
+
if (!parsed.success) {
|
|
541
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry could not be mapped to an mcpx server.", configPath));
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const entry = parsed.data;
|
|
545
|
+
const isDisabled = entry.disabled === true || disabledMcpServers.has(name);
|
|
546
|
+
if ((entry.type === undefined || entry.type === "http") && entry.url && !entry.command) {
|
|
547
|
+
result.candidates.push({
|
|
548
|
+
clientId: this.id,
|
|
549
|
+
configPath,
|
|
550
|
+
sourceEntryName: name,
|
|
551
|
+
serverName: name,
|
|
552
|
+
spec: {
|
|
553
|
+
transport: "http",
|
|
554
|
+
url: entry.url,
|
|
555
|
+
headers: entry.headers,
|
|
556
|
+
enabled: !isDisabled
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if ((entry.type === undefined || entry.type === "stdio") && entry.command && !entry.url) {
|
|
562
|
+
result.candidates.push({
|
|
563
|
+
clientId: this.id,
|
|
564
|
+
configPath,
|
|
565
|
+
sourceEntryName: name,
|
|
566
|
+
serverName: name,
|
|
567
|
+
spec: {
|
|
568
|
+
transport: "stdio",
|
|
569
|
+
command: entry.command,
|
|
570
|
+
args: entry.args,
|
|
571
|
+
env: entry.env,
|
|
572
|
+
cwd: entry.cwd,
|
|
573
|
+
enabled: !isDisabled
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry must define exactly one transport that mcpx supports.", configPath));
|
|
579
|
+
}
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
syncGateway(_config, options) {
|
|
583
|
+
const configPath = this.detectConfigPath();
|
|
584
|
+
if (!configPath) {
|
|
585
|
+
return errorResult(this.id, undefined, "Unable to resolve Claude config path.");
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
const raw = readJsonFile(configPath, {});
|
|
589
|
+
const managedNames = options.managedEntries.map((entry) => entry.name);
|
|
590
|
+
const serverEntries = Object.fromEntries(options.managedEntries.map((entry) => [entry.name, {
|
|
591
|
+
type: "http",
|
|
592
|
+
url: entry.url,
|
|
593
|
+
headers: entry.headers
|
|
594
|
+
}]));
|
|
595
|
+
const topLevelServers = raw.mcpServers ?? {};
|
|
596
|
+
removeSourceEntries(topLevelServers, options.sourceEntriesToRemove);
|
|
597
|
+
for (const name of managedNames) {
|
|
598
|
+
const topLevelConflict = ensureManagedEntryWritable(options.managedIndex, this.id, name, topLevelServers[name]);
|
|
599
|
+
if (topLevelConflict) {
|
|
600
|
+
return errorResult(this.id, configPath, topLevelConflict);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
pruneStaleManagedEntries(options.managedIndex, this.id, topLevelServers, managedNames);
|
|
604
|
+
for (const [name, entry] of Object.entries(serverEntries)) {
|
|
605
|
+
topLevelServers[name] = entry;
|
|
606
|
+
}
|
|
607
|
+
raw.mcpServers = topLevelServers;
|
|
608
|
+
syncDisabledMcpServersArray(raw, managedNames, options.managedEntries);
|
|
609
|
+
const projects = raw.projects ?? {};
|
|
610
|
+
for (const projectPath of Object.keys(projects)) {
|
|
611
|
+
const project = projects[projectPath] ?? {};
|
|
612
|
+
syncDisabledMcpServersArray(project, managedNames, options.managedEntries);
|
|
613
|
+
projects[projectPath] = project;
|
|
614
|
+
}
|
|
615
|
+
raw.projects = projects;
|
|
616
|
+
writeJsonAtomic(configPath, raw);
|
|
617
|
+
setManagedEntries(options.managedIndex, this.id, configPath, Object.fromEntries(Object.entries(serverEntries).map(([name, entry]) => [name, JSON.stringify(entry)])));
|
|
618
|
+
return okResult(this.id, configPath);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
return errorResult(this.id, configPath, error.message);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/adapters/claude-desktop.ts
|
|
626
|
+
import path4 from "node:path";
|
|
627
|
+
import { z as z3 } from "zod";
|
|
628
|
+
var stringMapSchema2 = z3.record(z3.string(), z3.string());
|
|
629
|
+
var claudeDesktopEntrySchema = z3.object({
|
|
630
|
+
type: z3.string().optional(),
|
|
631
|
+
url: z3.string().min(1).optional(),
|
|
632
|
+
headers: stringMapSchema2.optional(),
|
|
633
|
+
command: z3.string().min(1).optional(),
|
|
634
|
+
args: z3.array(z3.string()).optional(),
|
|
635
|
+
env: stringMapSchema2.optional(),
|
|
636
|
+
cwd: z3.string().min(1).optional(),
|
|
637
|
+
disabled: z3.boolean().optional()
|
|
638
|
+
}).passthrough();
|
|
639
|
+
|
|
640
|
+
class ClaudeDesktopAdapter {
|
|
641
|
+
id = "claude-desktop";
|
|
642
|
+
detectConfigPath() {
|
|
643
|
+
return path4.join(homeDir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
644
|
+
}
|
|
645
|
+
supportsHttp() {
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
scanForImports(_config, managedIndex) {
|
|
649
|
+
const configPath = this.detectConfigPath();
|
|
650
|
+
const result = emptyImportScan(this.id, configPath ?? undefined);
|
|
651
|
+
if (!configPath) {
|
|
652
|
+
return result;
|
|
653
|
+
}
|
|
654
|
+
const raw = readJsonFile(configPath, {});
|
|
655
|
+
const topLevelServers = raw.mcpServers ?? {};
|
|
656
|
+
for (const [name, rawEntry] of Object.entries(topLevelServers)) {
|
|
657
|
+
if (isManagedGatewayProjection(name) || managedIndex.managed[this.id]?.entries?.[name]) {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const parsed = claudeDesktopEntrySchema.safeParse(rawEntry);
|
|
661
|
+
if (!parsed.success) {
|
|
662
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry could not be mapped to an mcpx server.", configPath));
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const entry = parsed.data;
|
|
666
|
+
if ((entry.type === undefined || entry.type === "http") && entry.url && !entry.command) {
|
|
667
|
+
result.candidates.push({
|
|
668
|
+
clientId: this.id,
|
|
669
|
+
configPath,
|
|
670
|
+
sourceEntryName: name,
|
|
671
|
+
serverName: name,
|
|
672
|
+
spec: {
|
|
673
|
+
transport: "http",
|
|
674
|
+
url: entry.url,
|
|
675
|
+
headers: entry.headers,
|
|
676
|
+
enabled: entry.disabled !== true
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if ((entry.type === undefined || entry.type === "stdio") && entry.command && !entry.url) {
|
|
682
|
+
result.candidates.push({
|
|
683
|
+
clientId: this.id,
|
|
684
|
+
configPath,
|
|
685
|
+
sourceEntryName: name,
|
|
686
|
+
serverName: name,
|
|
687
|
+
spec: {
|
|
688
|
+
transport: "stdio",
|
|
689
|
+
command: entry.command,
|
|
690
|
+
args: entry.args,
|
|
691
|
+
env: entry.env,
|
|
692
|
+
cwd: entry.cwd,
|
|
693
|
+
enabled: entry.disabled !== true
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry must define exactly one transport that mcpx supports.", configPath));
|
|
699
|
+
}
|
|
700
|
+
return result;
|
|
701
|
+
}
|
|
702
|
+
syncGateway(_config, options) {
|
|
703
|
+
const configPath = this.detectConfigPath();
|
|
704
|
+
if (!configPath) {
|
|
705
|
+
return errorResult(this.id, undefined, "Unable to resolve Claude Desktop config path.");
|
|
706
|
+
}
|
|
707
|
+
try {
|
|
708
|
+
const raw = readJsonFile(configPath, {});
|
|
709
|
+
const enabledEntries = options.managedEntries.filter((entry) => entry.enabled);
|
|
710
|
+
const serverEntries = Object.fromEntries(enabledEntries.map((entry) => [entry.name, {
|
|
711
|
+
type: "http",
|
|
712
|
+
url: entry.url,
|
|
713
|
+
headers: entry.headers
|
|
714
|
+
}]));
|
|
715
|
+
const enabledManagedNames = enabledEntries.map((entry) => entry.name);
|
|
716
|
+
const topLevelServers = raw.mcpServers ?? {};
|
|
717
|
+
removeSourceEntries(topLevelServers, options.sourceEntriesToRemove);
|
|
718
|
+
for (const name of enabledManagedNames) {
|
|
719
|
+
const topLevelConflict = ensureManagedEntryWritable(options.managedIndex, this.id, name, topLevelServers[name]);
|
|
720
|
+
if (topLevelConflict) {
|
|
721
|
+
return errorResult(this.id, configPath, topLevelConflict);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
pruneStaleManagedEntries(options.managedIndex, this.id, topLevelServers, enabledManagedNames);
|
|
725
|
+
for (const [name, entry] of Object.entries(serverEntries)) {
|
|
726
|
+
topLevelServers[name] = entry;
|
|
727
|
+
}
|
|
728
|
+
raw.mcpServers = topLevelServers;
|
|
729
|
+
writeJsonAtomic(configPath, raw);
|
|
730
|
+
setManagedEntries(options.managedIndex, this.id, configPath, Object.fromEntries(options.managedEntries.map((entry) => [
|
|
731
|
+
entry.name,
|
|
732
|
+
JSON.stringify({ type: "http", url: entry.url, headers: entry.headers, enabled: entry.enabled })
|
|
733
|
+
])));
|
|
734
|
+
return okResult(this.id, configPath);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
return errorResult(this.id, configPath, error.message);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/adapters/codex.ts
|
|
742
|
+
import path5 from "node:path";
|
|
743
|
+
import fs4 from "node:fs";
|
|
744
|
+
import { parse, stringify } from "@iarna/toml";
|
|
745
|
+
import { z as z4 } from "zod";
|
|
746
|
+
var stringMapSchema3 = z4.preprocess((value) => {
|
|
747
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
748
|
+
return value;
|
|
749
|
+
}
|
|
750
|
+
return Object.fromEntries(Object.entries(value));
|
|
751
|
+
}, z4.record(z4.string(), z4.string()));
|
|
752
|
+
var codexEntrySchema = z4.object({
|
|
753
|
+
enabled: z4.boolean().optional(),
|
|
754
|
+
url: z4.string().min(1).optional(),
|
|
755
|
+
headers: stringMapSchema3.optional(),
|
|
756
|
+
http_headers: stringMapSchema3.optional(),
|
|
757
|
+
command: z4.string().min(1).optional(),
|
|
758
|
+
args: z4.array(z4.string()).optional(),
|
|
759
|
+
env: stringMapSchema3.optional(),
|
|
760
|
+
cwd: z4.string().min(1).optional()
|
|
761
|
+
}).passthrough();
|
|
762
|
+
|
|
763
|
+
class CodexAdapter {
|
|
764
|
+
id = "codex";
|
|
765
|
+
detectConfigPath() {
|
|
766
|
+
return path5.join(homeDir(), ".codex", "config.toml");
|
|
767
|
+
}
|
|
768
|
+
supportsHttp() {
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
scanForImports(_config, managedIndex) {
|
|
772
|
+
const configPath = this.detectConfigPath();
|
|
773
|
+
const result = emptyImportScan(this.id, configPath ?? undefined);
|
|
774
|
+
if (!configPath || !fs4.existsSync(configPath)) {
|
|
775
|
+
return result;
|
|
776
|
+
}
|
|
777
|
+
const existing = fs4.readFileSync(configPath, "utf8");
|
|
778
|
+
const doc = existing.trim().length > 0 ? parse(existing) : {};
|
|
779
|
+
const mcpServers = doc.mcp_servers ?? {};
|
|
780
|
+
for (const [name, rawEntry] of Object.entries(mcpServers)) {
|
|
781
|
+
if (isManagedGatewayProjection(name) || managedIndex.managed[this.id]?.entries?.[name]) {
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
const parsed = codexEntrySchema.safeParse(rawEntry);
|
|
785
|
+
if (!parsed.success) {
|
|
786
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry could not be mapped to an mcpx server.", configPath));
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
const entry = parsed.data;
|
|
790
|
+
if (entry.url && !entry.command) {
|
|
791
|
+
result.candidates.push({
|
|
792
|
+
clientId: this.id,
|
|
793
|
+
configPath,
|
|
794
|
+
sourceEntryName: name,
|
|
795
|
+
serverName: name,
|
|
796
|
+
spec: {
|
|
797
|
+
transport: "http",
|
|
798
|
+
url: entry.url,
|
|
799
|
+
headers: entry.http_headers ?? entry.headers,
|
|
800
|
+
enabled: entry.enabled !== false
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (entry.command && !entry.url) {
|
|
806
|
+
result.candidates.push({
|
|
807
|
+
clientId: this.id,
|
|
808
|
+
configPath,
|
|
809
|
+
sourceEntryName: name,
|
|
810
|
+
serverName: name,
|
|
811
|
+
spec: {
|
|
812
|
+
transport: "stdio",
|
|
813
|
+
command: entry.command,
|
|
814
|
+
args: entry.args,
|
|
815
|
+
env: entry.env,
|
|
816
|
+
cwd: entry.cwd,
|
|
817
|
+
enabled: entry.enabled !== false
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry must define exactly one transport that mcpx supports.", configPath));
|
|
823
|
+
}
|
|
824
|
+
return result;
|
|
825
|
+
}
|
|
826
|
+
syncGateway(_config, options) {
|
|
827
|
+
const configPath = this.detectConfigPath();
|
|
828
|
+
if (!configPath) {
|
|
829
|
+
return errorResult(this.id, undefined, "Unable to resolve Codex config.toml path.");
|
|
830
|
+
}
|
|
831
|
+
try {
|
|
832
|
+
const existing = fs4.existsSync(configPath) ? fs4.readFileSync(configPath, "utf8") : "";
|
|
833
|
+
const doc = existing.trim().length > 0 ? parse(existing) : {};
|
|
834
|
+
const mcpServers = doc.mcp_servers ?? {};
|
|
835
|
+
removeSourceEntries(mcpServers, options.sourceEntriesToRemove);
|
|
836
|
+
const managedNames = options.managedEntries.map((entry) => entry.name);
|
|
837
|
+
const serverEntries = Object.fromEntries(options.managedEntries.map((entry) => [entry.name, {
|
|
838
|
+
enabled: entry.enabled,
|
|
839
|
+
url: entry.url,
|
|
840
|
+
http_headers: entry.headers
|
|
841
|
+
}]));
|
|
842
|
+
for (const name of managedNames) {
|
|
843
|
+
const conflict = ensureManagedEntryWritable(options.managedIndex, this.id, name, mcpServers[name]);
|
|
844
|
+
if (conflict) {
|
|
845
|
+
return errorResult(this.id, configPath, conflict);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
pruneStaleManagedEntries(options.managedIndex, this.id, mcpServers, managedNames);
|
|
849
|
+
for (const [name, entry] of Object.entries(serverEntries)) {
|
|
850
|
+
mcpServers[name] = entry;
|
|
851
|
+
}
|
|
852
|
+
doc.mcp_servers = mcpServers;
|
|
853
|
+
fs4.mkdirSync(path5.dirname(configPath), { recursive: true });
|
|
854
|
+
const tmpPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
|
|
855
|
+
fs4.writeFileSync(tmpPath, stringify(doc), { mode: 384 });
|
|
856
|
+
fs4.renameSync(tmpPath, configPath);
|
|
857
|
+
setManagedEntries(options.managedIndex, this.id, configPath, Object.fromEntries(Object.entries(serverEntries).map(([name, entry]) => [name, JSON.stringify(entry)])));
|
|
858
|
+
return okResult(this.id, configPath);
|
|
859
|
+
} catch (error) {
|
|
860
|
+
return errorResult(this.id, configPath, error.message);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// src/adapters/cursor.ts
|
|
866
|
+
import path6 from "node:path";
|
|
867
|
+
import { z as z5 } from "zod";
|
|
868
|
+
var stringMapSchema4 = z5.record(z5.string(), z5.string());
|
|
869
|
+
var cursorEntrySchema = z5.object({
|
|
870
|
+
type: z5.string().optional(),
|
|
871
|
+
url: z5.string().min(1).optional(),
|
|
872
|
+
headers: stringMapSchema4.optional(),
|
|
873
|
+
command: z5.string().min(1).optional(),
|
|
874
|
+
args: z5.array(z5.string()).optional(),
|
|
875
|
+
env: stringMapSchema4.optional(),
|
|
876
|
+
cwd: z5.string().min(1).optional(),
|
|
877
|
+
disabled: z5.boolean().optional()
|
|
878
|
+
}).passthrough();
|
|
879
|
+
|
|
880
|
+
class CursorAdapter {
|
|
881
|
+
id = "cursor";
|
|
882
|
+
detectConfigPath() {
|
|
883
|
+
return path6.join(homeDir(), "Library", "Application Support", "Cursor", "User", "mcp.json");
|
|
884
|
+
}
|
|
885
|
+
supportsHttp() {
|
|
886
|
+
return true;
|
|
887
|
+
}
|
|
888
|
+
scanForImports(_config, managedIndex) {
|
|
889
|
+
const configPath = this.detectConfigPath();
|
|
890
|
+
const result = emptyImportScan(this.id, configPath ?? undefined);
|
|
891
|
+
if (!configPath) {
|
|
892
|
+
return result;
|
|
893
|
+
}
|
|
894
|
+
const raw = readJsonFile(configPath, {});
|
|
895
|
+
const servers = {
|
|
896
|
+
...raw.servers ?? {}
|
|
897
|
+
};
|
|
898
|
+
for (const [name, rawEntry] of Object.entries(servers)) {
|
|
899
|
+
if (isManagedGatewayProjection(name) || managedIndex.managed[this.id]?.entries?.[name]) {
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
const parsed = cursorEntrySchema.safeParse(rawEntry);
|
|
903
|
+
if (!parsed.success) {
|
|
904
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry could not be mapped to an mcpx server.", configPath));
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
const entry = parsed.data;
|
|
908
|
+
if ((entry.type === undefined || entry.type === "http") && entry.url && !entry.command) {
|
|
909
|
+
result.candidates.push({
|
|
910
|
+
clientId: this.id,
|
|
911
|
+
configPath,
|
|
912
|
+
sourceEntryName: name,
|
|
913
|
+
serverName: name,
|
|
914
|
+
spec: {
|
|
915
|
+
transport: "http",
|
|
916
|
+
url: entry.url,
|
|
917
|
+
headers: entry.headers,
|
|
918
|
+
enabled: entry.disabled !== true
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if ((entry.type === undefined || entry.type === "stdio") && entry.command && !entry.url) {
|
|
924
|
+
result.candidates.push({
|
|
925
|
+
clientId: this.id,
|
|
926
|
+
configPath,
|
|
927
|
+
sourceEntryName: name,
|
|
928
|
+
serverName: name,
|
|
929
|
+
spec: {
|
|
930
|
+
transport: "stdio",
|
|
931
|
+
command: entry.command,
|
|
932
|
+
args: entry.args,
|
|
933
|
+
env: entry.env,
|
|
934
|
+
cwd: entry.cwd,
|
|
935
|
+
enabled: entry.disabled !== true
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry must define exactly one transport that mcpx supports.", configPath));
|
|
941
|
+
}
|
|
942
|
+
return result;
|
|
943
|
+
}
|
|
944
|
+
syncGateway(_config, options) {
|
|
945
|
+
const configPath = this.detectConfigPath();
|
|
946
|
+
if (!configPath) {
|
|
947
|
+
return errorResult(this.id, undefined, "Unable to resolve Cursor MCP config path.");
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
const raw = readJsonFile(configPath, {});
|
|
951
|
+
const servers = {
|
|
952
|
+
...raw.servers ?? {}
|
|
953
|
+
};
|
|
954
|
+
removeSourceEntries(servers, options.sourceEntriesToRemove);
|
|
955
|
+
const enabledEntries = options.managedEntries.filter((entry) => entry.enabled);
|
|
956
|
+
const serverEntries = Object.fromEntries(enabledEntries.map((entry) => [entry.name, {
|
|
957
|
+
type: "http",
|
|
958
|
+
url: entry.url,
|
|
959
|
+
headers: entry.headers
|
|
960
|
+
}]));
|
|
961
|
+
const enabledManagedNames = enabledEntries.map((entry) => entry.name);
|
|
962
|
+
for (const name of enabledManagedNames) {
|
|
963
|
+
const conflict = ensureManagedEntryWritable(options.managedIndex, this.id, name, servers[name]);
|
|
964
|
+
if (conflict) {
|
|
965
|
+
return errorResult(this.id, configPath, conflict);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
pruneStaleManagedEntries(options.managedIndex, this.id, servers, enabledManagedNames);
|
|
969
|
+
for (const [name, entry] of Object.entries(serverEntries)) {
|
|
970
|
+
servers[name] = entry;
|
|
971
|
+
}
|
|
972
|
+
const next = {
|
|
973
|
+
...raw,
|
|
974
|
+
servers
|
|
975
|
+
};
|
|
976
|
+
writeJsonAtomic(configPath, next);
|
|
977
|
+
setManagedEntries(options.managedIndex, this.id, configPath, Object.fromEntries(options.managedEntries.map((entry) => [
|
|
978
|
+
entry.name,
|
|
979
|
+
JSON.stringify({ type: "http", url: entry.url, headers: entry.headers, enabled: entry.enabled })
|
|
980
|
+
])));
|
|
981
|
+
return okResult(this.id, configPath);
|
|
982
|
+
} catch (error) {
|
|
983
|
+
return errorResult(this.id, configPath, error.message);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// src/adapters/cline.ts
|
|
989
|
+
import path7 from "node:path";
|
|
990
|
+
import fs5 from "node:fs";
|
|
991
|
+
import { z as z6 } from "zod";
|
|
992
|
+
var stringMapSchema5 = z6.record(z6.string(), z6.string());
|
|
993
|
+
var clineEntrySchema = z6.object({
|
|
994
|
+
transportType: z6.string().optional(),
|
|
995
|
+
url: z6.string().min(1).optional(),
|
|
996
|
+
headers: stringMapSchema5.optional(),
|
|
997
|
+
command: z6.string().min(1).optional(),
|
|
998
|
+
args: z6.array(z6.string()).optional(),
|
|
999
|
+
env: stringMapSchema5.optional(),
|
|
1000
|
+
cwd: z6.string().min(1).optional(),
|
|
1001
|
+
disabled: z6.boolean().optional()
|
|
1002
|
+
}).passthrough();
|
|
1003
|
+
function getCandidatePaths() {
|
|
1004
|
+
return [
|
|
1005
|
+
path7.join(homeDir(), "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
|
|
1006
|
+
path7.join(homeDir(), "Library", "Application Support", "Cursor", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json")
|
|
1007
|
+
];
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
class ClineAdapter {
|
|
1011
|
+
id = "cline";
|
|
1012
|
+
detectConfigPath() {
|
|
1013
|
+
const candidates = getCandidatePaths();
|
|
1014
|
+
for (const candidate of candidates) {
|
|
1015
|
+
if (fs5.existsSync(candidate)) {
|
|
1016
|
+
return candidate;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return candidates[0] ?? null;
|
|
1020
|
+
}
|
|
1021
|
+
supportsHttp() {
|
|
1022
|
+
return true;
|
|
1023
|
+
}
|
|
1024
|
+
scanForImports(_config, managedIndex) {
|
|
1025
|
+
const configPath = this.detectConfigPath();
|
|
1026
|
+
const result = emptyImportScan(this.id, configPath ?? undefined);
|
|
1027
|
+
if (!configPath || !fs5.existsSync(configPath)) {
|
|
1028
|
+
return result;
|
|
1029
|
+
}
|
|
1030
|
+
const raw = readJsonFile(configPath, {});
|
|
1031
|
+
const servers = {
|
|
1032
|
+
...raw.mcpServers ?? {}
|
|
1033
|
+
};
|
|
1034
|
+
for (const [name, rawEntry] of Object.entries(servers)) {
|
|
1035
|
+
if (isManagedGatewayProjection(name) || managedIndex.managed[this.id]?.entries?.[name]) {
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
const parsed = clineEntrySchema.safeParse(rawEntry);
|
|
1039
|
+
if (!parsed.success) {
|
|
1040
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry could not be mapped to an mcpx server.", configPath));
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
const entry = parsed.data;
|
|
1044
|
+
if ((entry.transportType === undefined || entry.transportType === "http") && entry.url && !entry.command) {
|
|
1045
|
+
result.candidates.push({
|
|
1046
|
+
clientId: this.id,
|
|
1047
|
+
configPath,
|
|
1048
|
+
sourceEntryName: name,
|
|
1049
|
+
serverName: name,
|
|
1050
|
+
spec: {
|
|
1051
|
+
transport: "http",
|
|
1052
|
+
url: entry.url,
|
|
1053
|
+
headers: entry.headers,
|
|
1054
|
+
enabled: entry.disabled !== true
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
if ((entry.transportType === undefined || entry.transportType === "stdio") && entry.command && !entry.url) {
|
|
1060
|
+
result.candidates.push({
|
|
1061
|
+
clientId: this.id,
|
|
1062
|
+
configPath,
|
|
1063
|
+
sourceEntryName: name,
|
|
1064
|
+
serverName: name,
|
|
1065
|
+
spec: {
|
|
1066
|
+
transport: "stdio",
|
|
1067
|
+
command: entry.command,
|
|
1068
|
+
args: entry.args,
|
|
1069
|
+
env: entry.env,
|
|
1070
|
+
cwd: entry.cwd,
|
|
1071
|
+
enabled: entry.disabled !== true
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry must define exactly one transport that mcpx supports.", configPath));
|
|
1077
|
+
}
|
|
1078
|
+
return result;
|
|
1079
|
+
}
|
|
1080
|
+
syncGateway(_config, options) {
|
|
1081
|
+
const configPath = this.detectConfigPath();
|
|
1082
|
+
if (!configPath) {
|
|
1083
|
+
return errorResult(this.id, undefined, "Unable to resolve Cline MCP config path.");
|
|
1084
|
+
}
|
|
1085
|
+
try {
|
|
1086
|
+
const raw = readJsonFile(configPath, {});
|
|
1087
|
+
const servers = {
|
|
1088
|
+
...raw.mcpServers ?? {}
|
|
1089
|
+
};
|
|
1090
|
+
removeSourceEntries(servers, options.sourceEntriesToRemove);
|
|
1091
|
+
const managedNames = options.managedEntries.map((entry) => entry.name);
|
|
1092
|
+
const serverEntries = Object.fromEntries(options.managedEntries.map((entry) => [entry.name, {
|
|
1093
|
+
transportType: "http",
|
|
1094
|
+
url: entry.url,
|
|
1095
|
+
headers: entry.headers,
|
|
1096
|
+
disabled: !entry.enabled
|
|
1097
|
+
}]));
|
|
1098
|
+
for (const name of managedNames) {
|
|
1099
|
+
const conflict = ensureManagedEntryWritable(options.managedIndex, this.id, name, servers[name]);
|
|
1100
|
+
if (conflict) {
|
|
1101
|
+
return errorResult(this.id, configPath, conflict);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
pruneStaleManagedEntries(options.managedIndex, this.id, servers, managedNames);
|
|
1105
|
+
for (const [name, entry] of Object.entries(serverEntries)) {
|
|
1106
|
+
servers[name] = entry;
|
|
1107
|
+
}
|
|
1108
|
+
const next = {
|
|
1109
|
+
...raw,
|
|
1110
|
+
mcpServers: servers
|
|
1111
|
+
};
|
|
1112
|
+
writeJsonAtomic(configPath, next);
|
|
1113
|
+
setManagedEntries(options.managedIndex, this.id, configPath, Object.fromEntries(Object.entries(serverEntries).map(([name, entry]) => [name, JSON.stringify(entry)])));
|
|
1114
|
+
return okResult(this.id, configPath);
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
return errorResult(this.id, configPath, error.message);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// src/adapters/kiro.ts
|
|
1122
|
+
import path8 from "node:path";
|
|
1123
|
+
import { z as z7 } from "zod";
|
|
1124
|
+
var stringMapSchema6 = z7.record(z7.string(), z7.string());
|
|
1125
|
+
var kiroEntrySchema = z7.object({
|
|
1126
|
+
url: z7.string().min(1).optional(),
|
|
1127
|
+
headers: stringMapSchema6.optional(),
|
|
1128
|
+
command: z7.string().min(1).optional(),
|
|
1129
|
+
args: z7.array(z7.string()).optional(),
|
|
1130
|
+
env: stringMapSchema6.optional(),
|
|
1131
|
+
cwd: z7.string().min(1).optional(),
|
|
1132
|
+
disabled: z7.boolean().optional()
|
|
1133
|
+
}).passthrough();
|
|
1134
|
+
|
|
1135
|
+
class KiroAdapter {
|
|
1136
|
+
id = "kiro";
|
|
1137
|
+
detectConfigPath() {
|
|
1138
|
+
return path8.join(homeDir(), ".kiro", "settings", "mcp.json");
|
|
1139
|
+
}
|
|
1140
|
+
supportsHttp() {
|
|
1141
|
+
return true;
|
|
1142
|
+
}
|
|
1143
|
+
scanForImports(_config, managedIndex) {
|
|
1144
|
+
const configPath = this.detectConfigPath();
|
|
1145
|
+
const result = emptyImportScan(this.id, configPath ?? undefined);
|
|
1146
|
+
if (!configPath) {
|
|
1147
|
+
return result;
|
|
1148
|
+
}
|
|
1149
|
+
const raw = readJsonFile(configPath, {});
|
|
1150
|
+
const servers = {
|
|
1151
|
+
...raw.mcpServers ?? {}
|
|
1152
|
+
};
|
|
1153
|
+
for (const [name, rawEntry] of Object.entries(servers)) {
|
|
1154
|
+
if (isManagedGatewayProjection(name) || managedIndex.managed[this.id]?.entries?.[name]) {
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
const parsed = kiroEntrySchema.safeParse(rawEntry);
|
|
1158
|
+
if (!parsed.success) {
|
|
1159
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry could not be mapped to an mcpx server.", configPath));
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
const entry = parsed.data;
|
|
1163
|
+
if (entry.url && !entry.command) {
|
|
1164
|
+
result.candidates.push({
|
|
1165
|
+
clientId: this.id,
|
|
1166
|
+
configPath,
|
|
1167
|
+
sourceEntryName: name,
|
|
1168
|
+
serverName: name,
|
|
1169
|
+
spec: {
|
|
1170
|
+
transport: "http",
|
|
1171
|
+
url: entry.url,
|
|
1172
|
+
headers: entry.headers,
|
|
1173
|
+
enabled: entry.disabled !== true
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
if (entry.command && !entry.url) {
|
|
1179
|
+
result.candidates.push({
|
|
1180
|
+
clientId: this.id,
|
|
1181
|
+
configPath,
|
|
1182
|
+
sourceEntryName: name,
|
|
1183
|
+
serverName: name,
|
|
1184
|
+
spec: {
|
|
1185
|
+
transport: "stdio",
|
|
1186
|
+
command: entry.command,
|
|
1187
|
+
args: entry.args,
|
|
1188
|
+
env: entry.env,
|
|
1189
|
+
cwd: entry.cwd,
|
|
1190
|
+
enabled: entry.disabled !== true
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry must define exactly one transport that mcpx supports.", configPath));
|
|
1196
|
+
}
|
|
1197
|
+
return result;
|
|
1198
|
+
}
|
|
1199
|
+
syncGateway(_config, options) {
|
|
1200
|
+
const configPath = this.detectConfigPath();
|
|
1201
|
+
if (!configPath) {
|
|
1202
|
+
return errorResult(this.id, undefined, "Unable to resolve Kiro MCP config path.");
|
|
1203
|
+
}
|
|
1204
|
+
try {
|
|
1205
|
+
const raw = readJsonFile(configPath, {});
|
|
1206
|
+
const servers = {
|
|
1207
|
+
...raw.mcpServers ?? {}
|
|
1208
|
+
};
|
|
1209
|
+
removeSourceEntries(servers, options.sourceEntriesToRemove);
|
|
1210
|
+
const managedNames = options.managedEntries.map((entry) => entry.name);
|
|
1211
|
+
const serverEntries = Object.fromEntries(options.managedEntries.map((entry) => [entry.name, {
|
|
1212
|
+
url: entry.url,
|
|
1213
|
+
headers: entry.headers,
|
|
1214
|
+
disabled: !entry.enabled
|
|
1215
|
+
}]));
|
|
1216
|
+
for (const name of managedNames) {
|
|
1217
|
+
const conflict = ensureManagedEntryWritable(options.managedIndex, this.id, name, servers[name]);
|
|
1218
|
+
if (conflict) {
|
|
1219
|
+
return errorResult(this.id, configPath, conflict);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
pruneStaleManagedEntries(options.managedIndex, this.id, servers, managedNames);
|
|
1223
|
+
for (const [name, entry] of Object.entries(serverEntries)) {
|
|
1224
|
+
servers[name] = entry;
|
|
1225
|
+
}
|
|
1226
|
+
const next = {
|
|
1227
|
+
...raw,
|
|
1228
|
+
mcpServers: servers
|
|
1229
|
+
};
|
|
1230
|
+
writeJsonAtomic(configPath, next);
|
|
1231
|
+
setManagedEntries(options.managedIndex, this.id, configPath, Object.fromEntries(Object.entries(serverEntries).map(([name, entry]) => [name, JSON.stringify(entry)])));
|
|
1232
|
+
return okResult(this.id, configPath);
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
return errorResult(this.id, configPath, error.message);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// src/adapters/opencode.ts
|
|
1240
|
+
import path9 from "node:path";
|
|
1241
|
+
import { z as z8 } from "zod";
|
|
1242
|
+
var stringMapSchema7 = z8.record(z8.string(), z8.string());
|
|
1243
|
+
var openCodeEntrySchema = z8.object({
|
|
1244
|
+
type: z8.string().optional(),
|
|
1245
|
+
url: z8.string().min(1).optional(),
|
|
1246
|
+
headers: stringMapSchema7.optional(),
|
|
1247
|
+
command: z8.string().min(1).optional(),
|
|
1248
|
+
args: z8.array(z8.string()).optional(),
|
|
1249
|
+
env: stringMapSchema7.optional(),
|
|
1250
|
+
cwd: z8.string().min(1).optional(),
|
|
1251
|
+
enabled: z8.boolean().optional()
|
|
1252
|
+
}).passthrough();
|
|
1253
|
+
|
|
1254
|
+
class OpenCodeAdapter {
|
|
1255
|
+
id = "opencode";
|
|
1256
|
+
detectConfigPath() {
|
|
1257
|
+
return path9.join(homeDir(), ".config", "opencode", "opencode.json");
|
|
1258
|
+
}
|
|
1259
|
+
supportsHttp() {
|
|
1260
|
+
return true;
|
|
1261
|
+
}
|
|
1262
|
+
scanForImports(_config, managedIndex) {
|
|
1263
|
+
const configPath = this.detectConfigPath();
|
|
1264
|
+
const result = emptyImportScan(this.id, configPath ?? undefined);
|
|
1265
|
+
if (!configPath) {
|
|
1266
|
+
return result;
|
|
1267
|
+
}
|
|
1268
|
+
const raw = readJsonFile(configPath, {});
|
|
1269
|
+
const mcp = {
|
|
1270
|
+
...raw.mcp ?? {}
|
|
1271
|
+
};
|
|
1272
|
+
for (const [name, rawEntry] of Object.entries(mcp)) {
|
|
1273
|
+
if (isManagedGatewayProjection(name) || managedIndex.managed[this.id]?.entries?.[name]) {
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
const parsed = openCodeEntrySchema.safeParse(rawEntry);
|
|
1277
|
+
if (!parsed.success) {
|
|
1278
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry could not be mapped to an mcpx server.", configPath));
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
const entry = parsed.data;
|
|
1282
|
+
if ((entry.type === undefined || entry.type === "remote" || entry.type === "http") && entry.url && !entry.command) {
|
|
1283
|
+
result.candidates.push({
|
|
1284
|
+
clientId: this.id,
|
|
1285
|
+
configPath,
|
|
1286
|
+
sourceEntryName: name,
|
|
1287
|
+
serverName: name,
|
|
1288
|
+
spec: {
|
|
1289
|
+
transport: "http",
|
|
1290
|
+
url: entry.url,
|
|
1291
|
+
headers: entry.headers,
|
|
1292
|
+
enabled: entry.enabled !== false
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
if ((entry.type === undefined || entry.type === "stdio") && entry.command && !entry.url) {
|
|
1298
|
+
result.candidates.push({
|
|
1299
|
+
clientId: this.id,
|
|
1300
|
+
configPath,
|
|
1301
|
+
sourceEntryName: name,
|
|
1302
|
+
serverName: name,
|
|
1303
|
+
spec: {
|
|
1304
|
+
transport: "stdio",
|
|
1305
|
+
command: entry.command,
|
|
1306
|
+
args: entry.args,
|
|
1307
|
+
env: entry.env,
|
|
1308
|
+
cwd: entry.cwd,
|
|
1309
|
+
enabled: entry.enabled !== false
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry must define exactly one transport that mcpx supports.", configPath));
|
|
1315
|
+
}
|
|
1316
|
+
return result;
|
|
1317
|
+
}
|
|
1318
|
+
syncGateway(_config, options) {
|
|
1319
|
+
const configPath = this.detectConfigPath();
|
|
1320
|
+
if (!configPath) {
|
|
1321
|
+
return errorResult(this.id, undefined, "Unable to resolve OpenCode config path.");
|
|
1322
|
+
}
|
|
1323
|
+
try {
|
|
1324
|
+
const raw = readJsonFile(configPath, {});
|
|
1325
|
+
const mcp = {
|
|
1326
|
+
...raw.mcp ?? {}
|
|
1327
|
+
};
|
|
1328
|
+
removeSourceEntries(mcp, options.sourceEntriesToRemove);
|
|
1329
|
+
const managedNames = options.managedEntries.map((entry) => entry.name);
|
|
1330
|
+
const serverEntries = Object.fromEntries(options.managedEntries.map((entry) => [entry.name, {
|
|
1331
|
+
type: "remote",
|
|
1332
|
+
url: entry.url,
|
|
1333
|
+
headers: entry.headers,
|
|
1334
|
+
enabled: entry.enabled
|
|
1335
|
+
}]));
|
|
1336
|
+
for (const name of managedNames) {
|
|
1337
|
+
const conflict = ensureManagedEntryWritable(options.managedIndex, this.id, name, mcp[name]);
|
|
1338
|
+
if (conflict) {
|
|
1339
|
+
return errorResult(this.id, configPath, conflict);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
pruneStaleManagedEntries(options.managedIndex, this.id, mcp, managedNames);
|
|
1343
|
+
for (const [name, entry] of Object.entries(serverEntries)) {
|
|
1344
|
+
mcp[name] = entry;
|
|
1345
|
+
}
|
|
1346
|
+
const next = {
|
|
1347
|
+
...raw,
|
|
1348
|
+
mcp
|
|
1349
|
+
};
|
|
1350
|
+
writeJsonAtomic(configPath, next);
|
|
1351
|
+
setManagedEntries(options.managedIndex, this.id, configPath, Object.fromEntries(Object.entries(serverEntries).map(([name, entry]) => [name, JSON.stringify(entry)])));
|
|
1352
|
+
return okResult(this.id, configPath);
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
return errorResult(this.id, configPath, error.message);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// src/adapters/vscode.ts
|
|
1360
|
+
import path10 from "node:path";
|
|
1361
|
+
import { z as z9 } from "zod";
|
|
1362
|
+
var stringMapSchema8 = z9.record(z9.string(), z9.string());
|
|
1363
|
+
var vscodeEntrySchema = z9.object({
|
|
1364
|
+
type: z9.string().optional(),
|
|
1365
|
+
url: z9.string().min(1).optional(),
|
|
1366
|
+
headers: stringMapSchema8.optional(),
|
|
1367
|
+
command: z9.string().min(1).optional(),
|
|
1368
|
+
args: z9.array(z9.string()).optional(),
|
|
1369
|
+
env: stringMapSchema8.optional(),
|
|
1370
|
+
cwd: z9.string().min(1).optional(),
|
|
1371
|
+
disabled: z9.boolean().optional()
|
|
1372
|
+
}).passthrough();
|
|
1373
|
+
|
|
1374
|
+
class VsCodeAdapter {
|
|
1375
|
+
id = "vscode";
|
|
1376
|
+
detectConfigPath() {
|
|
1377
|
+
return path10.join(homeDir(), "Library", "Application Support", "Code", "User", "mcp.json");
|
|
1378
|
+
}
|
|
1379
|
+
supportsHttp() {
|
|
1380
|
+
return true;
|
|
1381
|
+
}
|
|
1382
|
+
scanForImports(_config, managedIndex) {
|
|
1383
|
+
const configPath = this.detectConfigPath();
|
|
1384
|
+
const result = emptyImportScan(this.id, configPath ?? undefined);
|
|
1385
|
+
if (!configPath) {
|
|
1386
|
+
return result;
|
|
1387
|
+
}
|
|
1388
|
+
const raw = readJsonFile(configPath, {});
|
|
1389
|
+
const servers = {
|
|
1390
|
+
...raw.servers ?? {}
|
|
1391
|
+
};
|
|
1392
|
+
for (const [name, rawEntry] of Object.entries(servers)) {
|
|
1393
|
+
if (isManagedGatewayProjection(name) || managedIndex.managed[this.id]?.entries?.[name]) {
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
const parsed = vscodeEntrySchema.safeParse(rawEntry);
|
|
1397
|
+
if (!parsed.success) {
|
|
1398
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry could not be mapped to an mcpx server.", configPath));
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
const entry = parsed.data;
|
|
1402
|
+
if ((entry.type === undefined || entry.type === "http") && entry.url && !entry.command) {
|
|
1403
|
+
result.candidates.push({
|
|
1404
|
+
clientId: this.id,
|
|
1405
|
+
configPath,
|
|
1406
|
+
sourceEntryName: name,
|
|
1407
|
+
serverName: name,
|
|
1408
|
+
spec: {
|
|
1409
|
+
transport: "http",
|
|
1410
|
+
url: entry.url,
|
|
1411
|
+
headers: entry.headers,
|
|
1412
|
+
enabled: entry.disabled !== true
|
|
1413
|
+
}
|
|
1414
|
+
});
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
if ((entry.type === undefined || entry.type === "stdio") && entry.command && !entry.url) {
|
|
1418
|
+
result.candidates.push({
|
|
1419
|
+
clientId: this.id,
|
|
1420
|
+
configPath,
|
|
1421
|
+
sourceEntryName: name,
|
|
1422
|
+
serverName: name,
|
|
1423
|
+
spec: {
|
|
1424
|
+
transport: "stdio",
|
|
1425
|
+
command: entry.command,
|
|
1426
|
+
args: entry.args,
|
|
1427
|
+
env: entry.env,
|
|
1428
|
+
cwd: entry.cwd,
|
|
1429
|
+
enabled: entry.disabled !== true
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry must define exactly one transport that mcpx supports.", configPath));
|
|
1435
|
+
}
|
|
1436
|
+
return result;
|
|
1437
|
+
}
|
|
1438
|
+
syncGateway(_config, options) {
|
|
1439
|
+
const configPath = this.detectConfigPath();
|
|
1440
|
+
if (!configPath) {
|
|
1441
|
+
return errorResult(this.id, undefined, "Unable to resolve VS Code MCP config path.");
|
|
1442
|
+
}
|
|
1443
|
+
try {
|
|
1444
|
+
const raw = readJsonFile(configPath, {});
|
|
1445
|
+
const servers = {
|
|
1446
|
+
...raw.servers ?? {}
|
|
1447
|
+
};
|
|
1448
|
+
removeSourceEntries(servers, options.sourceEntriesToRemove);
|
|
1449
|
+
const enabledEntries = options.managedEntries.filter((entry) => entry.enabled);
|
|
1450
|
+
const serverEntries = Object.fromEntries(enabledEntries.map((entry) => [entry.name, {
|
|
1451
|
+
type: "http",
|
|
1452
|
+
url: entry.url,
|
|
1453
|
+
headers: entry.headers
|
|
1454
|
+
}]));
|
|
1455
|
+
const enabledManagedNames = enabledEntries.map((entry) => entry.name);
|
|
1456
|
+
for (const name of enabledManagedNames) {
|
|
1457
|
+
const conflict = ensureManagedEntryWritable(options.managedIndex, this.id, name, servers[name]);
|
|
1458
|
+
if (conflict) {
|
|
1459
|
+
return errorResult(this.id, configPath, conflict);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
pruneStaleManagedEntries(options.managedIndex, this.id, servers, enabledManagedNames);
|
|
1463
|
+
for (const [name, entry] of Object.entries(serverEntries)) {
|
|
1464
|
+
servers[name] = entry;
|
|
1465
|
+
}
|
|
1466
|
+
const next = {
|
|
1467
|
+
...raw,
|
|
1468
|
+
servers
|
|
1469
|
+
};
|
|
1470
|
+
writeJsonAtomic(configPath, next);
|
|
1471
|
+
setManagedEntries(options.managedIndex, this.id, configPath, Object.fromEntries(options.managedEntries.map((entry) => [
|
|
1472
|
+
entry.name,
|
|
1473
|
+
JSON.stringify({ type: "http", url: entry.url, headers: entry.headers, enabled: entry.enabled })
|
|
1474
|
+
])));
|
|
1475
|
+
return okResult(this.id, configPath);
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
return errorResult(this.id, configPath, error.message);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// src/adapters/qwen.ts
|
|
1483
|
+
import path11 from "node:path";
|
|
1484
|
+
import { z as z10 } from "zod";
|
|
1485
|
+
var stringMapSchema9 = z10.record(z10.string(), z10.string());
|
|
1486
|
+
var qwenEntrySchema = z10.object({
|
|
1487
|
+
httpUrl: z10.string().min(1).optional(),
|
|
1488
|
+
headers: stringMapSchema9.optional(),
|
|
1489
|
+
command: z10.string().min(1).optional(),
|
|
1490
|
+
args: z10.array(z10.string()).optional(),
|
|
1491
|
+
env: stringMapSchema9.optional(),
|
|
1492
|
+
cwd: z10.string().min(1).optional(),
|
|
1493
|
+
disabled: z10.boolean().optional()
|
|
1494
|
+
}).passthrough();
|
|
1495
|
+
|
|
1496
|
+
class QwenAdapter {
|
|
1497
|
+
id = "qwen";
|
|
1498
|
+
detectConfigPath() {
|
|
1499
|
+
return path11.join(homeDir(), ".qwen", "settings.json");
|
|
1500
|
+
}
|
|
1501
|
+
supportsHttp() {
|
|
1502
|
+
return true;
|
|
1503
|
+
}
|
|
1504
|
+
scanForImports(_config, managedIndex) {
|
|
1505
|
+
const configPath = this.detectConfigPath();
|
|
1506
|
+
const result = emptyImportScan(this.id, configPath ?? undefined);
|
|
1507
|
+
if (!configPath) {
|
|
1508
|
+
return result;
|
|
1509
|
+
}
|
|
1510
|
+
const raw = readJsonFile(configPath, {});
|
|
1511
|
+
const excludedSet = new Set(raw.mcp?.excluded ?? []);
|
|
1512
|
+
const topLevelServers = raw.mcpServers ?? {};
|
|
1513
|
+
for (const [name, rawEntry] of Object.entries(topLevelServers)) {
|
|
1514
|
+
if (isManagedGatewayProjection(name) || managedIndex.managed[this.id]?.entries?.[name]) {
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
const parsed = qwenEntrySchema.safeParse(rawEntry);
|
|
1518
|
+
if (!parsed.success) {
|
|
1519
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry could not be mapped to an mcpx server.", configPath));
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
const entry = parsed.data;
|
|
1523
|
+
if (entry.httpUrl && !entry.command) {
|
|
1524
|
+
result.candidates.push({
|
|
1525
|
+
clientId: this.id,
|
|
1526
|
+
configPath,
|
|
1527
|
+
sourceEntryName: name,
|
|
1528
|
+
serverName: name,
|
|
1529
|
+
spec: {
|
|
1530
|
+
transport: "http",
|
|
1531
|
+
url: entry.httpUrl,
|
|
1532
|
+
headers: entry.headers,
|
|
1533
|
+
enabled: !(entry.disabled === true || excludedSet.has(name))
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
if (entry.command && !entry.httpUrl) {
|
|
1539
|
+
result.candidates.push({
|
|
1540
|
+
clientId: this.id,
|
|
1541
|
+
configPath,
|
|
1542
|
+
sourceEntryName: name,
|
|
1543
|
+
serverName: name,
|
|
1544
|
+
spec: {
|
|
1545
|
+
transport: "stdio",
|
|
1546
|
+
command: entry.command,
|
|
1547
|
+
args: entry.args,
|
|
1548
|
+
env: entry.env,
|
|
1549
|
+
cwd: entry.cwd,
|
|
1550
|
+
enabled: !(entry.disabled === true || excludedSet.has(name))
|
|
1551
|
+
}
|
|
1552
|
+
});
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
result.skipped.push(buildImportSkip(this.id, name, "Entry must define exactly one transport that mcpx supports.", configPath));
|
|
1556
|
+
}
|
|
1557
|
+
return result;
|
|
1558
|
+
}
|
|
1559
|
+
syncGateway(_config, options) {
|
|
1560
|
+
const configPath = this.detectConfigPath();
|
|
1561
|
+
if (!configPath) {
|
|
1562
|
+
return errorResult(this.id, undefined, "Unable to resolve Qwen config path.");
|
|
1563
|
+
}
|
|
1564
|
+
try {
|
|
1565
|
+
const raw = readJsonFile(configPath, {});
|
|
1566
|
+
const managedNames = options.managedEntries.map((entry) => entry.name);
|
|
1567
|
+
const serverEntries = Object.fromEntries(options.managedEntries.map((entry) => [entry.name, {
|
|
1568
|
+
httpUrl: entry.url,
|
|
1569
|
+
headers: entry.headers
|
|
1570
|
+
}]));
|
|
1571
|
+
const topLevelServers = raw.mcpServers ?? {};
|
|
1572
|
+
removeSourceEntries(topLevelServers, options.sourceEntriesToRemove);
|
|
1573
|
+
for (const name of managedNames) {
|
|
1574
|
+
const topLevelConflict = ensureManagedEntryWritable(options.managedIndex, this.id, name, topLevelServers[name]);
|
|
1575
|
+
if (topLevelConflict) {
|
|
1576
|
+
return errorResult(this.id, configPath, topLevelConflict);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
pruneStaleManagedEntries(options.managedIndex, this.id, topLevelServers, managedNames);
|
|
1580
|
+
for (const [name, entry] of Object.entries(serverEntries)) {
|
|
1581
|
+
topLevelServers[name] = entry;
|
|
1582
|
+
}
|
|
1583
|
+
raw.mcpServers = topLevelServers;
|
|
1584
|
+
syncMcpExcludedArray(raw, managedNames, options.managedEntries);
|
|
1585
|
+
writeJsonAtomic(configPath, raw);
|
|
1586
|
+
setManagedEntries(options.managedIndex, this.id, configPath, Object.fromEntries(options.managedEntries.map((entry) => [
|
|
1587
|
+
entry.name,
|
|
1588
|
+
JSON.stringify({ httpUrl: entry.url, headers: entry.headers, enabled: entry.enabled })
|
|
1589
|
+
])));
|
|
1590
|
+
return okResult(this.id, configPath);
|
|
1591
|
+
} catch (error) {
|
|
1592
|
+
return errorResult(this.id, configPath, error.message);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// src/adapters/index.ts
|
|
1598
|
+
function getAdapters() {
|
|
1599
|
+
return [
|
|
1600
|
+
new ClaudeAdapter,
|
|
1601
|
+
new ClaudeDesktopAdapter,
|
|
1602
|
+
new CodexAdapter,
|
|
1603
|
+
new CursorAdapter,
|
|
1604
|
+
new ClineAdapter,
|
|
1605
|
+
new OpenCodeAdapter,
|
|
1606
|
+
new KiroAdapter,
|
|
1607
|
+
new VsCodeAdapter,
|
|
1608
|
+
new QwenAdapter
|
|
1609
|
+
];
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// src/core/managed-index.ts
|
|
1613
|
+
function defaultManagedIndex() {
|
|
1614
|
+
return {
|
|
1615
|
+
schemaVersion: 1,
|
|
1616
|
+
managed: {}
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
function loadManagedIndex(filePath = getManagedIndexPath()) {
|
|
1620
|
+
const raw = readJsonFile(filePath, defaultManagedIndex());
|
|
1621
|
+
if (!raw || typeof raw !== "object") {
|
|
1622
|
+
return defaultManagedIndex();
|
|
1623
|
+
}
|
|
1624
|
+
if (raw.schemaVersion !== 1) {
|
|
1625
|
+
return defaultManagedIndex();
|
|
1626
|
+
}
|
|
1627
|
+
return raw;
|
|
1628
|
+
}
|
|
1629
|
+
function saveManagedIndex(index, filePath = getManagedIndexPath()) {
|
|
1630
|
+
writeJsonAtomic(filePath, index);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// src/core/sync.ts
|
|
1634
|
+
function getGatewayUrl(config) {
|
|
1635
|
+
return `http://127.0.0.1:${config.gateway.port}/mcp`;
|
|
1636
|
+
}
|
|
1637
|
+
function buildManagedEntries(config, gatewayUrl, localToken) {
|
|
1638
|
+
const entries = Object.entries(config.servers);
|
|
1639
|
+
return entries.map(([name, spec]) => ({
|
|
1640
|
+
name: `${name} (mcpx)`,
|
|
1641
|
+
url: `${gatewayUrl}?upstream=${encodeURIComponent(name)}`,
|
|
1642
|
+
headers: {
|
|
1643
|
+
Authorization: `Bearer ${localToken}`
|
|
1644
|
+
},
|
|
1645
|
+
enabled: isServerEnabled(spec)
|
|
1646
|
+
}));
|
|
1647
|
+
}
|
|
1648
|
+
function syncAllClients(config, secrets, targetClients) {
|
|
1649
|
+
const adapters = getAdapters();
|
|
1650
|
+
const managedIndexPath = getManagedIndexPath();
|
|
1651
|
+
const managedIndex = loadManagedIndex(managedIndexPath);
|
|
1652
|
+
const filteredAdapters = targetClients && targetClients.length > 0 ? adapters.filter((adapter) => targetClients.includes(adapter.id)) : adapters;
|
|
1653
|
+
const imports = {
|
|
1654
|
+
imported: [],
|
|
1655
|
+
duplicates: [],
|
|
1656
|
+
skipped: [],
|
|
1657
|
+
conflicts: [],
|
|
1658
|
+
errors: []
|
|
1659
|
+
};
|
|
1660
|
+
const sourceEntriesToRemove = new Map;
|
|
1661
|
+
for (const adapter of filteredAdapters) {
|
|
1662
|
+
try {
|
|
1663
|
+
const scan = adapter.scanForImports(config, managedIndex);
|
|
1664
|
+
imports.skipped.push(...scan.skipped);
|
|
1665
|
+
for (const candidate of scan.candidates) {
|
|
1666
|
+
const existing = config.servers[candidate.serverName];
|
|
1667
|
+
if (!existing) {
|
|
1668
|
+
config.servers[candidate.serverName] = candidate.spec;
|
|
1669
|
+
imports.imported.push({
|
|
1670
|
+
clientId: candidate.clientId,
|
|
1671
|
+
configPath: candidate.configPath,
|
|
1672
|
+
sourceEntryName: candidate.sourceEntryName,
|
|
1673
|
+
serverName: candidate.serverName
|
|
1674
|
+
});
|
|
1675
|
+
if (!sourceEntriesToRemove.has(candidate.clientId)) {
|
|
1676
|
+
sourceEntriesToRemove.set(candidate.clientId, new Set);
|
|
1677
|
+
}
|
|
1678
|
+
sourceEntriesToRemove.get(candidate.clientId)?.add(candidate.sourceEntryName);
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
if (serverSpecsEqual(existing, candidate.spec)) {
|
|
1682
|
+
imports.duplicates.push({
|
|
1683
|
+
clientId: candidate.clientId,
|
|
1684
|
+
configPath: candidate.configPath,
|
|
1685
|
+
sourceEntryName: candidate.sourceEntryName,
|
|
1686
|
+
serverName: candidate.serverName
|
|
1687
|
+
});
|
|
1688
|
+
if (!sourceEntriesToRemove.has(candidate.clientId)) {
|
|
1689
|
+
sourceEntriesToRemove.set(candidate.clientId, new Set);
|
|
1690
|
+
}
|
|
1691
|
+
sourceEntriesToRemove.get(candidate.clientId)?.add(candidate.sourceEntryName);
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
imports.conflicts.push({
|
|
1695
|
+
clientId: candidate.clientId,
|
|
1696
|
+
configPath: candidate.configPath,
|
|
1697
|
+
sourceEntryName: candidate.sourceEntryName,
|
|
1698
|
+
serverName: candidate.serverName,
|
|
1699
|
+
message: `Server "${candidate.serverName}" already exists in mcpx with a different configuration.`
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
} catch (error) {
|
|
1703
|
+
imports.errors.push({
|
|
1704
|
+
clientId: adapter.id,
|
|
1705
|
+
configPath: adapter.detectConfigPath() ?? undefined,
|
|
1706
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
const localToken = ensureGatewayToken(config, secrets);
|
|
1711
|
+
const gatewayUrl = getGatewayUrl(config);
|
|
1712
|
+
const managedEntries = buildManagedEntries(config, gatewayUrl, localToken);
|
|
1713
|
+
const results = [];
|
|
1714
|
+
for (const adapter of filteredAdapters) {
|
|
1715
|
+
if (!adapter.supportsHttp()) {
|
|
1716
|
+
results.push({
|
|
1717
|
+
clientId: adapter.id,
|
|
1718
|
+
status: "UNSUPPORTED_HTTP",
|
|
1719
|
+
message: "Client adapter does not support MCP over HTTP."
|
|
1720
|
+
});
|
|
1721
|
+
continue;
|
|
1722
|
+
}
|
|
1723
|
+
const result = adapter.syncGateway(config, {
|
|
1724
|
+
managedEntries,
|
|
1725
|
+
managedIndex,
|
|
1726
|
+
managedIndexPath,
|
|
1727
|
+
sourceEntriesToRemove: Array.from(sourceEntriesToRemove.get(adapter.id) ?? [])
|
|
1728
|
+
});
|
|
1729
|
+
results.push(result);
|
|
1730
|
+
}
|
|
1731
|
+
for (const result of results) {
|
|
1732
|
+
config.clients[result.clientId] = {
|
|
1733
|
+
status: result.status,
|
|
1734
|
+
configPath: result.configPath,
|
|
1735
|
+
message: result.message,
|
|
1736
|
+
lastSyncAt: new Date().toISOString()
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
saveManagedIndex(managedIndex, managedIndexPath);
|
|
1740
|
+
saveConfig(config);
|
|
1741
|
+
const hasErrors = results.some((result) => result.status === "ERROR") || imports.conflicts.length > 0 || imports.errors.length > 0;
|
|
1742
|
+
return {
|
|
1743
|
+
gatewayUrl,
|
|
1744
|
+
imports,
|
|
1745
|
+
results,
|
|
1746
|
+
hasErrors
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// src/core/server-auth.ts
|
|
1751
|
+
function normalizeNamePart(value) {
|
|
1752
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || "value";
|
|
1753
|
+
}
|
|
1754
|
+
function requireNonEmpty(value, label) {
|
|
1755
|
+
const normalized = value?.trim();
|
|
1756
|
+
if (!normalized) {
|
|
1757
|
+
throw new Error(`${label} must be non-empty.`);
|
|
1758
|
+
}
|
|
1759
|
+
return normalized;
|
|
1760
|
+
}
|
|
1761
|
+
function toSecretRef(secretName) {
|
|
1762
|
+
return `secret://${secretName}`;
|
|
1763
|
+
}
|
|
1764
|
+
function secretRefName(ref) {
|
|
1765
|
+
if (!ref.startsWith("secret://")) {
|
|
1766
|
+
return null;
|
|
1767
|
+
}
|
|
1768
|
+
const name = ref.slice("secret://".length).trim();
|
|
1769
|
+
return name.length > 0 ? name : null;
|
|
1770
|
+
}
|
|
1771
|
+
function defaultAuthSecretName(serverName, target) {
|
|
1772
|
+
return `auth_${normalizeNamePart(serverName)}_${target.kind}_${normalizeNamePart(target.key)}`;
|
|
1773
|
+
}
|
|
1774
|
+
function resolveAuthTarget(spec, header, env) {
|
|
1775
|
+
if (spec.transport === "http") {
|
|
1776
|
+
if (env) {
|
|
1777
|
+
throw new Error("--env is only valid for stdio upstreams.");
|
|
1778
|
+
}
|
|
1779
|
+
return {
|
|
1780
|
+
kind: "header",
|
|
1781
|
+
key: requireNonEmpty(header ?? "Authorization", "Header name")
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
if (header) {
|
|
1785
|
+
throw new Error("--header is only valid for HTTP upstreams.");
|
|
1786
|
+
}
|
|
1787
|
+
return {
|
|
1788
|
+
kind: "env",
|
|
1789
|
+
key: requireNonEmpty(env, "Env var name")
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
function applyAuthReference(spec, target, secretRef) {
|
|
1793
|
+
if (target.kind === "header") {
|
|
1794
|
+
if (spec.transport !== "http") {
|
|
1795
|
+
throw new Error(`Cannot set header auth on ${spec.transport} upstream.`);
|
|
1796
|
+
}
|
|
1797
|
+
const headers = { ...spec.headers ?? {} };
|
|
1798
|
+
headers[target.key] = secretRef;
|
|
1799
|
+
spec.headers = headers;
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
if (spec.transport !== "stdio") {
|
|
1803
|
+
throw new Error(`Cannot set env auth on ${spec.transport} upstream.`);
|
|
1804
|
+
}
|
|
1805
|
+
const env = { ...spec.env ?? {} };
|
|
1806
|
+
env[target.key] = secretRef;
|
|
1807
|
+
spec.env = env;
|
|
1808
|
+
}
|
|
1809
|
+
function removeAuthReference(spec, target) {
|
|
1810
|
+
if (target.kind === "header") {
|
|
1811
|
+
if (spec.transport !== "http") {
|
|
1812
|
+
throw new Error(`Cannot remove header auth from ${spec.transport} upstream.`);
|
|
1813
|
+
}
|
|
1814
|
+
if (!spec.headers || !(target.key in spec.headers)) {
|
|
1815
|
+
return null;
|
|
1816
|
+
}
|
|
1817
|
+
const previous2 = spec.headers[target.key] ?? null;
|
|
1818
|
+
const next2 = { ...spec.headers };
|
|
1819
|
+
delete next2[target.key];
|
|
1820
|
+
spec.headers = Object.keys(next2).length > 0 ? next2 : undefined;
|
|
1821
|
+
return previous2;
|
|
1822
|
+
}
|
|
1823
|
+
if (spec.transport !== "stdio") {
|
|
1824
|
+
throw new Error(`Cannot remove env auth from ${spec.transport} upstream.`);
|
|
1825
|
+
}
|
|
1826
|
+
if (!spec.env || !(target.key in spec.env)) {
|
|
1827
|
+
return null;
|
|
1828
|
+
}
|
|
1829
|
+
const previous = spec.env[target.key] ?? null;
|
|
1830
|
+
const next = { ...spec.env };
|
|
1831
|
+
delete next[target.key];
|
|
1832
|
+
spec.env = Object.keys(next).length > 0 ? next : undefined;
|
|
1833
|
+
return previous;
|
|
1834
|
+
}
|
|
1835
|
+
function listAuthBindings(spec) {
|
|
1836
|
+
if (spec.transport === "http") {
|
|
1837
|
+
return Object.entries(spec.headers ?? {}).map(([key, value]) => ({
|
|
1838
|
+
kind: "header",
|
|
1839
|
+
key,
|
|
1840
|
+
value
|
|
1841
|
+
}));
|
|
1842
|
+
}
|
|
1843
|
+
return Object.entries(spec.env ?? {}).map(([key, value]) => ({
|
|
1844
|
+
kind: "env",
|
|
1845
|
+
key,
|
|
1846
|
+
value
|
|
1847
|
+
}));
|
|
1848
|
+
}
|
|
1849
|
+
function maybePrefixBearer(target, value, raw = false) {
|
|
1850
|
+
if (raw || target.kind !== "header" || target.key.toLowerCase() !== "authorization") {
|
|
1851
|
+
return value;
|
|
1852
|
+
}
|
|
1853
|
+
if (/^\S+\s+\S+/.test(value)) {
|
|
1854
|
+
return value;
|
|
1855
|
+
}
|
|
1856
|
+
return `Bearer ${value}`;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// src/version.ts
|
|
1860
|
+
var APP_VERSION = "0.1.3";
|
|
1861
|
+
|
|
1862
|
+
// src/core/update.ts
|
|
1863
|
+
import fs6 from "node:fs";
|
|
1864
|
+
import path12 from "node:path";
|
|
1865
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
1866
|
+
function compareVersions(a, b) {
|
|
1867
|
+
const partsA = a.split(".").map(Number);
|
|
1868
|
+
const partsB = b.split(".").map(Number);
|
|
1869
|
+
for (let i = 0;i < 3; i += 1) {
|
|
1870
|
+
const numA = partsA[i] ?? 0;
|
|
1871
|
+
const numB = partsB[i] ?? 0;
|
|
1872
|
+
if (numA > numB)
|
|
1873
|
+
return 1;
|
|
1874
|
+
if (numA < numB)
|
|
1875
|
+
return -1;
|
|
1876
|
+
}
|
|
1877
|
+
return 0;
|
|
1878
|
+
}
|
|
1879
|
+
async function checkForUpdates() {
|
|
1880
|
+
try {
|
|
1881
|
+
const output = execFileSync2("npm", ["view", "@kwonye/mcpx", "version"], {
|
|
1882
|
+
encoding: "utf8",
|
|
1883
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1884
|
+
}).trim();
|
|
1885
|
+
const latestVersion = output;
|
|
1886
|
+
const updateAvailable = compareVersions(latestVersion, APP_VERSION) > 0;
|
|
1887
|
+
return {
|
|
1888
|
+
currentVersion: APP_VERSION,
|
|
1889
|
+
latestVersion,
|
|
1890
|
+
updateAvailable
|
|
1891
|
+
};
|
|
1892
|
+
} catch (error) {
|
|
1893
|
+
return {
|
|
1894
|
+
currentVersion: APP_VERSION,
|
|
1895
|
+
latestVersion: null,
|
|
1896
|
+
updateAvailable: false,
|
|
1897
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
function getStagedUpdate() {
|
|
1902
|
+
const stagedPath = getStagedVersionPath();
|
|
1903
|
+
if (!fs6.existsSync(stagedPath)) {
|
|
1904
|
+
return null;
|
|
1905
|
+
}
|
|
1906
|
+
try {
|
|
1907
|
+
const raw = fs6.readFileSync(stagedPath, "utf8");
|
|
1908
|
+
const info = JSON.parse(raw);
|
|
1909
|
+
if (!info.version || !info.cliPath) {
|
|
1910
|
+
return null;
|
|
1911
|
+
}
|
|
1912
|
+
const absoluteCliPath = path12.isAbsolute(info.cliPath) ? info.cliPath : path12.join(getUpdatesDir(), info.cliPath);
|
|
1913
|
+
if (!fs6.existsSync(absoluteCliPath)) {
|
|
1914
|
+
return null;
|
|
1915
|
+
}
|
|
1916
|
+
return {
|
|
1917
|
+
...info,
|
|
1918
|
+
cliPath: absoluteCliPath
|
|
1919
|
+
};
|
|
1920
|
+
} catch {
|
|
1921
|
+
return null;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
function stageUpdate(version, cliPath) {
|
|
1925
|
+
const updatesDir = getUpdatesDir();
|
|
1926
|
+
ensureDir(updatesDir);
|
|
1927
|
+
const stagedInfo = {
|
|
1928
|
+
version,
|
|
1929
|
+
cliPath: path12.isAbsolute(cliPath) ? path12.relative(updatesDir, cliPath) : cliPath,
|
|
1930
|
+
stagedAt: new Date().toISOString()
|
|
1931
|
+
};
|
|
1932
|
+
fs6.writeFileSync(getStagedVersionPath(), JSON.stringify(stagedInfo, null, 2), "utf8");
|
|
1933
|
+
}
|
|
1934
|
+
function clearStagedUpdate() {
|
|
1935
|
+
const stagedPath = getStagedVersionPath();
|
|
1936
|
+
if (fs6.existsSync(stagedPath)) {
|
|
1937
|
+
fs6.unlinkSync(stagedPath);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
function getStagedCliPath() {
|
|
1941
|
+
const staged = getStagedUpdate();
|
|
1942
|
+
return staged?.cliPath ?? null;
|
|
1943
|
+
}
|
|
1944
|
+
function removeOldVersions(keepCount = 2) {
|
|
1945
|
+
const updatesDir = getUpdatesDir();
|
|
1946
|
+
if (!fs6.existsSync(updatesDir)) {
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const entries = fs6.readdirSync(updatesDir, { withFileTypes: true });
|
|
1950
|
+
const versionDirs = entries.filter((entry) => entry.isDirectory() && entry.name.startsWith("v")).map((entry) => entry.name).sort((a, b) => compareVersions(b.slice(1), a.slice(1)));
|
|
1951
|
+
if (versionDirs.length <= keepCount) {
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
for (const oldVersion of versionDirs.slice(keepCount)) {
|
|
1955
|
+
const oldPath = path12.join(updatesDir, oldVersion);
|
|
1956
|
+
try {
|
|
1957
|
+
fs6.rmSync(oldPath, { recursive: true, force: true });
|
|
1958
|
+
} catch {}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// src/core/update-manager.ts
|
|
1963
|
+
import fs7 from "node:fs";
|
|
1964
|
+
import path13 from "node:path";
|
|
1965
|
+
import { execSync, spawn } from "node:child_process";
|
|
1966
|
+
function acquireLock() {
|
|
1967
|
+
const lockPath = getUpdateLockPath();
|
|
1968
|
+
try {
|
|
1969
|
+
ensureDir(path13.dirname(lockPath));
|
|
1970
|
+
fs7.writeFileSync(lockPath, `${process.pid}
|
|
1971
|
+
`, { flag: "wx" });
|
|
1972
|
+
return true;
|
|
1973
|
+
} catch {
|
|
1974
|
+
return false;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
function releaseLock() {
|
|
1978
|
+
const lockPath = getUpdateLockPath();
|
|
1979
|
+
try {
|
|
1980
|
+
fs7.unlinkSync(lockPath);
|
|
1981
|
+
} catch {}
|
|
1982
|
+
}
|
|
1983
|
+
function isUpdateInProgress() {
|
|
1984
|
+
const lockPath = getUpdateLockPath();
|
|
1985
|
+
if (!fs7.existsSync(lockPath)) {
|
|
1986
|
+
return false;
|
|
1987
|
+
}
|
|
1988
|
+
try {
|
|
1989
|
+
const raw = fs7.readFileSync(lockPath, "utf8").trim();
|
|
1990
|
+
const pid = Number(raw);
|
|
1991
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
1992
|
+
fs7.unlinkSync(lockPath);
|
|
1993
|
+
return false;
|
|
1994
|
+
}
|
|
1995
|
+
process.kill(pid, 0);
|
|
1996
|
+
return true;
|
|
1997
|
+
} catch {
|
|
1998
|
+
fs7.unlinkSync(lockPath);
|
|
1999
|
+
return false;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
async function downloadAndStageUpdate() {
|
|
2003
|
+
const updateStatus = await checkForUpdates();
|
|
2004
|
+
if (!updateStatus.latestVersion || !updateStatus.updateAvailable) {
|
|
2005
|
+
return { success: false, error: "No update available" };
|
|
2006
|
+
}
|
|
2007
|
+
const targetVersion = updateStatus.latestVersion;
|
|
2008
|
+
const updatesDir = getUpdatesDir();
|
|
2009
|
+
const versionDir = path13.join(updatesDir, `v${targetVersion}`);
|
|
2010
|
+
if (fs7.existsSync(versionDir)) {
|
|
2011
|
+
const existingStaged = getStagedUpdate();
|
|
2012
|
+
if (existingStaged?.version === targetVersion) {
|
|
2013
|
+
return { success: false, error: `Version ${targetVersion} already staged` };
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
try {
|
|
2017
|
+
ensureDir(versionDir);
|
|
2018
|
+
execSync(`npm pack @kwonye/mcpx@${targetVersion} --json`, {
|
|
2019
|
+
cwd: versionDir,
|
|
2020
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2021
|
+
});
|
|
2022
|
+
const tarballPath = path13.join(versionDir, `kwonye-mcpx-${targetVersion}.tgz`);
|
|
2023
|
+
if (!fs7.existsSync(tarballPath)) {
|
|
2024
|
+
throw new Error("Downloaded tarball not found");
|
|
2025
|
+
}
|
|
2026
|
+
execSync(`tar -xzf ${tarballPath} --strip-components=1`, {
|
|
2027
|
+
cwd: versionDir,
|
|
2028
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2029
|
+
});
|
|
2030
|
+
execSync("npm install --production --ignore-scripts", {
|
|
2031
|
+
cwd: versionDir,
|
|
2032
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2033
|
+
});
|
|
2034
|
+
const cliPath = path13.join(versionDir, "dist", "cli.js");
|
|
2035
|
+
if (!fs7.existsSync(cliPath)) {
|
|
2036
|
+
throw new Error("CLI entry point not found in downloaded package");
|
|
2037
|
+
}
|
|
2038
|
+
stageUpdate(targetVersion, cliPath);
|
|
2039
|
+
removeOldVersions(2);
|
|
2040
|
+
return { success: true, version: targetVersion };
|
|
2041
|
+
} catch (error) {
|
|
2042
|
+
try {
|
|
2043
|
+
fs7.rmSync(versionDir, { recursive: true, force: true });
|
|
2044
|
+
} catch {}
|
|
2045
|
+
return {
|
|
2046
|
+
success: false,
|
|
2047
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
function startBackgroundUpdateCheck() {
|
|
2052
|
+
if (isUpdateInProgress()) {
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
if (!acquireLock()) {
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
const child = spawn(process.execPath, [
|
|
2059
|
+
process.argv[1] ?? "",
|
|
2060
|
+
"update",
|
|
2061
|
+
"--background"
|
|
2062
|
+
], {
|
|
2063
|
+
detached: true,
|
|
2064
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
2065
|
+
env: {
|
|
2066
|
+
...process.env,
|
|
2067
|
+
MCPX_UPDATE_CHILD: "1"
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
child.unref();
|
|
2071
|
+
setTimeout(() => {
|
|
2072
|
+
releaseLock();
|
|
2073
|
+
}, 60000);
|
|
2074
|
+
}
|
|
2075
|
+
async function runBackgroundUpdate() {
|
|
2076
|
+
try {
|
|
2077
|
+
const result = await downloadAndStageUpdate();
|
|
2078
|
+
if (result.success && result.version) {
|
|
2079
|
+
console.error(`[update-manager] Update staged: v${result.version}. Will activate on next run.`);
|
|
2080
|
+
} else if (result.error && result.error !== "No update available") {
|
|
2081
|
+
console.error(`[update-manager] Update check failed: ${result.error}`);
|
|
2082
|
+
}
|
|
2083
|
+
} catch (error) {
|
|
2084
|
+
console.error(`[update-manager] Background update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2085
|
+
} finally {
|
|
2086
|
+
releaseLock();
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
async function performUpdate() {
|
|
2090
|
+
if (isUpdateInProgress()) {
|
|
2091
|
+
return { success: false, message: "Another update is already in progress." };
|
|
2092
|
+
}
|
|
2093
|
+
const result = await downloadAndStageUpdate();
|
|
2094
|
+
if (result.success && result.version) {
|
|
2095
|
+
return {
|
|
2096
|
+
success: true,
|
|
2097
|
+
message: `Update to v${result.version} ready! Will activate on next run.`
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
if (result.error === "No update available") {
|
|
2101
|
+
return { success: true, message: "Already on latest version." };
|
|
2102
|
+
}
|
|
2103
|
+
return {
|
|
2104
|
+
success: false,
|
|
2105
|
+
message: result.error ?? "Update failed"
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
function performRollback() {
|
|
2109
|
+
const currentStaged = getStagedUpdate();
|
|
2110
|
+
if (!currentStaged) {
|
|
2111
|
+
return { success: false, message: "No staged update to roll back." };
|
|
2112
|
+
}
|
|
2113
|
+
clearStagedUpdate();
|
|
2114
|
+
return {
|
|
2115
|
+
success: true,
|
|
2116
|
+
message: `Rolled back from v${currentStaged.version}. Using installed version.`
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// src/core/daemon.ts
|
|
2121
|
+
import fs8 from "node:fs";
|
|
2122
|
+
import net from "node:net";
|
|
2123
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2124
|
+
|
|
2125
|
+
// src/gateway/server.ts
|
|
2126
|
+
import http from "node:http";
|
|
2127
|
+
import { URL } from "node:url";
|
|
2128
|
+
import crypto3 from "node:crypto";
|
|
2129
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2130
|
+
import {
|
|
2131
|
+
StdioClientTransport,
|
|
2132
|
+
getDefaultEnvironment
|
|
2133
|
+
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
2134
|
+
var JSON_RPC_VERSION2 = "2.0";
|
|
2135
|
+
var SERVER_VERSION = APP_VERSION;
|
|
2136
|
+
var DEFAULT_UPSTREAM_TIMEOUT_MS = 60000;
|
|
2137
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
|
|
2138
|
+
var OAUTH_WELL_KNOWN_PREFIXES = [
|
|
2139
|
+
"/.well-known/oauth-protected-resource",
|
|
2140
|
+
"/.well-known/oauth-authorization-server",
|
|
2141
|
+
"/.well-known/openid-configuration"
|
|
2142
|
+
];
|
|
2143
|
+
|
|
2144
|
+
class UpstreamHttpError extends Error {
|
|
2145
|
+
status;
|
|
2146
|
+
wwwAuthenticate;
|
|
2147
|
+
bodyText;
|
|
2148
|
+
constructor(upstreamName, status, bodyText, wwwAuthenticate) {
|
|
2149
|
+
super(`Upstream ${upstreamName} returned HTTP ${status}: ${bodyText.slice(0, 400)}`);
|
|
2150
|
+
this.name = "UpstreamHttpError";
|
|
2151
|
+
this.status = status;
|
|
2152
|
+
this.bodyText = bodyText;
|
|
2153
|
+
this.wwwAuthenticate = wwwAuthenticate;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
function makeError(id, code, message, data) {
|
|
2157
|
+
return {
|
|
2158
|
+
jsonrpc: "2.0",
|
|
2159
|
+
id,
|
|
2160
|
+
error: {
|
|
2161
|
+
code,
|
|
2162
|
+
message,
|
|
2163
|
+
data
|
|
2164
|
+
}
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
function makeResult(id, result) {
|
|
2168
|
+
return {
|
|
2169
|
+
jsonrpc: "2.0",
|
|
2170
|
+
id,
|
|
2171
|
+
result
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
function listUpstreams(config, upstreamFilter) {
|
|
2175
|
+
const all = Object.entries(config.servers).filter(([, spec]) => isServerEnabled(spec)).map(([name, spec]) => ({ name, spec }));
|
|
2176
|
+
if (!upstreamFilter) {
|
|
2177
|
+
return all;
|
|
2178
|
+
}
|
|
2179
|
+
return all.filter((upstream) => upstream.name === upstreamFilter);
|
|
2180
|
+
}
|
|
2181
|
+
function getSingleUpstream(config) {
|
|
2182
|
+
const upstreams = listUpstreams(config);
|
|
2183
|
+
if (upstreams.length !== 1) {
|
|
2184
|
+
return null;
|
|
2185
|
+
}
|
|
2186
|
+
return upstreams[0] ?? null;
|
|
2187
|
+
}
|
|
2188
|
+
function getSingleHttpUpstream(config) {
|
|
2189
|
+
const upstream = getSingleUpstream(config);
|
|
2190
|
+
if (!upstream || upstream.spec.transport !== "http") {
|
|
2191
|
+
return null;
|
|
2192
|
+
}
|
|
2193
|
+
return upstream;
|
|
2194
|
+
}
|
|
2195
|
+
function getScopedHttpUpstream(config, upstreamFilter) {
|
|
2196
|
+
if (upstreamFilter) {
|
|
2197
|
+
const selected = config.servers[upstreamFilter];
|
|
2198
|
+
if (!selected || !isServerEnabled(selected) || selected.transport !== "http") {
|
|
2199
|
+
return null;
|
|
2200
|
+
}
|
|
2201
|
+
return {
|
|
2202
|
+
name: upstreamFilter,
|
|
2203
|
+
spec: selected
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
return getSingleHttpUpstream(config);
|
|
2207
|
+
}
|
|
2208
|
+
function getWellKnownPrefix(pathname) {
|
|
2209
|
+
for (const prefix of OAUTH_WELL_KNOWN_PREFIXES) {
|
|
2210
|
+
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
|
|
2211
|
+
return prefix;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
return null;
|
|
2215
|
+
}
|
|
2216
|
+
function getUpstreamPathSuffixForWellKnown(upstream) {
|
|
2217
|
+
const upstreamUrl = new URL(upstream.spec.url);
|
|
2218
|
+
if (upstreamUrl.pathname === "/") {
|
|
2219
|
+
return "";
|
|
2220
|
+
}
|
|
2221
|
+
return upstreamUrl.pathname.endsWith("/") ? upstreamUrl.pathname.slice(0, -1) : upstreamUrl.pathname;
|
|
2222
|
+
}
|
|
2223
|
+
function buildWellKnownUpstreamUrl(upstream, prefix) {
|
|
2224
|
+
const upstreamUrl = new URL(upstream.spec.url);
|
|
2225
|
+
const suffix = getUpstreamPathSuffixForWellKnown(upstream);
|
|
2226
|
+
return new URL(`${prefix}${suffix}`, upstreamUrl.origin);
|
|
2227
|
+
}
|
|
2228
|
+
function getLocalOriginFromRequest(request) {
|
|
2229
|
+
return `http://${request.headers.host ?? "127.0.0.1"}`;
|
|
2230
|
+
}
|
|
2231
|
+
function getRequestedUpstream(requestUrl) {
|
|
2232
|
+
const value = requestUrl.searchParams.get("upstream");
|
|
2233
|
+
if (!value) {
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
return value.trim() || undefined;
|
|
2237
|
+
}
|
|
2238
|
+
function appendUpstreamQuery(url, upstream) {
|
|
2239
|
+
if (!upstream) {
|
|
2240
|
+
return url;
|
|
2241
|
+
}
|
|
2242
|
+
const parsed = new URL(url);
|
|
2243
|
+
parsed.searchParams.set("upstream", upstream);
|
|
2244
|
+
return parsed.toString();
|
|
2245
|
+
}
|
|
2246
|
+
function rewriteWwwAuthenticateResourceMetadata(headerValue, localResourceMetadataUrl) {
|
|
2247
|
+
if (headerValue.includes("resource_metadata=")) {
|
|
2248
|
+
return headerValue.replace(/resource_metadata="[^"]*"/, `resource_metadata="${localResourceMetadataUrl}"`);
|
|
2249
|
+
}
|
|
2250
|
+
return `${headerValue}, resource_metadata="${localResourceMetadataUrl}"`;
|
|
2251
|
+
}
|
|
2252
|
+
function isOAuthCapableServer(spec) {
|
|
2253
|
+
if (spec.transport !== "http") {
|
|
2254
|
+
return false;
|
|
2255
|
+
}
|
|
2256
|
+
const headers = spec.headers ?? {};
|
|
2257
|
+
for (const headerName of Object.keys(headers)) {
|
|
2258
|
+
if (headerName.toLowerCase() === "authorization") {
|
|
2259
|
+
return true;
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
return false;
|
|
2263
|
+
}
|
|
2264
|
+
function splitNamespacedName(value) {
|
|
2265
|
+
const split = value.indexOf(".");
|
|
2266
|
+
if (split <= 0 || split >= value.length - 1) {
|
|
2267
|
+
return null;
|
|
2268
|
+
}
|
|
2269
|
+
return {
|
|
2270
|
+
serverName: value.slice(0, split),
|
|
2271
|
+
upstreamName: value.slice(split + 1)
|
|
2272
|
+
};
|
|
2273
|
+
}
|
|
2274
|
+
function parseNamespacedUri(uri) {
|
|
2275
|
+
if (uri.startsWith("mcpx://")) {
|
|
2276
|
+
const rest = uri.slice("mcpx://".length);
|
|
2277
|
+
const slashIndex = rest.indexOf("/");
|
|
2278
|
+
if (slashIndex <= 0 || slashIndex >= rest.length - 1) {
|
|
2279
|
+
return null;
|
|
2280
|
+
}
|
|
2281
|
+
const serverName = rest.slice(0, slashIndex);
|
|
2282
|
+
const encoded = rest.slice(slashIndex + 1);
|
|
2283
|
+
try {
|
|
2284
|
+
return {
|
|
2285
|
+
serverName,
|
|
2286
|
+
upstreamUri: decodeURIComponent(encoded)
|
|
2287
|
+
};
|
|
2288
|
+
} catch {
|
|
2289
|
+
return null;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
const nameSplit = splitNamespacedName(uri);
|
|
2293
|
+
if (!nameSplit) {
|
|
2294
|
+
return null;
|
|
2295
|
+
}
|
|
2296
|
+
return {
|
|
2297
|
+
serverName: nameSplit.serverName,
|
|
2298
|
+
upstreamUri: nameSplit.upstreamName
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
async function callUpstream(upstream, method, params, id, secrets, runtime, passthroughAuthorizationHeader) {
|
|
2302
|
+
if (upstream.spec.transport === "stdio") {
|
|
2303
|
+
return callStdioUpstream(upstream, method, params, secrets, runtime);
|
|
2304
|
+
}
|
|
2305
|
+
return callHttpUpstream(upstream, method, params, id, secrets, passthroughAuthorizationHeader);
|
|
2306
|
+
}
|
|
2307
|
+
function getConfiguredTimeoutMs() {
|
|
2308
|
+
const configuredTimeout = Number(process.env.MCPX_UPSTREAM_TIMEOUT_MS ?? DEFAULT_UPSTREAM_TIMEOUT_MS);
|
|
2309
|
+
return Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : DEFAULT_UPSTREAM_TIMEOUT_MS;
|
|
2310
|
+
}
|
|
2311
|
+
function withTimeout(work, timeoutMs, timeoutMessage) {
|
|
2312
|
+
return new Promise((resolve, reject) => {
|
|
2313
|
+
const timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
|
2314
|
+
work.then((value) => {
|
|
2315
|
+
clearTimeout(timer);
|
|
2316
|
+
resolve(value);
|
|
2317
|
+
}, (error) => {
|
|
2318
|
+
clearTimeout(timer);
|
|
2319
|
+
reject(error);
|
|
2320
|
+
});
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
function specFingerprint(spec) {
|
|
2324
|
+
return JSON.stringify(spec);
|
|
2325
|
+
}
|
|
2326
|
+
function resolveStdioEnv(spec, secrets) {
|
|
2327
|
+
if (!spec.env || Object.keys(spec.env).length === 0) {
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
const env = getDefaultEnvironment();
|
|
2331
|
+
for (const [key, value] of Object.entries(spec.env)) {
|
|
2332
|
+
env[key] = secrets.resolveMaybeSecret(value);
|
|
2333
|
+
}
|
|
2334
|
+
return env;
|
|
2335
|
+
}
|
|
2336
|
+
function buildStdioServerParameters(spec, secrets) {
|
|
2337
|
+
return {
|
|
2338
|
+
command: spec.command,
|
|
2339
|
+
args: spec.args ?? [],
|
|
2340
|
+
cwd: spec.cwd,
|
|
2341
|
+
env: resolveStdioEnv(spec, secrets)
|
|
2342
|
+
};
|
|
2343
|
+
}
|
|
2344
|
+
async function closeStdioConnection(entry) {
|
|
2345
|
+
try {
|
|
2346
|
+
const connection = await entry.promise;
|
|
2347
|
+
await connection.transport.close();
|
|
2348
|
+
} catch {}
|
|
2349
|
+
}
|
|
2350
|
+
async function reconcileStdioConnections(config, runtime) {
|
|
2351
|
+
const activeSpecs = new Map(Object.entries(config.servers).filter(([, spec]) => isServerEnabled(spec)).map(([name, spec]) => [name, specFingerprint(spec)]));
|
|
2352
|
+
const staleNames = [];
|
|
2353
|
+
for (const [name, entry] of runtime.stdioConnections.entries()) {
|
|
2354
|
+
const expectedFingerprint = activeSpecs.get(name);
|
|
2355
|
+
if (!expectedFingerprint || expectedFingerprint !== entry.fingerprint) {
|
|
2356
|
+
staleNames.push(name);
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
for (const name of staleNames) {
|
|
2360
|
+
const entry = runtime.stdioConnections.get(name);
|
|
2361
|
+
if (!entry) {
|
|
2362
|
+
continue;
|
|
2363
|
+
}
|
|
2364
|
+
runtime.stdioConnections.delete(name);
|
|
2365
|
+
await closeStdioConnection(entry);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
function invalidateStdioConnection(name, runtime) {
|
|
2369
|
+
const existing = runtime.stdioConnections.get(name);
|
|
2370
|
+
if (!existing) {
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
runtime.stdioConnections.delete(name);
|
|
2374
|
+
closeStdioConnection(existing);
|
|
2375
|
+
}
|
|
2376
|
+
async function getStdioConnection(upstream, secrets, runtime) {
|
|
2377
|
+
const fingerprint = specFingerprint(upstream.spec);
|
|
2378
|
+
const existing = runtime.stdioConnections.get(upstream.name);
|
|
2379
|
+
if (existing) {
|
|
2380
|
+
if (existing.fingerprint === fingerprint) {
|
|
2381
|
+
return existing.promise;
|
|
2382
|
+
}
|
|
2383
|
+
runtime.stdioConnections.delete(upstream.name);
|
|
2384
|
+
closeStdioConnection(existing);
|
|
2385
|
+
}
|
|
2386
|
+
const promise = (async () => {
|
|
2387
|
+
const transport = new StdioClientTransport(buildStdioServerParameters(upstream.spec, secrets));
|
|
2388
|
+
const client = new Client({
|
|
2389
|
+
name: "mcpx",
|
|
2390
|
+
version: SERVER_VERSION
|
|
2391
|
+
});
|
|
2392
|
+
await withTimeout(client.connect(transport), DEFAULT_CONNECT_TIMEOUT_MS, `Upstream ${upstream.name} failed to connect within ${DEFAULT_CONNECT_TIMEOUT_MS}ms.`);
|
|
2393
|
+
return {
|
|
2394
|
+
fingerprint,
|
|
2395
|
+
client,
|
|
2396
|
+
transport
|
|
2397
|
+
};
|
|
2398
|
+
})();
|
|
2399
|
+
runtime.stdioConnections.set(upstream.name, {
|
|
2400
|
+
fingerprint,
|
|
2401
|
+
promise
|
|
2402
|
+
});
|
|
2403
|
+
try {
|
|
2404
|
+
return await promise;
|
|
2405
|
+
} catch (error) {
|
|
2406
|
+
runtime.stdioConnections.delete(upstream.name);
|
|
2407
|
+
throw error;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
async function callStdioUpstream(upstream, method, params, secrets, runtime) {
|
|
2411
|
+
try {
|
|
2412
|
+
const connection = await getStdioConnection(upstream, secrets, runtime);
|
|
2413
|
+
const timeoutMs = getConfiguredTimeoutMs();
|
|
2414
|
+
const timeoutMessage = `Upstream ${upstream.name} timed out after ${timeoutMs}ms for method ${method}.`;
|
|
2415
|
+
if (method === "tools/list") {
|
|
2416
|
+
return withTimeout(connection.client.listTools(params), timeoutMs, timeoutMessage);
|
|
2417
|
+
}
|
|
2418
|
+
if (method === "resources/list") {
|
|
2419
|
+
return withTimeout(connection.client.listResources(params), timeoutMs, timeoutMessage);
|
|
2420
|
+
}
|
|
2421
|
+
if (method === "prompts/list") {
|
|
2422
|
+
return withTimeout(connection.client.listPrompts(params), timeoutMs, timeoutMessage);
|
|
2423
|
+
}
|
|
2424
|
+
if (method === "tools/call") {
|
|
2425
|
+
return withTimeout(connection.client.callTool(params), timeoutMs, timeoutMessage);
|
|
2426
|
+
}
|
|
2427
|
+
if (method === "resources/read") {
|
|
2428
|
+
return withTimeout(connection.client.readResource(params), timeoutMs, timeoutMessage);
|
|
2429
|
+
}
|
|
2430
|
+
if (method === "prompts/get") {
|
|
2431
|
+
return withTimeout(connection.client.getPrompt(params), timeoutMs, timeoutMessage);
|
|
2432
|
+
}
|
|
2433
|
+
throw new Error(`Unsupported stdio passthrough method: ${method}`);
|
|
2434
|
+
} catch (error) {
|
|
2435
|
+
invalidateStdioConnection(upstream.name, runtime);
|
|
2436
|
+
throw error;
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
async function callHttpUpstream(upstream, method, params, id, secrets, passthroughAuthorizationHeader) {
|
|
2440
|
+
const headers = {
|
|
2441
|
+
"content-type": "application/json",
|
|
2442
|
+
accept: "application/json, text/event-stream"
|
|
2443
|
+
};
|
|
2444
|
+
for (const [key, value] of Object.entries(upstream.spec.headers ?? {})) {
|
|
2445
|
+
headers[key] = secrets.resolveMaybeSecret(value);
|
|
2446
|
+
}
|
|
2447
|
+
if (passthroughAuthorizationHeader) {
|
|
2448
|
+
headers.Authorization = passthroughAuthorizationHeader;
|
|
2449
|
+
}
|
|
2450
|
+
const timeoutMs = getConfiguredTimeoutMs();
|
|
2451
|
+
const timeoutController = new AbortController;
|
|
2452
|
+
const timeoutHandle = setTimeout(() => timeoutController.abort(), timeoutMs);
|
|
2453
|
+
let response;
|
|
2454
|
+
try {
|
|
2455
|
+
response = await fetch(upstream.spec.url, {
|
|
2456
|
+
method: "POST",
|
|
2457
|
+
headers,
|
|
2458
|
+
body: JSON.stringify({
|
|
2459
|
+
jsonrpc: JSON_RPC_VERSION2,
|
|
2460
|
+
id,
|
|
2461
|
+
method,
|
|
2462
|
+
params
|
|
2463
|
+
}),
|
|
2464
|
+
signal: timeoutController.signal
|
|
2465
|
+
});
|
|
2466
|
+
} catch (error) {
|
|
2467
|
+
const name = error.name;
|
|
2468
|
+
if (name === "AbortError") {
|
|
2469
|
+
throw new Error(`Upstream ${upstream.name} timed out after ${timeoutMs}ms for method ${method}.`);
|
|
2470
|
+
}
|
|
2471
|
+
throw error;
|
|
2472
|
+
} finally {
|
|
2473
|
+
clearTimeout(timeoutHandle);
|
|
2474
|
+
}
|
|
2475
|
+
if (!response.ok) {
|
|
2476
|
+
const responseText = await response.text();
|
|
2477
|
+
throw new UpstreamHttpError(upstream.name, response.status, responseText, response.headers.get("www-authenticate") ?? undefined);
|
|
2478
|
+
}
|
|
2479
|
+
let payload = null;
|
|
2480
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
2481
|
+
if (contentType.includes("application/json")) {
|
|
2482
|
+
payload = JSON.parse(await response.text());
|
|
2483
|
+
} else if (contentType.includes("text/event-stream")) {
|
|
2484
|
+
payload = await readSseJsonRpcResponse(response, id);
|
|
2485
|
+
} else {
|
|
2486
|
+
try {
|
|
2487
|
+
payload = JSON.parse(await response.text());
|
|
2488
|
+
} catch {
|
|
2489
|
+
payload = null;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
if (!payload) {
|
|
2493
|
+
throw new Error(`Upstream ${upstream.name} response could not be parsed as JSON-RPC payload.`);
|
|
2494
|
+
}
|
|
2495
|
+
if (payload.error) {
|
|
2496
|
+
throw new Error(`Upstream ${upstream.name} error: ${payload.error.message}`);
|
|
2497
|
+
}
|
|
2498
|
+
return payload.result;
|
|
2499
|
+
}
|
|
2500
|
+
async function readSseJsonRpcResponse(response, expectedId) {
|
|
2501
|
+
if (!response.body) {
|
|
2502
|
+
return null;
|
|
2503
|
+
}
|
|
2504
|
+
const decoder = new TextDecoder;
|
|
2505
|
+
const reader = response.body.getReader();
|
|
2506
|
+
let buffer = "";
|
|
2507
|
+
let dataLines = [];
|
|
2508
|
+
let latestPayload = null;
|
|
2509
|
+
const consumeEvent = () => {
|
|
2510
|
+
if (dataLines.length === 0) {
|
|
2511
|
+
return null;
|
|
2512
|
+
}
|
|
2513
|
+
const combined = dataLines.join(`
|
|
2514
|
+
`).trim();
|
|
2515
|
+
dataLines = [];
|
|
2516
|
+
if (!combined || combined === "[DONE]") {
|
|
2517
|
+
return null;
|
|
2518
|
+
}
|
|
2519
|
+
try {
|
|
2520
|
+
const parsed = JSON.parse(combined);
|
|
2521
|
+
latestPayload = parsed;
|
|
2522
|
+
const parsedId = parsed.id ?? null;
|
|
2523
|
+
if (parsedId === expectedId) {
|
|
2524
|
+
return parsed;
|
|
2525
|
+
}
|
|
2526
|
+
return null;
|
|
2527
|
+
} catch {
|
|
2528
|
+
return null;
|
|
2529
|
+
}
|
|
2530
|
+
};
|
|
2531
|
+
try {
|
|
2532
|
+
while (true) {
|
|
2533
|
+
const { value, done } = await reader.read();
|
|
2534
|
+
if (done) {
|
|
2535
|
+
break;
|
|
2536
|
+
}
|
|
2537
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2538
|
+
while (true) {
|
|
2539
|
+
const newlineIndex = buffer.indexOf(`
|
|
2540
|
+
`);
|
|
2541
|
+
if (newlineIndex < 0) {
|
|
2542
|
+
break;
|
|
2543
|
+
}
|
|
2544
|
+
let line = buffer.slice(0, newlineIndex);
|
|
2545
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
2546
|
+
if (line.endsWith("\r")) {
|
|
2547
|
+
line = line.slice(0, -1);
|
|
2548
|
+
}
|
|
2549
|
+
if (line.startsWith("data:")) {
|
|
2550
|
+
dataLines.push(line.slice("data:".length).trimStart());
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
if (line === "") {
|
|
2554
|
+
const matched = consumeEvent();
|
|
2555
|
+
if (matched) {
|
|
2556
|
+
return matched;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
buffer += decoder.decode();
|
|
2562
|
+
if (buffer.length > 0) {
|
|
2563
|
+
let finalLine = buffer;
|
|
2564
|
+
if (finalLine.endsWith("\r")) {
|
|
2565
|
+
finalLine = finalLine.slice(0, -1);
|
|
2566
|
+
}
|
|
2567
|
+
if (finalLine.startsWith("data:")) {
|
|
2568
|
+
dataLines.push(finalLine.slice("data:".length).trimStart());
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
return consumeEvent() ?? latestPayload;
|
|
2572
|
+
} finally {
|
|
2573
|
+
try {
|
|
2574
|
+
await reader.cancel();
|
|
2575
|
+
} catch {}
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
function authHeaderIsValid(request, expectedToken) {
|
|
2579
|
+
const localTokenHeader = request.headers["x-mcpx-local-token"];
|
|
2580
|
+
if (typeof localTokenHeader === "string" && localTokenHeader === expectedToken) {
|
|
2581
|
+
return true;
|
|
2582
|
+
}
|
|
2583
|
+
if (Array.isArray(localTokenHeader) && localTokenHeader.includes(expectedToken)) {
|
|
2584
|
+
return true;
|
|
2585
|
+
}
|
|
2586
|
+
const authHeader = request.headers.authorization;
|
|
2587
|
+
if (!authHeader) {
|
|
2588
|
+
return false;
|
|
2589
|
+
}
|
|
2590
|
+
const [scheme, token] = authHeader.split(" ");
|
|
2591
|
+
return scheme === "Bearer" && token === expectedToken;
|
|
2592
|
+
}
|
|
2593
|
+
function isAuthChallenge(error) {
|
|
2594
|
+
return error instanceof UpstreamHttpError && (error.status === 401 || error.status === 403);
|
|
2595
|
+
}
|
|
2596
|
+
function getClientAuthorizationForUpstream(request, expectedToken) {
|
|
2597
|
+
const authHeader = request.headers.authorization;
|
|
2598
|
+
if (!authHeader) {
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
const [scheme, token] = authHeader.split(" ");
|
|
2602
|
+
if (scheme === "Bearer" && token === expectedToken) {
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
return authHeader;
|
|
2606
|
+
}
|
|
2607
|
+
async function handleListTools(config, secrets, runtime, upstreamFilter, clientAuthorizationHeader) {
|
|
2608
|
+
const tools = [];
|
|
2609
|
+
const flattenedUpstream = listUpstreams(config, upstreamFilter).length === 1;
|
|
2610
|
+
const flattenNames = Boolean(flattenedUpstream);
|
|
2611
|
+
const upstreams = listUpstreams(config, upstreamFilter);
|
|
2612
|
+
for (const upstream of upstreams) {
|
|
2613
|
+
try {
|
|
2614
|
+
const result = await callUpstream(upstream, "tools/list", {}, `list-tools-${upstream.name}`, secrets, runtime, clientAuthorizationHeader);
|
|
2615
|
+
for (const tool of result.tools ?? []) {
|
|
2616
|
+
const name = typeof tool.name === "string" ? tool.name : "tool";
|
|
2617
|
+
tools.push({
|
|
2618
|
+
...tool,
|
|
2619
|
+
name: flattenNames ? name : `${upstream.name}.${name}`
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
} catch (error) {
|
|
2623
|
+
if (upstreams.length === 1 && isAuthChallenge(error)) {
|
|
2624
|
+
throw error;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
return { tools };
|
|
2629
|
+
}
|
|
2630
|
+
async function handleListResources(config, secrets, runtime, upstreamFilter, clientAuthorizationHeader) {
|
|
2631
|
+
const resources = [];
|
|
2632
|
+
const flattenedUpstream = listUpstreams(config, upstreamFilter).length === 1;
|
|
2633
|
+
const flattenNames = Boolean(flattenedUpstream);
|
|
2634
|
+
const upstreams = listUpstreams(config, upstreamFilter);
|
|
2635
|
+
for (const upstream of upstreams) {
|
|
2636
|
+
try {
|
|
2637
|
+
const result = await callUpstream(upstream, "resources/list", {}, `list-resources-${upstream.name}`, secrets, runtime, clientAuthorizationHeader);
|
|
2638
|
+
for (const resource of result.resources ?? []) {
|
|
2639
|
+
const originalUri = typeof resource.uri === "string" ? resource.uri : "";
|
|
2640
|
+
const originalName = typeof resource.name === "string" ? resource.name : originalUri;
|
|
2641
|
+
resources.push({
|
|
2642
|
+
...resource,
|
|
2643
|
+
name: flattenNames ? originalName : `${upstream.name}.${originalName}`,
|
|
2644
|
+
uri: flattenNames ? originalUri : `mcpx://${upstream.name}/${encodeURIComponent(originalUri)}`
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
} catch (error) {
|
|
2648
|
+
if (upstreams.length === 1 && isAuthChallenge(error)) {
|
|
2649
|
+
throw error;
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
return { resources };
|
|
2654
|
+
}
|
|
2655
|
+
async function handleListPrompts(config, secrets, runtime, upstreamFilter, clientAuthorizationHeader) {
|
|
2656
|
+
const prompts = [];
|
|
2657
|
+
const flattenedUpstream = listUpstreams(config, upstreamFilter).length === 1;
|
|
2658
|
+
const flattenNames = Boolean(flattenedUpstream);
|
|
2659
|
+
const upstreams = listUpstreams(config, upstreamFilter);
|
|
2660
|
+
for (const upstream of upstreams) {
|
|
2661
|
+
try {
|
|
2662
|
+
const result = await callUpstream(upstream, "prompts/list", {}, `list-prompts-${upstream.name}`, secrets, runtime, clientAuthorizationHeader);
|
|
2663
|
+
for (const prompt of result.prompts ?? []) {
|
|
2664
|
+
const name = typeof prompt.name === "string" ? prompt.name : "prompt";
|
|
2665
|
+
prompts.push({
|
|
2666
|
+
...prompt,
|
|
2667
|
+
name: flattenNames ? name : `${upstream.name}.${name}`
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
} catch (error) {
|
|
2671
|
+
if (upstreams.length === 1 && isAuthChallenge(error)) {
|
|
2672
|
+
throw error;
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
return { prompts };
|
|
2677
|
+
}
|
|
2678
|
+
async function routeNamespacedCall(config, method, params, id, secrets, runtime, upstreamFilter, clientAuthorizationHeader) {
|
|
2679
|
+
if (!params || typeof params !== "object") {
|
|
2680
|
+
return makeError(id, -32602, "Missing params object.");
|
|
2681
|
+
}
|
|
2682
|
+
const upstreamEntries = listUpstreams(config, upstreamFilter);
|
|
2683
|
+
const upstreams = new Map(upstreamEntries.map((entry) => [entry.name, entry]));
|
|
2684
|
+
const flattenedUpstream = upstreamEntries.length === 1 ? upstreamEntries[0] : null;
|
|
2685
|
+
if (method === "tools/call") {
|
|
2686
|
+
const toolName = typeof params.name === "string" ? params.name : "";
|
|
2687
|
+
const split = splitNamespacedName(toolName);
|
|
2688
|
+
if (split && upstreamFilter && split.serverName !== upstreamFilter) {
|
|
2689
|
+
return makeError(id, -32602, `Tool belongs to upstream ${split.serverName}, but request is scoped to ${upstreamFilter}.`);
|
|
2690
|
+
}
|
|
2691
|
+
let upstream2;
|
|
2692
|
+
let upstreamToolName = toolName;
|
|
2693
|
+
if (split && upstreams.has(split.serverName)) {
|
|
2694
|
+
upstream2 = upstreams.get(split.serverName);
|
|
2695
|
+
upstreamToolName = split.upstreamName;
|
|
2696
|
+
} else if (flattenedUpstream) {
|
|
2697
|
+
upstream2 = flattenedUpstream;
|
|
2698
|
+
}
|
|
2699
|
+
if (!upstream2) {
|
|
2700
|
+
return makeError(id, -32602, "Tool name must be namespaced as <server>.<tool>.");
|
|
2701
|
+
}
|
|
2702
|
+
const upstreamParams2 = {
|
|
2703
|
+
...params,
|
|
2704
|
+
name: upstreamToolName
|
|
2705
|
+
};
|
|
2706
|
+
try {
|
|
2707
|
+
const result = await callUpstream(upstream2, method, upstreamParams2, id, secrets, runtime, clientAuthorizationHeader);
|
|
2708
|
+
return makeResult(id, result);
|
|
2709
|
+
} catch (error) {
|
|
2710
|
+
if (isAuthChallenge(error)) {
|
|
2711
|
+
throw error;
|
|
2712
|
+
}
|
|
2713
|
+
return makeError(id, -32000, error.message);
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
if (method === "prompts/get") {
|
|
2717
|
+
const promptName = typeof params.name === "string" ? params.name : "";
|
|
2718
|
+
const split = splitNamespacedName(promptName);
|
|
2719
|
+
if (split && upstreamFilter && split.serverName !== upstreamFilter) {
|
|
2720
|
+
return makeError(id, -32602, `Prompt belongs to upstream ${split.serverName}, but request is scoped to ${upstreamFilter}.`);
|
|
2721
|
+
}
|
|
2722
|
+
let upstream2;
|
|
2723
|
+
let upstreamPromptName = promptName;
|
|
2724
|
+
if (split && upstreams.has(split.serverName)) {
|
|
2725
|
+
upstream2 = upstreams.get(split.serverName);
|
|
2726
|
+
upstreamPromptName = split.upstreamName;
|
|
2727
|
+
} else if (flattenedUpstream) {
|
|
2728
|
+
upstream2 = flattenedUpstream;
|
|
2729
|
+
}
|
|
2730
|
+
if (!upstream2) {
|
|
2731
|
+
return makeError(id, -32602, "Prompt name must be namespaced as <server>.<prompt>.");
|
|
2732
|
+
}
|
|
2733
|
+
const upstreamParams2 = {
|
|
2734
|
+
...params,
|
|
2735
|
+
name: upstreamPromptName
|
|
2736
|
+
};
|
|
2737
|
+
try {
|
|
2738
|
+
const result = await callUpstream(upstream2, method, upstreamParams2, id, secrets, runtime, clientAuthorizationHeader);
|
|
2739
|
+
return makeResult(id, result);
|
|
2740
|
+
} catch (error) {
|
|
2741
|
+
if (isAuthChallenge(error)) {
|
|
2742
|
+
throw error;
|
|
2743
|
+
}
|
|
2744
|
+
return makeError(id, -32000, error.message);
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
const uri = typeof params.uri === "string" ? params.uri : "";
|
|
2748
|
+
const parsed = parseNamespacedUri(uri);
|
|
2749
|
+
if (parsed && upstreamFilter && parsed.serverName !== upstreamFilter) {
|
|
2750
|
+
return makeError(id, -32602, `Resource belongs to upstream ${parsed.serverName}, but request is scoped to ${upstreamFilter}.`);
|
|
2751
|
+
}
|
|
2752
|
+
let upstream;
|
|
2753
|
+
let upstreamUri = uri;
|
|
2754
|
+
if (parsed && upstreams.has(parsed.serverName)) {
|
|
2755
|
+
upstream = upstreams.get(parsed.serverName);
|
|
2756
|
+
upstreamUri = parsed.upstreamUri;
|
|
2757
|
+
} else if (flattenedUpstream) {
|
|
2758
|
+
upstream = flattenedUpstream;
|
|
2759
|
+
}
|
|
2760
|
+
if (!upstream) {
|
|
2761
|
+
return makeError(id, -32602, "Resource URI must be namespaced (mcpx://<server>/<encoded-uri>).", { uri });
|
|
2762
|
+
}
|
|
2763
|
+
const upstreamParams = {
|
|
2764
|
+
...params,
|
|
2765
|
+
uri: upstreamUri
|
|
2766
|
+
};
|
|
2767
|
+
try {
|
|
2768
|
+
const result = await callUpstream(upstream, method, upstreamParams, id, secrets, runtime, clientAuthorizationHeader);
|
|
2769
|
+
return makeResult(id, result);
|
|
2770
|
+
} catch (error) {
|
|
2771
|
+
if (isAuthChallenge(error)) {
|
|
2772
|
+
throw error;
|
|
2773
|
+
}
|
|
2774
|
+
return makeError(id, -32000, error.message);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
async function handleRequestObject(request, secrets, runtime, upstreamFilter, clientAuthorizationHeader) {
|
|
2778
|
+
const id = request.id ?? null;
|
|
2779
|
+
if (!request.method || typeof request.method !== "string") {
|
|
2780
|
+
return makeError(id, -32600, "Invalid JSON-RPC request: missing method.");
|
|
2781
|
+
}
|
|
2782
|
+
if (request.method === "initialize") {
|
|
2783
|
+
const requestedProtocol = request.params?.protocolVersion;
|
|
2784
|
+
const protocolVersion = typeof requestedProtocol === "string" && requestedProtocol.length > 0 ? requestedProtocol : "2025-11-25";
|
|
2785
|
+
return makeResult(id, {
|
|
2786
|
+
protocolVersion,
|
|
2787
|
+
capabilities: {
|
|
2788
|
+
tools: {},
|
|
2789
|
+
resources: {},
|
|
2790
|
+
prompts: {}
|
|
2791
|
+
},
|
|
2792
|
+
serverInfo: {
|
|
2793
|
+
name: "mcpx",
|
|
2794
|
+
version: SERVER_VERSION
|
|
2795
|
+
}
|
|
2796
|
+
});
|
|
2797
|
+
}
|
|
2798
|
+
if (request.method === "notifications/initialized") {
|
|
2799
|
+
return null;
|
|
2800
|
+
}
|
|
2801
|
+
if (request.method === "ping") {
|
|
2802
|
+
return makeResult(id, { ok: true });
|
|
2803
|
+
}
|
|
2804
|
+
const config = loadConfig();
|
|
2805
|
+
if (upstreamFilter && listUpstreams(config, upstreamFilter).length === 0) {
|
|
2806
|
+
return makeError(id, -32602, `Unknown upstream: ${upstreamFilter}`);
|
|
2807
|
+
}
|
|
2808
|
+
await reconcileStdioConnections(config, runtime);
|
|
2809
|
+
if (request.method === "tools/list") {
|
|
2810
|
+
const result = await handleListTools(config, secrets, runtime, upstreamFilter, clientAuthorizationHeader);
|
|
2811
|
+
return makeResult(id, result);
|
|
2812
|
+
}
|
|
2813
|
+
if (request.method === "resources/list") {
|
|
2814
|
+
const result = await handleListResources(config, secrets, runtime, upstreamFilter, clientAuthorizationHeader);
|
|
2815
|
+
return makeResult(id, result);
|
|
2816
|
+
}
|
|
2817
|
+
if (request.method === "prompts/list") {
|
|
2818
|
+
const result = await handleListPrompts(config, secrets, runtime, upstreamFilter, clientAuthorizationHeader);
|
|
2819
|
+
return makeResult(id, result);
|
|
2820
|
+
}
|
|
2821
|
+
if (request.method === "tools/call" || request.method === "resources/read" || request.method === "prompts/get") {
|
|
2822
|
+
return routeNamespacedCall(config, request.method, request.params, id, secrets, runtime, upstreamFilter, clientAuthorizationHeader);
|
|
2823
|
+
}
|
|
2824
|
+
return makeError(id, -32601, `Unsupported method: ${request.method}`);
|
|
2825
|
+
}
|
|
2826
|
+
async function maybeHandleWellKnownOAuthRequest(request, response, requestUrl, secrets) {
|
|
2827
|
+
const pathname = requestUrl.pathname;
|
|
2828
|
+
const wellKnownPrefix = getWellKnownPrefix(pathname);
|
|
2829
|
+
if (!wellKnownPrefix) {
|
|
2830
|
+
return false;
|
|
2831
|
+
}
|
|
2832
|
+
if (request.method !== "GET") {
|
|
2833
|
+
response.statusCode = 405;
|
|
2834
|
+
response.setHeader("content-type", "application/json");
|
|
2835
|
+
response.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
2836
|
+
return true;
|
|
2837
|
+
}
|
|
2838
|
+
const config = loadConfig();
|
|
2839
|
+
const requestedUpstream = getRequestedUpstream(requestUrl);
|
|
2840
|
+
const upstream = getScopedHttpUpstream(config, requestedUpstream);
|
|
2841
|
+
if (!upstream) {
|
|
2842
|
+
response.statusCode = 404;
|
|
2843
|
+
response.setHeader("content-type", "application/json");
|
|
2844
|
+
response.end(JSON.stringify({ error: "not_found" }));
|
|
2845
|
+
return true;
|
|
2846
|
+
}
|
|
2847
|
+
if (!isOAuthCapableServer(upstream.spec)) {
|
|
2848
|
+
response.statusCode = 404;
|
|
2849
|
+
response.setHeader("content-type", "application/json");
|
|
2850
|
+
response.end(JSON.stringify({ error: "not_found" }));
|
|
2851
|
+
return true;
|
|
2852
|
+
}
|
|
2853
|
+
const upstreamWellKnownUrl = buildWellKnownUpstreamUrl(upstream, wellKnownPrefix);
|
|
2854
|
+
const headers = {
|
|
2855
|
+
accept: "application/json"
|
|
2856
|
+
};
|
|
2857
|
+
const protocolVersion = request.headers["mcp-protocol-version"];
|
|
2858
|
+
if (typeof protocolVersion === "string" && protocolVersion.length > 0) {
|
|
2859
|
+
headers["mcp-protocol-version"] = protocolVersion;
|
|
2860
|
+
}
|
|
2861
|
+
for (const [key, value] of Object.entries(upstream.spec.headers ?? {})) {
|
|
2862
|
+
headers[key] = secrets.resolveMaybeSecret(value);
|
|
2863
|
+
}
|
|
2864
|
+
const upstreamResponse = await fetch(upstreamWellKnownUrl, {
|
|
2865
|
+
method: "GET",
|
|
2866
|
+
headers
|
|
2867
|
+
});
|
|
2868
|
+
let bodyText = await upstreamResponse.text();
|
|
2869
|
+
response.statusCode = upstreamResponse.status;
|
|
2870
|
+
const contentType = upstreamResponse.headers.get("content-type");
|
|
2871
|
+
if (contentType) {
|
|
2872
|
+
response.setHeader("content-type", contentType);
|
|
2873
|
+
}
|
|
2874
|
+
const cacheControl = upstreamResponse.headers.get("cache-control");
|
|
2875
|
+
if (cacheControl) {
|
|
2876
|
+
response.setHeader("cache-control", cacheControl);
|
|
2877
|
+
}
|
|
2878
|
+
const wwwAuthenticate = upstreamResponse.headers.get("www-authenticate");
|
|
2879
|
+
if (wwwAuthenticate) {
|
|
2880
|
+
response.setHeader("www-authenticate", wwwAuthenticate);
|
|
2881
|
+
}
|
|
2882
|
+
if (wellKnownPrefix === "/.well-known/oauth-protected-resource" && contentType?.includes("application/json") && upstreamResponse.ok) {
|
|
2883
|
+
try {
|
|
2884
|
+
const parsed = JSON.parse(bodyText);
|
|
2885
|
+
parsed.resource = appendUpstreamQuery(`${getLocalOriginFromRequest(request)}/mcp`, requestedUpstream);
|
|
2886
|
+
bodyText = JSON.stringify(parsed);
|
|
2887
|
+
} catch {}
|
|
2888
|
+
}
|
|
2889
|
+
response.end(bodyText);
|
|
2890
|
+
return true;
|
|
2891
|
+
}
|
|
2892
|
+
function createGatewayServer(options) {
|
|
2893
|
+
const debug = process.env.MCPX_GATEWAY_DEBUG === "1";
|
|
2894
|
+
const runtime = {
|
|
2895
|
+
stdioConnections: new Map
|
|
2896
|
+
};
|
|
2897
|
+
const server = http.createServer(async (request, response) => {
|
|
2898
|
+
let requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`);
|
|
2899
|
+
try {
|
|
2900
|
+
requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`);
|
|
2901
|
+
const upstreamFilter = getRequestedUpstream(requestUrl);
|
|
2902
|
+
if (debug) {
|
|
2903
|
+
console.error(`[mcpx gateway] ${request.method ?? "?"} ${requestUrl.pathname} auth=${request.headers.authorization ? "yes" : "no"} accept=${request.headers.accept ?? ""}`);
|
|
2904
|
+
console.error(`[mcpx gateway] headers=${JSON.stringify(request.headers)}`);
|
|
2905
|
+
}
|
|
2906
|
+
if (await maybeHandleWellKnownOAuthRequest(request, response, requestUrl, options.secrets)) {
|
|
2907
|
+
if (debug) {
|
|
2908
|
+
console.error(`[mcpx gateway] -> ${response.statusCode} (well-known oauth)`);
|
|
2909
|
+
}
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
if (requestUrl.pathname !== "/mcp") {
|
|
2913
|
+
response.statusCode = 404;
|
|
2914
|
+
response.setHeader("content-type", "application/json");
|
|
2915
|
+
response.end(JSON.stringify({ error: "not_found" }));
|
|
2916
|
+
if (debug) {
|
|
2917
|
+
console.error(`[mcpx gateway] -> 404`);
|
|
2918
|
+
}
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
if (request.method === "GET") {
|
|
2922
|
+
if (!authHeaderIsValid(request, options.expectedToken)) {
|
|
2923
|
+
response.statusCode = 401;
|
|
2924
|
+
response.setHeader("content-type", "application/json");
|
|
2925
|
+
response.end(JSON.stringify(makeError(null, -32001, "Unauthorized")));
|
|
2926
|
+
if (debug) {
|
|
2927
|
+
console.error(`[mcpx gateway] -> 401 (GET unauthorized)`);
|
|
2928
|
+
}
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
response.statusCode = 200;
|
|
2932
|
+
response.setHeader("content-type", "application/json");
|
|
2933
|
+
response.end(JSON.stringify({ ok: true, server: "mcpx" }));
|
|
2934
|
+
if (debug) {
|
|
2935
|
+
console.error(`[mcpx gateway] -> 200 (GET ok)`);
|
|
2936
|
+
}
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
if (request.method !== "POST") {
|
|
2940
|
+
response.statusCode = 405;
|
|
2941
|
+
response.end("Method Not Allowed");
|
|
2942
|
+
if (debug) {
|
|
2943
|
+
console.error(`[mcpx gateway] -> 405`);
|
|
2944
|
+
}
|
|
2945
|
+
return;
|
|
2946
|
+
}
|
|
2947
|
+
if (!authHeaderIsValid(request, options.expectedToken)) {
|
|
2948
|
+
response.statusCode = 401;
|
|
2949
|
+
response.setHeader("content-type", "application/json");
|
|
2950
|
+
response.end(JSON.stringify(makeError(null, -32001, "Unauthorized")));
|
|
2951
|
+
if (debug) {
|
|
2952
|
+
console.error(`[mcpx gateway] -> 401 (POST unauthorized)`);
|
|
2953
|
+
}
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
let body = "";
|
|
2957
|
+
request.setEncoding("utf8");
|
|
2958
|
+
for await (const chunk of request) {
|
|
2959
|
+
body += chunk;
|
|
2960
|
+
if (body.length > 1e7) {
|
|
2961
|
+
response.statusCode = 413;
|
|
2962
|
+
response.end("Payload Too Large");
|
|
2963
|
+
if (debug) {
|
|
2964
|
+
console.error(`[mcpx gateway] -> 413`);
|
|
2965
|
+
}
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
const parsed = JSON.parse(body);
|
|
2970
|
+
const hasInitialize = Array.isArray(parsed) ? parsed.some((item) => item.method === "initialize") : parsed.method === "initialize";
|
|
2971
|
+
const responses = [];
|
|
2972
|
+
const clientAuthorizationHeader = getClientAuthorizationForUpstream(request, options.expectedToken);
|
|
2973
|
+
if (Array.isArray(parsed)) {
|
|
2974
|
+
for (const item of parsed) {
|
|
2975
|
+
if (debug) {
|
|
2976
|
+
console.error(`[mcpx gateway] rpc method=${item.method} id=${item.id ?? "null"}`);
|
|
2977
|
+
}
|
|
2978
|
+
const rpcResponse = await handleRequestObject(item, options.secrets, runtime, upstreamFilter, clientAuthorizationHeader);
|
|
2979
|
+
if (debug && rpcResponse?.error) {
|
|
2980
|
+
console.error(`[mcpx gateway] rpc error code=${rpcResponse.error.code} message=${rpcResponse.error.message}`);
|
|
2981
|
+
}
|
|
2982
|
+
if (rpcResponse) {
|
|
2983
|
+
responses.push(rpcResponse);
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
} else {
|
|
2987
|
+
if (debug) {
|
|
2988
|
+
console.error(`[mcpx gateway] rpc method=${parsed.method} id=${parsed.id ?? "null"}`);
|
|
2989
|
+
if (parsed.method === "initialize") {
|
|
2990
|
+
console.error(`[mcpx gateway] rpc initialize params=${JSON.stringify(parsed.params ?? {})}`);
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
const rpcResponse = await handleRequestObject(parsed, options.secrets, runtime, upstreamFilter, clientAuthorizationHeader);
|
|
2994
|
+
if (debug && rpcResponse?.error) {
|
|
2995
|
+
console.error(`[mcpx gateway] rpc error code=${rpcResponse.error.code} message=${rpcResponse.error.message}`);
|
|
2996
|
+
}
|
|
2997
|
+
if (rpcResponse) {
|
|
2998
|
+
responses.push(rpcResponse);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
response.statusCode = 200;
|
|
3002
|
+
const acceptsSse = (request.headers.accept ?? "").includes("text/event-stream");
|
|
3003
|
+
if (acceptsSse) {
|
|
3004
|
+
response.setHeader("content-type", "text/event-stream");
|
|
3005
|
+
response.setHeader("cache-control", "no-cache");
|
|
3006
|
+
response.setHeader("connection", "keep-alive");
|
|
3007
|
+
} else {
|
|
3008
|
+
response.setHeader("content-type", "application/json");
|
|
3009
|
+
}
|
|
3010
|
+
if (hasInitialize) {
|
|
3011
|
+
const sessionId = crypto3.randomUUID();
|
|
3012
|
+
response.setHeader("mcp-session-id", sessionId);
|
|
3013
|
+
response.setHeader("MCP-Session-Id", sessionId);
|
|
3014
|
+
}
|
|
3015
|
+
if (debug) {
|
|
3016
|
+
console.error(`[mcpx gateway] -> 200 (rpc)`);
|
|
3017
|
+
}
|
|
3018
|
+
if (acceptsSse) {
|
|
3019
|
+
const payloads = responses.length > 0 ? responses : [];
|
|
3020
|
+
for (const payload of payloads) {
|
|
3021
|
+
response.write(`event: message
|
|
3022
|
+
`);
|
|
3023
|
+
response.write(`data: ${JSON.stringify(payload)}
|
|
3024
|
+
|
|
3025
|
+
`);
|
|
3026
|
+
}
|
|
3027
|
+
response.end();
|
|
3028
|
+
} else if (responses.length === 1) {
|
|
3029
|
+
response.end(JSON.stringify(responses[0]));
|
|
3030
|
+
} else {
|
|
3031
|
+
response.end(JSON.stringify(responses));
|
|
3032
|
+
}
|
|
3033
|
+
} catch (error) {
|
|
3034
|
+
if (isAuthChallenge(error)) {
|
|
3035
|
+
response.statusCode = error.status;
|
|
3036
|
+
response.setHeader("content-type", "application/json");
|
|
3037
|
+
if (error.wwwAuthenticate) {
|
|
3038
|
+
const localResourceMetadataUrl = appendUpstreamQuery(`${getLocalOriginFromRequest(request)}/.well-known/oauth-protected-resource`, getRequestedUpstream(requestUrl));
|
|
3039
|
+
response.setHeader("www-authenticate", rewriteWwwAuthenticateResourceMetadata(error.wwwAuthenticate, localResourceMetadataUrl));
|
|
3040
|
+
}
|
|
3041
|
+
response.end(error.bodyText.length > 0 ? error.bodyText : JSON.stringify({ error: "upstream_auth_required" }));
|
|
3042
|
+
if (debug) {
|
|
3043
|
+
console.error(`[mcpx gateway] -> ${error.status} upstream auth challenge`);
|
|
3044
|
+
}
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
response.statusCode = 500;
|
|
3048
|
+
response.setHeader("content-type", "application/json");
|
|
3049
|
+
response.end(JSON.stringify(makeError(null, -32000, error.message)));
|
|
3050
|
+
if (debug) {
|
|
3051
|
+
console.error(`[mcpx gateway] -> 500 ${error.message}`);
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
});
|
|
3055
|
+
server.on("close", () => {
|
|
3056
|
+
for (const entry of runtime.stdioConnections.values()) {
|
|
3057
|
+
closeStdioConnection(entry);
|
|
3058
|
+
}
|
|
3059
|
+
runtime.stdioConnections.clear();
|
|
3060
|
+
});
|
|
3061
|
+
server.listen(options.port, "127.0.0.1");
|
|
3062
|
+
return server;
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
// src/core/daemon.ts
|
|
3066
|
+
function readPidFromFile(pidPath = getPidPath()) {
|
|
3067
|
+
if (!fs8.existsSync(pidPath)) {
|
|
3068
|
+
return null;
|
|
3069
|
+
}
|
|
3070
|
+
const raw = fs8.readFileSync(pidPath, "utf8").trim();
|
|
3071
|
+
if (!raw) {
|
|
3072
|
+
return null;
|
|
3073
|
+
}
|
|
3074
|
+
const pid = Number(raw);
|
|
3075
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
3076
|
+
return null;
|
|
3077
|
+
}
|
|
3078
|
+
return pid;
|
|
3079
|
+
}
|
|
3080
|
+
function processExists(pid) {
|
|
3081
|
+
try {
|
|
3082
|
+
process.kill(pid, 0);
|
|
3083
|
+
return true;
|
|
3084
|
+
} catch {
|
|
3085
|
+
return false;
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
async function isPortAvailable(port) {
|
|
3089
|
+
return new Promise((resolve) => {
|
|
3090
|
+
const tester = net.createServer();
|
|
3091
|
+
tester.once("error", () => {
|
|
3092
|
+
resolve(false);
|
|
3093
|
+
});
|
|
3094
|
+
tester.once("listening", () => {
|
|
3095
|
+
tester.close(() => resolve(true));
|
|
3096
|
+
});
|
|
3097
|
+
tester.listen(port, "127.0.0.1");
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
3100
|
+
async function resolveGatewayPort(config) {
|
|
3101
|
+
if (await isPortAvailable(config.gateway.port)) {
|
|
3102
|
+
return config.gateway.port;
|
|
3103
|
+
}
|
|
3104
|
+
for (let offset = 1;offset <= 20; offset += 1) {
|
|
3105
|
+
const candidate = config.gateway.port + offset;
|
|
3106
|
+
if (candidate > 65535) {
|
|
3107
|
+
break;
|
|
3108
|
+
}
|
|
3109
|
+
if (await isPortAvailable(candidate)) {
|
|
3110
|
+
config.gateway.port = candidate;
|
|
3111
|
+
saveConfig(config);
|
|
3112
|
+
return candidate;
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
throw new Error(`No available local port found near ${config.gateway.port}.`);
|
|
3116
|
+
}
|
|
3117
|
+
function getDaemonStatus(config) {
|
|
3118
|
+
const pidPath = getPidPath();
|
|
3119
|
+
const pid = readPidFromFile(pidPath);
|
|
3120
|
+
if (!pid) {
|
|
3121
|
+
return {
|
|
3122
|
+
running: false,
|
|
3123
|
+
pidFile: pidPath,
|
|
3124
|
+
logFile: getLogPath(),
|
|
3125
|
+
port: config.gateway.port
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
return {
|
|
3129
|
+
running: processExists(pid),
|
|
3130
|
+
pid,
|
|
3131
|
+
pidFile: pidPath,
|
|
3132
|
+
logFile: getLogPath(),
|
|
3133
|
+
port: config.gateway.port
|
|
3134
|
+
};
|
|
3135
|
+
}
|
|
3136
|
+
async function startDaemon(config, cliPath, secrets) {
|
|
3137
|
+
const existingStatus = getDaemonStatus(config);
|
|
3138
|
+
if (existingStatus.running && existingStatus.pid) {
|
|
3139
|
+
return {
|
|
3140
|
+
started: false,
|
|
3141
|
+
pid: existingStatus.pid,
|
|
3142
|
+
port: config.gateway.port,
|
|
3143
|
+
message: "mcpx daemon already running."
|
|
3144
|
+
};
|
|
3145
|
+
}
|
|
3146
|
+
const port = await resolveGatewayPort(config);
|
|
3147
|
+
ensureGatewayToken(config, secrets);
|
|
3148
|
+
const pidPath = getPidPath();
|
|
3149
|
+
const logPath = getLogPath();
|
|
3150
|
+
ensureParentDir(pidPath);
|
|
3151
|
+
ensureParentDir(logPath);
|
|
3152
|
+
const logFd = fs8.openSync(logPath, "a", 384);
|
|
3153
|
+
const child = spawn2(process.execPath, [cliPath, "daemon", "run", "--port", String(port)], {
|
|
3154
|
+
detached: true,
|
|
3155
|
+
stdio: ["ignore", logFd, logFd],
|
|
3156
|
+
env: {
|
|
3157
|
+
...process.env,
|
|
3158
|
+
MCPX_DAEMON_CHILD: "1"
|
|
3159
|
+
}
|
|
3160
|
+
});
|
|
3161
|
+
child.unref();
|
|
3162
|
+
fs8.writeFileSync(pidPath, `${child.pid}
|
|
3163
|
+
`, { mode: 384 });
|
|
3164
|
+
startBackgroundUpdateCheck();
|
|
3165
|
+
return {
|
|
3166
|
+
started: true,
|
|
3167
|
+
pid: child.pid ?? -1,
|
|
3168
|
+
port,
|
|
3169
|
+
message: "mcpx daemon started."
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
function stopDaemon() {
|
|
3173
|
+
const pidPath = getPidPath();
|
|
3174
|
+
const pid = readPidFromFile(pidPath);
|
|
3175
|
+
if (!pid) {
|
|
3176
|
+
return {
|
|
3177
|
+
stopped: false,
|
|
3178
|
+
message: "mcpx daemon is not running."
|
|
3179
|
+
};
|
|
3180
|
+
}
|
|
3181
|
+
try {
|
|
3182
|
+
process.kill(pid, "SIGTERM");
|
|
3183
|
+
} catch {}
|
|
3184
|
+
try {
|
|
3185
|
+
fs8.unlinkSync(pidPath);
|
|
3186
|
+
} catch {}
|
|
3187
|
+
return {
|
|
3188
|
+
stopped: true,
|
|
3189
|
+
message: `Stopped mcpx daemon (pid ${pid}).`
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
async function restartDaemon(config, cliPath, secrets) {
|
|
3193
|
+
stopDaemon();
|
|
3194
|
+
return startDaemon(config, cliPath, secrets);
|
|
3195
|
+
}
|
|
3196
|
+
function readDaemonLogs(maxLines = 200) {
|
|
3197
|
+
const logPath = getLogPath();
|
|
3198
|
+
if (!fs8.existsSync(logPath)) {
|
|
3199
|
+
return "";
|
|
3200
|
+
}
|
|
3201
|
+
const lines = fs8.readFileSync(logPath, "utf8").split(`
|
|
3202
|
+
`);
|
|
3203
|
+
return lines.slice(Math.max(lines.length - maxLines, 0)).join(`
|
|
3204
|
+
`);
|
|
3205
|
+
}
|
|
3206
|
+
function runDaemonForeground(config, port, secrets) {
|
|
3207
|
+
return new Promise((resolve, reject) => {
|
|
3208
|
+
const tokenName = getGatewayTokenSecretName(config);
|
|
3209
|
+
const token = secrets.getSecret(tokenName);
|
|
3210
|
+
if (!token) {
|
|
3211
|
+
reject(new Error(`Local gateway token not found: ${tokenName}`));
|
|
3212
|
+
return;
|
|
3213
|
+
}
|
|
3214
|
+
const server = createGatewayServer({
|
|
3215
|
+
port,
|
|
3216
|
+
expectedToken: token,
|
|
3217
|
+
secrets
|
|
3218
|
+
});
|
|
3219
|
+
const cleanup = () => {
|
|
3220
|
+
server.close(() => resolve());
|
|
3221
|
+
};
|
|
3222
|
+
process.on("SIGINT", cleanup);
|
|
3223
|
+
process.on("SIGTERM", cleanup);
|
|
3224
|
+
server.on("error", (error) => {
|
|
3225
|
+
reject(error);
|
|
3226
|
+
});
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
// src/core/status.ts
|
|
3231
|
+
var STATUS_CLIENTS = ["claude", "claude-desktop", "codex", "cursor", "cline", "opencode", "kiro", "vscode", "qwen"];
|
|
3232
|
+
function describeServerTarget(spec) {
|
|
3233
|
+
if (spec.transport === "http") {
|
|
3234
|
+
return spec.url;
|
|
3235
|
+
}
|
|
3236
|
+
const args = (spec.args ?? []).join(" ");
|
|
3237
|
+
const command = `${spec.command}${args ? ` ${args}` : ""}`;
|
|
3238
|
+
return spec.cwd ? `${command} (cwd: ${spec.cwd})` : command;
|
|
3239
|
+
}
|
|
3240
|
+
function deriveClientStatus(managed, configured) {
|
|
3241
|
+
if (configured) {
|
|
3242
|
+
return configured;
|
|
3243
|
+
}
|
|
3244
|
+
return managed ? "SYNCED" : "SKIPPED";
|
|
3245
|
+
}
|
|
3246
|
+
function buildClientMappings(config, managedIndex, serverName) {
|
|
3247
|
+
return STATUS_CLIENTS.map((clientId) => {
|
|
3248
|
+
const configState = config.clients[clientId];
|
|
3249
|
+
const managedClient = managedIndex.managed[clientId];
|
|
3250
|
+
const managedEntry = managedClient?.entries?.[`${serverName} (mcpx)`];
|
|
3251
|
+
const managed = Boolean(managedEntry);
|
|
3252
|
+
return {
|
|
3253
|
+
clientId,
|
|
3254
|
+
managed,
|
|
3255
|
+
status: deriveClientStatus(managed, configState?.status),
|
|
3256
|
+
configPath: managedClient?.configPath ?? configState?.configPath,
|
|
3257
|
+
message: configState?.message,
|
|
3258
|
+
lastSyncAt: managedEntry?.lastSyncedAt ?? configState?.lastSyncAt
|
|
3259
|
+
};
|
|
3260
|
+
});
|
|
3261
|
+
}
|
|
3262
|
+
function buildAuthBindings(spec) {
|
|
3263
|
+
return listAuthBindings(spec).map((binding) => ({
|
|
3264
|
+
kind: binding.kind,
|
|
3265
|
+
key: binding.key,
|
|
3266
|
+
value: binding.value,
|
|
3267
|
+
secretName: secretRefName(binding.value) ?? undefined
|
|
3268
|
+
}));
|
|
3269
|
+
}
|
|
3270
|
+
function buildStatusReport(config, managedIndex, daemon = getDaemonStatus(config)) {
|
|
3271
|
+
const serverNames = Object.keys(config.servers).sort((left, right) => left.localeCompare(right));
|
|
3272
|
+
const servers = serverNames.map((name) => {
|
|
3273
|
+
const spec = config.servers[name];
|
|
3274
|
+
return {
|
|
3275
|
+
name,
|
|
3276
|
+
enabled: isServerEnabled(spec),
|
|
3277
|
+
transport: spec.transport,
|
|
3278
|
+
target: describeServerTarget(spec),
|
|
3279
|
+
authBindings: buildAuthBindings(spec),
|
|
3280
|
+
clients: buildClientMappings(config, managedIndex, name)
|
|
3281
|
+
};
|
|
3282
|
+
});
|
|
3283
|
+
return {
|
|
3284
|
+
gatewayUrl: getGatewayUrl(config),
|
|
3285
|
+
daemon,
|
|
3286
|
+
upstreamCount: servers.length,
|
|
3287
|
+
servers,
|
|
3288
|
+
clients: config.clients
|
|
3289
|
+
};
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
export { getConfigPath, getManagedIndexPath, defaultConfig, loadConfig, saveConfig, addServer, removeServer, updateServer, setServerEnabled, SecretsManager, readSecretValueFromStdin, probeHttpAuthRequirement, getAdapters, loadManagedIndex, getGatewayUrl, syncAllClients, toSecretRef, secretRefName, defaultAuthSecretName, resolveAuthTarget, applyAuthReference, removeAuthReference, listAuthBindings, maybePrefixBearer, APP_VERSION, compareVersions, checkForUpdates, getStagedUpdate, clearStagedUpdate, getStagedCliPath, startBackgroundUpdateCheck, runBackgroundUpdate, performUpdate, performRollback, getDaemonStatus, startDaemon, stopDaemon, restartDaemon, readDaemonLogs, runDaemonForeground, STATUS_CLIENTS, buildStatusReport };
|