metheus-governance-mcp-cli 0.2.36 → 0.2.38

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.
Files changed (3) hide show
  1. package/README.md +28 -0
  2. package/cli.mjs +233 -4
  3. package/package.json +3 -1
package/README.md CHANGED
@@ -103,6 +103,11 @@ Antigravity note:
103
103
  Cursor note:
104
104
  - this CLI manages MCP registration via Cursor global MCP config (`~/.cursor/mcp.json`).
105
105
 
106
+ Tool naming compatibility:
107
+ - Claude/Codex/Gemini keep canonical MCP tool names (`project.summary`, `ctxpack.merge.brief`, ...).
108
+ - Cursor/Antigravity sessions may receive safe aliases (`project_summary`, `ctxpack_merge_brief`, ...).
109
+ - Proxy maps alias calls back to canonical names automatically.
110
+
106
111
  Local bootstrap tools exposed by proxy:
107
112
 
108
113
  - `project.summary`
@@ -239,6 +244,7 @@ npm publish --access public
239
244
 
240
245
  ```bash
241
246
  npm run check
247
+ npm run test:compat
242
248
  npm run publish:dry
243
249
  ```
244
250
 
@@ -255,3 +261,25 @@ If npm account uses 2FA, pass OTP:
255
261
  ```bash
256
262
  node release.mjs --otp <6-digit-code>
257
263
  ```
264
+
265
+ ## Regression and smoke checks
266
+
267
+ Local compatibility selftest (no network):
268
+
269
+ ```bash
270
+ npm run test:compat
271
+ ```
272
+
273
+ Proxy smoke test (initialize -> tools/list -> project summary -> ctxpack local sync):
274
+
275
+ ```bash
276
+ npm run smoke:proxy -- --project-id <project_uuid> --ctxpack-key "<ctxpack_key>" --workspace-dir <workspace_path>
277
+ ```
278
+
279
+ Optional:
280
+ - `--client cursor-vscode` (default) or `--client antigravity`
281
+ - `--base-url https://metheus.gesiaplatform.com/governance/mcp`
282
+
283
+ Workspace fallback guardrail:
284
+ - If `METHEUS_WORKSPACE_DIR` is accidentally set to a non-existing boolean suffix path like `C:\code_test\true`,
285
+ proxy now recovers to the parent workspace (`C:\code_test`) instead of pinning to the bad path.
package/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import fs from "node:fs";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  import process from "node:process";
6
7
  import readline from "node:readline";
