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.
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
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
+ };