mcp-squared 0.3.2 → 0.3.3
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/index.js +4 -12
- package/dist/tui/config.js +3521 -0
- package/dist/tui/monitor.js +2144 -0
- package/package.json +3 -2
|
@@ -0,0 +1,3521 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __export = (target, all) => {
|
|
4
|
+
for (var name in all)
|
|
5
|
+
__defProp(target, name, {
|
|
6
|
+
get: all[name],
|
|
7
|
+
enumerable: true,
|
|
8
|
+
configurable: true,
|
|
9
|
+
set: (newValue) => all[name] = () => newValue
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
13
|
+
var __require = import.meta.require;
|
|
14
|
+
|
|
15
|
+
// src/config/paths.ts
|
|
16
|
+
import { existsSync, mkdirSync } from "fs";
|
|
17
|
+
import { homedir, platform } from "os";
|
|
18
|
+
import { dirname, join, resolve } from "path";
|
|
19
|
+
function getEnv(key) {
|
|
20
|
+
return Bun.env[key];
|
|
21
|
+
}
|
|
22
|
+
function getXdgConfigHome() {
|
|
23
|
+
return getEnv("XDG_CONFIG_HOME") || join(homedir(), ".config");
|
|
24
|
+
}
|
|
25
|
+
function getUserConfigDir() {
|
|
26
|
+
const os = platform();
|
|
27
|
+
if (os === "win32") {
|
|
28
|
+
return join(getEnv("APPDATA") || join(homedir(), "AppData", "Roaming"), APP_NAME);
|
|
29
|
+
}
|
|
30
|
+
return join(getXdgConfigHome(), APP_NAME);
|
|
31
|
+
}
|
|
32
|
+
function ensureDir(dir) {
|
|
33
|
+
if (!existsSync(dir)) {
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function getUserConfigPath() {
|
|
38
|
+
return join(getUserConfigDir(), "config.toml");
|
|
39
|
+
}
|
|
40
|
+
function getSocketFilePath(instanceId) {
|
|
41
|
+
if (!instanceId) {
|
|
42
|
+
return join(getUserConfigDir(), SOCKET_FILENAME);
|
|
43
|
+
}
|
|
44
|
+
return join(getSocketDir(), `mcp-squared.${instanceId}.sock`);
|
|
45
|
+
}
|
|
46
|
+
function getDaemonDir(configHash) {
|
|
47
|
+
if (configHash) {
|
|
48
|
+
return join(getUserConfigDir(), DAEMON_DIR_NAME, configHash);
|
|
49
|
+
}
|
|
50
|
+
return join(getUserConfigDir(), DAEMON_DIR_NAME);
|
|
51
|
+
}
|
|
52
|
+
function getDaemonRegistryPath(configHash) {
|
|
53
|
+
return join(getDaemonDir(configHash), DAEMON_REGISTRY_FILENAME);
|
|
54
|
+
}
|
|
55
|
+
function getDaemonSocketPath(configHash) {
|
|
56
|
+
return join(getDaemonDir(configHash), DAEMON_SOCKET_FILENAME);
|
|
57
|
+
}
|
|
58
|
+
function getInstanceRegistryDir() {
|
|
59
|
+
return join(getUserConfigDir(), INSTANCE_DIR_NAME);
|
|
60
|
+
}
|
|
61
|
+
function getSocketDir() {
|
|
62
|
+
return join(getUserConfigDir(), SOCKET_DIR_NAME);
|
|
63
|
+
}
|
|
64
|
+
function ensureInstanceRegistryDir() {
|
|
65
|
+
ensureDir(getInstanceRegistryDir());
|
|
66
|
+
}
|
|
67
|
+
function ensureSocketDir() {
|
|
68
|
+
ensureDir(getSocketDir());
|
|
69
|
+
}
|
|
70
|
+
function ensureDaemonDir(configHash) {
|
|
71
|
+
const daemonDir = getDaemonDir(configHash);
|
|
72
|
+
if (!existsSync(daemonDir)) {
|
|
73
|
+
mkdirSync(daemonDir, { recursive: true, mode: 448 });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function findProjectConfig(startDir) {
|
|
77
|
+
let currentDir = resolve(startDir);
|
|
78
|
+
const root = dirname(currentDir);
|
|
79
|
+
while (currentDir !== root) {
|
|
80
|
+
const directPath = join(currentDir, CONFIG_FILENAME);
|
|
81
|
+
if (existsSync(directPath)) {
|
|
82
|
+
return directPath;
|
|
83
|
+
}
|
|
84
|
+
const hiddenDirPath = join(currentDir, CONFIG_DIR_NAME, "config.toml");
|
|
85
|
+
if (existsSync(hiddenDirPath)) {
|
|
86
|
+
return hiddenDirPath;
|
|
87
|
+
}
|
|
88
|
+
const parentDir = dirname(currentDir);
|
|
89
|
+
if (parentDir === currentDir)
|
|
90
|
+
break;
|
|
91
|
+
currentDir = parentDir;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
function discoverConfigPath(cwd = process.cwd()) {
|
|
96
|
+
const envPath = getEnv("MCP_SQUARED_CONFIG");
|
|
97
|
+
if (envPath) {
|
|
98
|
+
const resolvedEnvPath = resolve(envPath);
|
|
99
|
+
if (existsSync(resolvedEnvPath)) {
|
|
100
|
+
return { path: resolvedEnvPath, source: "env" };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const projectPath = findProjectConfig(cwd);
|
|
104
|
+
if (projectPath) {
|
|
105
|
+
return { path: projectPath, source: "project" };
|
|
106
|
+
}
|
|
107
|
+
const userPath = getUserConfigPath();
|
|
108
|
+
if (existsSync(userPath)) {
|
|
109
|
+
return { path: userPath, source: "user" };
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
function getDefaultConfigPath() {
|
|
114
|
+
return {
|
|
115
|
+
path: getUserConfigPath(),
|
|
116
|
+
source: "user"
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function ensureConfigDir(configPath) {
|
|
120
|
+
const dir = dirname(configPath);
|
|
121
|
+
ensureDir(dir);
|
|
122
|
+
}
|
|
123
|
+
var SOCKET_FILENAME = "mcp-squared.sock", INSTANCE_DIR_NAME = "instances", SOCKET_DIR_NAME = "sockets", DAEMON_DIR_NAME = "daemon", DAEMON_REGISTRY_FILENAME = "daemon.json", DAEMON_SOCKET_FILENAME = "daemon.sock", CONFIG_FILENAME = "mcp-squared.toml", CONFIG_DIR_NAME = ".mcp-squared", APP_NAME = "mcp-squared";
|
|
124
|
+
var init_paths = () => {};
|
|
125
|
+
|
|
126
|
+
// src/tui/config.ts
|
|
127
|
+
import {
|
|
128
|
+
ASCIIFontRenderable,
|
|
129
|
+
BoxRenderable,
|
|
130
|
+
createCliRenderer,
|
|
131
|
+
InputRenderable,
|
|
132
|
+
RGBA,
|
|
133
|
+
SelectRenderable,
|
|
134
|
+
SelectRenderableEvents,
|
|
135
|
+
TextRenderable
|
|
136
|
+
} from "@opentui/core";
|
|
137
|
+
|
|
138
|
+
// src/config/instance-registry.ts
|
|
139
|
+
init_paths();
|
|
140
|
+
import {
|
|
141
|
+
existsSync as existsSync2,
|
|
142
|
+
mkdirSync as mkdirSync2,
|
|
143
|
+
readdirSync,
|
|
144
|
+
readFileSync,
|
|
145
|
+
renameSync,
|
|
146
|
+
unlinkSync,
|
|
147
|
+
writeFileSync
|
|
148
|
+
} from "fs";
|
|
149
|
+
import { connect } from "net";
|
|
150
|
+
import { join as join2 } from "path";
|
|
151
|
+
|
|
152
|
+
// src/config/pid.ts
|
|
153
|
+
function isProcessRunning(pid) {
|
|
154
|
+
if (pid <= 0) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
process.kill(pid, 0);
|
|
159
|
+
return true;
|
|
160
|
+
} catch (err) {
|
|
161
|
+
const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
|
|
162
|
+
return code !== "ESRCH";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/config/instance-registry.ts
|
|
167
|
+
var ENTRY_EXTENSION = ".json";
|
|
168
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 300;
|
|
169
|
+
function ensureRegistryDir() {
|
|
170
|
+
const dir = getInstanceRegistryDir();
|
|
171
|
+
if (!existsSync2(dir)) {
|
|
172
|
+
mkdirSync2(dir, { recursive: true });
|
|
173
|
+
}
|
|
174
|
+
return dir;
|
|
175
|
+
}
|
|
176
|
+
function isTcpEndpoint(endpoint) {
|
|
177
|
+
return endpoint.startsWith("tcp://");
|
|
178
|
+
}
|
|
179
|
+
function parseTcpEndpoint(endpoint) {
|
|
180
|
+
let url;
|
|
181
|
+
try {
|
|
182
|
+
url = new URL(endpoint);
|
|
183
|
+
} catch {
|
|
184
|
+
throw new Error(`Invalid TCP endpoint: ${endpoint}`);
|
|
185
|
+
}
|
|
186
|
+
if (url.protocol !== "tcp:") {
|
|
187
|
+
throw new Error(`Invalid TCP endpoint protocol: ${url.protocol}`);
|
|
188
|
+
}
|
|
189
|
+
const host = url.hostname;
|
|
190
|
+
const port = Number.parseInt(url.port, 10);
|
|
191
|
+
if (!host || Number.isNaN(port)) {
|
|
192
|
+
throw new Error(`Invalid TCP endpoint: ${endpoint}`);
|
|
193
|
+
}
|
|
194
|
+
return { host, port };
|
|
195
|
+
}
|
|
196
|
+
function isValidEntry(data) {
|
|
197
|
+
if (!data || typeof data !== "object") {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
const record = data;
|
|
201
|
+
const id = record["id"];
|
|
202
|
+
if (typeof id !== "string" || id.trim() === "") {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
const pid = record["pid"];
|
|
206
|
+
if (typeof pid !== "number" || pid <= 0) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const socketPath = record["socketPath"];
|
|
210
|
+
if (typeof socketPath !== "string" || socketPath.trim() === "") {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const startedAt = record["startedAt"];
|
|
214
|
+
if (typeof startedAt !== "number" || startedAt <= 0) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
const cwd = record["cwd"];
|
|
218
|
+
if (cwd && typeof cwd !== "string") {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
const role = record["role"];
|
|
222
|
+
if (role && typeof role !== "string") {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
const launcher = record["launcher"];
|
|
226
|
+
if (launcher && typeof launcher !== "string") {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
const ppid = record["ppid"];
|
|
230
|
+
if (ppid && typeof ppid !== "number") {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
const user = record["user"];
|
|
234
|
+
if (user && typeof user !== "string") {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
const processName = record["processName"];
|
|
238
|
+
if (processName && typeof processName !== "string") {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
const parentProcessName = record["parentProcessName"];
|
|
242
|
+
if (parentProcessName && typeof parentProcessName !== "string") {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
const parentCommand = record["parentCommand"];
|
|
246
|
+
if (parentCommand && typeof parentCommand !== "string") {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
const configPath = record["configPath"];
|
|
250
|
+
if (configPath && typeof configPath !== "string") {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
const version = record["version"];
|
|
254
|
+
if (version && typeof version !== "string") {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const command = record["command"];
|
|
258
|
+
if (command && typeof command !== "string") {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
function writeInstanceEntry(entry) {
|
|
264
|
+
const dir = ensureRegistryDir();
|
|
265
|
+
const entryPath = join2(dir, `${entry.id}${ENTRY_EXTENSION}`);
|
|
266
|
+
const tempPath = join2(dir, `.${entry.id}.${process.pid}.tmp`);
|
|
267
|
+
const payload = `${JSON.stringify(entry, null, 2)}
|
|
268
|
+
`;
|
|
269
|
+
writeFileSync(tempPath, payload, { encoding: "utf8" });
|
|
270
|
+
renameSync(tempPath, entryPath);
|
|
271
|
+
return entryPath;
|
|
272
|
+
}
|
|
273
|
+
function readInstanceEntry(entryPath) {
|
|
274
|
+
if (!existsSync2(entryPath)) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const raw = readFileSync(entryPath, { encoding: "utf8" });
|
|
279
|
+
const data = JSON.parse(raw);
|
|
280
|
+
return isValidEntry(data) ? data : null;
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function deleteInstanceEntry(entryPath) {
|
|
286
|
+
if (!existsSync2(entryPath)) {
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
unlinkSync(entryPath);
|
|
291
|
+
return true;
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function listInstanceEntries(options = {}) {
|
|
297
|
+
const { pruneInvalid = false } = options;
|
|
298
|
+
const dir = getInstanceRegistryDir();
|
|
299
|
+
if (!existsSync2(dir)) {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
const files = readdirSync(dir);
|
|
303
|
+
const entries = [];
|
|
304
|
+
for (const file of files) {
|
|
305
|
+
if (!file.endsWith(ENTRY_EXTENSION)) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const entryPath = join2(dir, file);
|
|
309
|
+
const entry = readInstanceEntry(entryPath);
|
|
310
|
+
if (!entry) {
|
|
311
|
+
if (pruneInvalid) {
|
|
312
|
+
deleteInstanceEntry(entryPath);
|
|
313
|
+
}
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
entries.push({ ...entry, entryPath });
|
|
317
|
+
}
|
|
318
|
+
return entries;
|
|
319
|
+
}
|
|
320
|
+
async function canConnect(endpoint, timeoutMs) {
|
|
321
|
+
if (!isTcpEndpoint(endpoint) && !existsSync2(endpoint)) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
return new Promise((resolve2) => {
|
|
325
|
+
let socket = null;
|
|
326
|
+
try {
|
|
327
|
+
socket = isTcpEndpoint(endpoint) ? connect(parseTcpEndpoint(endpoint)) : connect(endpoint);
|
|
328
|
+
} catch {
|
|
329
|
+
resolve2(false);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!socket) {
|
|
333
|
+
resolve2(false);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const timeoutId = setTimeout(() => {
|
|
337
|
+
socket.destroy();
|
|
338
|
+
resolve2(false);
|
|
339
|
+
}, timeoutMs);
|
|
340
|
+
socket.once("connect", () => {
|
|
341
|
+
clearTimeout(timeoutId);
|
|
342
|
+
socket.destroy();
|
|
343
|
+
resolve2(true);
|
|
344
|
+
});
|
|
345
|
+
socket.once("error", () => {
|
|
346
|
+
clearTimeout(timeoutId);
|
|
347
|
+
socket.destroy();
|
|
348
|
+
resolve2(false);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
async function isInstanceAlive(entry, timeoutMs) {
|
|
353
|
+
if (!isProcessRunning(entry.pid)) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
if (entry.role === "proxy") {
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
return canConnect(entry.socketPath, timeoutMs);
|
|
360
|
+
}
|
|
361
|
+
async function listActiveInstanceEntries(options = {}) {
|
|
362
|
+
const { prune = true, timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS } = options;
|
|
363
|
+
const entries = listInstanceEntries({ pruneInvalid: prune });
|
|
364
|
+
const active = [];
|
|
365
|
+
for (const entry of entries) {
|
|
366
|
+
const alive = await isInstanceAlive(entry, timeoutMs);
|
|
367
|
+
if (alive) {
|
|
368
|
+
active.push(entry);
|
|
369
|
+
} else if (prune) {
|
|
370
|
+
deleteInstanceEntry(entry.entryPath);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
active.sort((a, b) => b.startedAt - a.startedAt);
|
|
374
|
+
return active;
|
|
375
|
+
}
|
|
376
|
+
// src/config/load.ts
|
|
377
|
+
import { parse as parseToml } from "smol-toml";
|
|
378
|
+
import { ZodError } from "zod";
|
|
379
|
+
|
|
380
|
+
// src/config/schema.ts
|
|
381
|
+
import { z } from "zod";
|
|
382
|
+
var LATEST_SCHEMA_VERSION = 1;
|
|
383
|
+
var LogLevelSchema = z.enum([
|
|
384
|
+
"fatal",
|
|
385
|
+
"error",
|
|
386
|
+
"warn",
|
|
387
|
+
"info",
|
|
388
|
+
"debug",
|
|
389
|
+
"trace"
|
|
390
|
+
]);
|
|
391
|
+
var EnvRecordSchema = z.record(z.string(), z.string()).default({});
|
|
392
|
+
var UpstreamBaseSchema = z.object({
|
|
393
|
+
label: z.string().min(1).optional(),
|
|
394
|
+
enabled: z.boolean().default(true),
|
|
395
|
+
env: EnvRecordSchema
|
|
396
|
+
});
|
|
397
|
+
var UpstreamStdioSchema = UpstreamBaseSchema.extend({
|
|
398
|
+
transport: z.literal("stdio"),
|
|
399
|
+
stdio: z.object({
|
|
400
|
+
command: z.string().min(1),
|
|
401
|
+
args: z.array(z.string()).default([]),
|
|
402
|
+
cwd: z.string().min(1).optional()
|
|
403
|
+
})
|
|
404
|
+
});
|
|
405
|
+
var OAuthConfigSchema = z.object({
|
|
406
|
+
callbackPort: z.number().int().min(1024).max(65535).default(8089),
|
|
407
|
+
clientName: z.string().default("MCP\xB2")
|
|
408
|
+
});
|
|
409
|
+
var UpstreamSseSchema = UpstreamBaseSchema.extend({
|
|
410
|
+
transport: z.literal("sse"),
|
|
411
|
+
sse: z.object({
|
|
412
|
+
url: z.string().url(),
|
|
413
|
+
headers: z.record(z.string(), z.string()).default({}),
|
|
414
|
+
auth: z.union([z.boolean(), OAuthConfigSchema]).optional()
|
|
415
|
+
})
|
|
416
|
+
});
|
|
417
|
+
var UpstreamServerSchema = z.discriminatedUnion("transport", [
|
|
418
|
+
UpstreamStdioSchema,
|
|
419
|
+
UpstreamSseSchema
|
|
420
|
+
]);
|
|
421
|
+
var SecurityToolsSchema = z.object({
|
|
422
|
+
allow: z.array(z.string()).default([]),
|
|
423
|
+
block: z.array(z.string()).default([]),
|
|
424
|
+
confirm: z.array(z.string()).default(["*:*"])
|
|
425
|
+
});
|
|
426
|
+
var SecuritySchema = z.object({
|
|
427
|
+
tools: SecurityToolsSchema.default({
|
|
428
|
+
allow: [],
|
|
429
|
+
block: [],
|
|
430
|
+
confirm: ["*:*"]
|
|
431
|
+
})
|
|
432
|
+
}).default({
|
|
433
|
+
tools: { allow: [], block: [], confirm: ["*:*"] }
|
|
434
|
+
});
|
|
435
|
+
var SearchModeSchema = z.enum(["fast", "semantic", "hybrid"]);
|
|
436
|
+
var DetailLevelSchema = z.enum(["L0", "L1", "L2"]);
|
|
437
|
+
var FindToolsSchema = z.object({
|
|
438
|
+
defaultLimit: z.number().int().min(1).default(5),
|
|
439
|
+
maxLimit: z.number().int().min(1).max(200).default(50),
|
|
440
|
+
defaultMode: SearchModeSchema.default("fast"),
|
|
441
|
+
defaultDetailLevel: DetailLevelSchema.default("L1")
|
|
442
|
+
});
|
|
443
|
+
var IndexSchema = z.object({
|
|
444
|
+
refreshIntervalMs: z.number().int().min(1000).default(30000)
|
|
445
|
+
});
|
|
446
|
+
var LoggingSchema = z.object({
|
|
447
|
+
level: LogLevelSchema.default("info")
|
|
448
|
+
});
|
|
449
|
+
var EmbeddingsSchema = z.object({
|
|
450
|
+
enabled: z.boolean().default(false)
|
|
451
|
+
});
|
|
452
|
+
var SelectionCacheSchema = z.object({
|
|
453
|
+
enabled: z.boolean().default(true),
|
|
454
|
+
minCooccurrenceThreshold: z.number().int().min(1).default(2),
|
|
455
|
+
maxBundleSuggestions: z.number().int().min(0).default(3)
|
|
456
|
+
});
|
|
457
|
+
var OperationsSchema = z.object({
|
|
458
|
+
findTools: FindToolsSchema.default({
|
|
459
|
+
defaultLimit: 5,
|
|
460
|
+
maxLimit: 50,
|
|
461
|
+
defaultMode: "fast",
|
|
462
|
+
defaultDetailLevel: "L1"
|
|
463
|
+
}),
|
|
464
|
+
index: IndexSchema.default({ refreshIntervalMs: 30000 }),
|
|
465
|
+
logging: LoggingSchema.default({ level: "info" }),
|
|
466
|
+
embeddings: EmbeddingsSchema.default({ enabled: false }),
|
|
467
|
+
selectionCache: SelectionCacheSchema.default({
|
|
468
|
+
enabled: true,
|
|
469
|
+
minCooccurrenceThreshold: 2,
|
|
470
|
+
maxBundleSuggestions: 3
|
|
471
|
+
})
|
|
472
|
+
}).default({
|
|
473
|
+
findTools: {
|
|
474
|
+
defaultLimit: 5,
|
|
475
|
+
maxLimit: 50,
|
|
476
|
+
defaultMode: "fast",
|
|
477
|
+
defaultDetailLevel: "L1"
|
|
478
|
+
},
|
|
479
|
+
index: { refreshIntervalMs: 30000 },
|
|
480
|
+
logging: { level: "info" },
|
|
481
|
+
embeddings: { enabled: false },
|
|
482
|
+
selectionCache: {
|
|
483
|
+
enabled: true,
|
|
484
|
+
minCooccurrenceThreshold: 2,
|
|
485
|
+
maxBundleSuggestions: 3
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
var ConfigSchema = z.object({
|
|
489
|
+
schemaVersion: z.literal(1).default(1),
|
|
490
|
+
upstreams: z.record(z.string().min(1), UpstreamServerSchema).default({}),
|
|
491
|
+
security: SecuritySchema,
|
|
492
|
+
operations: OperationsSchema
|
|
493
|
+
});
|
|
494
|
+
var DEFAULT_CONFIG = ConfigSchema.parse({});
|
|
495
|
+
|
|
496
|
+
// src/config/migrations/index.ts
|
|
497
|
+
class UnknownSchemaVersionError extends Error {
|
|
498
|
+
version;
|
|
499
|
+
latestVersion;
|
|
500
|
+
constructor(version, latestVersion) {
|
|
501
|
+
super(`Unknown schema version ${version} (latest supported: ${latestVersion}). This config may have been created by a newer version of MCP\xB2.`);
|
|
502
|
+
this.version = version;
|
|
503
|
+
this.latestVersion = latestVersion;
|
|
504
|
+
this.name = "UnknownSchemaVersionError";
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function getSchemaVersion(config) {
|
|
508
|
+
const version = config["schemaVersion"];
|
|
509
|
+
if (typeof version === "number" && Number.isInteger(version)) {
|
|
510
|
+
return version;
|
|
511
|
+
}
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
function migrateConfig(input) {
|
|
515
|
+
let config = { ...input };
|
|
516
|
+
let version = getSchemaVersion(config);
|
|
517
|
+
if (version > LATEST_SCHEMA_VERSION) {
|
|
518
|
+
throw new UnknownSchemaVersionError(version, LATEST_SCHEMA_VERSION);
|
|
519
|
+
}
|
|
520
|
+
while (version < LATEST_SCHEMA_VERSION) {
|
|
521
|
+
switch (version) {
|
|
522
|
+
case 0:
|
|
523
|
+
config = migrateV0ToV1(config);
|
|
524
|
+
version = 1;
|
|
525
|
+
break;
|
|
526
|
+
default:
|
|
527
|
+
throw new UnknownSchemaVersionError(version, LATEST_SCHEMA_VERSION);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
config["schemaVersion"] = LATEST_SCHEMA_VERSION;
|
|
531
|
+
return config;
|
|
532
|
+
}
|
|
533
|
+
function migrateV0ToV1(config) {
|
|
534
|
+
return { ...config, schemaVersion: 1 };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/config/load.ts
|
|
538
|
+
init_paths();
|
|
539
|
+
class ConfigError extends Error {
|
|
540
|
+
cause;
|
|
541
|
+
constructor(message, cause) {
|
|
542
|
+
super(message);
|
|
543
|
+
this.name = "ConfigError";
|
|
544
|
+
this.cause = cause;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
class ConfigNotFoundError extends ConfigError {
|
|
549
|
+
constructor() {
|
|
550
|
+
super("No configuration file found");
|
|
551
|
+
this.name = "ConfigNotFoundError";
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
class ConfigParseError extends ConfigError {
|
|
556
|
+
filePath;
|
|
557
|
+
constructor(filePath, cause) {
|
|
558
|
+
super(`Failed to parse config file: ${filePath}`, cause);
|
|
559
|
+
this.filePath = filePath;
|
|
560
|
+
this.name = "ConfigParseError";
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
class ConfigValidationError extends ConfigError {
|
|
565
|
+
filePath;
|
|
566
|
+
zodError;
|
|
567
|
+
constructor(filePath, zodError) {
|
|
568
|
+
const issues = zodError.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join(`
|
|
569
|
+
`);
|
|
570
|
+
super(`Invalid configuration in ${filePath}:
|
|
571
|
+
${issues}`, zodError);
|
|
572
|
+
this.filePath = filePath;
|
|
573
|
+
this.zodError = zodError;
|
|
574
|
+
this.name = "ConfigValidationError";
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function loadConfig(cwd) {
|
|
578
|
+
const discovered = discoverConfigPath(cwd);
|
|
579
|
+
if (!discovered) {
|
|
580
|
+
return {
|
|
581
|
+
config: DEFAULT_CONFIG,
|
|
582
|
+
path: getDefaultConfigPath().path,
|
|
583
|
+
source: "user"
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
return loadConfigFromPath(discovered.path, discovered.source);
|
|
587
|
+
}
|
|
588
|
+
async function loadConfigFromPath(filePath, source) {
|
|
589
|
+
const file = Bun.file(filePath);
|
|
590
|
+
const exists = await file.exists();
|
|
591
|
+
if (!exists) {
|
|
592
|
+
throw new ConfigNotFoundError;
|
|
593
|
+
}
|
|
594
|
+
let content;
|
|
595
|
+
try {
|
|
596
|
+
content = await file.text();
|
|
597
|
+
} catch (err) {
|
|
598
|
+
throw new ConfigParseError(filePath, err);
|
|
599
|
+
}
|
|
600
|
+
let rawConfig;
|
|
601
|
+
try {
|
|
602
|
+
rawConfig = parseToml(content);
|
|
603
|
+
} catch (err) {
|
|
604
|
+
throw new ConfigParseError(filePath, err);
|
|
605
|
+
}
|
|
606
|
+
const migrated = migrateConfig(rawConfig);
|
|
607
|
+
let config;
|
|
608
|
+
try {
|
|
609
|
+
config = ConfigSchema.parse(migrated);
|
|
610
|
+
} catch (err) {
|
|
611
|
+
if (err instanceof ZodError) {
|
|
612
|
+
throw new ConfigValidationError(filePath, err);
|
|
613
|
+
}
|
|
614
|
+
throw err;
|
|
615
|
+
}
|
|
616
|
+
return { config, path: filePath, source };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/config/index.ts
|
|
620
|
+
init_paths();
|
|
621
|
+
|
|
622
|
+
// src/config/save.ts
|
|
623
|
+
init_paths();
|
|
624
|
+
import { stringify as stringifyToml } from "smol-toml";
|
|
625
|
+
|
|
626
|
+
class ConfigSaveError extends Error {
|
|
627
|
+
filePath;
|
|
628
|
+
constructor(filePath, cause) {
|
|
629
|
+
super(`Failed to save config file: ${filePath}`);
|
|
630
|
+
this.filePath = filePath;
|
|
631
|
+
this.name = "ConfigSaveError";
|
|
632
|
+
this.cause = cause;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async function saveConfig(filePath, config) {
|
|
636
|
+
ensureConfigDir(filePath);
|
|
637
|
+
let tomlContent;
|
|
638
|
+
try {
|
|
639
|
+
tomlContent = stringifyToml(config);
|
|
640
|
+
} catch (err) {
|
|
641
|
+
throw new ConfigSaveError(filePath, err);
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
await Bun.write(filePath, tomlContent);
|
|
645
|
+
} catch (err) {
|
|
646
|
+
throw new ConfigSaveError(filePath, err);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// src/config/validate.ts
|
|
650
|
+
var COMMANDS_REQUIRING_ARGS = new Set([
|
|
651
|
+
"npx",
|
|
652
|
+
"npm",
|
|
653
|
+
"bunx",
|
|
654
|
+
"bun",
|
|
655
|
+
"pnpx",
|
|
656
|
+
"yarn",
|
|
657
|
+
"node",
|
|
658
|
+
"deno",
|
|
659
|
+
"python",
|
|
660
|
+
"python3",
|
|
661
|
+
"uvx",
|
|
662
|
+
"uv"
|
|
663
|
+
]);
|
|
664
|
+
function validateStdioUpstream(name, config) {
|
|
665
|
+
const issues = [];
|
|
666
|
+
const { command, args } = config.stdio;
|
|
667
|
+
const commandBase = command.split("/").pop() ?? command;
|
|
668
|
+
if (COMMANDS_REQUIRING_ARGS.has(commandBase) && args.length === 0) {
|
|
669
|
+
issues.push({
|
|
670
|
+
severity: "error",
|
|
671
|
+
upstream: name,
|
|
672
|
+
message: `Command '${command}' requires arguments but args is empty`,
|
|
673
|
+
suggestion: `Add the package/script to run, e.g., args = ["-y", "package-name"]`
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
if ((commandBase === "bash" || commandBase === "sh") && args.length === 0) {
|
|
677
|
+
issues.push({
|
|
678
|
+
severity: "error",
|
|
679
|
+
upstream: name,
|
|
680
|
+
message: `Command '${command}' with empty args will read from stdin, not run an MCP server`,
|
|
681
|
+
suggestion: `Add a script to run, e.g., args = ["-c", "your-command"]`
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
if (commandBase === "docker" && args.length === 0) {
|
|
685
|
+
issues.push({
|
|
686
|
+
severity: "error",
|
|
687
|
+
upstream: name,
|
|
688
|
+
message: `Command 'docker' requires arguments to run a container`,
|
|
689
|
+
suggestion: `Add docker subcommand and image, e.g., args = ["run", "-i", "image-name"]`
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
return issues;
|
|
693
|
+
}
|
|
694
|
+
var LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
695
|
+
function validateSseUpstream(name, config) {
|
|
696
|
+
const issues = [];
|
|
697
|
+
try {
|
|
698
|
+
const parsedUrl = new URL(config.sse.url);
|
|
699
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
700
|
+
const isLocal = LOCAL_HOSTNAMES.has(hostname);
|
|
701
|
+
if (parsedUrl.protocol === "http:" && !isLocal) {
|
|
702
|
+
issues.push({
|
|
703
|
+
severity: "warning",
|
|
704
|
+
upstream: name,
|
|
705
|
+
message: `SSE upstream uses unencrypted HTTP URL: ${config.sse.url}`,
|
|
706
|
+
suggestion: "Use HTTPS for remote upstreams to prevent token/header exposure in transit"
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
} catch {}
|
|
710
|
+
for (const [headerName, headerValue] of Object.entries(config.sse.headers)) {
|
|
711
|
+
const isAuthorization = headerName.toLowerCase() === "authorization";
|
|
712
|
+
const isBearer = /^Bearer\s+/i.test(headerValue);
|
|
713
|
+
const usesEnvPlaceholder = /^Bearer\s+\$/i.test(headerValue.trim());
|
|
714
|
+
if (isAuthorization && isBearer && !usesEnvPlaceholder) {
|
|
715
|
+
issues.push({
|
|
716
|
+
severity: "warning",
|
|
717
|
+
upstream: name,
|
|
718
|
+
message: "Authorization header appears to contain a literal bearer token",
|
|
719
|
+
suggestion: 'Use an environment placeholder, e.g., Authorization = "Bearer $API_TOKEN"'
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return issues;
|
|
724
|
+
}
|
|
725
|
+
function validateUpstreamConfig(name, config) {
|
|
726
|
+
if (config.transport === "stdio") {
|
|
727
|
+
return validateStdioUpstream(name, config);
|
|
728
|
+
}
|
|
729
|
+
return validateSseUpstream(name, config);
|
|
730
|
+
}
|
|
731
|
+
function validateConfig(config) {
|
|
732
|
+
const issues = [];
|
|
733
|
+
for (const [name, upstream] of Object.entries(config.upstreams)) {
|
|
734
|
+
if (!upstream.enabled)
|
|
735
|
+
continue;
|
|
736
|
+
issues.push(...validateUpstreamConfig(name, upstream));
|
|
737
|
+
}
|
|
738
|
+
return issues;
|
|
739
|
+
}
|
|
740
|
+
function formatValidationIssues(issues) {
|
|
741
|
+
if (issues.length === 0)
|
|
742
|
+
return "";
|
|
743
|
+
const lines = [];
|
|
744
|
+
const errors = issues.filter((i) => i.severity === "error");
|
|
745
|
+
const warnings = issues.filter((i) => i.severity === "warning");
|
|
746
|
+
if (errors.length > 0) {
|
|
747
|
+
lines.push(`
|
|
748
|
+
\x1B[31mConfiguration Errors:\x1B[0m`);
|
|
749
|
+
for (const error of errors) {
|
|
750
|
+
lines.push(` \x1B[31m\u2717\x1B[0m ${error.upstream}: ${error.message}`);
|
|
751
|
+
if (error.suggestion) {
|
|
752
|
+
lines.push(` \x1B[90m\u2192 ${error.suggestion}\x1B[0m`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (warnings.length > 0) {
|
|
757
|
+
lines.push(`
|
|
758
|
+
\x1B[33mConfiguration Warnings:\x1B[0m`);
|
|
759
|
+
for (const warning of warnings) {
|
|
760
|
+
lines.push(` \x1B[33m\u26A0\x1B[0m ${warning.upstream}: ${warning.message}`);
|
|
761
|
+
if (warning.suggestion) {
|
|
762
|
+
lines.push(` \x1B[90m\u2192 ${warning.suggestion}\x1B[0m`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return lines.join(`
|
|
767
|
+
`);
|
|
768
|
+
}
|
|
769
|
+
// src/upstream/cataloger.ts
|
|
770
|
+
import { UnauthorizedError as UnauthorizedError2 } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
771
|
+
import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
|
|
772
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
773
|
+
import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
774
|
+
|
|
775
|
+
// src/oauth/browser.ts
|
|
776
|
+
import { spawn } from "child_process";
|
|
777
|
+
async function openBrowser(url) {
|
|
778
|
+
const platform2 = process.platform;
|
|
779
|
+
let command;
|
|
780
|
+
let args;
|
|
781
|
+
switch (platform2) {
|
|
782
|
+
case "darwin":
|
|
783
|
+
command = "open";
|
|
784
|
+
args = [url];
|
|
785
|
+
break;
|
|
786
|
+
case "win32":
|
|
787
|
+
command = "powershell";
|
|
788
|
+
args = [
|
|
789
|
+
"-NoProfile",
|
|
790
|
+
"-NonInteractive",
|
|
791
|
+
"-Command",
|
|
792
|
+
`Start-Process '${url.replace(/'/g, "''")}'`
|
|
793
|
+
];
|
|
794
|
+
break;
|
|
795
|
+
default:
|
|
796
|
+
command = "xdg-open";
|
|
797
|
+
args = [url];
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
return new Promise((resolve2) => {
|
|
801
|
+
const child = spawn(command, args, {
|
|
802
|
+
detached: true,
|
|
803
|
+
stdio: "ignore"
|
|
804
|
+
});
|
|
805
|
+
child.on("error", () => {
|
|
806
|
+
resolve2(false);
|
|
807
|
+
});
|
|
808
|
+
child.on("spawn", () => {
|
|
809
|
+
child.unref();
|
|
810
|
+
resolve2(true);
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
function logAuthorizationUrl(url) {
|
|
815
|
+
console.error(`
|
|
816
|
+
Please open this URL in your browser to authorize:
|
|
817
|
+
`);
|
|
818
|
+
console.error(` ${url}
|
|
819
|
+
`);
|
|
820
|
+
console.error(`Waiting for authorization...
|
|
821
|
+
`);
|
|
822
|
+
}
|
|
823
|
+
// src/oauth/callback-server.ts
|
|
824
|
+
import { createServer } from "http";
|
|
825
|
+
function getSuccessHtml() {
|
|
826
|
+
return `<!DOCTYPE html>
|
|
827
|
+
<html>
|
|
828
|
+
<head>
|
|
829
|
+
<title>Authorization Complete</title>
|
|
830
|
+
<style>
|
|
831
|
+
body {
|
|
832
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
833
|
+
display: flex;
|
|
834
|
+
justify-content: center;
|
|
835
|
+
align-items: center;
|
|
836
|
+
height: 100vh;
|
|
837
|
+
margin: 0;
|
|
838
|
+
background: #f5f5f5;
|
|
839
|
+
}
|
|
840
|
+
.container {
|
|
841
|
+
text-align: center;
|
|
842
|
+
padding: 40px;
|
|
843
|
+
background: white;
|
|
844
|
+
border-radius: 8px;
|
|
845
|
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
846
|
+
}
|
|
847
|
+
h1 { color: #22c55e; margin-bottom: 10px; }
|
|
848
|
+
p { color: #666; }
|
|
849
|
+
</style>
|
|
850
|
+
</head>
|
|
851
|
+
<body>
|
|
852
|
+
<div class="container">
|
|
853
|
+
<h1>Authorization Complete</h1>
|
|
854
|
+
<p>You can close this window and return to your terminal.</p>
|
|
855
|
+
</div>
|
|
856
|
+
<script>
|
|
857
|
+
// Try to close the window after a short delay
|
|
858
|
+
setTimeout(() => { window.close(); }, 2000);
|
|
859
|
+
</script>
|
|
860
|
+
</body>
|
|
861
|
+
</html>`;
|
|
862
|
+
}
|
|
863
|
+
function escapeHtml(str) {
|
|
864
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
865
|
+
}
|
|
866
|
+
function getErrorHtml(error, description) {
|
|
867
|
+
const safeError = escapeHtml(error);
|
|
868
|
+
const safeDescription = description ? escapeHtml(description) : undefined;
|
|
869
|
+
return `<!DOCTYPE html>
|
|
870
|
+
<html>
|
|
871
|
+
<head>
|
|
872
|
+
<title>Authorization Failed</title>
|
|
873
|
+
<style>
|
|
874
|
+
body {
|
|
875
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
876
|
+
display: flex;
|
|
877
|
+
justify-content: center;
|
|
878
|
+
align-items: center;
|
|
879
|
+
height: 100vh;
|
|
880
|
+
margin: 0;
|
|
881
|
+
background: #f5f5f5;
|
|
882
|
+
}
|
|
883
|
+
.container {
|
|
884
|
+
text-align: center;
|
|
885
|
+
padding: 40px;
|
|
886
|
+
background: white;
|
|
887
|
+
border-radius: 8px;
|
|
888
|
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
889
|
+
}
|
|
890
|
+
h1 { color: #ef4444; margin-bottom: 10px; }
|
|
891
|
+
p { color: #666; }
|
|
892
|
+
.error { font-family: monospace; background: #fee; padding: 10px; border-radius: 4px; }
|
|
893
|
+
</style>
|
|
894
|
+
</head>
|
|
895
|
+
<body>
|
|
896
|
+
<div class="container">
|
|
897
|
+
<h1>Authorization Failed</h1>
|
|
898
|
+
<p class="error">${safeError}${safeDescription ? `: ${safeDescription}` : ""}</p>
|
|
899
|
+
<p>Please close this window and try again.</p>
|
|
900
|
+
</div>
|
|
901
|
+
</body>
|
|
902
|
+
</html>`;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
class OAuthCallbackServer {
|
|
906
|
+
server = null;
|
|
907
|
+
port;
|
|
908
|
+
path;
|
|
909
|
+
timeoutMs;
|
|
910
|
+
constructor(options = {}) {
|
|
911
|
+
this.port = options.port ?? 8089;
|
|
912
|
+
this.path = options.path ?? "/callback";
|
|
913
|
+
this.timeoutMs = options.timeoutMs ?? 300000;
|
|
914
|
+
}
|
|
915
|
+
async waitForCallback() {
|
|
916
|
+
return new Promise((resolve2, reject) => {
|
|
917
|
+
let resolved = false;
|
|
918
|
+
const timeoutId = setTimeout(() => {
|
|
919
|
+
fail(new Error(`OAuth callback timeout after ${this.timeoutMs}ms`));
|
|
920
|
+
}, this.timeoutMs);
|
|
921
|
+
const cleanup = () => {
|
|
922
|
+
if (timeoutId) {
|
|
923
|
+
clearTimeout(timeoutId);
|
|
924
|
+
}
|
|
925
|
+
this.stop();
|
|
926
|
+
};
|
|
927
|
+
const complete = (result) => {
|
|
928
|
+
if (resolved)
|
|
929
|
+
return;
|
|
930
|
+
resolved = true;
|
|
931
|
+
cleanup();
|
|
932
|
+
resolve2(result);
|
|
933
|
+
};
|
|
934
|
+
const fail = (error) => {
|
|
935
|
+
if (resolved)
|
|
936
|
+
return;
|
|
937
|
+
resolved = true;
|
|
938
|
+
cleanup();
|
|
939
|
+
reject(error);
|
|
940
|
+
};
|
|
941
|
+
this.server = createServer((req, res) => {
|
|
942
|
+
const url = new URL(req.url ?? "/", `http://localhost:${this.port}`);
|
|
943
|
+
if (url.pathname !== this.path) {
|
|
944
|
+
res.writeHead(404);
|
|
945
|
+
res.end("Not Found");
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const code = url.searchParams.get("code");
|
|
949
|
+
const error = url.searchParams.get("error");
|
|
950
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
951
|
+
const state = url.searchParams.get("state");
|
|
952
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
953
|
+
if (error) {
|
|
954
|
+
res.end(getErrorHtml(error, errorDescription ?? undefined));
|
|
955
|
+
} else {
|
|
956
|
+
res.end(getSuccessHtml());
|
|
957
|
+
}
|
|
958
|
+
const result = {};
|
|
959
|
+
if (code)
|
|
960
|
+
result.code = code;
|
|
961
|
+
if (error)
|
|
962
|
+
result.error = error;
|
|
963
|
+
if (errorDescription)
|
|
964
|
+
result.errorDescription = errorDescription;
|
|
965
|
+
if (state)
|
|
966
|
+
result.state = state;
|
|
967
|
+
complete(result);
|
|
968
|
+
});
|
|
969
|
+
this.server.on("error", (err) => {
|
|
970
|
+
fail(err);
|
|
971
|
+
});
|
|
972
|
+
this.server.listen(this.port, "127.0.0.1", () => {
|
|
973
|
+
const addr = this.server?.address();
|
|
974
|
+
if (addr) {}
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
stop() {
|
|
979
|
+
if (this.server) {
|
|
980
|
+
this.server.close();
|
|
981
|
+
this.server = null;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
getCallbackUrl() {
|
|
985
|
+
return `http://127.0.0.1:${this.port}${this.path}`;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
// src/oauth/preflight.ts
|
|
989
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
990
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
991
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
992
|
+
|
|
993
|
+
// src/version.ts
|
|
994
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
995
|
+
import { createRequire } from "module";
|
|
996
|
+
function normalizeVersion(value) {
|
|
997
|
+
if (typeof value !== "string") {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const trimmed = value.trim();
|
|
1001
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
1002
|
+
}
|
|
1003
|
+
function readManifestFile(manifestUrl) {
|
|
1004
|
+
const raw = readFileSync2(manifestUrl, "utf8");
|
|
1005
|
+
return JSON.parse(raw);
|
|
1006
|
+
}
|
|
1007
|
+
function readBundledManifestFile() {
|
|
1008
|
+
const require2 = createRequire(import.meta.url);
|
|
1009
|
+
return require2("../package.json");
|
|
1010
|
+
}
|
|
1011
|
+
function resolveVersion(options = {}) {
|
|
1012
|
+
const readManifest = options.readManifest ?? readManifestFile;
|
|
1013
|
+
const manifestUrl = options.manifestUrl ?? new URL("../package.json", import.meta.url);
|
|
1014
|
+
try {
|
|
1015
|
+
const manifest = readManifest(manifestUrl);
|
|
1016
|
+
const manifestVersion = normalizeVersion(manifest.version);
|
|
1017
|
+
if (manifestVersion) {
|
|
1018
|
+
return manifestVersion;
|
|
1019
|
+
}
|
|
1020
|
+
} catch {}
|
|
1021
|
+
const envVersion = normalizeVersion((options.env ?? process.env)["npm_package_version"]);
|
|
1022
|
+
if (envVersion) {
|
|
1023
|
+
return envVersion;
|
|
1024
|
+
}
|
|
1025
|
+
const readBundledManifest = options.readBundledManifest ?? readBundledManifestFile;
|
|
1026
|
+
try {
|
|
1027
|
+
const bundledVersion = normalizeVersion(readBundledManifest().version);
|
|
1028
|
+
if (bundledVersion) {
|
|
1029
|
+
return bundledVersion;
|
|
1030
|
+
}
|
|
1031
|
+
} catch {}
|
|
1032
|
+
return normalizeVersion(options.fallbackVersion) ?? "0.0.0";
|
|
1033
|
+
}
|
|
1034
|
+
var VERSION = resolveVersion();
|
|
1035
|
+
|
|
1036
|
+
// src/oauth/provider.ts
|
|
1037
|
+
var DEFAULT_OAUTH_CALLBACK_PORT = 8089;
|
|
1038
|
+
var DEFAULT_OAUTH_CLIENT_NAME = "MCP\xB2";
|
|
1039
|
+
function isValidCallbackPort(value) {
|
|
1040
|
+
return Number.isInteger(value) && value >= 1 && value <= 65535;
|
|
1041
|
+
}
|
|
1042
|
+
function isValidClientName(value) {
|
|
1043
|
+
return value.trim().length > 0;
|
|
1044
|
+
}
|
|
1045
|
+
function resolveOAuthProviderOptions(authConfig) {
|
|
1046
|
+
if (!authConfig || typeof authConfig !== "object") {
|
|
1047
|
+
return {
|
|
1048
|
+
callbackPort: DEFAULT_OAUTH_CALLBACK_PORT,
|
|
1049
|
+
clientName: DEFAULT_OAUTH_CLIENT_NAME
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
const callbackPort = authConfig.callbackPort ?? DEFAULT_OAUTH_CALLBACK_PORT;
|
|
1053
|
+
if (!isValidCallbackPort(callbackPort)) {
|
|
1054
|
+
throw new RangeError(`Invalid OAuth callbackPort: ${callbackPort}`);
|
|
1055
|
+
}
|
|
1056
|
+
const clientName = authConfig.clientName ?? DEFAULT_OAUTH_CLIENT_NAME;
|
|
1057
|
+
if (typeof clientName !== "string" || !isValidClientName(clientName)) {
|
|
1058
|
+
throw new TypeError(`Invalid OAuth clientName: ${String(clientName)}`);
|
|
1059
|
+
}
|
|
1060
|
+
return {
|
|
1061
|
+
callbackPort,
|
|
1062
|
+
clientName
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
class McpOAuthProvider {
|
|
1067
|
+
upstreamName;
|
|
1068
|
+
storage;
|
|
1069
|
+
callbackPort;
|
|
1070
|
+
_clientName;
|
|
1071
|
+
_nonInteractive;
|
|
1072
|
+
_state;
|
|
1073
|
+
constructor(upstreamName, storage, options = {}) {
|
|
1074
|
+
this.upstreamName = upstreamName;
|
|
1075
|
+
this.storage = storage;
|
|
1076
|
+
this.callbackPort = options.callbackPort ?? DEFAULT_OAUTH_CALLBACK_PORT;
|
|
1077
|
+
this._clientName = options.clientName ?? DEFAULT_OAUTH_CLIENT_NAME;
|
|
1078
|
+
this._nonInteractive = options.nonInteractive ?? false;
|
|
1079
|
+
}
|
|
1080
|
+
get redirectUrl() {
|
|
1081
|
+
return `http://localhost:${this.callbackPort}/callback`;
|
|
1082
|
+
}
|
|
1083
|
+
get clientMetadata() {
|
|
1084
|
+
return {
|
|
1085
|
+
client_name: this._clientName,
|
|
1086
|
+
redirect_uris: [this.redirectUrl],
|
|
1087
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1088
|
+
response_types: ["code"],
|
|
1089
|
+
token_endpoint_auth_method: "none"
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
state() {
|
|
1093
|
+
if (!this._state) {
|
|
1094
|
+
const array = new Uint8Array(32);
|
|
1095
|
+
crypto.getRandomValues(array);
|
|
1096
|
+
this._state = Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
1097
|
+
}
|
|
1098
|
+
return this._state;
|
|
1099
|
+
}
|
|
1100
|
+
verifyState(receivedState) {
|
|
1101
|
+
return this._state === receivedState;
|
|
1102
|
+
}
|
|
1103
|
+
clientInformation() {
|
|
1104
|
+
const data = this.storage.load(this.upstreamName);
|
|
1105
|
+
return data?.clientInfo;
|
|
1106
|
+
}
|
|
1107
|
+
saveClientInformation(clientInfo) {
|
|
1108
|
+
const data = this.storage.load(this.upstreamName) ?? {};
|
|
1109
|
+
data.clientInfo = clientInfo;
|
|
1110
|
+
this.storage.save(this.upstreamName, data);
|
|
1111
|
+
}
|
|
1112
|
+
tokens() {
|
|
1113
|
+
const data = this.storage.load(this.upstreamName);
|
|
1114
|
+
return data?.tokens;
|
|
1115
|
+
}
|
|
1116
|
+
saveTokens(tokens) {
|
|
1117
|
+
const data = this.storage.load(this.upstreamName) ?? {};
|
|
1118
|
+
data.tokens = tokens;
|
|
1119
|
+
if (tokens.expires_in) {
|
|
1120
|
+
data.expiresAt = Date.now() + tokens.expires_in * 1000;
|
|
1121
|
+
}
|
|
1122
|
+
this.storage.save(this.upstreamName, data);
|
|
1123
|
+
}
|
|
1124
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
1125
|
+
if (this._nonInteractive) {
|
|
1126
|
+
throw new Error(`OAuth authorization required. Run: mcp-squared auth ${this.upstreamName}`);
|
|
1127
|
+
}
|
|
1128
|
+
const urlString = authorizationUrl.toString();
|
|
1129
|
+
console.error(`
|
|
1130
|
+
Opening browser for authorization...`);
|
|
1131
|
+
console.error(`URL: ${urlString}
|
|
1132
|
+
`);
|
|
1133
|
+
const opened = await openBrowser(urlString);
|
|
1134
|
+
if (!opened) {
|
|
1135
|
+
logAuthorizationUrl(urlString);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
saveCodeVerifier(codeVerifier) {
|
|
1139
|
+
const data = this.storage.load(this.upstreamName) ?? {};
|
|
1140
|
+
data.codeVerifier = codeVerifier;
|
|
1141
|
+
this.storage.save(this.upstreamName, data);
|
|
1142
|
+
}
|
|
1143
|
+
async codeVerifier() {
|
|
1144
|
+
const data = this.storage.load(this.upstreamName);
|
|
1145
|
+
if (!data?.codeVerifier) {
|
|
1146
|
+
throw new Error("No code verifier stored");
|
|
1147
|
+
}
|
|
1148
|
+
return data.codeVerifier;
|
|
1149
|
+
}
|
|
1150
|
+
clearCodeVerifier() {
|
|
1151
|
+
const data = this.storage.load(this.upstreamName);
|
|
1152
|
+
if (data) {
|
|
1153
|
+
const { codeVerifier: _, ...rest } = data;
|
|
1154
|
+
this.storage.save(this.upstreamName, rest);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
invalidateCredentials(scope) {
|
|
1158
|
+
const data = this.storage.load(this.upstreamName);
|
|
1159
|
+
if (!data)
|
|
1160
|
+
return;
|
|
1161
|
+
switch (scope) {
|
|
1162
|
+
case "all":
|
|
1163
|
+
this.storage.delete(this.upstreamName);
|
|
1164
|
+
break;
|
|
1165
|
+
case "client": {
|
|
1166
|
+
const { clientInfo: _, ...rest } = data;
|
|
1167
|
+
this.storage.save(this.upstreamName, rest);
|
|
1168
|
+
break;
|
|
1169
|
+
}
|
|
1170
|
+
case "tokens": {
|
|
1171
|
+
const { tokens: _, expiresAt: __, ...rest } = data;
|
|
1172
|
+
this.storage.save(this.upstreamName, rest);
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
case "verifier": {
|
|
1176
|
+
const { codeVerifier: _, ...rest } = data;
|
|
1177
|
+
this.storage.save(this.upstreamName, rest);
|
|
1178
|
+
break;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
isInteractive() {
|
|
1183
|
+
return true;
|
|
1184
|
+
}
|
|
1185
|
+
isNonInteractive() {
|
|
1186
|
+
return this._nonInteractive;
|
|
1187
|
+
}
|
|
1188
|
+
isTokenExpired(bufferMs = 60000) {
|
|
1189
|
+
const data = this.storage.load(this.upstreamName);
|
|
1190
|
+
if (!data?.expiresAt)
|
|
1191
|
+
return true;
|
|
1192
|
+
return Date.now() >= data.expiresAt - bufferMs;
|
|
1193
|
+
}
|
|
1194
|
+
clearAll() {
|
|
1195
|
+
this.storage.delete(this.upstreamName);
|
|
1196
|
+
this._state = undefined;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// src/oauth/token-storage.ts
|
|
1201
|
+
import {
|
|
1202
|
+
chmodSync,
|
|
1203
|
+
existsSync as existsSync3,
|
|
1204
|
+
mkdirSync as mkdirSync3,
|
|
1205
|
+
readFileSync as readFileSync3,
|
|
1206
|
+
unlinkSync as unlinkSync2,
|
|
1207
|
+
writeFileSync as writeFileSync2
|
|
1208
|
+
} from "fs";
|
|
1209
|
+
import { homedir as homedir2 } from "os";
|
|
1210
|
+
import { join as join3 } from "path";
|
|
1211
|
+
function getDefaultTokenDir() {
|
|
1212
|
+
const configDir = process.env["XDG_CONFIG_HOME"] || join3(homedir2(), ".config");
|
|
1213
|
+
return join3(configDir, "mcp-squared", "tokens");
|
|
1214
|
+
}
|
|
1215
|
+
function sanitizeUpstreamName(name) {
|
|
1216
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
class TokenStorage {
|
|
1220
|
+
baseDir;
|
|
1221
|
+
constructor(baseDir) {
|
|
1222
|
+
this.baseDir = baseDir ?? getDefaultTokenDir();
|
|
1223
|
+
this.ensureDir();
|
|
1224
|
+
}
|
|
1225
|
+
ensureDir() {
|
|
1226
|
+
if (!existsSync3(this.baseDir)) {
|
|
1227
|
+
mkdirSync3(this.baseDir, { recursive: true, mode: 448 });
|
|
1228
|
+
}
|
|
1229
|
+
try {
|
|
1230
|
+
chmodSync(this.baseDir, 448);
|
|
1231
|
+
} catch {}
|
|
1232
|
+
}
|
|
1233
|
+
getFilePath(upstreamName) {
|
|
1234
|
+
const safeName = sanitizeUpstreamName(upstreamName);
|
|
1235
|
+
return join3(this.baseDir, `${safeName}.json`);
|
|
1236
|
+
}
|
|
1237
|
+
load(upstreamName) {
|
|
1238
|
+
const filePath = this.getFilePath(upstreamName);
|
|
1239
|
+
if (!existsSync3(filePath)) {
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
try {
|
|
1243
|
+
const content = readFileSync3(filePath, "utf-8");
|
|
1244
|
+
return JSON.parse(content);
|
|
1245
|
+
} catch {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
save(upstreamName, data) {
|
|
1250
|
+
this.ensureDir();
|
|
1251
|
+
const filePath = this.getFilePath(upstreamName);
|
|
1252
|
+
const dataWithTimestamp = {
|
|
1253
|
+
...data,
|
|
1254
|
+
updatedAt: Date.now()
|
|
1255
|
+
};
|
|
1256
|
+
writeFileSync2(filePath, JSON.stringify(dataWithTimestamp, null, 2), {
|
|
1257
|
+
mode: 384
|
|
1258
|
+
});
|
|
1259
|
+
try {
|
|
1260
|
+
chmodSync(filePath, 384);
|
|
1261
|
+
} catch {}
|
|
1262
|
+
}
|
|
1263
|
+
delete(upstreamName) {
|
|
1264
|
+
const filePath = this.getFilePath(upstreamName);
|
|
1265
|
+
if (existsSync3(filePath)) {
|
|
1266
|
+
unlinkSync2(filePath);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
isExpired(upstreamName, bufferMs = 60000) {
|
|
1270
|
+
const data = this.load(upstreamName);
|
|
1271
|
+
if (!data?.tokens || !data.expiresAt) {
|
|
1272
|
+
return true;
|
|
1273
|
+
}
|
|
1274
|
+
return Date.now() >= data.expiresAt - bufferMs;
|
|
1275
|
+
}
|
|
1276
|
+
updateTokens(upstreamName, tokens) {
|
|
1277
|
+
const existing = this.load(upstreamName) ?? {};
|
|
1278
|
+
const updatedData = {
|
|
1279
|
+
...existing,
|
|
1280
|
+
tokens
|
|
1281
|
+
};
|
|
1282
|
+
if (tokens.expires_in) {
|
|
1283
|
+
updatedData.expiresAt = Date.now() + tokens.expires_in * 1000;
|
|
1284
|
+
}
|
|
1285
|
+
this.save(upstreamName, updatedData);
|
|
1286
|
+
}
|
|
1287
|
+
saveCodeVerifier(upstreamName, codeVerifier) {
|
|
1288
|
+
const existing = this.load(upstreamName) ?? {};
|
|
1289
|
+
this.save(upstreamName, {
|
|
1290
|
+
...existing,
|
|
1291
|
+
codeVerifier
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
getAndClearCodeVerifier(upstreamName) {
|
|
1295
|
+
const data = this.load(upstreamName);
|
|
1296
|
+
const verifier = data?.codeVerifier;
|
|
1297
|
+
if (verifier && data) {
|
|
1298
|
+
const { codeVerifier: _, ...rest } = data;
|
|
1299
|
+
this.save(upstreamName, rest);
|
|
1300
|
+
}
|
|
1301
|
+
return verifier;
|
|
1302
|
+
}
|
|
1303
|
+
saveState(upstreamName, state) {
|
|
1304
|
+
const existing = this.load(upstreamName) ?? {};
|
|
1305
|
+
this.save(upstreamName, {
|
|
1306
|
+
...existing,
|
|
1307
|
+
state
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
verifyAndClearState(upstreamName, state) {
|
|
1311
|
+
const data = this.load(upstreamName);
|
|
1312
|
+
const storedState = data?.state;
|
|
1313
|
+
if (storedState && data) {
|
|
1314
|
+
const { state: _, ...rest } = data;
|
|
1315
|
+
this.save(upstreamName, rest);
|
|
1316
|
+
}
|
|
1317
|
+
return storedState === state;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// src/oauth/preflight.ts
|
|
1322
|
+
function getPreflightClientMetadata() {
|
|
1323
|
+
return {
|
|
1324
|
+
name: "mcp-squared-preflight",
|
|
1325
|
+
version: VERSION
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
async function performPreflightAuth(config) {
|
|
1329
|
+
const result = {
|
|
1330
|
+
authenticated: [],
|
|
1331
|
+
alreadyValid: [],
|
|
1332
|
+
failed: []
|
|
1333
|
+
};
|
|
1334
|
+
const tokenStorage = new TokenStorage;
|
|
1335
|
+
const sseUpstreams = [];
|
|
1336
|
+
for (const [name, upstream] of Object.entries(config.upstreams)) {
|
|
1337
|
+
if (!upstream.enabled)
|
|
1338
|
+
continue;
|
|
1339
|
+
if (upstream.transport !== "sse")
|
|
1340
|
+
continue;
|
|
1341
|
+
const sseConfig = upstream;
|
|
1342
|
+
if (!sseConfig.sse.auth)
|
|
1343
|
+
continue;
|
|
1344
|
+
sseUpstreams.push({ name, config: sseConfig });
|
|
1345
|
+
}
|
|
1346
|
+
if (sseUpstreams.length === 0) {
|
|
1347
|
+
return result;
|
|
1348
|
+
}
|
|
1349
|
+
for (const { name, config: sseConfig } of sseUpstreams) {
|
|
1350
|
+
try {
|
|
1351
|
+
const { callbackPort, clientName } = resolveOAuthProviderOptions(sseConfig.sse.auth);
|
|
1352
|
+
const authProvider = new McpOAuthProvider(name, tokenStorage, {
|
|
1353
|
+
callbackPort,
|
|
1354
|
+
clientName
|
|
1355
|
+
});
|
|
1356
|
+
const existingTokens = authProvider.tokens();
|
|
1357
|
+
if (existingTokens && !authProvider.isTokenExpired()) {
|
|
1358
|
+
result.alreadyValid.push(name);
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
console.error(`
|
|
1362
|
+
[preflight] OAuth required for '${name}'`);
|
|
1363
|
+
console.error(`[preflight] Server URL: ${sseConfig.sse.url}`);
|
|
1364
|
+
await performInteractiveAuth(name, sseConfig, authProvider, callbackPort);
|
|
1365
|
+
result.authenticated.push(name);
|
|
1366
|
+
console.error(`[preflight] \u2713 Authentication successful for '${name}'`);
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1369
|
+
result.failed.push({ name, error: message });
|
|
1370
|
+
console.error(`[preflight] \u2717 Authentication failed for '${name}': ${message}`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
return result;
|
|
1374
|
+
}
|
|
1375
|
+
async function performInteractiveAuth(name, sseConfig, authProvider, callbackPort) {
|
|
1376
|
+
const callbackServer = new OAuthCallbackServer({
|
|
1377
|
+
port: callbackPort,
|
|
1378
|
+
path: "/callback",
|
|
1379
|
+
timeoutMs: 300000
|
|
1380
|
+
});
|
|
1381
|
+
console.error(`[preflight:${name}] Callback URL: ${callbackServer.getCallbackUrl()}`);
|
|
1382
|
+
const transport = new StreamableHTTPClientTransport(new URL(sseConfig.sse.url), {
|
|
1383
|
+
authProvider,
|
|
1384
|
+
requestInit: {
|
|
1385
|
+
headers: { ...sseConfig.sse.headers }
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
const client = new Client(getPreflightClientMetadata());
|
|
1389
|
+
try {
|
|
1390
|
+
console.error(`[preflight:${name}] Connecting to server (will trigger OAuth)...`);
|
|
1391
|
+
await client.connect(transport);
|
|
1392
|
+
callbackServer.stop();
|
|
1393
|
+
try {
|
|
1394
|
+
await client.close();
|
|
1395
|
+
} catch {}
|
|
1396
|
+
console.error(`[preflight:${name}] Already authenticated!`);
|
|
1397
|
+
return;
|
|
1398
|
+
} catch (err) {
|
|
1399
|
+
if (!(err instanceof UnauthorizedError)) {
|
|
1400
|
+
callbackServer.stop();
|
|
1401
|
+
throw err;
|
|
1402
|
+
}
|
|
1403
|
+
console.error(`[preflight:${name}] Waiting for browser authorization...`);
|
|
1404
|
+
}
|
|
1405
|
+
try {
|
|
1406
|
+
const callbackResult = await callbackServer.waitForCallback();
|
|
1407
|
+
if (callbackResult.error) {
|
|
1408
|
+
throw new Error(`OAuth error: ${callbackResult.error}${callbackResult.errorDescription ? `: ${callbackResult.errorDescription}` : ""}`);
|
|
1409
|
+
}
|
|
1410
|
+
if (!callbackResult.code) {
|
|
1411
|
+
throw new Error("No authorization code received");
|
|
1412
|
+
}
|
|
1413
|
+
if (!callbackResult.state || !authProvider.verifyState(callbackResult.state)) {
|
|
1414
|
+
throw new Error("OAuth state mismatch - possible CSRF attack");
|
|
1415
|
+
}
|
|
1416
|
+
console.error(`[preflight:${name}] Authorization code received, completing...`);
|
|
1417
|
+
await transport.finishAuth(callbackResult.code);
|
|
1418
|
+
authProvider.clearCodeVerifier();
|
|
1419
|
+
} finally {
|
|
1420
|
+
callbackServer.stop();
|
|
1421
|
+
try {
|
|
1422
|
+
await client.close();
|
|
1423
|
+
} catch {}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
// src/security/policy.ts
|
|
1427
|
+
var CONFIRMATION_TTL_MS = 5 * 60 * 1000;
|
|
1428
|
+
var pendingConfirmations = new Map;
|
|
1429
|
+
function matchesPattern(pattern, serverKey, toolName) {
|
|
1430
|
+
const [patternServer, patternTool] = pattern.split(":", 2);
|
|
1431
|
+
if (!patternServer || !patternTool) {
|
|
1432
|
+
return false;
|
|
1433
|
+
}
|
|
1434
|
+
const serverMatches = patternServer === "*" || patternServer === serverKey;
|
|
1435
|
+
const toolMatches = patternTool === "*" || patternTool === toolName;
|
|
1436
|
+
return serverMatches && toolMatches;
|
|
1437
|
+
}
|
|
1438
|
+
function matchesAnyPattern(patterns, serverKey, toolName) {
|
|
1439
|
+
return patterns.some((pattern) => matchesPattern(pattern, serverKey, toolName));
|
|
1440
|
+
}
|
|
1441
|
+
function generateToken() {
|
|
1442
|
+
const bytes = new Uint8Array(32);
|
|
1443
|
+
crypto.getRandomValues(bytes);
|
|
1444
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1445
|
+
}
|
|
1446
|
+
function cleanupExpiredTokens() {
|
|
1447
|
+
const now = Date.now();
|
|
1448
|
+
for (const [token, confirmation] of pendingConfirmations) {
|
|
1449
|
+
if (now - confirmation.createdAt > CONFIRMATION_TTL_MS) {
|
|
1450
|
+
pendingConfirmations.delete(token);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
function validateConfirmationToken(token, serverKey, toolName) {
|
|
1455
|
+
cleanupExpiredTokens();
|
|
1456
|
+
const confirmation = pendingConfirmations.get(token);
|
|
1457
|
+
if (!confirmation) {
|
|
1458
|
+
return false;
|
|
1459
|
+
}
|
|
1460
|
+
if (confirmation.serverKey !== serverKey || confirmation.toolName !== toolName) {
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
pendingConfirmations.delete(token);
|
|
1464
|
+
return true;
|
|
1465
|
+
}
|
|
1466
|
+
function createConfirmationToken(serverKey, toolName) {
|
|
1467
|
+
cleanupExpiredTokens();
|
|
1468
|
+
const token = generateToken();
|
|
1469
|
+
pendingConfirmations.set(token, {
|
|
1470
|
+
serverKey,
|
|
1471
|
+
toolName,
|
|
1472
|
+
createdAt: Date.now()
|
|
1473
|
+
});
|
|
1474
|
+
return token;
|
|
1475
|
+
}
|
|
1476
|
+
function evaluatePolicy(context, config) {
|
|
1477
|
+
const { serverKey, toolName, confirmationToken } = context;
|
|
1478
|
+
const { block, confirm, allow } = config.security.tools;
|
|
1479
|
+
if (matchesAnyPattern(block, serverKey, toolName)) {
|
|
1480
|
+
return {
|
|
1481
|
+
decision: "block",
|
|
1482
|
+
reason: `Tool "${toolName}" on server "${serverKey}" is blocked by security policy`
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
if (matchesAnyPattern(allow, serverKey, toolName)) {
|
|
1486
|
+
return {
|
|
1487
|
+
decision: "allow",
|
|
1488
|
+
reason: `Tool "${toolName}" is allowed by security policy`
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
if (matchesAnyPattern(confirm, serverKey, toolName)) {
|
|
1492
|
+
if (confirmationToken && validateConfirmationToken(confirmationToken, serverKey, toolName)) {
|
|
1493
|
+
return {
|
|
1494
|
+
decision: "allow",
|
|
1495
|
+
reason: `Tool "${toolName}" confirmed with valid token`
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
const token = createConfirmationToken(serverKey, toolName);
|
|
1499
|
+
return {
|
|
1500
|
+
decision: "confirm",
|
|
1501
|
+
reason: `Tool "${toolName}" on server "${serverKey}" requires confirmation`,
|
|
1502
|
+
confirmationToken: token
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
return {
|
|
1506
|
+
decision: "block",
|
|
1507
|
+
reason: `Tool "${toolName}" on server "${serverKey}" is not in the allow or confirm list`
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
function compilePolicy(config) {
|
|
1511
|
+
return {
|
|
1512
|
+
blockPatterns: config.security.tools.block,
|
|
1513
|
+
confirmPatterns: config.security.tools.confirm,
|
|
1514
|
+
allowPatterns: config.security.tools.allow
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
function getToolVisibilityCompiled(serverKey, toolName, policy) {
|
|
1518
|
+
return getToolVisibilityFromPatterns(serverKey, toolName, policy.blockPatterns, policy.confirmPatterns, policy.allowPatterns);
|
|
1519
|
+
}
|
|
1520
|
+
function getToolVisibilityFromPatterns(serverKey, toolName, block, confirm, allow) {
|
|
1521
|
+
if (matchesAnyPattern(block, serverKey, toolName)) {
|
|
1522
|
+
return { visible: false, requiresConfirmation: false };
|
|
1523
|
+
}
|
|
1524
|
+
if (matchesAnyPattern(allow, serverKey, toolName)) {
|
|
1525
|
+
return { visible: true, requiresConfirmation: false };
|
|
1526
|
+
}
|
|
1527
|
+
if (matchesAnyPattern(confirm, serverKey, toolName)) {
|
|
1528
|
+
return { visible: true, requiresConfirmation: true };
|
|
1529
|
+
}
|
|
1530
|
+
return { visible: false, requiresConfirmation: false };
|
|
1531
|
+
}
|
|
1532
|
+
// src/security/sanitize.ts
|
|
1533
|
+
var DEFAULT_MAX_LENGTH = 2000;
|
|
1534
|
+
var DEFAULT_INJECTION_PATTERNS = [
|
|
1535
|
+
/ignore\s+(all\s+)?previous\s+instructions?/gi,
|
|
1536
|
+
/disregard\s+(all\s+)?previous/gi,
|
|
1537
|
+
/forget\s+(everything|all|what)\s+(you('ve)?|i)\s+(said|told|learned)/gi,
|
|
1538
|
+
/override\s+(all\s+)?(previous\s+)?instructions?/gi,
|
|
1539
|
+
/you\s+are\s+(now\s+)?(a|an|the)\s+\w+/gi,
|
|
1540
|
+
/act\s+as\s+(a|an|the)?\s*\w+/gi,
|
|
1541
|
+
/pretend\s+(to\s+be|you('re)?)\s+/gi,
|
|
1542
|
+
/your\s+new\s+(role|persona|identity)/gi,
|
|
1543
|
+
/from\s+now\s+on\s+(you|act|behave)/gi,
|
|
1544
|
+
/show\s+(me\s+)?(your\s+)?(system\s+)?prompt/gi,
|
|
1545
|
+
/print\s+(your\s+)?instructions/gi,
|
|
1546
|
+
/reveal\s+(your\s+)?configuration/gi,
|
|
1547
|
+
/what\s+(are|is)\s+your\s+(system\s+)?(prompt|instructions)/gi,
|
|
1548
|
+
/repeat\s+(your\s+)?(system\s+)?prompt/gi,
|
|
1549
|
+
/developer\s+mode/gi,
|
|
1550
|
+
/\bdan\s+mode\b/gi,
|
|
1551
|
+
/\bdeveloper\s+override\b/gi,
|
|
1552
|
+
/\[system\]/gi,
|
|
1553
|
+
/\[admin\]/gi,
|
|
1554
|
+
/\[assistant\]/gi,
|
|
1555
|
+
/\[user\]/gi,
|
|
1556
|
+
/<<\s*system\s*>>/gi,
|
|
1557
|
+
/<<\s*admin\s*>>/gi,
|
|
1558
|
+
/base64[:\s]/gi,
|
|
1559
|
+
/decode\s+this/gi,
|
|
1560
|
+
/execute\s+the\s+following/gi
|
|
1561
|
+
];
|
|
1562
|
+
function sanitizeDescription(description, options = {}) {
|
|
1563
|
+
if (description === undefined || description === null) {
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
const maxLength = options.maxLength ?? DEFAULT_MAX_LENGTH;
|
|
1567
|
+
const patterns = options.stripPatterns ?? DEFAULT_INJECTION_PATTERNS;
|
|
1568
|
+
const normalizeWs = options.normalizeWhitespace ?? true;
|
|
1569
|
+
let sanitized = description;
|
|
1570
|
+
sanitized = sanitized.normalize("NFC");
|
|
1571
|
+
sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
|
1572
|
+
for (const pattern of patterns) {
|
|
1573
|
+
pattern.lastIndex = 0;
|
|
1574
|
+
sanitized = sanitized.replace(pattern, "[REDACTED]");
|
|
1575
|
+
}
|
|
1576
|
+
if (normalizeWs) {
|
|
1577
|
+
sanitized = sanitized.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, `
|
|
1578
|
+
|
|
1579
|
+
`).trim();
|
|
1580
|
+
}
|
|
1581
|
+
if (sanitized.length > maxLength) {
|
|
1582
|
+
sanitized = `${sanitized.slice(0, maxLength - 3)}...`;
|
|
1583
|
+
}
|
|
1584
|
+
return sanitized;
|
|
1585
|
+
}
|
|
1586
|
+
// src/utils/tool-names.ts
|
|
1587
|
+
function parseQualifiedName(input) {
|
|
1588
|
+
const colonIndex = input.indexOf(":");
|
|
1589
|
+
if (colonIndex === -1) {
|
|
1590
|
+
return {
|
|
1591
|
+
serverKey: null,
|
|
1592
|
+
toolName: input
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
return {
|
|
1596
|
+
serverKey: input.slice(0, colonIndex),
|
|
1597
|
+
toolName: input.slice(colonIndex + 1)
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
function formatQualifiedName(serverKey, toolName) {
|
|
1601
|
+
return `${serverKey}:${toolName}`;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// src/utils/transport.ts
|
|
1605
|
+
async function safelyCloseTransport(transport, timeoutMs = 1000) {
|
|
1606
|
+
const childProcess = transport?._process;
|
|
1607
|
+
if (childProcess && typeof childProcess.kill === "function") {
|
|
1608
|
+
try {
|
|
1609
|
+
await Promise.race([
|
|
1610
|
+
transport.close(),
|
|
1611
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Close timeout")), timeoutMs))
|
|
1612
|
+
]);
|
|
1613
|
+
} catch (err) {
|
|
1614
|
+
if (err instanceof Error && err.message !== "Close timeout") {
|
|
1615
|
+
console.warn(`[mcp\xB2] Transport close warning: ${err.message}`);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
const processStillRunning = childProcess.exitCode == null && !childProcess.killed;
|
|
1619
|
+
if (processStillRunning) {
|
|
1620
|
+
try {
|
|
1621
|
+
childProcess.kill("SIGTERM");
|
|
1622
|
+
if (typeof childProcess.once === "function") {
|
|
1623
|
+
await waitForProcessExit(childProcess, 5000);
|
|
1624
|
+
}
|
|
1625
|
+
} catch (err) {
|
|
1626
|
+
if (err instanceof Error) {
|
|
1627
|
+
console.warn(`[mcp\xB2] Process cleanup warning: ${err.message}`);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
try {
|
|
1634
|
+
await transport.close();
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
if (err instanceof Error) {
|
|
1637
|
+
console.warn(`[mcp\xB2] Transport close warning: ${err.message}`);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
async function waitForProcessExit(proc, timeoutMs) {
|
|
1642
|
+
if (proc.exitCode != null) {
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
return new Promise((resolve2) => {
|
|
1646
|
+
const timeoutId = setTimeout(() => {
|
|
1647
|
+
if (proc.exitCode == null) {
|
|
1648
|
+
try {
|
|
1649
|
+
proc.kill("SIGKILL");
|
|
1650
|
+
} catch {}
|
|
1651
|
+
}
|
|
1652
|
+
proc.off("exit", onExit);
|
|
1653
|
+
resolve2();
|
|
1654
|
+
}, timeoutMs);
|
|
1655
|
+
timeoutId.unref();
|
|
1656
|
+
const onExit = () => {
|
|
1657
|
+
clearTimeout(timeoutId);
|
|
1658
|
+
proc.off("exit", onExit);
|
|
1659
|
+
resolve2();
|
|
1660
|
+
};
|
|
1661
|
+
proc.once("exit", onExit);
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// src/upstream/cataloger.ts
|
|
1666
|
+
function resolveEnvVars(env) {
|
|
1667
|
+
const resolved = {};
|
|
1668
|
+
for (const [key, value] of Object.entries(env)) {
|
|
1669
|
+
if (value.startsWith("$")) {
|
|
1670
|
+
const envKey = value.slice(1);
|
|
1671
|
+
resolved[key] = process.env[envKey] ?? "";
|
|
1672
|
+
} else {
|
|
1673
|
+
resolved[key] = value;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return resolved;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
class Cataloger {
|
|
1680
|
+
connections = new Map;
|
|
1681
|
+
connectTimeoutMs;
|
|
1682
|
+
constructor(options = {}) {
|
|
1683
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? 30000;
|
|
1684
|
+
}
|
|
1685
|
+
async connectAll(config) {
|
|
1686
|
+
const connectPromises = [];
|
|
1687
|
+
for (const [key, serverConfig] of Object.entries(config.upstreams)) {
|
|
1688
|
+
if (serverConfig.enabled) {
|
|
1689
|
+
connectPromises.push(this.connect(key, serverConfig));
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
await Promise.allSettled(connectPromises);
|
|
1693
|
+
}
|
|
1694
|
+
async connect(key, config) {
|
|
1695
|
+
if (this.connections.has(key)) {
|
|
1696
|
+
await this.disconnect(key);
|
|
1697
|
+
}
|
|
1698
|
+
const connection = {
|
|
1699
|
+
key,
|
|
1700
|
+
config,
|
|
1701
|
+
status: "connecting",
|
|
1702
|
+
error: undefined,
|
|
1703
|
+
serverName: undefined,
|
|
1704
|
+
serverVersion: undefined,
|
|
1705
|
+
tools: [],
|
|
1706
|
+
client: null,
|
|
1707
|
+
transport: null,
|
|
1708
|
+
authProvider: null,
|
|
1709
|
+
authPending: false,
|
|
1710
|
+
authStateVersion: this.getAuthStateVersion(key)
|
|
1711
|
+
};
|
|
1712
|
+
this.connections.set(key, connection);
|
|
1713
|
+
try {
|
|
1714
|
+
const client = new Client2({
|
|
1715
|
+
name: "mcp-squared",
|
|
1716
|
+
version: "1.0.0"
|
|
1717
|
+
});
|
|
1718
|
+
let transport;
|
|
1719
|
+
const isStdio = config.transport === "stdio";
|
|
1720
|
+
if (isStdio) {
|
|
1721
|
+
transport = this.createStdioTransport(config);
|
|
1722
|
+
} else {
|
|
1723
|
+
const { transport: httpTransport, authProvider } = this.createHttpTransport(key, config);
|
|
1724
|
+
transport = httpTransport;
|
|
1725
|
+
connection.authProvider = authProvider;
|
|
1726
|
+
}
|
|
1727
|
+
connection.client = client;
|
|
1728
|
+
connection.transport = transport;
|
|
1729
|
+
const connectPromise = client.connect(transport);
|
|
1730
|
+
let timeoutId;
|
|
1731
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1732
|
+
timeoutId = setTimeout(() => reject(new Error("Connection timeout")), this.connectTimeoutMs);
|
|
1733
|
+
});
|
|
1734
|
+
try {
|
|
1735
|
+
await Promise.race([connectPromise, timeoutPromise]);
|
|
1736
|
+
if (isStdio && transport instanceof StdioClientTransport) {
|
|
1737
|
+
const childProcess = transport._process;
|
|
1738
|
+
if (childProcess && childProcess.exitCode !== null) {
|
|
1739
|
+
throw new Error(`Process exited during initialization with code ${childProcess.exitCode}`);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
} catch (err) {
|
|
1743
|
+
if (err instanceof UnauthorizedError2 && connection.authProvider) {
|
|
1744
|
+
if (connection.authProvider.isNonInteractive()) {
|
|
1745
|
+
connection.authPending = true;
|
|
1746
|
+
connection.status = "error";
|
|
1747
|
+
connection.error = `OAuth authorization required. Run: mcp-squared auth ${key}`;
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
throw err;
|
|
1751
|
+
}
|
|
1752
|
+
throw err;
|
|
1753
|
+
} finally {
|
|
1754
|
+
if (timeoutId !== undefined) {
|
|
1755
|
+
clearTimeout(timeoutId);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
const serverInfo = client.getServerVersion();
|
|
1759
|
+
connection.serverName = serverInfo?.name;
|
|
1760
|
+
connection.serverVersion = serverInfo?.version;
|
|
1761
|
+
const { tools } = await client.listTools();
|
|
1762
|
+
connection.tools = tools.map((tool) => ({
|
|
1763
|
+
name: tool.name,
|
|
1764
|
+
description: sanitizeDescription(tool.description),
|
|
1765
|
+
inputSchema: tool.inputSchema,
|
|
1766
|
+
serverKey: key
|
|
1767
|
+
}));
|
|
1768
|
+
connection.status = "connected";
|
|
1769
|
+
} catch (err) {
|
|
1770
|
+
connection.status = "error";
|
|
1771
|
+
connection.error = err instanceof Error ? err.message : String(err);
|
|
1772
|
+
try {
|
|
1773
|
+
await this.cleanupConnection(connection);
|
|
1774
|
+
} catch (_cleanupErr) {}
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
async disconnect(key) {
|
|
1778
|
+
const connection = this.connections.get(key);
|
|
1779
|
+
if (!connection)
|
|
1780
|
+
return;
|
|
1781
|
+
await this.cleanupConnection(connection);
|
|
1782
|
+
connection.status = "disconnected";
|
|
1783
|
+
connection.tools = [];
|
|
1784
|
+
this.connections.delete(key);
|
|
1785
|
+
}
|
|
1786
|
+
async disconnectAll() {
|
|
1787
|
+
const disconnectPromises = Array.from(this.connections.keys()).map((key) => this.disconnect(key));
|
|
1788
|
+
await Promise.allSettled(disconnectPromises);
|
|
1789
|
+
}
|
|
1790
|
+
getAllTools() {
|
|
1791
|
+
const allTools = [];
|
|
1792
|
+
for (const connection of this.connections.values()) {
|
|
1793
|
+
if (connection.status === "connected") {
|
|
1794
|
+
allTools.push(...connection.tools);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
return allTools;
|
|
1798
|
+
}
|
|
1799
|
+
getToolsForServer(key) {
|
|
1800
|
+
const connection = this.connections.get(key);
|
|
1801
|
+
if (!connection || connection.status !== "connected") {
|
|
1802
|
+
return [];
|
|
1803
|
+
}
|
|
1804
|
+
return connection.tools;
|
|
1805
|
+
}
|
|
1806
|
+
findToolsByName(toolName) {
|
|
1807
|
+
const matches = [];
|
|
1808
|
+
for (const connection of this.connections.values()) {
|
|
1809
|
+
if (connection.status === "connected") {
|
|
1810
|
+
const tool = connection.tools.find((t) => t.name === toolName);
|
|
1811
|
+
if (tool) {
|
|
1812
|
+
matches.push(tool);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
return matches;
|
|
1817
|
+
}
|
|
1818
|
+
findTool(name) {
|
|
1819
|
+
const parsed = parseQualifiedName(name);
|
|
1820
|
+
if (parsed.serverKey !== null) {
|
|
1821
|
+
const connection = this.connections.get(parsed.serverKey);
|
|
1822
|
+
if (!connection || connection.status !== "connected") {
|
|
1823
|
+
return { tool: undefined, ambiguous: false, alternatives: [] };
|
|
1824
|
+
}
|
|
1825
|
+
const tool = connection.tools.find((t) => t.name === parsed.toolName);
|
|
1826
|
+
return { tool, ambiguous: false, alternatives: [] };
|
|
1827
|
+
}
|
|
1828
|
+
const matches = this.findToolsByName(parsed.toolName);
|
|
1829
|
+
if (matches.length === 0) {
|
|
1830
|
+
return { tool: undefined, ambiguous: false, alternatives: [] };
|
|
1831
|
+
}
|
|
1832
|
+
if (matches.length === 1) {
|
|
1833
|
+
return { tool: matches[0], ambiguous: false, alternatives: [] };
|
|
1834
|
+
}
|
|
1835
|
+
const alternatives = matches.map((t) => formatQualifiedName(t.serverKey, t.name));
|
|
1836
|
+
return { tool: undefined, ambiguous: true, alternatives };
|
|
1837
|
+
}
|
|
1838
|
+
getStatus() {
|
|
1839
|
+
const status = new Map;
|
|
1840
|
+
for (const [key, connection] of this.connections) {
|
|
1841
|
+
status.set(key, {
|
|
1842
|
+
status: connection.status,
|
|
1843
|
+
error: connection.error
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
return status;
|
|
1847
|
+
}
|
|
1848
|
+
getConnection(key) {
|
|
1849
|
+
return this.connections.get(key);
|
|
1850
|
+
}
|
|
1851
|
+
hasConnections() {
|
|
1852
|
+
for (const connection of this.connections.values()) {
|
|
1853
|
+
if (connection.status === "connected") {
|
|
1854
|
+
return true;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
return false;
|
|
1858
|
+
}
|
|
1859
|
+
getConflictingTools() {
|
|
1860
|
+
const toolServers = new Map;
|
|
1861
|
+
const conflicts = new Map;
|
|
1862
|
+
for (const connection of this.connections.values()) {
|
|
1863
|
+
if (connection.status === "connected") {
|
|
1864
|
+
for (const tool of connection.tools) {
|
|
1865
|
+
const servers = toolServers.get(tool.name) ?? [];
|
|
1866
|
+
servers.push(connection.key);
|
|
1867
|
+
toolServers.set(tool.name, servers);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
for (const [toolName, servers] of toolServers) {
|
|
1872
|
+
if (servers.length > 1) {
|
|
1873
|
+
conflicts.set(toolName, servers.map((serverKey) => formatQualifiedName(serverKey, toolName)));
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
return conflicts;
|
|
1877
|
+
}
|
|
1878
|
+
logConflicts() {
|
|
1879
|
+
const conflicts = this.getConflictingTools();
|
|
1880
|
+
if (conflicts.size > 0) {
|
|
1881
|
+
console.warn("[mcp\xB2] Tool name conflicts detected. Use qualified names to avoid ambiguity:");
|
|
1882
|
+
for (const [toolName, qualified] of conflicts) {
|
|
1883
|
+
console.warn(` - "${toolName}" available as: ${qualified.join(", ")}`);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
async callTool(toolName, args) {
|
|
1888
|
+
const result = this.findTool(toolName);
|
|
1889
|
+
if (result.ambiguous) {
|
|
1890
|
+
throw new Error(`Ambiguous tool name "${toolName}". Use a qualified name: ${result.alternatives.join(", ")}`);
|
|
1891
|
+
}
|
|
1892
|
+
if (!result.tool) {
|
|
1893
|
+
throw new Error(`Tool not found: ${toolName}`);
|
|
1894
|
+
}
|
|
1895
|
+
const connection = this.connections.get(result.tool.serverKey);
|
|
1896
|
+
if (!connection?.client || connection.status !== "connected") {
|
|
1897
|
+
throw new Error(`Server not connected: ${result.tool.serverKey}`);
|
|
1898
|
+
}
|
|
1899
|
+
const parsed = parseQualifiedName(toolName);
|
|
1900
|
+
const bareToolName = parsed.toolName;
|
|
1901
|
+
const callResult = await connection.client.callTool({
|
|
1902
|
+
name: bareToolName,
|
|
1903
|
+
arguments: args
|
|
1904
|
+
});
|
|
1905
|
+
return {
|
|
1906
|
+
content: callResult.content,
|
|
1907
|
+
isError: callResult.isError
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
async refreshTools(key) {
|
|
1911
|
+
const connection = this.connections.get(key);
|
|
1912
|
+
if (!connection) {
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
if (!connection.client || connection.status !== "connected") {
|
|
1916
|
+
await this.reconnectIfAuthStateUpdated(connection);
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
try {
|
|
1920
|
+
const { tools } = await connection.client.listTools();
|
|
1921
|
+
connection.tools = tools.map((tool) => ({
|
|
1922
|
+
name: tool.name,
|
|
1923
|
+
description: sanitizeDescription(tool.description),
|
|
1924
|
+
inputSchema: tool.inputSchema,
|
|
1925
|
+
serverKey: key
|
|
1926
|
+
}));
|
|
1927
|
+
connection.error = undefined;
|
|
1928
|
+
connection.authPending = false;
|
|
1929
|
+
} catch (err) {
|
|
1930
|
+
if (err instanceof UnauthorizedError2 && connection.authProvider) {
|
|
1931
|
+
if (connection.authProvider.isNonInteractive()) {
|
|
1932
|
+
connection.authPending = true;
|
|
1933
|
+
connection.status = "error";
|
|
1934
|
+
connection.error = `OAuth authorization required. Run: mcp-squared auth ${key}`;
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
connection.status = "error";
|
|
1939
|
+
connection.error = err instanceof Error ? err.message : String(err);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
async refreshAllTools() {
|
|
1943
|
+
const refreshPromises = [];
|
|
1944
|
+
for (const key of this.connections.keys()) {
|
|
1945
|
+
refreshPromises.push(this.refreshTools(key));
|
|
1946
|
+
}
|
|
1947
|
+
await Promise.allSettled(refreshPromises);
|
|
1948
|
+
}
|
|
1949
|
+
async reconnectIfAuthStateUpdated(connection) {
|
|
1950
|
+
if (!connection.authPending || !connection.authProvider) {
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
const authStateVersion = this.getAuthStateVersion(connection.key);
|
|
1954
|
+
if (authStateVersion <= connection.authStateVersion) {
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
connection.authStateVersion = authStateVersion;
|
|
1958
|
+
await this.connect(connection.key, connection.config);
|
|
1959
|
+
}
|
|
1960
|
+
getAuthStateVersion(key) {
|
|
1961
|
+
const tokenStorage = new TokenStorage;
|
|
1962
|
+
return tokenStorage.load(key)?.updatedAt ?? 0;
|
|
1963
|
+
}
|
|
1964
|
+
createStdioTransport(config) {
|
|
1965
|
+
const resolvedEnv = resolveEnvVars(config.env);
|
|
1966
|
+
const envWithDefaults = { ...process.env, ...resolvedEnv };
|
|
1967
|
+
return new StdioClientTransport({
|
|
1968
|
+
command: config.stdio.command,
|
|
1969
|
+
args: config.stdio.args,
|
|
1970
|
+
env: envWithDefaults,
|
|
1971
|
+
...config.stdio.cwd ? { cwd: config.stdio.cwd } : {},
|
|
1972
|
+
stderr: "pipe"
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
createHttpTransport(key, config) {
|
|
1976
|
+
const resolvedEnv = resolveEnvVars(config.env);
|
|
1977
|
+
const headers = {};
|
|
1978
|
+
for (const [key2, value] of Object.entries(config.sse.headers)) {
|
|
1979
|
+
if (value.startsWith("$")) {
|
|
1980
|
+
const envKey = value.slice(1);
|
|
1981
|
+
headers[key2] = resolvedEnv[envKey] ?? process.env[envKey] ?? "";
|
|
1982
|
+
} else {
|
|
1983
|
+
headers[key2] = value;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
let authProvider = null;
|
|
1987
|
+
const tokenStorage = new TokenStorage;
|
|
1988
|
+
const hasStoredTokens = tokenStorage.load(key)?.tokens !== undefined;
|
|
1989
|
+
if (config.sse.auth || hasStoredTokens) {
|
|
1990
|
+
const authOptions = typeof config.sse.auth === "object" ? config.sse.auth : {};
|
|
1991
|
+
authProvider = new McpOAuthProvider(key, tokenStorage, {
|
|
1992
|
+
...authOptions,
|
|
1993
|
+
nonInteractive: true
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
const transportOptions = {
|
|
1997
|
+
requestInit: {
|
|
1998
|
+
headers
|
|
1999
|
+
}
|
|
2000
|
+
};
|
|
2001
|
+
if (authProvider) {
|
|
2002
|
+
transportOptions.authProvider = authProvider;
|
|
2003
|
+
}
|
|
2004
|
+
const transport = new StreamableHTTPClientTransport2(new URL(config.sse.url), transportOptions);
|
|
2005
|
+
return { transport, authProvider };
|
|
2006
|
+
}
|
|
2007
|
+
async cleanupConnection(connection) {
|
|
2008
|
+
if (connection.transport) {
|
|
2009
|
+
await safelyCloseTransport(connection.transport);
|
|
2010
|
+
connection.transport = null;
|
|
2011
|
+
}
|
|
2012
|
+
if (connection.client) {
|
|
2013
|
+
try {
|
|
2014
|
+
await connection.client.close();
|
|
2015
|
+
} catch {}
|
|
2016
|
+
connection.client = null;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
// src/upstream/client.ts
|
|
2021
|
+
import { UnauthorizedError as UnauthorizedError3 } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
2022
|
+
import { Client as Client3 } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2023
|
+
import { StdioClientTransport as StdioClientTransport2 } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
2024
|
+
import { StreamableHTTPClientTransport as StreamableHTTPClientTransport3 } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
2025
|
+
function resolveEnvVars2(env) {
|
|
2026
|
+
const resolved = {};
|
|
2027
|
+
for (const [key, value] of Object.entries(env)) {
|
|
2028
|
+
if (value.startsWith("$")) {
|
|
2029
|
+
const envKey = value.slice(1);
|
|
2030
|
+
resolved[key] = process.env[envKey] || "";
|
|
2031
|
+
} else {
|
|
2032
|
+
resolved[key] = value;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
return resolved;
|
|
2036
|
+
}
|
|
2037
|
+
function createStdioTransport(config, log, verbose, onStderr) {
|
|
2038
|
+
const fullCommand = [config.stdio.command, ...config.stdio.args].join(" ");
|
|
2039
|
+
log(`Command: ${fullCommand}`);
|
|
2040
|
+
if (config.stdio.cwd) {
|
|
2041
|
+
log(`Working dir: ${config.stdio.cwd}`);
|
|
2042
|
+
}
|
|
2043
|
+
const resolvedEnv = resolveEnvVars2(config.env || {});
|
|
2044
|
+
if (verbose && Object.keys(resolvedEnv).length > 0) {
|
|
2045
|
+
log(`Environment: ${Object.keys(resolvedEnv).join(", ")}`);
|
|
2046
|
+
}
|
|
2047
|
+
const envWithDefaults = { ...process.env, ...resolvedEnv };
|
|
2048
|
+
log("Creating stdio transport...");
|
|
2049
|
+
const transport = new StdioClientTransport2({
|
|
2050
|
+
command: config.stdio.command,
|
|
2051
|
+
args: config.stdio.args,
|
|
2052
|
+
env: envWithDefaults,
|
|
2053
|
+
...config.stdio.cwd ? { cwd: config.stdio.cwd } : {},
|
|
2054
|
+
stderr: "pipe"
|
|
2055
|
+
});
|
|
2056
|
+
if (transport.stderr) {
|
|
2057
|
+
transport.stderr.on("data", (chunk) => {
|
|
2058
|
+
onStderr(chunk.toString());
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
return transport;
|
|
2062
|
+
}
|
|
2063
|
+
function createHttpTransport(config, log, verbose, authProvider) {
|
|
2064
|
+
log(`URL: ${config.sse.url}`);
|
|
2065
|
+
const headers = { ...config.sse.headers };
|
|
2066
|
+
if (verbose && Object.keys(headers).length > 0) {
|
|
2067
|
+
log(`Headers: ${Object.keys(headers).join(", ")}`);
|
|
2068
|
+
}
|
|
2069
|
+
if (authProvider) {
|
|
2070
|
+
log("OAuth: dynamic client registration enabled");
|
|
2071
|
+
}
|
|
2072
|
+
log("Creating HTTP streaming transport...");
|
|
2073
|
+
const transportOptions = {
|
|
2074
|
+
requestInit: {
|
|
2075
|
+
headers
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
if (authProvider) {
|
|
2079
|
+
transportOptions.authProvider = authProvider;
|
|
2080
|
+
}
|
|
2081
|
+
const transport = new StreamableHTTPClientTransport3(new URL(config.sse.url), transportOptions);
|
|
2082
|
+
return transport;
|
|
2083
|
+
}
|
|
2084
|
+
async function handleOAuthCallback(transport, provider, log, callbackServerFactory = (options) => new OAuthCallbackServer(options)) {
|
|
2085
|
+
const callbackUrl = new URL(provider.redirectUrl);
|
|
2086
|
+
const callbackPort = Number.parseInt(callbackUrl.port, 10);
|
|
2087
|
+
if (Number.isNaN(callbackPort) || callbackPort <= 0) {
|
|
2088
|
+
throw new Error(`Invalid OAuth callback URL: ${provider.redirectUrl}`);
|
|
2089
|
+
}
|
|
2090
|
+
const callbackServer = callbackServerFactory({
|
|
2091
|
+
port: callbackPort,
|
|
2092
|
+
path: callbackUrl.pathname || "/callback",
|
|
2093
|
+
timeoutMs: 300000
|
|
2094
|
+
});
|
|
2095
|
+
log("Waiting for browser authorization...");
|
|
2096
|
+
log(`Callback URL: ${callbackServer.getCallbackUrl()}`);
|
|
2097
|
+
try {
|
|
2098
|
+
const result = await callbackServer.waitForCallback();
|
|
2099
|
+
if (result.error) {
|
|
2100
|
+
throw new Error(`OAuth error: ${result.error}${result.errorDescription ? `: ${result.errorDescription}` : ""}`);
|
|
2101
|
+
}
|
|
2102
|
+
if (!result.code) {
|
|
2103
|
+
throw new Error("No authorization code received");
|
|
2104
|
+
}
|
|
2105
|
+
if (!result.state || !provider.verifyState(result.state)) {
|
|
2106
|
+
throw new Error("OAuth state mismatch - possible CSRF attack");
|
|
2107
|
+
}
|
|
2108
|
+
log("Received authorization code, exchanging for token...");
|
|
2109
|
+
await transport.finishAuth(result.code);
|
|
2110
|
+
provider.clearCodeVerifier();
|
|
2111
|
+
log("OAuth authentication complete");
|
|
2112
|
+
} finally {
|
|
2113
|
+
callbackServer.stop();
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
async function testUpstreamConnection(name, config, options = {}) {
|
|
2117
|
+
const { timeoutMs = 30000, verbose = false } = options;
|
|
2118
|
+
const startTime = Date.now();
|
|
2119
|
+
const log = (msg) => verbose && console.log(` [${name}] ${msg}`);
|
|
2120
|
+
let stderrOutput = "";
|
|
2121
|
+
let client = null;
|
|
2122
|
+
let transport = null;
|
|
2123
|
+
let httpTransport = null;
|
|
2124
|
+
let authProvider;
|
|
2125
|
+
try {
|
|
2126
|
+
client = options.clientFactory?.() ?? new Client3({
|
|
2127
|
+
name: "mcp-squared-test",
|
|
2128
|
+
version: "1.0.0"
|
|
2129
|
+
});
|
|
2130
|
+
if (!client) {
|
|
2131
|
+
throw new Error("Client initialization failed");
|
|
2132
|
+
}
|
|
2133
|
+
if (config.transport === "stdio") {
|
|
2134
|
+
const transportFactory = options.stdioTransportFactory ?? createStdioTransport;
|
|
2135
|
+
transport = transportFactory(config, log, verbose, (text) => {
|
|
2136
|
+
stderrOutput += text;
|
|
2137
|
+
if (verbose) {
|
|
2138
|
+
for (const line of text.split(`
|
|
2139
|
+
`).filter((l) => l.trim())) {
|
|
2140
|
+
console.log(` [${name}] stderr: ${line}`);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
});
|
|
2144
|
+
} else if (config.transport === "sse") {
|
|
2145
|
+
const sseConfig = config;
|
|
2146
|
+
const tokenStorage = new TokenStorage;
|
|
2147
|
+
const hasStoredTokens = tokenStorage.load(name)?.tokens !== undefined;
|
|
2148
|
+
if (sseConfig.sse.auth || hasStoredTokens) {
|
|
2149
|
+
const authOptions = resolveOAuthProviderOptions(sseConfig.sse.auth);
|
|
2150
|
+
authProvider = new McpOAuthProvider(name, tokenStorage, authOptions);
|
|
2151
|
+
}
|
|
2152
|
+
const httpTransportFactory = options.httpTransportFactory ?? createHttpTransport;
|
|
2153
|
+
httpTransport = httpTransportFactory(sseConfig, log, verbose, authProvider);
|
|
2154
|
+
transport = httpTransport;
|
|
2155
|
+
} else {
|
|
2156
|
+
const unknownConfig = config;
|
|
2157
|
+
return {
|
|
2158
|
+
success: false,
|
|
2159
|
+
serverName: undefined,
|
|
2160
|
+
serverVersion: undefined,
|
|
2161
|
+
tools: [],
|
|
2162
|
+
error: `Unknown transport type: ${unknownConfig.transport}`,
|
|
2163
|
+
durationMs: Date.now() - startTime,
|
|
2164
|
+
stderr: undefined
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
log(`Connecting (timeout: ${timeoutMs}ms)...`);
|
|
2168
|
+
const connectStart = Date.now();
|
|
2169
|
+
const connectPromise = client.connect(transport);
|
|
2170
|
+
let timeoutId;
|
|
2171
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
2172
|
+
timeoutId = setTimeout(() => reject(new Error("Connection timeout")), timeoutMs);
|
|
2173
|
+
});
|
|
2174
|
+
try {
|
|
2175
|
+
await Promise.race([connectPromise, timeoutPromise]);
|
|
2176
|
+
} catch (err) {
|
|
2177
|
+
if (err instanceof UnauthorizedError3 && authProvider && httpTransport) {
|
|
2178
|
+
if (authProvider.isInteractive()) {
|
|
2179
|
+
log("OAuth authorization required, opening browser...");
|
|
2180
|
+
await handleOAuthCallback(httpTransport, authProvider, log, options.oauthCallbackServerFactory);
|
|
2181
|
+
log("Retrying connection after OAuth...");
|
|
2182
|
+
await Promise.race([client.connect(transport), timeoutPromise]);
|
|
2183
|
+
} else {
|
|
2184
|
+
throw err;
|
|
2185
|
+
}
|
|
2186
|
+
} else {
|
|
2187
|
+
throw err;
|
|
2188
|
+
}
|
|
2189
|
+
} finally {
|
|
2190
|
+
if (timeoutId !== undefined) {
|
|
2191
|
+
clearTimeout(timeoutId);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
log(`Connected in ${Date.now() - connectStart}ms`);
|
|
2195
|
+
const serverInfo = client.getServerVersion();
|
|
2196
|
+
if (serverInfo) {
|
|
2197
|
+
log(`Server: ${serverInfo.name} v${serverInfo.version}`);
|
|
2198
|
+
}
|
|
2199
|
+
log("Fetching tools...");
|
|
2200
|
+
const toolsStart = Date.now();
|
|
2201
|
+
const { tools } = await client.listTools();
|
|
2202
|
+
log(`Got ${tools.length} tools in ${Date.now() - toolsStart}ms`);
|
|
2203
|
+
const toolInfos = tools.map((tool) => {
|
|
2204
|
+
const info = { name: tool.name };
|
|
2205
|
+
if (tool.description !== undefined) {
|
|
2206
|
+
info.description = tool.description;
|
|
2207
|
+
}
|
|
2208
|
+
return info;
|
|
2209
|
+
});
|
|
2210
|
+
return {
|
|
2211
|
+
success: true,
|
|
2212
|
+
serverName: serverInfo?.name,
|
|
2213
|
+
serverVersion: serverInfo?.version,
|
|
2214
|
+
tools: toolInfos,
|
|
2215
|
+
error: undefined,
|
|
2216
|
+
durationMs: Date.now() - startTime,
|
|
2217
|
+
stderr: stderrOutput || undefined
|
|
2218
|
+
};
|
|
2219
|
+
} catch (err) {
|
|
2220
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
2221
|
+
log(`Error: ${errorMessage}`);
|
|
2222
|
+
return {
|
|
2223
|
+
success: false,
|
|
2224
|
+
serverName: undefined,
|
|
2225
|
+
serverVersion: undefined,
|
|
2226
|
+
tools: [],
|
|
2227
|
+
error: errorMessage,
|
|
2228
|
+
durationMs: Date.now() - startTime,
|
|
2229
|
+
stderr: stderrOutput || undefined
|
|
2230
|
+
};
|
|
2231
|
+
} finally {
|
|
2232
|
+
log("Cleaning up...");
|
|
2233
|
+
if (transport) {
|
|
2234
|
+
await safelyCloseTransport(transport);
|
|
2235
|
+
}
|
|
2236
|
+
if (client) {
|
|
2237
|
+
try {
|
|
2238
|
+
await client.close();
|
|
2239
|
+
} catch {}
|
|
2240
|
+
}
|
|
2241
|
+
log("Done");
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
// src/tui/config.ts
|
|
2245
|
+
var PROJECT_DESCRIPTION = "Mercury Control Plane";
|
|
2246
|
+
async function runConfigTui() {
|
|
2247
|
+
const renderer = await createCliRenderer({
|
|
2248
|
+
exitOnCtrlC: false,
|
|
2249
|
+
targetFps: 30
|
|
2250
|
+
});
|
|
2251
|
+
const { config, path } = await loadConfig().catch(() => ({
|
|
2252
|
+
config: DEFAULT_CONFIG,
|
|
2253
|
+
path: getDefaultConfigPath().path
|
|
2254
|
+
}));
|
|
2255
|
+
const state = {
|
|
2256
|
+
config: structuredClone(config),
|
|
2257
|
+
configPath: path,
|
|
2258
|
+
isDirty: false,
|
|
2259
|
+
currentScreen: "main",
|
|
2260
|
+
selectedUpstream: null
|
|
2261
|
+
};
|
|
2262
|
+
renderer.setBackgroundColor("#0f172a");
|
|
2263
|
+
const app = new ConfigTuiApp(renderer, state);
|
|
2264
|
+
app.showMainMenu();
|
|
2265
|
+
renderer.keyInput.on("keypress", (key) => {
|
|
2266
|
+
if (key.name === "c" && key.ctrl) {
|
|
2267
|
+
app.handleExit();
|
|
2268
|
+
}
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
class ConfigTuiApp {
|
|
2273
|
+
renderer;
|
|
2274
|
+
state;
|
|
2275
|
+
container = null;
|
|
2276
|
+
constructor(renderer, state) {
|
|
2277
|
+
this.renderer = renderer;
|
|
2278
|
+
this.state = state;
|
|
2279
|
+
}
|
|
2280
|
+
clearScreen() {
|
|
2281
|
+
if (this.container) {
|
|
2282
|
+
this.renderer.root.remove(this.container.id);
|
|
2283
|
+
}
|
|
2284
|
+
this.container = new BoxRenderable(this.renderer, {
|
|
2285
|
+
id: "config-container",
|
|
2286
|
+
flexDirection: "column",
|
|
2287
|
+
width: "100%",
|
|
2288
|
+
height: "100%",
|
|
2289
|
+
alignItems: "center",
|
|
2290
|
+
justifyContent: "center",
|
|
2291
|
+
padding: 2
|
|
2292
|
+
});
|
|
2293
|
+
this.renderer.root.add(this.container);
|
|
2294
|
+
}
|
|
2295
|
+
addHeader() {
|
|
2296
|
+
if (!this.container)
|
|
2297
|
+
return;
|
|
2298
|
+
const titleRow = new BoxRenderable(this.renderer, {
|
|
2299
|
+
id: "config-title-row",
|
|
2300
|
+
flexDirection: "row",
|
|
2301
|
+
alignItems: "flex-start",
|
|
2302
|
+
marginBottom: 1
|
|
2303
|
+
});
|
|
2304
|
+
this.container.add(titleRow);
|
|
2305
|
+
const titleMcp = new ASCIIFontRenderable(this.renderer, {
|
|
2306
|
+
id: "config-title-mcp",
|
|
2307
|
+
text: "MCP",
|
|
2308
|
+
font: "tiny",
|
|
2309
|
+
color: RGBA.fromHex("#38bdf8")
|
|
2310
|
+
});
|
|
2311
|
+
titleRow.add(titleMcp);
|
|
2312
|
+
const titleSquared = new TextRenderable(this.renderer, {
|
|
2313
|
+
id: "config-title-squared",
|
|
2314
|
+
content: "\xB2",
|
|
2315
|
+
fg: "#38bdf8"
|
|
2316
|
+
});
|
|
2317
|
+
titleRow.add(titleSquared);
|
|
2318
|
+
const subtitle = new TextRenderable(this.renderer, {
|
|
2319
|
+
id: "config-subtitle",
|
|
2320
|
+
content: PROJECT_DESCRIPTION,
|
|
2321
|
+
fg: "#94a3b8",
|
|
2322
|
+
marginBottom: 1
|
|
2323
|
+
});
|
|
2324
|
+
this.container.add(subtitle);
|
|
2325
|
+
const versionText = new TextRenderable(this.renderer, {
|
|
2326
|
+
id: "config-version",
|
|
2327
|
+
content: `v${VERSION}${this.state.isDirty ? " (unsaved changes)" : ""}`,
|
|
2328
|
+
fg: this.state.isDirty ? "#fbbf24" : "#64748b",
|
|
2329
|
+
marginBottom: 2
|
|
2330
|
+
});
|
|
2331
|
+
this.container.add(versionText);
|
|
2332
|
+
}
|
|
2333
|
+
showMainMenu() {
|
|
2334
|
+
this.state.currentScreen = "main";
|
|
2335
|
+
this.clearScreen();
|
|
2336
|
+
this.addHeader();
|
|
2337
|
+
if (!this.container)
|
|
2338
|
+
return;
|
|
2339
|
+
const upstreamCount = Object.keys(this.state.config.upstreams).length;
|
|
2340
|
+
const menuBox = new BoxRenderable(this.renderer, {
|
|
2341
|
+
id: "config-menu-box",
|
|
2342
|
+
width: 50,
|
|
2343
|
+
height: 12,
|
|
2344
|
+
border: true,
|
|
2345
|
+
borderStyle: "single",
|
|
2346
|
+
borderColor: "#475569",
|
|
2347
|
+
title: "Configuration",
|
|
2348
|
+
titleAlignment: "center",
|
|
2349
|
+
backgroundColor: "#1e293b"
|
|
2350
|
+
});
|
|
2351
|
+
this.container.add(menuBox);
|
|
2352
|
+
const options = [
|
|
2353
|
+
{
|
|
2354
|
+
name: `Upstream Servers (${upstreamCount})`,
|
|
2355
|
+
description: "Manage MCP server connections",
|
|
2356
|
+
value: "upstreams"
|
|
2357
|
+
},
|
|
2358
|
+
{
|
|
2359
|
+
name: "Security Settings",
|
|
2360
|
+
description: "Configure tool access controls",
|
|
2361
|
+
value: "security"
|
|
2362
|
+
},
|
|
2363
|
+
{
|
|
2364
|
+
name: "Operations",
|
|
2365
|
+
description: "Limits, logging, and performance",
|
|
2366
|
+
value: "operations"
|
|
2367
|
+
},
|
|
2368
|
+
{
|
|
2369
|
+
name: this.state.isDirty ? "Save Changes" : "Save",
|
|
2370
|
+
description: this.state.isDirty ? "Write changes to config file" : "No changes to save",
|
|
2371
|
+
value: "save"
|
|
2372
|
+
},
|
|
2373
|
+
{
|
|
2374
|
+
name: "Exit",
|
|
2375
|
+
description: this.state.isDirty ? "Exit (will prompt to save)" : "Exit configuration",
|
|
2376
|
+
value: "exit"
|
|
2377
|
+
}
|
|
2378
|
+
];
|
|
2379
|
+
const menu = new SelectRenderable(this.renderer, {
|
|
2380
|
+
id: "config-menu",
|
|
2381
|
+
width: "100%",
|
|
2382
|
+
height: "100%",
|
|
2383
|
+
options,
|
|
2384
|
+
backgroundColor: "transparent",
|
|
2385
|
+
selectedBackgroundColor: "#334155",
|
|
2386
|
+
textColor: "#e2e8f0",
|
|
2387
|
+
selectedTextColor: "#38bdf8",
|
|
2388
|
+
showDescription: true,
|
|
2389
|
+
descriptionColor: "#64748b",
|
|
2390
|
+
selectedDescriptionColor: "#94a3b8",
|
|
2391
|
+
wrapSelection: true
|
|
2392
|
+
});
|
|
2393
|
+
menuBox.add(menu);
|
|
2394
|
+
menu.on(SelectRenderableEvents.ITEM_SELECTED, (_index, option) => {
|
|
2395
|
+
this.handleMainMenuSelection(option.value);
|
|
2396
|
+
});
|
|
2397
|
+
menu.focus();
|
|
2398
|
+
this.addInstructions("\u2191\u2193 Navigate | Enter Select | Ctrl+C Quit");
|
|
2399
|
+
}
|
|
2400
|
+
handleMainMenuSelection(value) {
|
|
2401
|
+
switch (value) {
|
|
2402
|
+
case "upstreams":
|
|
2403
|
+
this.showUpstreamsScreen();
|
|
2404
|
+
break;
|
|
2405
|
+
case "security":
|
|
2406
|
+
this.showSecurityScreen();
|
|
2407
|
+
break;
|
|
2408
|
+
case "operations":
|
|
2409
|
+
this.showOperationsScreen();
|
|
2410
|
+
break;
|
|
2411
|
+
case "save":
|
|
2412
|
+
this.handleSave();
|
|
2413
|
+
break;
|
|
2414
|
+
case "exit":
|
|
2415
|
+
this.handleExit();
|
|
2416
|
+
break;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
showUpstreamsScreen() {
|
|
2420
|
+
this.state.currentScreen = "upstreams";
|
|
2421
|
+
this.clearScreen();
|
|
2422
|
+
this.addHeader();
|
|
2423
|
+
if (!this.container)
|
|
2424
|
+
return;
|
|
2425
|
+
const menuBox = new BoxRenderable(this.renderer, {
|
|
2426
|
+
id: "upstreams-box",
|
|
2427
|
+
width: 60,
|
|
2428
|
+
height: 14,
|
|
2429
|
+
border: true,
|
|
2430
|
+
borderStyle: "single",
|
|
2431
|
+
borderColor: "#475569",
|
|
2432
|
+
title: "Upstream Servers",
|
|
2433
|
+
titleAlignment: "center",
|
|
2434
|
+
backgroundColor: "#1e293b"
|
|
2435
|
+
});
|
|
2436
|
+
this.container.add(menuBox);
|
|
2437
|
+
const upstreamEntries = Object.entries(this.state.config.upstreams);
|
|
2438
|
+
const options = [
|
|
2439
|
+
{
|
|
2440
|
+
name: "+ Add New Upstream",
|
|
2441
|
+
description: "Configure a new MCP server connection",
|
|
2442
|
+
value: { action: "add" }
|
|
2443
|
+
}
|
|
2444
|
+
];
|
|
2445
|
+
for (const [name, upstream] of upstreamEntries) {
|
|
2446
|
+
const status = upstream.enabled ? "\u2713" : "\u2717";
|
|
2447
|
+
const transport = upstream.transport.toUpperCase();
|
|
2448
|
+
options.push({
|
|
2449
|
+
name: `${status} ${name} [${transport}]`,
|
|
2450
|
+
description: this.getUpstreamDescription(upstream),
|
|
2451
|
+
value: { action: "edit", name }
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
options.push({
|
|
2455
|
+
name: "\u2190 Back to Main Menu",
|
|
2456
|
+
description: "",
|
|
2457
|
+
value: { action: "back" }
|
|
2458
|
+
});
|
|
2459
|
+
const menu = new SelectRenderable(this.renderer, {
|
|
2460
|
+
id: "upstreams-menu",
|
|
2461
|
+
width: "100%",
|
|
2462
|
+
height: "100%",
|
|
2463
|
+
options,
|
|
2464
|
+
backgroundColor: "transparent",
|
|
2465
|
+
selectedBackgroundColor: "#334155",
|
|
2466
|
+
textColor: "#e2e8f0",
|
|
2467
|
+
selectedTextColor: "#38bdf8",
|
|
2468
|
+
showDescription: true,
|
|
2469
|
+
descriptionColor: "#64748b",
|
|
2470
|
+
selectedDescriptionColor: "#94a3b8",
|
|
2471
|
+
wrapSelection: true
|
|
2472
|
+
});
|
|
2473
|
+
menuBox.add(menu);
|
|
2474
|
+
menu.on(SelectRenderableEvents.ITEM_SELECTED, (_index, option) => {
|
|
2475
|
+
const val = option.value;
|
|
2476
|
+
if (val.action === "add") {
|
|
2477
|
+
this.showAddUpstreamScreen();
|
|
2478
|
+
} else if (val.action === "edit" && val.name) {
|
|
2479
|
+
this.state.selectedUpstream = val.name;
|
|
2480
|
+
this.showEditUpstreamScreen(val.name);
|
|
2481
|
+
} else if (val.action === "back") {
|
|
2482
|
+
this.showMainMenu();
|
|
2483
|
+
}
|
|
2484
|
+
});
|
|
2485
|
+
menu.focus();
|
|
2486
|
+
this.addInstructions("\u2191\u2193 Navigate | Enter Select | Esc Back");
|
|
2487
|
+
this.renderer.keyInput.once("keypress", (key) => {
|
|
2488
|
+
if (key.name === "escape") {
|
|
2489
|
+
this.showMainMenu();
|
|
2490
|
+
}
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
getUpstreamDescription(upstream) {
|
|
2494
|
+
const envCount = Object.keys(upstream.env || {}).length;
|
|
2495
|
+
const envSuffix = envCount > 0 ? ` (${envCount} env var${envCount > 1 ? "s" : ""})` : "";
|
|
2496
|
+
if (upstream.transport === "stdio") {
|
|
2497
|
+
return `${upstream.stdio.command} ${upstream.stdio.args.join(" ")}${envSuffix}`;
|
|
2498
|
+
}
|
|
2499
|
+
return `${upstream.sse.url}${envSuffix}`;
|
|
2500
|
+
}
|
|
2501
|
+
showAddUpstreamScreen() {
|
|
2502
|
+
this.state.currentScreen = "add-upstream";
|
|
2503
|
+
this.clearScreen();
|
|
2504
|
+
this.addHeader();
|
|
2505
|
+
if (!this.container)
|
|
2506
|
+
return;
|
|
2507
|
+
const menuBox = new BoxRenderable(this.renderer, {
|
|
2508
|
+
id: "add-upstream-box",
|
|
2509
|
+
width: 60,
|
|
2510
|
+
height: 12,
|
|
2511
|
+
border: true,
|
|
2512
|
+
borderStyle: "single",
|
|
2513
|
+
borderColor: "#475569",
|
|
2514
|
+
title: "Add Upstream Server",
|
|
2515
|
+
titleAlignment: "center",
|
|
2516
|
+
backgroundColor: "#1e293b",
|
|
2517
|
+
flexDirection: "column",
|
|
2518
|
+
padding: 1
|
|
2519
|
+
});
|
|
2520
|
+
this.container.add(menuBox);
|
|
2521
|
+
const descText = new TextRenderable(this.renderer, {
|
|
2522
|
+
id: "transport-desc",
|
|
2523
|
+
content: "Select transport type:",
|
|
2524
|
+
fg: "#94a3b8",
|
|
2525
|
+
marginBottom: 1
|
|
2526
|
+
});
|
|
2527
|
+
menuBox.add(descText);
|
|
2528
|
+
const options = [
|
|
2529
|
+
{
|
|
2530
|
+
name: "Stdio (local process)",
|
|
2531
|
+
description: "Launch a local command as MCP server",
|
|
2532
|
+
value: "stdio"
|
|
2533
|
+
},
|
|
2534
|
+
{
|
|
2535
|
+
name: "HTTP/SSE (remote server)",
|
|
2536
|
+
description: "Connect to a remote MCP server via HTTP",
|
|
2537
|
+
value: "sse"
|
|
2538
|
+
},
|
|
2539
|
+
{
|
|
2540
|
+
name: "\u2190 Back",
|
|
2541
|
+
description: "",
|
|
2542
|
+
value: "back"
|
|
2543
|
+
}
|
|
2544
|
+
];
|
|
2545
|
+
const menu = new SelectRenderable(this.renderer, {
|
|
2546
|
+
id: "transport-menu",
|
|
2547
|
+
width: "100%",
|
|
2548
|
+
height: "100%",
|
|
2549
|
+
options,
|
|
2550
|
+
backgroundColor: "transparent",
|
|
2551
|
+
selectedBackgroundColor: "#334155",
|
|
2552
|
+
textColor: "#e2e8f0",
|
|
2553
|
+
selectedTextColor: "#38bdf8",
|
|
2554
|
+
showDescription: true,
|
|
2555
|
+
descriptionColor: "#64748b",
|
|
2556
|
+
wrapSelection: true
|
|
2557
|
+
});
|
|
2558
|
+
menuBox.add(menu);
|
|
2559
|
+
menu.on(SelectRenderableEvents.ITEM_SELECTED, (_index, option) => {
|
|
2560
|
+
switch (option.value) {
|
|
2561
|
+
case "stdio":
|
|
2562
|
+
this.showAddStdioScreen();
|
|
2563
|
+
break;
|
|
2564
|
+
case "sse":
|
|
2565
|
+
this.showAddSseScreen();
|
|
2566
|
+
break;
|
|
2567
|
+
case "back":
|
|
2568
|
+
this.showUpstreamsScreen();
|
|
2569
|
+
break;
|
|
2570
|
+
}
|
|
2571
|
+
});
|
|
2572
|
+
menu.focus();
|
|
2573
|
+
this.addInstructions("\u2191\u2193 Navigate | Enter Select | Esc Back");
|
|
2574
|
+
}
|
|
2575
|
+
showAddStdioScreen() {
|
|
2576
|
+
this.state.currentScreen = "add-stdio";
|
|
2577
|
+
this.clearScreen();
|
|
2578
|
+
this.addHeader();
|
|
2579
|
+
if (!this.container)
|
|
2580
|
+
return;
|
|
2581
|
+
const formBox = new BoxRenderable(this.renderer, {
|
|
2582
|
+
id: "add-stdio-box",
|
|
2583
|
+
width: 60,
|
|
2584
|
+
height: 22,
|
|
2585
|
+
border: true,
|
|
2586
|
+
borderStyle: "single",
|
|
2587
|
+
borderColor: "#475569",
|
|
2588
|
+
title: "Add Stdio Upstream",
|
|
2589
|
+
titleAlignment: "center",
|
|
2590
|
+
backgroundColor: "#1e293b",
|
|
2591
|
+
flexDirection: "column",
|
|
2592
|
+
padding: 1
|
|
2593
|
+
});
|
|
2594
|
+
this.container.add(formBox);
|
|
2595
|
+
const nameLabel = new TextRenderable(this.renderer, {
|
|
2596
|
+
id: "name-label",
|
|
2597
|
+
content: "Name (unique identifier):",
|
|
2598
|
+
fg: "#94a3b8",
|
|
2599
|
+
marginBottom: 0
|
|
2600
|
+
});
|
|
2601
|
+
formBox.add(nameLabel);
|
|
2602
|
+
const nameInput = new InputRenderable(this.renderer, {
|
|
2603
|
+
id: "name-input",
|
|
2604
|
+
width: "100%",
|
|
2605
|
+
placeholder: "e.g., github, filesystem",
|
|
2606
|
+
backgroundColor: "#0f172a",
|
|
2607
|
+
focusedBackgroundColor: "#1e293b",
|
|
2608
|
+
textColor: "#e2e8f0",
|
|
2609
|
+
marginBottom: 1,
|
|
2610
|
+
onPaste: (event) => {
|
|
2611
|
+
nameInput.value = (nameInput.value || "") + event.text;
|
|
2612
|
+
}
|
|
2613
|
+
});
|
|
2614
|
+
formBox.add(nameInput);
|
|
2615
|
+
const commandLabel = new TextRenderable(this.renderer, {
|
|
2616
|
+
id: "command-label",
|
|
2617
|
+
content: "Command (with arguments):",
|
|
2618
|
+
fg: "#94a3b8"
|
|
2619
|
+
});
|
|
2620
|
+
formBox.add(commandLabel);
|
|
2621
|
+
const commandInput = new InputRenderable(this.renderer, {
|
|
2622
|
+
id: "command-input",
|
|
2623
|
+
width: "100%",
|
|
2624
|
+
placeholder: "e.g., npx -y @modelcontextprotocol/server-github",
|
|
2625
|
+
backgroundColor: "#0f172a",
|
|
2626
|
+
focusedBackgroundColor: "#1e293b",
|
|
2627
|
+
textColor: "#e2e8f0",
|
|
2628
|
+
marginBottom: 1,
|
|
2629
|
+
onPaste: (event) => {
|
|
2630
|
+
commandInput.value = (commandInput.value || "") + event.text;
|
|
2631
|
+
}
|
|
2632
|
+
});
|
|
2633
|
+
formBox.add(commandInput);
|
|
2634
|
+
const envLabel = new TextRenderable(this.renderer, {
|
|
2635
|
+
id: "env-label",
|
|
2636
|
+
content: "Environment variables (optional, comma-separated):",
|
|
2637
|
+
fg: "#94a3b8",
|
|
2638
|
+
marginBottom: 0
|
|
2639
|
+
});
|
|
2640
|
+
formBox.add(envLabel);
|
|
2641
|
+
const envInput = new InputRenderable(this.renderer, {
|
|
2642
|
+
id: "env-input",
|
|
2643
|
+
width: "100%",
|
|
2644
|
+
placeholder: "e.g., GITHUB_TOKEN=$GITHUB_TOKEN, API_KEY=xxx",
|
|
2645
|
+
backgroundColor: "#0f172a",
|
|
2646
|
+
focusedBackgroundColor: "#1e293b",
|
|
2647
|
+
textColor: "#e2e8f0",
|
|
2648
|
+
marginBottom: 1,
|
|
2649
|
+
onPaste: (event) => {
|
|
2650
|
+
envInput.value = (envInput.value || "") + event.text;
|
|
2651
|
+
}
|
|
2652
|
+
});
|
|
2653
|
+
formBox.add(envInput);
|
|
2654
|
+
const submitOptions = [
|
|
2655
|
+
{ name: "[ Save Upstream ]", description: "", value: "save" },
|
|
2656
|
+
{ name: "[ Cancel ]", description: "", value: "cancel" }
|
|
2657
|
+
];
|
|
2658
|
+
const submitSelect = new SelectRenderable(this.renderer, {
|
|
2659
|
+
id: "submit-select",
|
|
2660
|
+
width: "100%",
|
|
2661
|
+
height: 3,
|
|
2662
|
+
options: submitOptions,
|
|
2663
|
+
backgroundColor: "transparent",
|
|
2664
|
+
selectedBackgroundColor: "#334155",
|
|
2665
|
+
textColor: "#e2e8f0",
|
|
2666
|
+
selectedTextColor: "#38bdf8",
|
|
2667
|
+
wrapSelection: true
|
|
2668
|
+
});
|
|
2669
|
+
formBox.add(submitSelect);
|
|
2670
|
+
const fields = [nameInput, commandInput, envInput, submitSelect];
|
|
2671
|
+
let focusIndex = 0;
|
|
2672
|
+
const focusField = (index) => {
|
|
2673
|
+
focusIndex = index;
|
|
2674
|
+
const field = fields[index];
|
|
2675
|
+
if (field)
|
|
2676
|
+
field.focus();
|
|
2677
|
+
};
|
|
2678
|
+
const parseEnvVars = (input) => {
|
|
2679
|
+
const env = {};
|
|
2680
|
+
if (!input.trim())
|
|
2681
|
+
return env;
|
|
2682
|
+
const pairs = input.split(",").map((s) => s.trim()).filter(Boolean);
|
|
2683
|
+
for (const pair of pairs) {
|
|
2684
|
+
const eqIndex = pair.indexOf("=");
|
|
2685
|
+
if (eqIndex > 0) {
|
|
2686
|
+
const key = pair.substring(0, eqIndex).trim();
|
|
2687
|
+
const value = pair.substring(eqIndex + 1).trim();
|
|
2688
|
+
if (key) {
|
|
2689
|
+
env[key] = value;
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
return env;
|
|
2694
|
+
};
|
|
2695
|
+
const saveUpstream = () => {
|
|
2696
|
+
const trimmedName = nameInput.value?.trim() || "";
|
|
2697
|
+
const trimmedCommand = commandInput.value?.trim() || "";
|
|
2698
|
+
if (!trimmedName) {
|
|
2699
|
+
nameInput.focus();
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
if (!trimmedCommand) {
|
|
2703
|
+
commandInput.focus();
|
|
2704
|
+
return;
|
|
2705
|
+
}
|
|
2706
|
+
const envVars = parseEnvVars(envInput.value || "");
|
|
2707
|
+
const parts = trimmedCommand.split(/\s+/);
|
|
2708
|
+
const command = parts[0] || "";
|
|
2709
|
+
const args = parts.slice(1);
|
|
2710
|
+
this.state.config.upstreams[trimmedName] = {
|
|
2711
|
+
transport: "stdio",
|
|
2712
|
+
enabled: true,
|
|
2713
|
+
env: envVars,
|
|
2714
|
+
stdio: { command, args }
|
|
2715
|
+
};
|
|
2716
|
+
this.state.isDirty = true;
|
|
2717
|
+
cleanup();
|
|
2718
|
+
this.showUpstreamsScreen();
|
|
2719
|
+
};
|
|
2720
|
+
submitSelect.on(SelectRenderableEvents.ITEM_SELECTED, (_i, opt) => {
|
|
2721
|
+
if (opt.value === "save") {
|
|
2722
|
+
saveUpstream();
|
|
2723
|
+
} else {
|
|
2724
|
+
cleanup();
|
|
2725
|
+
this.showAddUpstreamScreen();
|
|
2726
|
+
}
|
|
2727
|
+
});
|
|
2728
|
+
const handleKeypress = (key) => {
|
|
2729
|
+
if (key.name === "escape") {
|
|
2730
|
+
cleanup();
|
|
2731
|
+
this.showAddUpstreamScreen();
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
if (key.name === "tab" && !key.shift) {
|
|
2735
|
+
focusField((focusIndex + 1) % fields.length);
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
if (key.name === "tab" && key.shift) {
|
|
2739
|
+
focusField((focusIndex - 1 + fields.length) % fields.length);
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
};
|
|
2743
|
+
const cleanup = () => {
|
|
2744
|
+
this.renderer.keyInput.off("keypress", handleKeypress);
|
|
2745
|
+
};
|
|
2746
|
+
this.renderer.keyInput.on("keypress", handleKeypress);
|
|
2747
|
+
focusField(0);
|
|
2748
|
+
this.addInstructions("Tab: next field | Shift+Tab: prev | Esc: cancel");
|
|
2749
|
+
}
|
|
2750
|
+
showAddSseScreen() {
|
|
2751
|
+
this.state.currentScreen = "add-sse";
|
|
2752
|
+
this.clearScreen();
|
|
2753
|
+
this.addHeader();
|
|
2754
|
+
if (!this.container)
|
|
2755
|
+
return;
|
|
2756
|
+
const formBox = new BoxRenderable(this.renderer, {
|
|
2757
|
+
id: "add-sse-box",
|
|
2758
|
+
width: 60,
|
|
2759
|
+
height: 26,
|
|
2760
|
+
border: true,
|
|
2761
|
+
borderStyle: "single",
|
|
2762
|
+
borderColor: "#475569",
|
|
2763
|
+
title: "Add HTTP/SSE Upstream",
|
|
2764
|
+
titleAlignment: "center",
|
|
2765
|
+
backgroundColor: "#1e293b",
|
|
2766
|
+
flexDirection: "column",
|
|
2767
|
+
padding: 1
|
|
2768
|
+
});
|
|
2769
|
+
this.container.add(formBox);
|
|
2770
|
+
const nameLabel = new TextRenderable(this.renderer, {
|
|
2771
|
+
id: "name-label",
|
|
2772
|
+
content: "Name (unique identifier):",
|
|
2773
|
+
fg: "#94a3b8",
|
|
2774
|
+
marginBottom: 0
|
|
2775
|
+
});
|
|
2776
|
+
formBox.add(nameLabel);
|
|
2777
|
+
const nameInput = new InputRenderable(this.renderer, {
|
|
2778
|
+
id: "name-input",
|
|
2779
|
+
width: "100%",
|
|
2780
|
+
placeholder: "e.g., stripe, remote-api",
|
|
2781
|
+
backgroundColor: "#0f172a",
|
|
2782
|
+
focusedBackgroundColor: "#1e293b",
|
|
2783
|
+
textColor: "#e2e8f0",
|
|
2784
|
+
marginBottom: 1,
|
|
2785
|
+
onPaste: (event) => {
|
|
2786
|
+
nameInput.value = (nameInput.value || "") + event.text;
|
|
2787
|
+
}
|
|
2788
|
+
});
|
|
2789
|
+
formBox.add(nameInput);
|
|
2790
|
+
const urlLabel = new TextRenderable(this.renderer, {
|
|
2791
|
+
id: "url-label",
|
|
2792
|
+
content: "Server URL:",
|
|
2793
|
+
fg: "#94a3b8"
|
|
2794
|
+
});
|
|
2795
|
+
formBox.add(urlLabel);
|
|
2796
|
+
const urlInput = new InputRenderable(this.renderer, {
|
|
2797
|
+
id: "url-input",
|
|
2798
|
+
width: "100%",
|
|
2799
|
+
placeholder: "e.g., https://api.example.com/mcp",
|
|
2800
|
+
backgroundColor: "#0f172a",
|
|
2801
|
+
focusedBackgroundColor: "#1e293b",
|
|
2802
|
+
textColor: "#e2e8f0",
|
|
2803
|
+
marginBottom: 1,
|
|
2804
|
+
onPaste: (event) => {
|
|
2805
|
+
urlInput.value = (urlInput.value || "") + event.text;
|
|
2806
|
+
}
|
|
2807
|
+
});
|
|
2808
|
+
formBox.add(urlInput);
|
|
2809
|
+
const headersLabel = new TextRenderable(this.renderer, {
|
|
2810
|
+
id: "headers-label",
|
|
2811
|
+
content: "HTTP Headers (optional, comma-separated):",
|
|
2812
|
+
fg: "#94a3b8",
|
|
2813
|
+
marginBottom: 0
|
|
2814
|
+
});
|
|
2815
|
+
formBox.add(headersLabel);
|
|
2816
|
+
const headersInput = new InputRenderable(this.renderer, {
|
|
2817
|
+
id: "headers-input",
|
|
2818
|
+
width: "100%",
|
|
2819
|
+
placeholder: "e.g., Authorization=Bearer $API_KEY",
|
|
2820
|
+
backgroundColor: "#0f172a",
|
|
2821
|
+
focusedBackgroundColor: "#1e293b",
|
|
2822
|
+
textColor: "#e2e8f0",
|
|
2823
|
+
marginBottom: 1,
|
|
2824
|
+
onPaste: (event) => {
|
|
2825
|
+
headersInput.value = (headersInput.value || "") + event.text;
|
|
2826
|
+
}
|
|
2827
|
+
});
|
|
2828
|
+
formBox.add(headersInput);
|
|
2829
|
+
const authLabel = new TextRenderable(this.renderer, {
|
|
2830
|
+
id: "auth-label",
|
|
2831
|
+
content: "OAuth Authentication:",
|
|
2832
|
+
fg: "#94a3b8",
|
|
2833
|
+
marginBottom: 0
|
|
2834
|
+
});
|
|
2835
|
+
formBox.add(authLabel);
|
|
2836
|
+
const authOptions = [
|
|
2837
|
+
{ name: "Disabled", description: "", value: "disabled" },
|
|
2838
|
+
{
|
|
2839
|
+
name: "Enabled (default port 8089)",
|
|
2840
|
+
description: "",
|
|
2841
|
+
value: "enabled"
|
|
2842
|
+
}
|
|
2843
|
+
];
|
|
2844
|
+
const authSelect = new SelectRenderable(this.renderer, {
|
|
2845
|
+
id: "auth-select",
|
|
2846
|
+
width: "100%",
|
|
2847
|
+
height: 2,
|
|
2848
|
+
options: authOptions,
|
|
2849
|
+
backgroundColor: "#0f172a",
|
|
2850
|
+
selectedBackgroundColor: "#334155",
|
|
2851
|
+
textColor: "#e2e8f0",
|
|
2852
|
+
selectedTextColor: "#38bdf8",
|
|
2853
|
+
wrapSelection: true
|
|
2854
|
+
});
|
|
2855
|
+
formBox.add(authSelect);
|
|
2856
|
+
const envLabel = new TextRenderable(this.renderer, {
|
|
2857
|
+
id: "env-label",
|
|
2858
|
+
content: "Environment variables (optional, comma-separated):",
|
|
2859
|
+
fg: "#94a3b8",
|
|
2860
|
+
marginBottom: 0,
|
|
2861
|
+
marginTop: 1
|
|
2862
|
+
});
|
|
2863
|
+
formBox.add(envLabel);
|
|
2864
|
+
const envInput = new InputRenderable(this.renderer, {
|
|
2865
|
+
id: "env-input",
|
|
2866
|
+
width: "100%",
|
|
2867
|
+
placeholder: "e.g., API_KEY=$STRIPE_API_KEY",
|
|
2868
|
+
backgroundColor: "#0f172a",
|
|
2869
|
+
focusedBackgroundColor: "#1e293b",
|
|
2870
|
+
textColor: "#e2e8f0",
|
|
2871
|
+
marginBottom: 1,
|
|
2872
|
+
onPaste: (event) => {
|
|
2873
|
+
envInput.value = (envInput.value || "") + event.text;
|
|
2874
|
+
}
|
|
2875
|
+
});
|
|
2876
|
+
formBox.add(envInput);
|
|
2877
|
+
const submitOptions = [
|
|
2878
|
+
{ name: "[ Save Upstream ]", description: "", value: "save" },
|
|
2879
|
+
{ name: "[ Cancel ]", description: "", value: "cancel" }
|
|
2880
|
+
];
|
|
2881
|
+
const submitSelect = new SelectRenderable(this.renderer, {
|
|
2882
|
+
id: "submit-select",
|
|
2883
|
+
width: "100%",
|
|
2884
|
+
height: 3,
|
|
2885
|
+
options: submitOptions,
|
|
2886
|
+
backgroundColor: "transparent",
|
|
2887
|
+
selectedBackgroundColor: "#334155",
|
|
2888
|
+
textColor: "#e2e8f0",
|
|
2889
|
+
selectedTextColor: "#38bdf8",
|
|
2890
|
+
wrapSelection: true
|
|
2891
|
+
});
|
|
2892
|
+
formBox.add(submitSelect);
|
|
2893
|
+
const fields = [
|
|
2894
|
+
nameInput,
|
|
2895
|
+
urlInput,
|
|
2896
|
+
headersInput,
|
|
2897
|
+
authSelect,
|
|
2898
|
+
envInput,
|
|
2899
|
+
submitSelect
|
|
2900
|
+
];
|
|
2901
|
+
let focusIndex = 0;
|
|
2902
|
+
let selectedAuthIndex = 0;
|
|
2903
|
+
const focusField = (index) => {
|
|
2904
|
+
focusIndex = index;
|
|
2905
|
+
const field = fields[index];
|
|
2906
|
+
if (field)
|
|
2907
|
+
field.focus();
|
|
2908
|
+
};
|
|
2909
|
+
authSelect.on(SelectRenderableEvents.ITEM_SELECTED, (index, _opt) => {
|
|
2910
|
+
selectedAuthIndex = index;
|
|
2911
|
+
});
|
|
2912
|
+
const parseKeyValuePairs = (input) => {
|
|
2913
|
+
const result = {};
|
|
2914
|
+
if (!input.trim())
|
|
2915
|
+
return result;
|
|
2916
|
+
const pairs = input.split(",").map((s) => s.trim()).filter(Boolean);
|
|
2917
|
+
for (const pair of pairs) {
|
|
2918
|
+
const eqIndex = pair.indexOf("=");
|
|
2919
|
+
if (eqIndex > 0) {
|
|
2920
|
+
const key = pair.substring(0, eqIndex).trim();
|
|
2921
|
+
const value = pair.substring(eqIndex + 1).trim();
|
|
2922
|
+
if (key) {
|
|
2923
|
+
result[key] = value;
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
return result;
|
|
2928
|
+
};
|
|
2929
|
+
const saveUpstream = () => {
|
|
2930
|
+
const trimmedName = nameInput.value?.trim() || "";
|
|
2931
|
+
const trimmedUrl = urlInput.value?.trim() || "";
|
|
2932
|
+
if (!trimmedName) {
|
|
2933
|
+
nameInput.focus();
|
|
2934
|
+
return;
|
|
2935
|
+
}
|
|
2936
|
+
if (!trimmedUrl) {
|
|
2937
|
+
urlInput.focus();
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
try {
|
|
2941
|
+
new URL(trimmedUrl);
|
|
2942
|
+
} catch {
|
|
2943
|
+
urlInput.focus();
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
const envVars = parseKeyValuePairs(envInput.value || "");
|
|
2947
|
+
const headers = parseKeyValuePairs(headersInput.value || "");
|
|
2948
|
+
const authEnabled = selectedAuthIndex === 1;
|
|
2949
|
+
this.state.config.upstreams[trimmedName] = {
|
|
2950
|
+
transport: "sse",
|
|
2951
|
+
enabled: true,
|
|
2952
|
+
env: envVars,
|
|
2953
|
+
sse: {
|
|
2954
|
+
url: trimmedUrl,
|
|
2955
|
+
headers,
|
|
2956
|
+
auth: authEnabled ? true : undefined
|
|
2957
|
+
}
|
|
2958
|
+
};
|
|
2959
|
+
this.state.isDirty = true;
|
|
2960
|
+
cleanup();
|
|
2961
|
+
this.showUpstreamsScreen();
|
|
2962
|
+
};
|
|
2963
|
+
submitSelect.on(SelectRenderableEvents.ITEM_SELECTED, (_i, opt) => {
|
|
2964
|
+
if (opt.value === "save") {
|
|
2965
|
+
saveUpstream();
|
|
2966
|
+
} else {
|
|
2967
|
+
cleanup();
|
|
2968
|
+
this.showAddUpstreamScreen();
|
|
2969
|
+
}
|
|
2970
|
+
});
|
|
2971
|
+
const handleKeypress = (key) => {
|
|
2972
|
+
if (key.name === "escape") {
|
|
2973
|
+
cleanup();
|
|
2974
|
+
this.showAddUpstreamScreen();
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
if (key.name === "tab" && !key.shift) {
|
|
2978
|
+
focusField((focusIndex + 1) % fields.length);
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
if (key.name === "tab" && key.shift) {
|
|
2982
|
+
focusField((focusIndex - 1 + fields.length) % fields.length);
|
|
2983
|
+
return;
|
|
2984
|
+
}
|
|
2985
|
+
};
|
|
2986
|
+
const cleanup = () => {
|
|
2987
|
+
this.renderer.keyInput.off("keypress", handleKeypress);
|
|
2988
|
+
};
|
|
2989
|
+
this.renderer.keyInput.on("keypress", handleKeypress);
|
|
2990
|
+
focusField(0);
|
|
2991
|
+
this.addInstructions("Tab: next field | Shift+Tab: prev | Esc: cancel");
|
|
2992
|
+
}
|
|
2993
|
+
showEditUpstreamScreen(name) {
|
|
2994
|
+
this.state.currentScreen = "edit-upstream";
|
|
2995
|
+
this.clearScreen();
|
|
2996
|
+
this.addHeader();
|
|
2997
|
+
if (!this.container)
|
|
2998
|
+
return;
|
|
2999
|
+
const upstream = this.state.config.upstreams[name];
|
|
3000
|
+
if (!upstream) {
|
|
3001
|
+
this.showUpstreamsScreen();
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
const boxHeight = upstream.transport === "sse" ? 22 : 18;
|
|
3005
|
+
const menuBox = new BoxRenderable(this.renderer, {
|
|
3006
|
+
id: "edit-upstream-box",
|
|
3007
|
+
width: 60,
|
|
3008
|
+
height: boxHeight,
|
|
3009
|
+
border: true,
|
|
3010
|
+
borderStyle: "single",
|
|
3011
|
+
borderColor: "#475569",
|
|
3012
|
+
title: `Edit: ${name}`,
|
|
3013
|
+
titleAlignment: "center",
|
|
3014
|
+
backgroundColor: "#1e293b",
|
|
3015
|
+
flexDirection: "column",
|
|
3016
|
+
padding: 1
|
|
3017
|
+
});
|
|
3018
|
+
this.container.add(menuBox);
|
|
3019
|
+
const transportText = new TextRenderable(this.renderer, {
|
|
3020
|
+
id: "edit-transport",
|
|
3021
|
+
content: `Transport: ${upstream.transport.toUpperCase()}`,
|
|
3022
|
+
fg: "#94a3b8",
|
|
3023
|
+
marginBottom: 0
|
|
3024
|
+
});
|
|
3025
|
+
menuBox.add(transportText);
|
|
3026
|
+
if (upstream.transport === "stdio") {
|
|
3027
|
+
const cmdText = new TextRenderable(this.renderer, {
|
|
3028
|
+
id: "edit-command",
|
|
3029
|
+
content: `Command: ${upstream.stdio.command} ${upstream.stdio.args.join(" ")}`,
|
|
3030
|
+
fg: "#94a3b8",
|
|
3031
|
+
marginBottom: 0
|
|
3032
|
+
});
|
|
3033
|
+
menuBox.add(cmdText);
|
|
3034
|
+
} else {
|
|
3035
|
+
const urlText = new TextRenderable(this.renderer, {
|
|
3036
|
+
id: "edit-url",
|
|
3037
|
+
content: `URL: ${upstream.sse.url}`,
|
|
3038
|
+
fg: "#94a3b8",
|
|
3039
|
+
marginBottom: 0
|
|
3040
|
+
});
|
|
3041
|
+
menuBox.add(urlText);
|
|
3042
|
+
const headerCount = Object.keys(upstream.sse.headers || {}).length;
|
|
3043
|
+
const headersText = new TextRenderable(this.renderer, {
|
|
3044
|
+
id: "edit-headers",
|
|
3045
|
+
content: `Headers: ${headerCount > 0 ? headerCount : "(none)"}`,
|
|
3046
|
+
fg: "#94a3b8",
|
|
3047
|
+
marginBottom: 0
|
|
3048
|
+
});
|
|
3049
|
+
menuBox.add(headersText);
|
|
3050
|
+
const authStatus = upstream.sse.auth ? "Enabled" : "Disabled";
|
|
3051
|
+
const authText = new TextRenderable(this.renderer, {
|
|
3052
|
+
id: "edit-auth",
|
|
3053
|
+
content: `OAuth: ${authStatus}`,
|
|
3054
|
+
fg: "#94a3b8",
|
|
3055
|
+
marginBottom: 0
|
|
3056
|
+
});
|
|
3057
|
+
menuBox.add(authText);
|
|
3058
|
+
}
|
|
3059
|
+
const envEntries = Object.entries(upstream.env || {});
|
|
3060
|
+
const envLabel = new TextRenderable(this.renderer, {
|
|
3061
|
+
id: "edit-env-label",
|
|
3062
|
+
content: `Environment (${envEntries.length}):`,
|
|
3063
|
+
fg: "#94a3b8",
|
|
3064
|
+
marginTop: 1,
|
|
3065
|
+
marginBottom: 0
|
|
3066
|
+
});
|
|
3067
|
+
menuBox.add(envLabel);
|
|
3068
|
+
if (envEntries.length > 0) {
|
|
3069
|
+
for (const [key, value] of envEntries.slice(0, 3)) {
|
|
3070
|
+
const maskedValue = value.startsWith("$") ? value : "***";
|
|
3071
|
+
const envText = new TextRenderable(this.renderer, {
|
|
3072
|
+
id: `edit-env-${key}`,
|
|
3073
|
+
content: ` ${key}=${maskedValue}`,
|
|
3074
|
+
fg: "#64748b"
|
|
3075
|
+
});
|
|
3076
|
+
menuBox.add(envText);
|
|
3077
|
+
}
|
|
3078
|
+
if (envEntries.length > 3) {
|
|
3079
|
+
const moreText = new TextRenderable(this.renderer, {
|
|
3080
|
+
id: "edit-env-more",
|
|
3081
|
+
content: ` ... and ${envEntries.length - 3} more`,
|
|
3082
|
+
fg: "#64748b"
|
|
3083
|
+
});
|
|
3084
|
+
menuBox.add(moreText);
|
|
3085
|
+
}
|
|
3086
|
+
} else {
|
|
3087
|
+
const noEnvText = new TextRenderable(this.renderer, {
|
|
3088
|
+
id: "edit-env-none",
|
|
3089
|
+
content: " (none)",
|
|
3090
|
+
fg: "#64748b"
|
|
3091
|
+
});
|
|
3092
|
+
menuBox.add(noEnvText);
|
|
3093
|
+
}
|
|
3094
|
+
const options = [
|
|
3095
|
+
{
|
|
3096
|
+
name: "Test Connection",
|
|
3097
|
+
description: "Connect and list available tools",
|
|
3098
|
+
value: "test"
|
|
3099
|
+
},
|
|
3100
|
+
{
|
|
3101
|
+
name: upstream.enabled ? "Disable" : "Enable",
|
|
3102
|
+
description: upstream.enabled ? "Stop using this upstream" : "Start using this upstream",
|
|
3103
|
+
value: "toggle"
|
|
3104
|
+
},
|
|
3105
|
+
{
|
|
3106
|
+
name: "Delete",
|
|
3107
|
+
description: "Remove this upstream configuration",
|
|
3108
|
+
value: "delete"
|
|
3109
|
+
},
|
|
3110
|
+
{
|
|
3111
|
+
name: "\u2190 Back",
|
|
3112
|
+
description: "",
|
|
3113
|
+
value: "back"
|
|
3114
|
+
}
|
|
3115
|
+
];
|
|
3116
|
+
const menu = new SelectRenderable(this.renderer, {
|
|
3117
|
+
id: "edit-menu",
|
|
3118
|
+
width: "100%",
|
|
3119
|
+
height: "100%",
|
|
3120
|
+
options,
|
|
3121
|
+
backgroundColor: "transparent",
|
|
3122
|
+
selectedBackgroundColor: "#334155",
|
|
3123
|
+
textColor: "#e2e8f0",
|
|
3124
|
+
selectedTextColor: "#38bdf8",
|
|
3125
|
+
showDescription: true,
|
|
3126
|
+
descriptionColor: "#64748b",
|
|
3127
|
+
wrapSelection: true
|
|
3128
|
+
});
|
|
3129
|
+
menuBox.add(menu);
|
|
3130
|
+
menu.on(SelectRenderableEvents.ITEM_SELECTED, (_index, option) => {
|
|
3131
|
+
switch (option.value) {
|
|
3132
|
+
case "test":
|
|
3133
|
+
this.showTestScreen(name, upstream);
|
|
3134
|
+
break;
|
|
3135
|
+
case "toggle":
|
|
3136
|
+
upstream.enabled = !upstream.enabled;
|
|
3137
|
+
this.state.isDirty = true;
|
|
3138
|
+
this.showEditUpstreamScreen(name);
|
|
3139
|
+
break;
|
|
3140
|
+
case "delete":
|
|
3141
|
+
delete this.state.config.upstreams[name];
|
|
3142
|
+
this.state.isDirty = true;
|
|
3143
|
+
this.showUpstreamsScreen();
|
|
3144
|
+
break;
|
|
3145
|
+
case "back":
|
|
3146
|
+
this.showUpstreamsScreen();
|
|
3147
|
+
break;
|
|
3148
|
+
}
|
|
3149
|
+
});
|
|
3150
|
+
menu.focus();
|
|
3151
|
+
this.addInstructions("\u2191\u2193 Navigate | Enter Select | Esc Back");
|
|
3152
|
+
}
|
|
3153
|
+
async showTestScreen(name, upstream) {
|
|
3154
|
+
this.clearScreen();
|
|
3155
|
+
this.addHeader();
|
|
3156
|
+
if (!this.container)
|
|
3157
|
+
return;
|
|
3158
|
+
const testBox = new BoxRenderable(this.renderer, {
|
|
3159
|
+
id: "test-box",
|
|
3160
|
+
width: 60,
|
|
3161
|
+
height: 16,
|
|
3162
|
+
border: true,
|
|
3163
|
+
borderStyle: "single",
|
|
3164
|
+
borderColor: "#475569",
|
|
3165
|
+
title: `Testing: ${name}`,
|
|
3166
|
+
titleAlignment: "center",
|
|
3167
|
+
backgroundColor: "#1e293b",
|
|
3168
|
+
flexDirection: "column",
|
|
3169
|
+
padding: 1
|
|
3170
|
+
});
|
|
3171
|
+
this.container.add(testBox);
|
|
3172
|
+
const statusText = new TextRenderable(this.renderer, {
|
|
3173
|
+
id: "test-status",
|
|
3174
|
+
content: "Connecting...",
|
|
3175
|
+
fg: "#fbbf24"
|
|
3176
|
+
});
|
|
3177
|
+
testBox.add(statusText);
|
|
3178
|
+
this.addInstructions("Please wait...");
|
|
3179
|
+
const result = await testUpstreamConnection(name, upstream);
|
|
3180
|
+
this.clearScreen();
|
|
3181
|
+
this.addHeader();
|
|
3182
|
+
if (!this.container)
|
|
3183
|
+
return;
|
|
3184
|
+
const resultBox = new BoxRenderable(this.renderer, {
|
|
3185
|
+
id: "result-box",
|
|
3186
|
+
width: 60,
|
|
3187
|
+
height: 18,
|
|
3188
|
+
border: true,
|
|
3189
|
+
borderStyle: "single",
|
|
3190
|
+
borderColor: result.success ? "#4ade80" : "#f87171",
|
|
3191
|
+
title: result.success ? `\u2713 ${name} - Success` : `\u2717 ${name} - Failed`,
|
|
3192
|
+
titleAlignment: "center",
|
|
3193
|
+
backgroundColor: "#1e293b",
|
|
3194
|
+
flexDirection: "column",
|
|
3195
|
+
padding: 1
|
|
3196
|
+
});
|
|
3197
|
+
this.container.add(resultBox);
|
|
3198
|
+
if (result.success) {
|
|
3199
|
+
if (result.serverName) {
|
|
3200
|
+
const serverText = new TextRenderable(this.renderer, {
|
|
3201
|
+
id: "test-server",
|
|
3202
|
+
content: `Server: ${result.serverName}${result.serverVersion ? ` v${result.serverVersion}` : ""}`,
|
|
3203
|
+
fg: "#94a3b8"
|
|
3204
|
+
});
|
|
3205
|
+
resultBox.add(serverText);
|
|
3206
|
+
}
|
|
3207
|
+
const toolsHeader = new TextRenderable(this.renderer, {
|
|
3208
|
+
id: "test-tools-header",
|
|
3209
|
+
content: `Tools available: ${result.tools.length}`,
|
|
3210
|
+
fg: "#e2e8f0",
|
|
3211
|
+
marginTop: 1
|
|
3212
|
+
});
|
|
3213
|
+
resultBox.add(toolsHeader);
|
|
3214
|
+
for (const tool of result.tools.slice(0, 8)) {
|
|
3215
|
+
const toolText = new TextRenderable(this.renderer, {
|
|
3216
|
+
id: `test-tool-${tool.name}`,
|
|
3217
|
+
content: ` \u2022 ${tool.name}`,
|
|
3218
|
+
fg: "#94a3b8"
|
|
3219
|
+
});
|
|
3220
|
+
resultBox.add(toolText);
|
|
3221
|
+
}
|
|
3222
|
+
if (result.tools.length > 8) {
|
|
3223
|
+
const moreText = new TextRenderable(this.renderer, {
|
|
3224
|
+
id: "test-tools-more",
|
|
3225
|
+
content: ` ... and ${result.tools.length - 8} more`,
|
|
3226
|
+
fg: "#64748b"
|
|
3227
|
+
});
|
|
3228
|
+
resultBox.add(moreText);
|
|
3229
|
+
}
|
|
3230
|
+
const timeText = new TextRenderable(this.renderer, {
|
|
3231
|
+
id: "test-time",
|
|
3232
|
+
content: `Time: ${result.durationMs}ms`,
|
|
3233
|
+
fg: "#64748b",
|
|
3234
|
+
marginTop: 1
|
|
3235
|
+
});
|
|
3236
|
+
resultBox.add(timeText);
|
|
3237
|
+
} else {
|
|
3238
|
+
const errorText = new TextRenderable(this.renderer, {
|
|
3239
|
+
id: "test-error",
|
|
3240
|
+
content: `Error: ${result.error}`,
|
|
3241
|
+
fg: "#f87171"
|
|
3242
|
+
});
|
|
3243
|
+
resultBox.add(errorText);
|
|
3244
|
+
const timeText = new TextRenderable(this.renderer, {
|
|
3245
|
+
id: "test-time",
|
|
3246
|
+
content: `Time: ${result.durationMs}ms`,
|
|
3247
|
+
fg: "#64748b",
|
|
3248
|
+
marginTop: 1
|
|
3249
|
+
});
|
|
3250
|
+
resultBox.add(timeText);
|
|
3251
|
+
}
|
|
3252
|
+
const backOption = [
|
|
3253
|
+
{ name: "\u2190 Back", description: "", value: "back" }
|
|
3254
|
+
];
|
|
3255
|
+
const backMenu = new SelectRenderable(this.renderer, {
|
|
3256
|
+
id: "test-back",
|
|
3257
|
+
width: "100%",
|
|
3258
|
+
height: 2,
|
|
3259
|
+
options: backOption,
|
|
3260
|
+
backgroundColor: "transparent",
|
|
3261
|
+
selectedBackgroundColor: "#334155",
|
|
3262
|
+
textColor: "#e2e8f0",
|
|
3263
|
+
selectedTextColor: "#38bdf8",
|
|
3264
|
+
marginTop: 1
|
|
3265
|
+
});
|
|
3266
|
+
resultBox.add(backMenu);
|
|
3267
|
+
backMenu.on(SelectRenderableEvents.ITEM_SELECTED, () => {
|
|
3268
|
+
this.showEditUpstreamScreen(name);
|
|
3269
|
+
});
|
|
3270
|
+
backMenu.focus();
|
|
3271
|
+
this.addInstructions("Enter to go back");
|
|
3272
|
+
}
|
|
3273
|
+
showSecurityScreen() {
|
|
3274
|
+
this.state.currentScreen = "security";
|
|
3275
|
+
this.clearScreen();
|
|
3276
|
+
this.addHeader();
|
|
3277
|
+
if (!this.container)
|
|
3278
|
+
return;
|
|
3279
|
+
const security = this.state.config.security;
|
|
3280
|
+
const infoBox = new BoxRenderable(this.renderer, {
|
|
3281
|
+
id: "security-box",
|
|
3282
|
+
width: 60,
|
|
3283
|
+
height: 14,
|
|
3284
|
+
border: true,
|
|
3285
|
+
borderStyle: "single",
|
|
3286
|
+
borderColor: "#475569",
|
|
3287
|
+
title: "Security Settings",
|
|
3288
|
+
titleAlignment: "center",
|
|
3289
|
+
backgroundColor: "#1e293b",
|
|
3290
|
+
flexDirection: "column",
|
|
3291
|
+
padding: 1
|
|
3292
|
+
});
|
|
3293
|
+
this.container.add(infoBox);
|
|
3294
|
+
const allowText = new TextRenderable(this.renderer, {
|
|
3295
|
+
id: "allow-label",
|
|
3296
|
+
content: `Allow patterns: ${security.tools.allow.join(", ") || "(none)"}`,
|
|
3297
|
+
fg: "#4ade80",
|
|
3298
|
+
marginBottom: 1
|
|
3299
|
+
});
|
|
3300
|
+
infoBox.add(allowText);
|
|
3301
|
+
const blockText = new TextRenderable(this.renderer, {
|
|
3302
|
+
id: "block-label",
|
|
3303
|
+
content: `Block patterns: ${security.tools.block.join(", ") || "(none)"}`,
|
|
3304
|
+
fg: "#f87171",
|
|
3305
|
+
marginBottom: 1
|
|
3306
|
+
});
|
|
3307
|
+
infoBox.add(blockText);
|
|
3308
|
+
const confirmText = new TextRenderable(this.renderer, {
|
|
3309
|
+
id: "confirm-label",
|
|
3310
|
+
content: `Confirm patterns: ${security.tools.confirm.join(", ") || "(none)"}`,
|
|
3311
|
+
fg: "#fbbf24",
|
|
3312
|
+
marginBottom: 2
|
|
3313
|
+
});
|
|
3314
|
+
infoBox.add(confirmText);
|
|
3315
|
+
const hintText = new TextRenderable(this.renderer, {
|
|
3316
|
+
id: "hint-text",
|
|
3317
|
+
content: "Edit mcp-squared.toml directly for advanced security config",
|
|
3318
|
+
fg: "#64748b",
|
|
3319
|
+
marginBottom: 1
|
|
3320
|
+
});
|
|
3321
|
+
infoBox.add(hintText);
|
|
3322
|
+
const backOption = [
|
|
3323
|
+
{ name: "\u2190 Back to Main Menu", description: "", value: "back" }
|
|
3324
|
+
];
|
|
3325
|
+
const backMenu = new SelectRenderable(this.renderer, {
|
|
3326
|
+
id: "security-back",
|
|
3327
|
+
width: "100%",
|
|
3328
|
+
height: 2,
|
|
3329
|
+
options: backOption,
|
|
3330
|
+
backgroundColor: "transparent",
|
|
3331
|
+
selectedBackgroundColor: "#334155",
|
|
3332
|
+
textColor: "#e2e8f0",
|
|
3333
|
+
selectedTextColor: "#38bdf8"
|
|
3334
|
+
});
|
|
3335
|
+
infoBox.add(backMenu);
|
|
3336
|
+
backMenu.on(SelectRenderableEvents.ITEM_SELECTED, () => {
|
|
3337
|
+
this.showMainMenu();
|
|
3338
|
+
});
|
|
3339
|
+
backMenu.focus();
|
|
3340
|
+
this.addInstructions("Enter to go back | Esc Back");
|
|
3341
|
+
const handleEscape = (key) => {
|
|
3342
|
+
if (key.name === "escape") {
|
|
3343
|
+
this.renderer.keyInput.off("keypress", handleEscape);
|
|
3344
|
+
this.showMainMenu();
|
|
3345
|
+
}
|
|
3346
|
+
};
|
|
3347
|
+
this.renderer.keyInput.on("keypress", handleEscape);
|
|
3348
|
+
}
|
|
3349
|
+
showOperationsScreen() {
|
|
3350
|
+
this.state.currentScreen = "operations";
|
|
3351
|
+
this.clearScreen();
|
|
3352
|
+
this.addHeader();
|
|
3353
|
+
if (!this.container)
|
|
3354
|
+
return;
|
|
3355
|
+
const ops = this.state.config.operations;
|
|
3356
|
+
const infoBox = new BoxRenderable(this.renderer, {
|
|
3357
|
+
id: "operations-box",
|
|
3358
|
+
width: 60,
|
|
3359
|
+
height: 14,
|
|
3360
|
+
border: true,
|
|
3361
|
+
borderStyle: "single",
|
|
3362
|
+
borderColor: "#475569",
|
|
3363
|
+
title: "Operations Settings",
|
|
3364
|
+
titleAlignment: "center",
|
|
3365
|
+
backgroundColor: "#1e293b",
|
|
3366
|
+
flexDirection: "column",
|
|
3367
|
+
padding: 1
|
|
3368
|
+
});
|
|
3369
|
+
this.container.add(infoBox);
|
|
3370
|
+
const limitText = new TextRenderable(this.renderer, {
|
|
3371
|
+
id: "limit-label",
|
|
3372
|
+
content: `Default find_tools limit: ${ops.findTools.defaultLimit}`,
|
|
3373
|
+
fg: "#e2e8f0",
|
|
3374
|
+
marginBottom: 1
|
|
3375
|
+
});
|
|
3376
|
+
infoBox.add(limitText);
|
|
3377
|
+
const maxLimitText = new TextRenderable(this.renderer, {
|
|
3378
|
+
id: "max-limit-label",
|
|
3379
|
+
content: `Max find_tools limit: ${ops.findTools.maxLimit}`,
|
|
3380
|
+
fg: "#e2e8f0",
|
|
3381
|
+
marginBottom: 1
|
|
3382
|
+
});
|
|
3383
|
+
infoBox.add(maxLimitText);
|
|
3384
|
+
const refreshText = new TextRenderable(this.renderer, {
|
|
3385
|
+
id: "refresh-label",
|
|
3386
|
+
content: `Index refresh interval: ${ops.index.refreshIntervalMs}ms`,
|
|
3387
|
+
fg: "#e2e8f0",
|
|
3388
|
+
marginBottom: 1
|
|
3389
|
+
});
|
|
3390
|
+
infoBox.add(refreshText);
|
|
3391
|
+
const logText = new TextRenderable(this.renderer, {
|
|
3392
|
+
id: "log-label",
|
|
3393
|
+
content: `Log level: ${ops.logging.level}`,
|
|
3394
|
+
fg: "#e2e8f0",
|
|
3395
|
+
marginBottom: 2
|
|
3396
|
+
});
|
|
3397
|
+
infoBox.add(logText);
|
|
3398
|
+
const hintText = new TextRenderable(this.renderer, {
|
|
3399
|
+
id: "hint-text",
|
|
3400
|
+
content: "Edit mcp-squared.toml directly for advanced settings",
|
|
3401
|
+
fg: "#64748b",
|
|
3402
|
+
marginBottom: 1
|
|
3403
|
+
});
|
|
3404
|
+
infoBox.add(hintText);
|
|
3405
|
+
const backOption = [
|
|
3406
|
+
{ name: "\u2190 Back to Main Menu", description: "", value: "back" }
|
|
3407
|
+
];
|
|
3408
|
+
const backMenu = new SelectRenderable(this.renderer, {
|
|
3409
|
+
id: "ops-back",
|
|
3410
|
+
width: "100%",
|
|
3411
|
+
height: 2,
|
|
3412
|
+
options: backOption,
|
|
3413
|
+
backgroundColor: "transparent",
|
|
3414
|
+
selectedBackgroundColor: "#334155",
|
|
3415
|
+
textColor: "#e2e8f0",
|
|
3416
|
+
selectedTextColor: "#38bdf8"
|
|
3417
|
+
});
|
|
3418
|
+
infoBox.add(backMenu);
|
|
3419
|
+
backMenu.on(SelectRenderableEvents.ITEM_SELECTED, () => {
|
|
3420
|
+
this.showMainMenu();
|
|
3421
|
+
});
|
|
3422
|
+
backMenu.focus();
|
|
3423
|
+
this.addInstructions("Enter to go back | Esc Back");
|
|
3424
|
+
const handleEscape = (key) => {
|
|
3425
|
+
if (key.name === "escape") {
|
|
3426
|
+
this.renderer.keyInput.off("keypress", handleEscape);
|
|
3427
|
+
this.showMainMenu();
|
|
3428
|
+
}
|
|
3429
|
+
};
|
|
3430
|
+
this.renderer.keyInput.on("keypress", handleEscape);
|
|
3431
|
+
}
|
|
3432
|
+
async handleSave() {
|
|
3433
|
+
if (!this.state.isDirty) {
|
|
3434
|
+
this.showMainMenu();
|
|
3435
|
+
return;
|
|
3436
|
+
}
|
|
3437
|
+
try {
|
|
3438
|
+
await saveConfig(this.state.configPath, this.state.config);
|
|
3439
|
+
this.state.isDirty = false;
|
|
3440
|
+
this.showMainMenu();
|
|
3441
|
+
} catch (err) {
|
|
3442
|
+
console.error("Failed to save config:", err);
|
|
3443
|
+
this.showMainMenu();
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
handleExit() {
|
|
3447
|
+
if (this.state.isDirty) {
|
|
3448
|
+
this.showExitConfirmation();
|
|
3449
|
+
} else {
|
|
3450
|
+
this.renderer.destroy();
|
|
3451
|
+
process.exit(0);
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
showExitConfirmation() {
|
|
3455
|
+
this.clearScreen();
|
|
3456
|
+
this.addHeader();
|
|
3457
|
+
if (!this.container)
|
|
3458
|
+
return;
|
|
3459
|
+
const confirmBox = new BoxRenderable(this.renderer, {
|
|
3460
|
+
id: "confirm-box",
|
|
3461
|
+
width: 50,
|
|
3462
|
+
height: 8,
|
|
3463
|
+
border: true,
|
|
3464
|
+
borderStyle: "single",
|
|
3465
|
+
borderColor: "#fbbf24",
|
|
3466
|
+
title: "Unsaved Changes",
|
|
3467
|
+
titleAlignment: "center",
|
|
3468
|
+
backgroundColor: "#1e293b"
|
|
3469
|
+
});
|
|
3470
|
+
this.container.add(confirmBox);
|
|
3471
|
+
const options = [
|
|
3472
|
+
{ name: "Save and Exit", description: "", value: "save-exit" },
|
|
3473
|
+
{ name: "Exit without Saving", description: "", value: "exit" },
|
|
3474
|
+
{ name: "Cancel", description: "", value: "cancel" }
|
|
3475
|
+
];
|
|
3476
|
+
const menu = new SelectRenderable(this.renderer, {
|
|
3477
|
+
id: "confirm-menu",
|
|
3478
|
+
width: "100%",
|
|
3479
|
+
height: "100%",
|
|
3480
|
+
options,
|
|
3481
|
+
backgroundColor: "transparent",
|
|
3482
|
+
selectedBackgroundColor: "#334155",
|
|
3483
|
+
textColor: "#e2e8f0",
|
|
3484
|
+
selectedTextColor: "#38bdf8",
|
|
3485
|
+
wrapSelection: true
|
|
3486
|
+
});
|
|
3487
|
+
confirmBox.add(menu);
|
|
3488
|
+
menu.on(SelectRenderableEvents.ITEM_SELECTED, async (_index, option) => {
|
|
3489
|
+
switch (option.value) {
|
|
3490
|
+
case "save-exit":
|
|
3491
|
+
await this.handleSave();
|
|
3492
|
+
this.renderer.destroy();
|
|
3493
|
+
process.exit(0);
|
|
3494
|
+
break;
|
|
3495
|
+
case "exit":
|
|
3496
|
+
this.renderer.destroy();
|
|
3497
|
+
process.exit(0);
|
|
3498
|
+
break;
|
|
3499
|
+
case "cancel":
|
|
3500
|
+
this.showMainMenu();
|
|
3501
|
+
break;
|
|
3502
|
+
}
|
|
3503
|
+
});
|
|
3504
|
+
menu.focus();
|
|
3505
|
+
this.addInstructions("\u2191\u2193 Navigate | Enter Select");
|
|
3506
|
+
}
|
|
3507
|
+
addInstructions(text) {
|
|
3508
|
+
if (!this.container)
|
|
3509
|
+
return;
|
|
3510
|
+
const instructions = new TextRenderable(this.renderer, {
|
|
3511
|
+
id: "config-instructions",
|
|
3512
|
+
content: text,
|
|
3513
|
+
fg: "#64748b",
|
|
3514
|
+
marginTop: 2
|
|
3515
|
+
});
|
|
3516
|
+
this.container.add(instructions);
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
export {
|
|
3520
|
+
runConfigTui
|
|
3521
|
+
};
|