mcp-squared 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,6 +72,9 @@ bun run build
72
72
  # Run tests
73
73
  bun test
74
74
 
75
+ # Evaluate tool-routing behavior (discovery-first quality check)
76
+ bun run eval:routing
77
+
75
78
  # Dependency audit
76
79
  bun run audit
77
80
  ```
@@ -103,6 +106,7 @@ mcp-squared config # Launch configuration TUI
103
106
  mcp-squared test [upstream] # Test upstream server connections
104
107
  mcp-squared auth <upstream> # OAuth auth for SSE/HTTP upstreams
105
108
  mcp-squared import # Import MCP configs from other tools
109
+ mcp-squared migrate # Apply one-time config migrations
106
110
  mcp-squared install # Install MCP² into other MCP clients
107
111
  mcp-squared monitor # Launch server monitor TUI
108
112
  mcp-squared --help # Full command reference
@@ -159,6 +163,23 @@ auth = true
159
163
 
160
164
  Security policies (allow/block/confirm) live under `security.tools`. Confirmation flows return a short-lived token that must be provided to `execute` to proceed. OAuth tokens for SSE upstreams are stored under `~/.config/mcp-squared/tokens/<upstream>.json`.
161
165
 
166
+ `mcp-squared init` now seeds code-search routing preferences so `find_tools` prioritizes common code indexers by default:
167
+
168
+ ```toml
169
+ [operations.findTools.preferredNamespacesByIntent]
170
+ codeSearch = ["auggie", "ctxdb"]
171
+ ```
172
+
173
+ Tune this list (or set it to `[]`) if your environment uses different namespaces.
174
+
175
+ For existing configs created before this default, run:
176
+
177
+ ```bash
178
+ mcp-squared migrate
179
+ ```
180
+
181
+ Use `mcp-squared migrate --dry-run` to preview without writing.
182
+
162
183
  ## Tool API (Meta-Tools)
163
184
 
164
185
  MCP² exposes these tools to MCP clients:
@@ -168,6 +189,14 @@ MCP² exposes these tools to MCP clients:
168
189
  - `list_namespaces` - List upstream namespaces (optionally with tool names)
169
190
  - `clear_selection_cache` - Reset co-occurrence based suggestions
170
191
 
192
+ Recommended workflow for LLM clients:
193
+ 1. Call `find_tools` first to discover candidate tools for the task.
194
+ 2. Call `describe_tools` for selected candidates to confirm exact argument schemas.
195
+ 3. Call `execute` with a qualified tool name (`namespace:tool_name`).
196
+ 4. Use `list_namespaces` if tool names are ambiguous or you need namespace context.
197
+
198
+ For codebase-search tasks, `find_tools` applies intent-aware ranking and may return namespace guidance (for example preferring configured code-search namespaces like `auggie`).
199
+
171
200
  ## Search Modes
172
201
 
173
202
  `find_tools` supports three search modes:
package/dist/index.js CHANGED
@@ -354,6 +354,11 @@ maxLimit = 50
354
354
  defaultMode = "fast"
355
355
  defaultDetailLevel = "L1"
356
356
 
357
+ [operations.findTools.preferredNamespacesByIntent]
358
+ # Default-on code-search namespace preference.
359
+ # Adjust or clear if your stack uses different code indexers.
360
+ codeSearch = ["auggie", "ctxdb"]
361
+
357
362
  [operations.embeddings]
358
363
  # Enable to use semantic or hybrid search modes.
359
364
  # Requires onnxruntime shared library on the system.
@@ -455,6 +460,9 @@ function parseArgs(args) {
455
460
  project: false,
456
461
  force: false
457
462
  },
463
+ migrate: {
464
+ dryRun: false
465
+ },
458
466
  install: {
459
467
  interactive: true,
460
468
  dryRun: false,
@@ -511,6 +519,9 @@ function parseArgs(args) {
511
519
  case "init":
512
520
  result.mode = "init";
513
521
  break;
522
+ case "migrate":
523
+ result.mode = "migrate";
524
+ break;
514
525
  case "monitor":
515
526
  result.mode = "monitor";
516
527
  break;
@@ -561,6 +572,7 @@ function parseArgs(args) {
561
572
  case "--dry-run":
562
573
  result.import.dryRun = true;
563
574
  result.install.dryRun = true;
575
+ result.migrate.dryRun = true;
564
576
  break;
565
577
  case "--no-interactive":
566
578
  result.import.interactive = false;
@@ -685,6 +697,7 @@ Usage:
685
697
  mcp-squared auth <upstream> Authenticate with an OAuth-protected upstream
686
698
  mcp-squared import [options] Import MCP configs from other tools
687
699
  mcp-squared init [options] Generate a starter config file with security profile
700
+ mcp-squared migrate [options] Apply config migrations to existing config
688
701
  mcp-squared install [options] Install MCP\xB2 into other MCP clients
689
702
  mcp-squared monitor [options] Launch server monitor TUI
690
703
  mcp-squared daemon [options] Start shared MCP\xB2 daemon
@@ -698,6 +711,7 @@ Commands:
698
711
  auth <name> Authenticate with an OAuth-protected upstream
699
712
  import Import MCP server configs from other tools
700
713
  init Generate a starter config with security profile
714
+ migrate Apply one-time config migrations to existing config
701
715
  install Install MCP\xB2 as a server in other MCP clients
702
716
  monitor Launch server monitor TUI
703
717
  daemon Start shared daemon for multiple clients
@@ -724,6 +738,9 @@ Init Options:
724
738
  --project Write to project-local mcp-squared.toml (default: user-level)
725
739
  --force Overwrite existing config without prompting
726
740
 
741
+ Migrate Options:
742
+ --dry-run Preview migration changes without writing
743
+
727
744
  Install Options:
728
745
  --tool=<tool> Target tool (skip selection prompt)
729
746
  --scope=<scope> Scope: user or project
@@ -760,6 +777,8 @@ Examples:
760
777
  mcp-squared init Generate hardened config (confirm-all by default)
761
778
  mcp-squared init --security=permissive Generate permissive config (allow-all)
762
779
  mcp-squared init --project Generate project-local config
780
+ mcp-squared migrate Apply config migrations to current config file
781
+ mcp-squared migrate --dry-run Preview config migrations without writing
763
782
  mcp-squared import --list List all discovered MCP configs
764
783
  mcp-squared import --dry-run Preview import changes
765
784
  mcp-squared import Import with interactive conflict resolution
@@ -1076,11 +1095,17 @@ var SecuritySchema = z.object({
1076
1095
  });
1077
1096
  var SearchModeSchema = z.enum(["fast", "semantic", "hybrid"]);
1078
1097
  var DetailLevelSchema = z.enum(["L0", "L1", "L2"]);
1098
+ var PreferredNamespacesByIntentSchema = z.object({
1099
+ codeSearch: z.array(z.string().min(1)).default([])
1100
+ });
1079
1101
  var FindToolsSchema = z.object({
1080
1102
  defaultLimit: z.number().int().min(1).default(5),
1081
1103
  maxLimit: z.number().int().min(1).max(200).default(50),
1082
1104
  defaultMode: SearchModeSchema.default("fast"),
1083
- defaultDetailLevel: DetailLevelSchema.default("L1")
1105
+ defaultDetailLevel: DetailLevelSchema.default("L1"),
1106
+ preferredNamespacesByIntent: PreferredNamespacesByIntentSchema.default({
1107
+ codeSearch: []
1108
+ })
1084
1109
  });
1085
1110
  var IndexSchema = z.object({
1086
1111
  refreshIntervalMs: z.number().int().min(1000).default(30000)
@@ -1101,7 +1126,8 @@ var OperationsSchema = z.object({
1101
1126
  defaultLimit: 5,
1102
1127
  maxLimit: 50,
1103
1128
  defaultMode: "fast",
1104
- defaultDetailLevel: "L1"
1129
+ defaultDetailLevel: "L1",
1130
+ preferredNamespacesByIntent: { codeSearch: [] }
1105
1131
  }),
1106
1132
  index: IndexSchema.default({ refreshIntervalMs: 30000 }),
1107
1133
  logging: LoggingSchema.default({ level: "info" }),
@@ -1116,7 +1142,8 @@ var OperationsSchema = z.object({
1116
1142
  defaultLimit: 5,
1117
1143
  maxLimit: 50,
1118
1144
  defaultMode: "fast",
1119
- defaultDetailLevel: "L1"
1145
+ defaultDetailLevel: "L1",
1146
+ preferredNamespacesByIntent: { codeSearch: [] }
1120
1147
  },
1121
1148
  index: { refreshIntervalMs: 30000 },
1122
1149
  logging: { level: "info" },
@@ -1872,18 +1899,32 @@ function readManifestFile(manifestUrl) {
1872
1899
  }
1873
1900
  function readBundledManifestFile() {
1874
1901
  const require2 = createRequire(import.meta.url);
1875
- return require2("../package.json");
1902
+ const candidates = ["../package.json", "../../package.json"];
1903
+ for (const candidate of candidates) {
1904
+ try {
1905
+ return require2(candidate);
1906
+ } catch {}
1907
+ }
1908
+ throw new Error("Unable to resolve bundled package.json");
1909
+ }
1910
+ function getDefaultManifestUrls() {
1911
+ return [
1912
+ new URL("../package.json", import.meta.url),
1913
+ new URL("../../package.json", import.meta.url)
1914
+ ];
1876
1915
  }
1877
1916
  function resolveVersion(options = {}) {
1878
1917
  const readManifest = options.readManifest ?? readManifestFile;
1879
- const manifestUrl = options.manifestUrl ?? new URL("../package.json", import.meta.url);
1880
- try {
1881
- const manifest = readManifest(manifestUrl);
1882
- const manifestVersion = normalizeVersion(manifest.version);
1883
- if (manifestVersion) {
1884
- return manifestVersion;
1885
- }
1886
- } catch {}
1918
+ const manifestUrls = options.manifestUrls ? [...options.manifestUrls] : options.manifestUrl ? [options.manifestUrl] : getDefaultManifestUrls();
1919
+ for (const manifestUrl of manifestUrls) {
1920
+ try {
1921
+ const manifest = readManifest(manifestUrl);
1922
+ const manifestVersion = normalizeVersion(manifest.version);
1923
+ if (manifestVersion) {
1924
+ return manifestVersion;
1925
+ }
1926
+ } catch {}
1927
+ }
1887
1928
  const envVersion = normalizeVersion((options.env ?? process.env)["npm_package_version"]);
1888
1929
  if (envVersion) {
1889
1930
  return envVersion;
@@ -4263,6 +4304,81 @@ Installation cancelled.`);
4263
4304
  process.exit(0);