@@ -42,6 +43,7 @@ function printUsage() {
42
43
  ` ${cmd} setup [--project-id <uuid>] [--ctxpack-key <key>] [--base-url <url>] [--workspace-dir <path|auto>] [--workspace-fallback-dir <path>] [--name <server_name>]`,
43
44
  ` ${cmd} doctor [--project-id <uuid>] [--ctxpack-key <key>] [--base-url <url>] [--timeout-seconds <n>]`,
44
45
  ` ${cmd} proxy [--project-id <uuid>] [--ctxpack-key <key>] [--base-url <url>] [--workspace-dir <path|auto>] [--include-drafts <true|false>] [--auto-pull-on-conflict <true|false>] [--timeout-seconds <n>]`,
46
+ ` ${cmd} selftest [--json <true|false>]`,
45
47
  ` ${cmd} ctxpack pull [--project-id <uuid>] [--base-url <url>] [--workspace-dir <path|auto>] [--paths <csv>] [--timeout-seconds <n>]`,
46
48
  ` ${cmd} auth status`,
47
49
  ` ${cmd} auth login [--base-url <url>] [--flow <auto|device|callback|manual>] [--keycloak-url <url>] [--realm <name>] [--client-id <id>] [--open-browser <true|false>] [--callback-port <n>] [--timeout-seconds <n>] [--manual <true|false>]`,
@@ -604,7 +606,7 @@ function extractWeakWorkspaceCandidateFromRequest(requestObj) {
604
606
  }
605
607
 
606
608
  function extractStrongWorkspaceCandidateFromEnv() {
607
- const rawCandidate = firstNonEmptyString([
609
+ const rawCandidates = [
608
610
  process.env.METHEUS_WORKSPACE_DIR,
609
611
  process.env.METHEUS_WORKSPACE_URI,
610
612
  process.env.CODEX_WORKSPACE_DIR,
@@ -612,8 +614,12 @@ function extractStrongWorkspaceCandidateFromEnv() {
612
614
  process.env.CLAUDE_WORKSPACE_DIR,
613
615
  process.env.CLAUDE_WORKSPACE_URI,
614
616
  process.env.CLAUDE_PROJECT_DIR,
615
- ]);
616
- return sanitizeWorkspaceCandidate(rawCandidate);
617
+ ];
618
+ for (const rawCandidate of rawCandidates) {
619
+ const normalized = sanitizeWorkspaceFallbackEnvCandidate(rawCandidate);
620
+ if (normalized) return normalized;
621
+ }
622
+ return "";
617
623
  }
618
624
 
619
625
  function extractWorkspaceCandidateFromEnv() {
@@ -635,6 +641,35 @@ function extractWeakWorkspaceCandidateFromEnv() {
635
641
  return sanitizeWorkspaceCandidate(rawCandidate);
636
642
  }
637
643
 
644
+ function sanitizeWorkspaceFallbackEnvCandidate(rawCandidate) {
645
+ const input = String(rawCandidate || "").trim();
646
+ if (!input) return "";
647
+
648
+ const direct = sanitizeWorkspaceCandidate(input);
649
+ if (direct && fs.existsSync(direct)) {
650
+ return direct;
651
+ }
652
+
653
+ const fileCandidate = fileURIToLocalPath(input);
654
+ const candidate = firstNonEmptyString([fileCandidate, input]);
655
+ if (!candidate) return direct;
656
+
657
+ const resolved = resolveWorkspaceDir(candidate);
658
+ if (!resolved || isEditorInstallDirectory(resolved)) {
659
+ return direct;
660
+ }
661
+
662
+ const leaf = path.basename(resolved).trim().toLowerCase();
663
+ if ((leaf === "true" || leaf === "false") && !fs.existsSync(resolved)) {
664
+ const parent = sanitizeWorkspaceCandidate(path.dirname(resolved));
665
+ if (parent && fs.existsSync(parent)) {
666
+ return parent;
667
+ }
668
+ }
669
+
670
+ return direct;
671
+ }
672
+
638
673
  function resolveWorkspaceDirForRequest(defaultWorkspaceDir, requestObj, toolArgs) {
639
674
  const strongRequestCandidate = extractStrongWorkspaceCandidateFromRequest(requestObj, toolArgs);
640
675
  const strongEnvCandidate = extractStrongWorkspaceCandidateFromEnv();
@@ -3491,6 +3526,83 @@ function ensureArray(value) {
3491
3526
  return Array.isArray(value) ? value : [];
3492
3527
  }
3493
3528
 
3529
+ function normalizeSafeToolAliasName(rawName) {
3530
+ const base = String(rawName || "")
3531
+ .trim()
3532
+ .replace(/[^a-zA-Z0-9_]/g, "_")
3533
+ .replace(/_+/g, "_")
3534
+ .replace(/^_+|_+$/g, "");
3535
+ if (!base) return "";
3536
+ if (/^[0-9]/.test(base)) return `tool_${base}`;
3537
+ return base;
3538
+ }
3539
+
3540
+ function buildToolAliasMaps(tools) {
3541
+ const canonicalNames = new Set(
3542
+ ensureArray(tools)
3543
+ .map((tool) => String(tool?.name || "").trim())
3544
+ .filter(Boolean),
3545
+ );
3546
+ const aliasToCanonical = new Map();
3547
+ const canonicalToAlias = new Map();
3548
+ const reserved = new Set(canonicalNames);
3549
+
3550
+ for (const canonicalName of canonicalNames) {
3551
+ const aliasBase = normalizeSafeToolAliasName(canonicalName);
3552
+ if (!aliasBase || aliasBase === canonicalName) continue;
3553
+ let alias = aliasBase;
3554
+ let counter = 2;
3555
+ while (reserved.has(alias) || aliasToCanonical.has(alias)) {
3556
+ alias = `${aliasBase}_${counter}`;
3557
+ counter += 1;
3558
+ }
3559
+ aliasToCanonical.set(alias, canonicalName);
3560
+ canonicalToAlias.set(canonicalName, alias);
3561
+ reserved.add(alias);
3562
+ }
3563
+ return { aliasToCanonical, canonicalToAlias };
3564
+ }
3565
+
3566
+ function applyToolAliasesToToolsListResponse(responseObj, canonicalToAlias) {
3567
+ const result = safeObject(responseObj.result);
3568
+ const tools = ensureArray(result.tools);
3569
+ result.tools = tools.map((tool) => {
3570
+ const safeTool = safeObject(tool);
3571
+ const currentName = String(safeTool.name || "").trim();
3572
+ const aliasName = canonicalToAlias.get(currentName);
3573
+ if (!aliasName) return safeTool;
3574
+ return { ...safeTool, name: aliasName };
3575
+ });
3576
+ responseObj.result = result;
3577
+ return responseObj;
3578
+ }
3579
+
3580
+ function rewriteAliasedToolCallToCanonical(requestObj, aliasToCanonical) {
3581
+ if (!isJsonRpcMethod(requestObj, "tools/call")) return requestObj;
3582
+ const params = safeObject(requestObj.params);
3583
+ const currentName = String(params.name ?? params.tool_name ?? params.toolName ?? "").trim();
3584
+ if (!currentName) return requestObj;
3585
+ const canonicalName = String(aliasToCanonical.get(currentName) || "").trim();
3586
+ if (!canonicalName) return requestObj;
3587
+
3588
+ const nextParams = { ...params, name: canonicalName };
3589
+ if (Object.prototype.hasOwnProperty.call(nextParams, "tool_name")) {
3590
+ nextParams.tool_name = canonicalName;
3591
+ }
3592
+ if (Object.prototype.hasOwnProperty.call(nextParams, "toolName")) {
3593
+ nextParams.toolName = canonicalName;
3594
+ }
3595
+ return { ...requestObj, params: nextParams };
3596
+ }
3597
+
3598
+ function shouldUseSafeToolAliasesForClient(initParamsRaw) {
3599
+ const initParams = safeObject(initParamsRaw);
3600
+ const clientInfo = safeObject(initParams.clientInfo);
3601
+ const name = String(clientInfo.name || "").trim().toLowerCase();
3602
+ if (!name) return false;
3603
+ return name.includes("cursor") || name.includes("antigravity");
3604
+ }
3605
+
3494
3606
  function injectWorkspaceDirIntoToolSchemas(tools) {
3495
3607
  const workspaceDirProp = {
3496
3608
  type: "string",
@@ -4044,6 +4156,9 @@ async function runProxy(flags) {
4044
4156
  let lastRefreshError = "";
4045
4157
  let sessionWorkspaceDir = "";
4046
4158
  let sessionWorkspaceTrusted = false;
4159
+ let sessionUseSafeToolAliases = false;
4160
+ let sessionToolAliasToCanonical = new Map();
4161
+ let sessionToolCanonicalToAlias = new Map();
4047
4162
 
4048
4163
  // Proxy-initiated requests (e.g., roots/list) pending client responses.
4049
4164
  const pendingProxyRequests = new Map(); // id → callback(responseObj)
@@ -4122,7 +4237,7 @@ async function runProxy(flags) {
4122
4237
  const line = String(lineRaw || "").trim();
4123
4238
  if (!line) return;
4124
4239
 
4125
- const requestObj = tryJsonParse(line);
4240
+ let requestObj = tryJsonParse(line);
4126
4241
  if (requestObj == null) {
4127
4242
  writeProxyJson(jsonRpcError(null, -32700, "parse error"));
4128
4243
  return;
@@ -4175,6 +4290,13 @@ async function runProxy(flags) {
4175
4290
  return;
4176
4291
  }
4177
4292
 
4293
+ if (isJsonRpcMethod(requestObj, "initialize") && shouldUseSafeToolAliasesForClient(requestObj?.params)) {
4294
+ sessionUseSafeToolAliases = true;
4295
+ }
4296
+ if (sessionUseSafeToolAliases) {
4297
+ requestObj = rewriteAliasedToolCallToCanonical(requestObj, sessionToolAliasToCanonical);
4298
+ }
4299
+
4178
4300
  const { name: toolName, args: toolArgs } = extractToolCall(requestObj);
4179
4301
  let strongRequestWorkspaceCandidate = "";
4180
4302
  let weakRequestWorkspaceCandidate = "";
@@ -4430,6 +4552,13 @@ async function runProxy(flags) {
4430
4552
  }
4431
4553
  if (isJsonRpcMethod(requestObj, "tools/list")) {
4432
4554
  patched = appendLocalToolToToolsList(patched);
4555
+ if (sessionUseSafeToolAliases) {
4556
+ const tools = ensureArray(safeObject(patched.result).tools);
4557
+ const aliasMaps = buildToolAliasMaps(tools);
4558
+ sessionToolAliasToCanonical = aliasMaps.aliasToCanonical;
4559
+ sessionToolCanonicalToAlias = aliasMaps.canonicalToAlias;
4560
+ patched = applyToolAliasesToToolsListResponse(patched, sessionToolCanonicalToAlias);
4561
+ }
4433
4562
  } else if (isJsonRpcMethod(requestObj, "initialize")) {
4434
4563
  patched = appendProjectHintToInitialize(patched, args);
4435
4564
  // Log initialize params for workspace debugging.
@@ -5005,6 +5134,102 @@ function runSetup(flags) {
5005
5134
  runSetupInternal(flags, { ensureOnly: false });
5006
5135
  }
5007
5136
 
5137
+ function runSelftest(flags = {}) {
5138
+ const jsonMode = boolFromRaw(flags.json, false);
5139
+ const checks = [];
5140
+ const push = (name, ok, detail = "") => checks.push({ name, ok: Boolean(ok), detail: String(detail || "") });
5141
+
5142
+ const aliasMaps = buildToolAliasMaps([
5143
+ { name: "project.summary" },
5144
+ { name: "ctxpack.merge.brief" },
5145
+ { name: "workitem.list" },
5146
+ ]);
5147
+ const summaryAlias = String(aliasMaps.canonicalToAlias.get("project.summary") || "");
5148
+ const roundTrip = rewriteAliasedToolCallToCanonical(
5149
+ {
5150
+ jsonrpc: "2.0",
5151
+ id: 1,
5152
+ method: "tools/call",
5153
+ params: {
5154
+ name: summaryAlias,
5155
+ arguments: {},
5156
+ },
5157
+ },
5158
+ aliasMaps.aliasToCanonical,
5159
+ );
5160
+ const roundTripOk = summaryAlias === "project_summary" && String(roundTrip?.params?.name || "") === "project.summary";
5161
+ push(
5162
+ "alias_roundtrip_project_summary",
5163
+ roundTripOk,
5164
+ `alias=${summaryAlias || "(none)"} canonical=${String(roundTrip?.params?.name || "(none)")}`,
5165
+ );
5166
+
5167
+ const collisionMaps = buildToolAliasMaps([{ name: "a.b" }, { name: "a_b" }]);
5168
+ const aliasDot = String(collisionMaps.canonicalToAlias.get("a.b") || "");
5169
+ const aliasUnderscore = String(collisionMaps.canonicalToAlias.get("a_b") || "");
5170
+ const collisionOk = aliasDot === "a_b_2" && aliasUnderscore === "";
5171
+ push(
5172
+ "alias_collision_safe_suffix",
5173
+ collisionOk,
5174
+ `a.b->${aliasDot || "(none)"} a_b->${aliasUnderscore || "(none)"}`,
5175
+ );
5176
+
5177
+ let tempRoot = "";
5178
+ try {
5179
+ tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "metheus-selftest-"));
5180
+ const parent = path.join(tempRoot, "workspace");
5181
+ fs.mkdirSync(parent, { recursive: true });
5182
+ const suspicious = path.join(parent, "true");
5183
+ const recovered = sanitizeWorkspaceFallbackEnvCandidate(suspicious);
5184
+ const recoveredOk = normalizedPathForCompare(recovered) === normalizedPathForCompare(parent);
5185
+ push(
5186
+ "workspace_env_true_suffix_recovery",
5187
+ recoveredOk,
5188
+ `input=${suspicious} recovered=${recovered || "(none)"}`,
5189
+ );
5190
+
5191
+ fs.mkdirSync(suspicious, { recursive: true });
5192
+ const keptExisting = sanitizeWorkspaceFallbackEnvCandidate(suspicious);
5193
+ const keptExistingOk = normalizedPathForCompare(keptExisting) === normalizedPathForCompare(suspicious);
5194
+ push(
5195
+ "workspace_env_existing_true_dir_kept",
5196
+ keptExistingOk,
5197
+ `input=${suspicious} recovered=${keptExisting || "(none)"}`,
5198
+ );
5199
+ } catch (err) {
5200
+ push("workspace_env_guard_test_setup", false, String(err?.message || err));
5201
+ } finally {
5202
+ if (tempRoot) {
5203
+ try {
5204
+ fs.rmSync(tempRoot, { recursive: true, force: true });
5205
+ } catch {
5206
+ // ignore selftest cleanup error
5207
+ }
5208
+ }
5209
+ }
5210
+
5211
+ const failed = checks.filter((row) => !row.ok);
5212
+ const payload = {
5213
+ ok: failed.length === 0,
5214
+ pass_count: checks.length - failed.length,
5215
+ fail_count: failed.length,
5216
+ checks,
5217
+ };
5218
+
5219
+ if (jsonMode) {
5220
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
5221
+ } else {
5222
+ process.stdout.write(`Selftest: ${payload.ok ? "PASS" : "FAIL"} (${payload.pass_count}/${checks.length})\n`);
5223
+ for (const row of checks) {
5224
+ process.stdout.write(`- ${row.ok ? "PASS" : "FAIL"} ${row.name}${row.detail ? ` :: ${row.detail}` : ""}\n`);
5225
+ }
5226
+ }
5227
+
5228
+ if (!payload.ok) {
5229
+ process.exitCode = 1;
5230
+ }
5231
+ }
5232
+
5008
5233
  async function runBootstrap(flags) {
5009
5234
  process.stdout.write("Bootstrap start.\n");
5010
5235
  let resolved = resolveCurrentAccessToken();
@@ -5065,6 +5290,10 @@ async function main() {
5065
5290
  await runDoctor(flags);
5066
5291
  return;
5067
5292
  }
5293
+ if (command === "selftest") {
5294
+ runSelftest(flags);
5295
+ return;
5296
+ }
5068
5297
  if (command === "auth") {
5069
5298
  await runAuth(rest);
5070
5299
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [
@@ -11,6 +11,8 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "check": "node --check cli.mjs && node --check release.mjs",
14
+ "test:compat": "node cli.mjs selftest --json",
15
+ "smoke:proxy": "node scripts/smoke-proxy.mjs",
14
16
  "pack:dry": "npm pack --dry-run",
15
17
  "publish:dry": "node release.mjs --dry-run",
16
18
  "publish:public": "node release.mjs"