4264
4305
  }
4265
4306
 
4307
+ // src/migrate/runner.ts
4308
+ import { readFileSync as readFileSync5 } from "fs";
4309
+ import { parse as parseToml4 } from "smol-toml";
4310
+ var DEFAULT_CODE_SEARCH_NAMESPACES = ["auggie", "ctxdb"];
4311
+ function isRecord(value) {
4312
+ return typeof value === "object" && value !== null;
4313
+ }
4314
+ function hasOwn(obj, key) {
4315
+ return Object.hasOwn(obj, key);
4316
+ }
4317
+ function isCodeSearchExplicitlyConfigured(rawConfig) {
4318
+ if (!isRecord(rawConfig)) {
4319
+ return false;
4320
+ }
4321
+ const operations = rawConfig["operations"];
4322
+ if (!isRecord(operations)) {
4323
+ return false;
4324
+ }
4325
+ const findTools = operations["findTools"];
4326
+ if (!isRecord(findTools)) {
4327
+ return false;
4328
+ }
4329
+ const preferredNamespacesByIntent = findTools["preferredNamespacesByIntent"];
4330
+ if (!isRecord(preferredNamespacesByIntent)) {
4331
+ return false;
4332
+ }
4333
+ return hasOwn(preferredNamespacesByIntent, "codeSearch");
4334
+ }
4335
+ function applyCodeSearchPreferenceMigration(config, options) {
4336
+ if (options.codeSearchExplicitlyConfigured) {
4337
+ return { config, changed: false };
4338
+ }
4339
+ return {
4340
+ changed: true,
4341
+ config: {
4342
+ ...config,
4343
+ operations: {
4344
+ ...config.operations,
4345
+ findTools: {
4346
+ ...config.operations.findTools,
4347
+ preferredNamespacesByIntent: {
4348
+ ...config.operations.findTools.preferredNamespacesByIntent,
4349
+ codeSearch: [...DEFAULT_CODE_SEARCH_NAMESPACES]
4350
+ }
4351
+ }
4352
+ }
4353
+ }
4354
+ };
4355
+ }
4356
+ async function runMigrate(args) {
4357
+ const discovered = discoverConfigPath();
4358
+ if (!discovered) {
4359
+ console.error("No configuration file found. Run 'mcp-squared init' to create one first.");
4360
+ process.exit(1);
4361
+ }
4362
+ const loaded = await loadConfigFromPath(discovered.path, discovered.source);
4363
+ const rawConfig = parseToml4(readFileSync5(discovered.path, "utf-8"));
4364
+ const codeSearchExplicitlyConfigured = isCodeSearchExplicitlyConfigured(rawConfig);
4365
+ const { config: migratedConfig, changed } = applyCodeSearchPreferenceMigration(loaded.config, {
4366
+ codeSearchExplicitlyConfigured
4367
+ });
4368
+ if (!changed) {
4369
+ console.log(`No migration needed: code-search preferences already configured in ${discovered.path}`);
4370
+ return;
4371
+ }
4372
+ if (args.dryRun) {
4373
+ console.log(`[dry-run] Would update ${discovered.path}`);
4374
+ console.log('[dry-run] Set operations.findTools.preferredNamespacesByIntent.codeSearch = ["auggie", "ctxdb"]');
4375
+ return;
4376
+ }
4377
+ await saveConfig(discovered.path, migratedConfig);
4378
+ console.log(`Updated ${discovered.path}`);
4379
+ console.log('Set operations.findTools.preferredNamespacesByIntent.codeSearch = ["auggie", "ctxdb"]');
4380
+ }
4381
+
4266
4382
  // src/oauth/browser.ts
4267
4383
  import { spawn as spawn2 } from "child_process";
4268
4384
  async function openBrowser(url) {
@@ -4650,7 +4766,7 @@ import {
4650
4766
  chmodSync,
4651
4767
  existsSync as existsSync8,
4652
4768
  mkdirSync as mkdirSync5,
4653
- readFileSync as readFileSync5,
4769
+ readFileSync as readFileSync6,
4654
4770
  unlinkSync as unlinkSync4,
4655
4771
  writeFileSync as writeFileSync4
4656
4772
  } from "fs";
@@ -4688,7 +4804,7 @@ class TokenStorage {
4688
4804
  return;
4689
4805
  }
4690
4806
  try {
4691
- const content = readFileSync5(filePath, "utf-8");
4807
+ const content = readFileSync6(filePath, "utf-8");
4692
4808
  return JSON.parse(content);
4693
4809
  } catch {
4694
4810
  return;
@@ -4876,6 +4992,15 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4876
4992
  import { StdioServerTransport as StdioServerTransport2 } from "@modelcontextprotocol/sdk/server/stdio.js";
4877
4993
  import { z as z3 } from "zod";
4878
4994
 
4995
+ // agent_safety_kit/guard/errors.ts
4996
+ class PolicyDenied extends Error {
4997
+ decision;
4998
+ constructor(message, decision) {
4999
+ super(message);
5000
+ this.name = "PolicyDenied";
5001
+ this.decision = decision;
5002
+ }
5003
+ }
4879
5004
  // agent_safety_kit/policy/matchers.ts
4880
5005
  var GLOB_SPECIALS = /[.+^${}()|[\]\\]/g;
4881
5006
  var PATH_KEY_RE = /(path|file|dir|cwd|workspace|root)/i;
@@ -5013,16 +5138,6 @@ function valuesConstrainedByGlob(values, allowlist) {
5013
5138
  return values.every((value) => matchesAnyGlob(allowlist, value));
5014
5139
  }
5015
5140
 
5016
- // agent_safety_kit/guard/errors.ts
5017
- class PolicyDenied extends Error {
5018
- decision;
5019
- constructor(message, decision) {
5020
- super(message);
5021
- this.name = "PolicyDenied";
5022
- this.decision = decision;
5023
- }
5024
- }
5025
-
5026
5141
  // agent_safety_kit/guard/ratelimit.ts
5027
5142
  class SlidingWindowRateLimiter {
5028
5143
  requests = new Map;
@@ -5232,7 +5347,7 @@ class Guard {
5232
5347
  }
5233
5348
  }
5234
5349
  // agent_safety_kit/policy/load.ts
5235
- import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
5350
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
5236
5351
  import { resolve as resolve3 } from "path";
5237
5352
  import { parse as parseYaml } from "yaml";
5238
5353
 
@@ -5295,7 +5410,7 @@ function load_policy(options = {}) {
5295
5410
  if (!existsSync9(sourcePath)) {
5296
5411
  throw new Error(`Agent safety policy file not found at ${sourcePath}`);
5297
5412
  }
5298
- const raw = readFileSync6(sourcePath, "utf8");
5413
+ const raw = readFileSync7(sourcePath, "utf8");
5299
5414
  const parsed = parseYaml(raw);
5300
5415
  const policy = AgentPolicySchema.parse(parsed);
5301
5416
  const playbookName = options.playbook ?? envConfig.playbook;
@@ -5315,6 +5430,7 @@ function load_policy(options = {}) {
5315
5430
  rules: playbook.rules
5316
5431
  };
5317
5432
  }
5433
+
5318
5434
  // agent_safety_kit/observability/sinks/null.ts
5319
5435
  class NullSpan {
5320
5436
  setAttributes(_attributes) {}
@@ -7753,11 +7869,80 @@ class McpSquaredServer {
7753
7869
  this.registerMetaTools(this.mcpServer);
7754
7870
  }
7755
7871
  createMcpServer(name, version) {
7756
- return new McpServer({ name, version }, {
7872
+ return new McpServer({
7873
+ name,
7874
+ version,
7875
+ title: "MCP\xB2 Meta Router",
7876
+ description: "Discover and execute tools across upstream MCP servers through a compact meta-tool interface."
7877
+ }, {
7757
7878
  capabilities: {
7758
7879
  tools: {}
7880
+ },
7881
+ instructions: this.buildServerInstructions()
7882
+ });
7883
+ }
7884
+ buildServerInstructions() {
7885
+ const codeSearchNamespaces = this.getCodeSearchNamespaces();
7886
+ const codeSearchHint = codeSearchNamespaces.length > 0 ? ` Prefer these configured code-search namespaces when relevant: ${codeSearchNamespaces.join(", ")}.` : "";
7887
+ return [
7888
+ "Use discovery-first workflow: call `find_tools` before defaulting to local shell discovery (for example `grep`/`rg`).",
7889
+ `For codebase lookup tasks, start with \`find_tools\` queries such as "code search", "find symbol", or "references".${codeSearchHint}`,
7890
+ "After choosing a candidate, call `describe_tools` to confirm schema, then call `execute` with a qualified tool name (`namespace:tool_name`).",
7891
+ "If capabilities are unclear or a name is ambiguous, call `list_namespaces`."
7892
+ ].join(" ");
7893
+ }
7894
+ getCodeSearchNamespaces() {
7895
+ const configured = this.config.operations.findTools.preferredNamespacesByIntent.codeSearch;
7896
+ const upstreamKeys = Object.keys(this.config.upstreams);
7897
+ if (configured.length > 0) {
7898
+ const deduped = [...new Set(configured)];
7899
+ const present = deduped.filter((ns) => upstreamKeys.includes(ns));
7900
+ return present.length > 0 ? present : deduped;
7901
+ }
7902
+ return upstreamKeys.filter((key) => /(auggie|augment|code|source|repo|search)/i.test(key));
7903
+ }
7904
+ isCodeSearchQuery(query) {
7905
+ return /\b(codebase|source code|repository|repo)\b/i.test(query) || /\b(code search|search code|find symbol|symbol lookup|definition|references?|usages?)\b/i.test(query);
7906
+ }
7907
+ rankToolsForQuery(tools, query) {
7908
+ if (!this.isCodeSearchQuery(query) || tools.length < 2) {
7909
+ return tools;
7910
+ }
7911
+ const preferredNamespaces = new Set(this.getCodeSearchNamespaces());
7912
+ const scored = tools.map((tool, index) => {
7913
+ const haystack = `${tool.serverKey} ${tool.name} ${tool.description ?? ""}`.toLowerCase();
7914
+ let score = 0;
7915
+ if (preferredNamespaces.has(tool.serverKey))
7916
+ score += 100;
7917
+ if (/(auggie|augment)/i.test(tool.serverKey))
7918
+ score += 40;
7919
+ if (/\b(search|find|query|lookup)\b/.test(haystack))
7920
+ score += 15;
7921
+ if (/\b(code|symbol|definition|reference|repo|repository|source)\b/.test(haystack)) {
7922
+ score += 20;
7923
+ }
7924
+ return { tool, index, score };
7925
+ });
7926
+ scored.sort((a, b) => {
7927
+ if (a.score === b.score) {
7928
+ return a.index - b.index;
7759
7929
  }
7930
+ return b.score - a.score;
7760
7931
  });
7932
+ return scored.map((entry) => entry.tool);
7933
+ }
7934
+ buildFindToolsGuidance(query) {
7935
+ const guidance = {
7936
+ nextStep: "Use describe_tools for selected candidates, then execute with a qualified name."
7937
+ };
7938
+ if (this.isCodeSearchQuery(query)) {
7939
+ const preferredNamespaces = this.getCodeSearchNamespaces();
7940
+ if (preferredNamespaces.length > 0) {
7941
+ guidance.preferredNamespaces = preferredNamespaces;
7942
+ guidance.note = "For codebase exploration, prefer these namespaces before local shell grep/rg.";
7943
+ }
7944
+ }
7945
+ return guidance;
7761
7946
  }
7762
7947
  createSessionServer() {
7763
7948
  const server = this.createMcpServer(this.serverName, this.serverVersion);
@@ -7783,9 +7968,14 @@ class McpSquaredServer {
7783
7968
  }
7784
7969
  registerMetaTools(server) {
7785
7970
  server.registerTool("find_tools", {
7786
- description: "Search for available tools across all connected upstream MCP servers. Returns a list of tool summaries matching the query.",
7971
+ title: "Discover Upstream Tools",
7972
+ description: "Call this first for capability discovery. Search available tools across all connected upstream MCP servers and return ranked tool summaries matching the query.",
7973
+ annotations: {
7974
+ readOnlyHint: true,
7975
+ openWorldHint: false
7976
+ },
7787
7977
  inputSchema: {
7788
- query: z3.string().describe("Natural language search query to find relevant tools"),
7978
+ query: z3.string().describe('Natural language query describing the task (for example: "code search", "find symbol", "create issue")'),
7789
7979
  limit: z3.number().int().min(1).max(this.maxLimit).default(this.config.operations.findTools.defaultLimit).describe("Maximum number of results to return"),
7790
7980
  mode: SearchModeSchema.optional().describe('Search mode: "fast" (FTS5), "semantic" (embeddings), or "hybrid" (FTS5 + rerank)'),
7791
7981
  detail_level: DetailLevelSchema.optional().describe('Level of detail: "L0" (name only), "L1" (summary with description, default), "L2" (full schema)')
@@ -7800,8 +7990,10 @@ class McpSquaredServer {
7800
7990
  mode: args.mode
7801
7991
  });
7802
7992
  const filteredTools = this.filterToolsByPolicy(result.tools);
7993
+ const rankedTools = this.rankToolsForQuery(filteredTools, args.query);
7803
7994
  const detailLevel = args.detail_level ?? this.config.operations.findTools.defaultDetailLevel;
7804
- const tools = this.formatToolsForDetailLevel(filteredTools, detailLevel);
7995
+ const tools = this.formatToolsForDetailLevel(rankedTools, detailLevel);
7996
+ const guidance = this.buildFindToolsGuidance(args.query);
7805
7997
  const selectionCacheConfig = this.config.operations.selectionCache;
7806
7998
  let suggestedTools;
7807
7999
  if (selectionCacheConfig.enabled && selectionCacheConfig.maxBundleSuggestions > 0) {
@@ -7826,6 +8018,7 @@ class McpSquaredServer {
7826
8018
  searchMode: result.searchMode,
7827
8019
  embeddingsAvailable: this.retriever.hasEmbeddings(),
7828
8020
  tools,
8021
+ guidance,
7829
8022
  ...suggestedTools && { suggestedTools }
7830
8023
  })
7831
8024
  }
@@ -7837,7 +8030,12 @@ class McpSquaredServer {
7837
8030
  }
7838
8031
  }));
7839
8032
  server.registerTool("describe_tools", {
7840
- description: "Get full JSON schemas for the specified tools. Use this after find_tools to get detailed parameter information before calling a tool.",
8033
+ title: "Inspect Tool Schemas",
8034
+ description: "After find_tools, fetch full JSON schemas for selected tools to validate required arguments before execution.",
8035
+ annotations: {
8036
+ readOnlyHint: true,
8037
+ openWorldHint: false
8038
+ },
7841
8039
  inputSchema: {
7842
8040
  tool_names: z3.array(z3.string()).min(1).max(20).describe("List of tool names to get schemas for")
7843
8041
  }
@@ -7883,7 +8081,13 @@ class McpSquaredServer {
7883
8081
  }
7884
8082
  }));
7885
8083
  server.registerTool("execute", {
7886
- description: "Execute a tool on an upstream MCP server. The tool must exist and the arguments must match its schema.",
8084
+ title: "Execute Upstream Tool",
8085
+ description: "Execute an upstream tool after find_tools/describe_tools selection. The tool must exist and the arguments must match its schema.",
8086
+ annotations: {
8087
+ readOnlyHint: false,
8088
+ destructiveHint: false,
8089
+ openWorldHint: true
8090
+ },
7887
8091
  inputSchema: {
7888
8092
  tool_name: z3.string().describe("Name of the tool to execute"),
7889
8093
  arguments: z3.record(z3.string(), z3.unknown()).default({}).describe("Arguments to pass to the tool"),
@@ -8014,7 +8218,14 @@ class McpSquaredServer {
8014
8218
  }
8015
8219
  }));
8016
8220
  server.registerTool("clear_selection_cache", {
8221
+ title: "Reset Selection Cache",
8017
8222
  description: "Clears all learned tool co-occurrence patterns. Use this to reset the selection cache if suggestions become stale or irrelevant.",
8223
+ annotations: {
8224
+ readOnlyHint: false,
8225
+ destructiveHint: true,
8226
+ idempotentHint: true,
8227
+ openWorldHint: false
8228
+ },
8018
8229
  inputSchema: {}
8019
8230
  }, async () => this.runTaskSpan("clear_selection_cache", async () => {
8020
8231
  const requestId = this.statsCollector.startRequest();
@@ -8042,7 +8253,12 @@ class McpSquaredServer {
8042
8253
  }
8043
8254
  }));
8044
8255
  server.registerTool("list_namespaces", {
8045
- description: "Lists all available namespaces (upstream MCP servers). Use this to discover available servers and understand which namespaces are available when disambiguating tool names with qualified format (namespace:tool_name).",
8256
+ title: "List Upstream Namespaces",
8257
+ description: "List available namespaces (upstream MCP servers) and optional tool names. Use this to discover routing options and disambiguate qualified names (namespace:tool_name).",
8258
+ annotations: {
8259
+ readOnlyHint: true,
8260
+ openWorldHint: false
8261
+ },
8046
8262
  inputSchema: {
8047
8263
  include_tools: z3.boolean().default(false).describe("If true, includes the list of tool names available in each namespace")
8048
8264
  }
@@ -8931,6 +9147,9 @@ async function main(argv = process.argv.slice(2)) {
8931
9147
  await runInit2(args.init);
8932
9148
  break;
8933
9149
  }
9150
+ case "migrate":
9151
+ await runMigrate(args.migrate);
9152
+ break;
8934
9153
  case "monitor":
8935
9154
  await runMonitor(args.monitor);
8936
9155
  break;
@@ -434,11 +434,17 @@ var SecuritySchema = z.object({
434
434
  });
435
435
  var SearchModeSchema = z.enum(["fast", "semantic", "hybrid"]);
436
436
  var DetailLevelSchema = z.enum(["L0", "L1", "L2"]);
437
+ var PreferredNamespacesByIntentSchema = z.object({
438
+ codeSearch: z.array(z.string().min(1)).default([])
439
+ });
437
440
  var FindToolsSchema = z.object({
438
441
  defaultLimit: z.number().int().min(1).default(5),
439
442
  maxLimit: z.number().int().min(1).max(200).default(50),
440
443
  defaultMode: SearchModeSchema.default("fast"),
441
- defaultDetailLevel: DetailLevelSchema.default("L1")
444
+ defaultDetailLevel: DetailLevelSchema.default("L1"),
445
+ preferredNamespacesByIntent: PreferredNamespacesByIntentSchema.default({
446
+ codeSearch: []
447
+ })
442
448
  });
443
449
  var IndexSchema = z.object({
444
450
  refreshIntervalMs: z.number().int().min(1000).default(30000)
@@ -459,7 +465,8 @@ var OperationsSchema = z.object({
459
465
  defaultLimit: 5,
460
466
  maxLimit: 50,
461
467
  defaultMode: "fast",
462
- defaultDetailLevel: "L1"
468
+ defaultDetailLevel: "L1",
469
+ preferredNamespacesByIntent: { codeSearch: [] }
463
470
  }),
464
471
  index: IndexSchema.default({ refreshIntervalMs: 30000 }),
465
472
  logging: LoggingSchema.default({ level: "info" }),
@@ -474,7 +481,8 @@ var OperationsSchema = z.object({
474
481
  defaultLimit: 5,
475
482
  maxLimit: 50,
476
483
  defaultMode: "fast",
477
- defaultDetailLevel: "L1"
484
+ defaultDetailLevel: "L1",
485
+ preferredNamespacesByIntent: { codeSearch: [] }
478
486
  },
479
487
  index: { refreshIntervalMs: 30000 },
480
488
  logging: { level: "info" },
@@ -1006,18 +1014,32 @@ function readManifestFile(manifestUrl) {
1006
1014
  }
1007
1015
  function readBundledManifestFile() {
1008
1016
  const require2 = createRequire(import.meta.url);
1009
- return require2("../package.json");
1017
+ const candidates = ["../package.json", "../../package.json"];
1018
+ for (const candidate of candidates) {
1019
+ try {
1020
+ return require2(candidate);
1021
+ } catch {}
1022
+ }
1023
+ throw new Error("Unable to resolve bundled package.json");
1024
+ }
1025
+ function getDefaultManifestUrls() {
1026
+ return [
1027
+ new URL("../package.json", import.meta.url),
1028
+ new URL("../../package.json", import.meta.url)
1029
+ ];
1010
1030
  }
1011
1031
  function resolveVersion(options = {}) {
1012
1032
  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 {}
1033
+ const manifestUrls = options.manifestUrls ? [...options.manifestUrls] : options.manifestUrl ? [options.manifestUrl] : getDefaultManifestUrls();
1034
+ for (const manifestUrl of manifestUrls) {
1035
+ try {
1036
+ const manifest = readManifest(manifestUrl);
1037
+ const manifestVersion = normalizeVersion(manifest.version);
1038
+ if (manifestVersion) {
1039
+ return manifestVersion;
1040
+ }
1041
+ } catch {}
1042
+ }
1021
1043
  const envVersion = normalizeVersion((options.env ?? process.env)["npm_package_version"]);
1022
1044
  if (envVersion) {
1023
1045
  return envVersion;
@@ -2241,6 +2263,132 @@ async function testUpstreamConnection(name, config, options = {}) {
2241
2263
  log("Done");
2242
2264
  }
2243
2265
  }
2266
+ // src/tui/upstream-edit.ts
2267
+ function hasNameConflict(upstreams, nextName, existingName) {
2268
+ return Boolean(existingName && nextName !== existingName && typeof upstreams[nextName] !== "undefined");
2269
+ }
2270
+ function parseKeyValuePairsInput(input) {
2271
+ const result = {};
2272
+ if (!input.trim())
2273
+ return result;
2274
+ const pairs = input.split(",").map((s) => s.trim()).filter(Boolean);
2275
+ for (const pair of pairs) {
2276
+ const eqIndex = pair.indexOf("=");
2277
+ if (eqIndex > 0) {
2278
+ const key = pair.substring(0, eqIndex).trim();
2279
+ const value = pair.substring(eqIndex + 1).trim();
2280
+ if (key) {
2281
+ result[key] = value;
2282
+ }
2283
+ }
2284
+ }
2285
+ return result;
2286
+ }
2287
+ function stringifyKeyValuePairsInput(pairs) {
2288
+ const entries = Object.entries(pairs || {});
2289
+ if (entries.length === 0) {
2290
+ return "";
2291
+ }
2292
+ return entries.map(([key, value]) => `${key}=${value}`).join(", ");
2293
+ }
2294
+ function saveStdioUpstreamFromForm(input) {
2295
+ const trimmedName = input.name.trim();
2296
+ const trimmedCommand = input.commandLine.trim();
2297
+ if (!trimmedName) {
2298
+ return { ok: false, reason: "name_required" };
2299
+ }
2300
+ if (!trimmedCommand) {
2301
+ return { ok: false, reason: "command_required" };
2302
+ }
2303
+ if (hasNameConflict(input.upstreams, trimmedName, input.existingName)) {
2304
+ return { ok: false, reason: "name_conflict" };
2305
+ }
2306
+ const parts = trimmedCommand.split(/\s+/);
2307
+ const command = parts[0] || "";
2308
+ const args = parts.slice(1);
2309
+ const env = parseKeyValuePairsInput(input.envInput);
2310
+ if (input.existingName && input.existingName !== trimmedName) {
2311
+ delete input.upstreams[input.existingName];
2312
+ }
2313
+ input.upstreams[trimmedName] = {
2314
+ transport: "stdio",
2315
+ enabled: input.existingUpstream?.enabled ?? true,
2316
+ env,
2317
+ stdio: { command, args }
2318
+ };
2319
+ return { ok: true, savedName: trimmedName };
2320
+ }
2321
+ function saveSseUpstreamFromForm(input) {
2322
+ const trimmedName = input.name.trim();
2323
+ const trimmedUrl = input.url.trim();
2324
+ if (!trimmedName) {
2325
+ return { ok: false, reason: "name_required" };
2326
+ }
2327
+ if (!trimmedUrl) {
2328
+ return { ok: false, reason: "url_required" };
2329
+ }
2330
+ try {
2331
+ new URL(trimmedUrl);
2332
+ } catch {
2333
+ return { ok: false, reason: "invalid_url" };
2334
+ }
2335
+ if (hasNameConflict(input.upstreams, trimmedName, input.existingName)) {
2336
+ return { ok: false, reason: "name_conflict" };
2337
+ }
2338
+ const headers = parseKeyValuePairsInput(input.headersInput);
2339
+ const env = parseKeyValuePairsInput(input.envInput);
2340
+ if (input.existingName && input.existingName !== trimmedName) {
2341
+ delete input.upstreams[input.existingName];
2342
+ }
2343
+ input.upstreams[trimmedName] = {
2344
+ transport: "sse",
2345
+ enabled: input.existingUpstream?.enabled ?? true,
2346
+ env,
2347
+ sse: {
2348
+ url: trimmedUrl,
2349
+ headers,
2350
+ auth: input.authEnabled ? true : undefined
2351
+ }
2352
+ };
2353
+ return { ok: true, savedName: trimmedName };
2354
+ }
2355
+ function deleteUpstreamByName(upstreams, name) {
2356
+ if (typeof upstreams[name] === "undefined") {
2357
+ return false;
2358
+ }
2359
+ delete upstreams[name];
2360
+ return true;
2361
+ }
2362
+ function getUpstreamEditMenuOptions(upstream) {
2363
+ return [
2364
+ {
2365
+ name: "Edit Configuration",
2366
+ description: "Update connection details and environment",
2367
+ value: "edit"
2368
+ },
2369
+ {
2370
+ name: "Test Connection",
2371
+ description: "Connect and list available tools",
2372
+ value: "test"
2373
+ },
2374
+ {
2375
+ name: upstream.enabled ? "Disable" : "Enable",
2376
+ description: upstream.enabled ? "Stop using this upstream" : "Start using this upstream",
2377
+ value: "toggle"
2378
+ },
2379
+ {
2380
+ name: "Delete",
2381
+ description: "Remove this upstream configuration",
2382
+ value: "delete"
2383
+ },
2384
+ {
2385
+ name: "\u2190 Back",
2386
+ description: "",
2387
+ value: "back"
2388
+ }
2389
+ ];
2390
+ }
2391
+
2244
2392
  // src/tui/config.ts
2245
2393
  var PROJECT_DESCRIPTION = "Mercury Control Plane";
2246
2394
  async function runConfigTui() {
@@ -2572,12 +2720,13 @@ class ConfigTuiApp {
2572
2720
  menu.focus();
2573
2721
  this.addInstructions("\u2191\u2193 Navigate | Enter Select | Esc Back");
2574
2722
  }
2575
- showAddStdioScreen() {
2723
+ showAddStdioScreen(existingName, existingUpstream) {
2576
2724
  this.state.currentScreen = "add-stdio";
2577
2725
  this.clearScreen();
2578
2726
  this.addHeader();
2579
2727
  if (!this.container)
2580
2728
  return;
2729
+ const isEditMode = Boolean(existingName && existingUpstream);
2581
2730
  const formBox = new BoxRenderable(this.renderer, {
2582
2731
  id: "add-stdio-box",
2583
2732
  width: 60,
@@ -2585,7 +2734,7 @@ class ConfigTuiApp {
2585
2734
  border: true,
2586
2735
  borderStyle: "single",
2587
2736
  borderColor: "#475569",
2588
- title: "Add Stdio Upstream",
2737
+ title: isEditMode ? `Edit Stdio Upstream: ${existingName}` : "Add Stdio Upstream",
2589
2738
  titleAlignment: "center",
2590
2739
  backgroundColor: "#1e293b",
2591
2740
  flexDirection: "column",
@@ -2612,6 +2761,7 @@ class ConfigTuiApp {
2612
2761
  }
2613
2762
  });
2614
2763
  formBox.add(nameInput);
2764
+ nameInput.value = existingName ?? "";
2615
2765
  const commandLabel = new TextRenderable(this.renderer, {
2616
2766
  id: "command-label",
2617
2767
  content: "Command (with arguments):",
@@ -2631,6 +2781,7 @@ class ConfigTuiApp {
2631
2781
  }
2632
2782
  });
2633
2783
  formBox.add(commandInput);
2784
+ commandInput.value = existingUpstream ? [existingUpstream.stdio.command, ...existingUpstream.stdio.args].filter(Boolean).join(" ") : "";
2634
2785
  const envLabel = new TextRenderable(this.renderer, {
2635
2786
  id: "env-label",
2636
2787
  content: "Environment variables (optional, comma-separated):",
@@ -2651,6 +2802,7 @@ class ConfigTuiApp {
2651
2802
  }
2652
2803
  });
2653
2804
  formBox.add(envInput);
2805
+ envInput.value = stringifyKeyValuePairsInput(existingUpstream?.env);
2654
2806
  const submitOptions = [
2655
2807
  { name: "[ Save Upstream ]", description: "", value: "save" },
2656
2808
  { name: "[ Cancel ]", description: "", value: "cancel" }
@@ -2675,44 +2827,28 @@ class ConfigTuiApp {
2675
2827
  if (field)
2676
2828
  field.focus();
2677
2829
  };
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
2830
  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;
2831
+ const result = saveStdioUpstreamFromForm({
2832
+ upstreams: this.state.config.upstreams,
2833
+ name: nameInput.value || "",
2834
+ commandLine: commandInput.value || "",
2835
+ envInput: envInput.value || "",
2836
+ existingName: isEditMode ? existingName : undefined,
2837
+ existingUpstream
2838
+ });
2839
+ if (!result.ok) {
2840
+ switch (result.reason) {
2841
+ case "name_required":
2842
+ case "name_conflict":
2843
+ nameInput.focus();
2844
+ return;
2845
+ case "command_required":
2846
+ commandInput.focus();
2847
+ return;
2848
+ default:
2849
+ return;
2850
+ }
2705
2851
  }
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
2852
  this.state.isDirty = true;
2717
2853
  cleanup();
2718
2854
  this.showUpstreamsScreen();
@@ -2722,13 +2858,21 @@ class ConfigTuiApp {
2722
2858
  saveUpstream();
2723
2859
  } else {
2724
2860
  cleanup();
2725
- this.showAddUpstreamScreen();
2861
+ if (isEditMode && existingName) {
2862
+ this.showEditUpstreamScreen(existingName);
2863
+ } else {
2864
+ this.showAddUpstreamScreen();
2865
+ }
2726
2866
  }
2727
2867
  });
2728
2868
  const handleKeypress = (key) => {
2729
2869
  if (key.name === "escape") {
2730
2870
  cleanup();
2731
- this.showAddUpstreamScreen();
2871
+ if (isEditMode && existingName) {
2872
+ this.showEditUpstreamScreen(existingName);
2873
+ } else {
2874
+ this.showAddUpstreamScreen();
2875
+ }
2732
2876
  return;
2733
2877
  }
2734
2878
  if (key.name === "tab" && !key.shift) {
@@ -2745,14 +2889,15 @@ class ConfigTuiApp {
2745
2889
  };
2746
2890
  this.renderer.keyInput.on("keypress", handleKeypress);
2747
2891
  focusField(0);
2748
- this.addInstructions("Tab: next field | Shift+Tab: prev | Esc: cancel");
2892
+ this.addInstructions("Tab: next field | Shift+Tab: prev | Esc: back");
2749
2893
  }
2750
- showAddSseScreen() {
2894
+ showAddSseScreen(existingName, existingUpstream) {
2751
2895
  this.state.currentScreen = "add-sse";
2752
2896
  this.clearScreen();
2753
2897
  this.addHeader();
2754
2898
  if (!this.container)
2755
2899
  return;
2900
+ const isEditMode = Boolean(existingName && existingUpstream);
2756
2901
  const formBox = new BoxRenderable(this.renderer, {
2757
2902
  id: "add-sse-box",
2758
2903
  width: 60,
@@ -2760,7 +2905,7 @@ class ConfigTuiApp {
2760
2905
  border: true,
2761
2906
  borderStyle: "single",
2762
2907
  borderColor: "#475569",
2763
- title: "Add HTTP/SSE Upstream",
2908
+ title: isEditMode ? `Edit HTTP/SSE Upstream: ${existingName}` : "Add HTTP/SSE Upstream",
2764
2909
  titleAlignment: "center",
2765
2910
  backgroundColor: "#1e293b",
2766
2911
  flexDirection: "column",
@@ -2787,6 +2932,7 @@ class ConfigTuiApp {
2787
2932
  }
2788
2933
  });
2789
2934
  formBox.add(nameInput);
2935
+ nameInput.value = existingName ?? "";
2790
2936
  const urlLabel = new TextRenderable(this.renderer, {
2791
2937
  id: "url-label",
2792
2938
  content: "Server URL:",
@@ -2806,6 +2952,7 @@ class ConfigTuiApp {
2806
2952
  }
2807
2953
  });
2808
2954
  formBox.add(urlInput);
2955
+ urlInput.value = existingUpstream?.sse.url ?? "";
2809
2956
  const headersLabel = new TextRenderable(this.renderer, {
2810
2957
  id: "headers-label",
2811
2958
  content: "HTTP Headers (optional, comma-separated):",
@@ -2826,6 +2973,7 @@ class ConfigTuiApp {
2826
2973
  }
2827
2974
  });
2828
2975
  formBox.add(headersInput);
2976
+ headersInput.value = stringifyKeyValuePairsInput(existingUpstream?.sse.headers);
2829
2977
  const authLabel = new TextRenderable(this.renderer, {
2830
2978
  id: "auth-label",
2831
2979
  content: "OAuth Authentication:",
@@ -2833,7 +2981,14 @@ class ConfigTuiApp {
2833
2981
  marginBottom: 0
2834
2982
  });
2835
2983
  formBox.add(authLabel);
2836
- const authOptions = [
2984
+ const authOptions = existingUpstream?.sse.auth ? [
2985
+ {
2986
+ name: "Enabled (default port 8089)",
2987
+ description: "",
2988
+ value: "enabled"
2989
+ },
2990
+ { name: "Disabled", description: "", value: "disabled" }
2991
+ ] : [
2837
2992
  { name: "Disabled", description: "", value: "disabled" },
2838
2993
  {
2839
2994
  name: "Enabled (default port 8089)",
@@ -2874,6 +3029,7 @@ class ConfigTuiApp {
2874
3029
  }
2875
3030
  });
2876
3031
  formBox.add(envInput);
3032
+ envInput.value = stringifyKeyValuePairsInput(existingUpstream?.env);
2877
3033
  const submitOptions = [
2878
3034
  { name: "[ Save Upstream ]", description: "", value: "save" },
2879
3035
  { name: "[ Cancel ]", description: "", value: "cancel" }
@@ -2899,63 +3055,41 @@ class ConfigTuiApp {
2899
3055
  submitSelect
2900
3056
  ];
2901
3057
  let focusIndex = 0;
2902
- let selectedAuthIndex = 0;
3058
+ let selectedAuthValue = String(authOptions[0]?.value ?? "disabled");
2903
3059
  const focusField = (index) => {
2904
3060
  focusIndex = index;
2905
3061
  const field = fields[index];
2906
3062
  if (field)
2907
3063
  field.focus();
2908
3064
  };
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
- };
3065
+ authSelect.on(SelectRenderableEvents.ITEM_SELECTED, (_index, opt) => {
3066
+ selectedAuthValue = String(opt.value);
3067
+ });
2929
3068
  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
3069
+ const result = saveSseUpstreamFromForm({
3070
+ upstreams: this.state.config.upstreams,
3071
+ name: nameInput.value || "",
3072
+ url: urlInput.value || "",
3073
+ headersInput: headersInput.value || "",
3074
+ envInput: envInput.value || "",
3075
+ authEnabled: selectedAuthValue === "enabled",
3076
+ existingName: isEditMode ? existingName : undefined,
3077
+ existingUpstream
3078
+ });
3079
+ if (!result.ok) {
3080
+ switch (result.reason) {
3081
+ case "name_required":
3082
+ case "name_conflict":
3083
+ nameInput.focus();
3084
+ return;
3085
+ case "url_required":
3086
+ case "invalid_url":
3087
+ urlInput.focus();
3088
+ return;
3089
+ default:
3090
+ return;
2957
3091
  }
2958
- };
3092
+ }
2959
3093
  this.state.isDirty = true;
2960
3094
  cleanup();
2961
3095
  this.showUpstreamsScreen();
@@ -2965,13 +3099,21 @@ class ConfigTuiApp {
2965
3099
  saveUpstream();
2966
3100
  } else {
2967
3101
  cleanup();
2968
- this.showAddUpstreamScreen();
3102
+ if (isEditMode && existingName) {
3103
+ this.showEditUpstreamScreen(existingName);
3104
+ } else {
3105
+ this.showAddUpstreamScreen();
3106
+ }
2969
3107
  }
2970
3108
  });
2971
3109
  const handleKeypress = (key) => {
2972
3110
  if (key.name === "escape") {
2973
3111
  cleanup();
2974
- this.showAddUpstreamScreen();
3112
+ if (isEditMode && existingName) {
3113
+ this.showEditUpstreamScreen(existingName);
3114
+ } else {
3115
+ this.showAddUpstreamScreen();
3116
+ }
2975
3117
  return;
2976
3118
  }
2977
3119
  if (key.name === "tab" && !key.shift) {
@@ -2988,7 +3130,7 @@ class ConfigTuiApp {
2988
3130
  };
2989
3131
  this.renderer.keyInput.on("keypress", handleKeypress);
2990
3132
  focusField(0);
2991
- this.addInstructions("Tab: next field | Shift+Tab: prev | Esc: cancel");
3133
+ this.addInstructions("Tab: next field | Shift+Tab: prev | Esc: back");
2992
3134
  }
2993
3135
  showEditUpstreamScreen(name) {
2994
3136
  this.state.currentScreen = "edit-upstream";
@@ -3091,28 +3233,7 @@ class ConfigTuiApp {
3091
3233
  });
3092
3234
  menuBox.add(noEnvText);
3093
3235
  }
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
- ];
3236
+ const options = getUpstreamEditMenuOptions(upstream);
3116
3237
  const menu = new SelectRenderable(this.renderer, {
3117
3238
  id: "edit-menu",
3118
3239
  width: "100%",
@@ -3129,6 +3250,13 @@ class ConfigTuiApp {
3129
3250
  menuBox.add(menu);
3130
3251
  menu.on(SelectRenderableEvents.ITEM_SELECTED, (_index, option) => {
3131
3252
  switch (option.value) {
3253
+ case "edit":
3254
+ if (upstream.transport === "stdio") {
3255
+ this.showAddStdioScreen(name, upstream);
3256
+ } else {
3257
+ this.showAddSseScreen(name, upstream);
3258
+ }
3259
+ break;
3132
3260
  case "test":
3133
3261
  this.showTestScreen(name, upstream);
3134
3262
  break;
@@ -3138,8 +3266,7 @@ class ConfigTuiApp {
3138
3266
  this.showEditUpstreamScreen(name);
3139
3267
  break;
3140
3268
  case "delete":
3141
- delete this.state.config.upstreams[name];
3142
- this.state.isDirty = true;
3269
+ this.state.isDirty = deleteUpstreamByName(this.state.config.upstreams, name) || this.state.isDirty;
3143
3270
  this.showUpstreamsScreen();
3144
3271
  break;
3145
3272
  case "back":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-squared",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "MCP² (Mercury Control Plane) - A local-first meta-server and proxy for the Model Context Protocol",
5
5
  "author": "aditzel",
6
6
  "license": "Apache-2.0",
@@ -28,6 +28,7 @@
28
28
  "test": "bun test",
29
29
  "test:fast": "SKIP_SLOW_TESTS=true bun test",
30
30
  "test:watch": "bun test --watch",
31
+ "coverage:check": "bun run scripts/check-line-coverage.ts coverage/coverage-summary.txt 80",
31
32
  "typecheck": "tsc --noEmit",
32
33
  "lint": "biome check src tests scripts AGENTS.md CLAUDE.md WARP.md README.md CHANGELOG.md package.json biome.json tsconfig.json",
33
34
  "release:check": "bun run audit && bun test && bun run build && bun run build:verify && bun run lint && bun run typecheck && bun pm pack --dry-run",
@@ -35,7 +36,8 @@
35
36
  "lint:fix": "biome check --write .",
36
37
  "format": "biome format --write .",
37
38
  "clean": "rm -rf dist",
38
- "safety:sim": "bun run agent_safety_kit/cost_model/simulate.ts --tasks agent_safety_kit/cost_model/tasks.csv --pricing agent_safety_kit/cost_model/pricing.csv --out agent_safety_kit/cost_model/report.md"
39
+ "safety:sim": "bun run agent_safety_kit/cost_model/simulate.ts --tasks agent_safety_kit/cost_model/tasks.csv --pricing agent_safety_kit/cost_model/pricing.csv --out agent_safety_kit/cost_model/report.md",
40
+ "eval:routing": "bun run scripts/eval-tool-routing.ts"
39
41
  },
40
42
  "devDependencies": {
41
43
  "@biomejs/biome": "^2.4.4",