metheus-governance-mcp-cli 0.2.26 → 0.2.29

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 +7 -2
  2. package/cli.mjs +256 -36
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -48,6 +48,7 @@ metheus-governance-mcp-cli setup --project-id <project_uuid> --ctxpack-key "<ctx
48
48
 
49
49
  Gemini CLI note:
50
50
  - `gemini mcp` commands require Gemini auth to be configured first (`GEMINI_API_KEY` or `~/.gemini/settings.json` auth).
51
+ - `setup` registers Gemini in both `user` and `project` scopes to improve auto-discovery across folders.
51
52
 
52
53
  When a client does not send workspace metadata (for example some Codex sessions),
53
54
  set a stable fallback root once:
@@ -58,7 +59,7 @@ metheus-governance-mcp-cli setup --project-id <project_uuid> --ctxpack-key "<ctx
58
59
 
59
60
  This sets fallback workspace context for clients that do not pass workspace metadata:
60
61
  - Codex/Antigravity/Cursor: `METHEUS_WORKSPACE_DIR` env
61
- - Gemini: pinned `--workspace-dir <fallback>` in registered MCP command
62
+ - Gemini: pinned `--workspace-dir <fallback>` and `METHEUS_WORKSPACE_DIR` in MCP registration
62
63
 
63
64
  Guardrail note:
64
65
  - By default, CLI blocks reading/writing ctxpack sync metadata when workspace root resolves to the home directory.
@@ -92,7 +93,11 @@ Checks:
92
93
  `setup` auto-registers this server for `codex`, `claude`, `gemini`, `antigravity`, and `cursor` when those CLIs are available.
93
94
 
94
95
  Antigravity note:
95
- - this CLI manages MCP registration via Antigravity local config file (`mcp.json`) because Antigravity does not provide full `mcp list/get/remove` subcommands.
96
+ - this CLI manages MCP registration via Antigravity local config because Antigravity does not provide full `mcp list/get/remove` subcommands.
97
+ - primary config path: `~/.gemini/antigravity/mcp_config.json`
98
+ - legacy mirror path: `%APPDATA%/Antigravity/User/mcp.json` (Windows) for compatibility
99
+ - for compatibility across Antigravity variants, setup writes both `mcpServers` and `servers` keys.
100
+ - proxy stdio now supports both `Content-Length` framed MCP and JSONL input for VS Code-family clients.
96
101
 
97
102
  Cursor note:
98
103
  - this CLI manages MCP registration via Cursor global MCP config (`~/.cursor/mcp.json`).
package/cli.mjs CHANGED
@@ -86,7 +86,15 @@ function resolveHomeFilePath(relativePath) {
86
86
  return path.join(home, relativePath);
87
87
  }
88
88
 
89
- function antigravityMcpConfigFilePath() {
89
+ function antigravityPrimaryMcpConfigFilePath() {
90
+ const home = String(process.env.USERPROFILE || process.env.HOME || "").trim();
91
+ if (home) {
92
+ return path.join(home, ".gemini", "antigravity", "mcp_config.json");
93
+ }
94
+ return path.resolve(process.cwd(), ".gemini", "antigravity", "mcp_config.json");
95
+ }
96
+
97
+ function antigravityLegacyMcpConfigFilePath() {
90
98
  const home = String(process.env.USERPROFILE || process.env.HOME || "").trim();
91
99
  if (process.platform === "win32") {
92
100
  const appData = String(process.env.APPDATA || "").trim();
@@ -110,44 +118,117 @@ function antigravityMcpConfigFilePath() {
110
118
  return path.resolve(process.cwd(), ".antigravity", "mcp.json");
111
119
  }
112
120
 
121
+ function antigravityMcpConfigFilePaths() {
122
+ const seen = new Set();
123
+ const out = [];
124
+ const pushUnique = (value) => {
125
+ const normalized = String(value || "").trim();
126
+ if (!normalized || seen.has(normalized)) return;
127
+ seen.add(normalized);
128
+ out.push(normalized);
129
+ };
130
+ pushUnique(antigravityPrimaryMcpConfigFilePath());
131
+ pushUnique(antigravityLegacyMcpConfigFilePath());
132
+ return out;
133
+ }
134
+
113
135
  function loadAntigravityMcpConfig() {
114
- const filePath = antigravityMcpConfigFilePath();
115
- try {
116
- const raw = fs.readFileSync(filePath, "utf8");
117
- const parsed = tryJsonParse(raw);
118
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
119
- return { filePath, config: parsed };
136
+ const candidatePaths = antigravityMcpConfigFilePaths();
137
+ for (const filePath of candidatePaths) {
138
+ try {
139
+ const raw = fs.readFileSync(filePath, "utf8");
140
+ const parsed = tryJsonParse(raw);
141
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
142
+ return { filePath, config: parsed };
143
+ }
144
+ } catch {
145
+ // no-op
120
146
  }
121
- } catch {
122
- // no-op
123
147
  }
124
148
  return {
125
- filePath,
149
+ filePath: candidatePaths[0] || antigravityPrimaryMcpConfigFilePath(),
126
150
  config: {
151
+ mcpServers: {},
127
152
  servers: {},
128
- inputs: [],
129
153
  },
130
154
  };
131
155
  }
132
156
 
133
157
  function saveAntigravityMcpConfig(filePath, config) {
158
+ const primaryPath = String(filePath || "").trim();
159
+ if (!primaryPath) return false;
160
+ const mirrorPaths = antigravityMcpConfigFilePaths().filter((item) => item !== primaryPath);
134
161
  try {
135
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
136
- fs.writeFileSync(filePath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
137
- return true;
162
+ fs.mkdirSync(path.dirname(primaryPath), { recursive: true });
163
+ fs.writeFileSync(primaryPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
138
164
  } catch {
139
165
  return false;
140
166
  }
167
+ for (const extraPath of mirrorPaths) {
168
+ try {
169
+ fs.mkdirSync(path.dirname(extraPath), { recursive: true });
170
+ fs.writeFileSync(extraPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
171
+ } catch {
172
+ // best effort mirror for compatibility
173
+ }
174
+ }
175
+ return true;
141
176
  }
142
177
 
143
178
  function getAntigravityServerEntry(serverName) {
144
179
  const { config } = loadAntigravityMcpConfig();
145
- const servers = safeObject(config?.servers);
180
+ const servers = safeObject(config?.mcpServers ?? config?.servers);
181
+ const entry = safeObject(servers[serverName]);
182
+ if (!entry.command && !entry.url) return null;
183
+ return entry;
184
+ }
185
+
186
+ function geminiSettingsFilePath(scope = "project") {
187
+ const normalized = String(scope || "project").trim().toLowerCase();
188
+ if (normalized === "user") {
189
+ const home = String(process.env.USERPROFILE || process.env.HOME || "").trim();
190
+ if (home) {
191
+ return path.join(home, ".gemini", "settings.json");
192
+ }
193
+ }
194
+ return path.resolve(process.cwd(), ".gemini", "settings.json");
195
+ }
196
+
197
+ function loadGeminiSettings(scope = "project") {
198
+ const filePath = geminiSettingsFilePath(scope);
199
+ try {
200
+ const raw = fs.readFileSync(filePath, "utf8");
201
+ const parsed = tryJsonParse(raw);
202
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
203
+ return { filePath, config: parsed };
204
+ }
205
+ } catch {
206
+ // no-op
207
+ }
208
+ return {
209
+ filePath,
210
+ config: {
211
+ mcpServers: {},
212
+ },
213
+ };
214
+ }
215
+
216
+ function getGeminiServerEntryByScope(serverName, scope = "project") {
217
+ const { config } = loadGeminiSettings(scope);
218
+ const servers = safeObject(config?.mcpServers);
146
219
  const entry = safeObject(servers[serverName]);
147
220
  if (!entry.command && !entry.url) return null;
148
221
  return entry;
149
222
  }
150
223
 
224
+ function getGeminiServerEntry(serverName) {
225
+ return (
226
+ getGeminiServerEntryByScope(serverName, "project")
227
+ || getGeminiServerEntryByScope(serverName, "user")
228
+ || null
229
+ );
230
+ }
231
+
151
232
  function cursorMcpConfigFilePath() {
152
233
  const home = String(process.env.USERPROFILE || process.env.HOME || "").trim();
153
234
  if (home) {
@@ -3898,12 +3979,7 @@ async function runProxy(flags) {
3898
3979
  let sessionWorkspaceDir = "";
3899
3980
  let sessionWorkspaceTrusted = false;
3900
3981
 
3901
- const rl = readline.createInterface({
3902
- input: process.stdin,
3903
- crlfDelay: Infinity,
3904
- });
3905
-
3906
- rl.on("line", async (lineRaw) => {
3982
+ const handleIncomingMessage = async (lineRaw) => {
3907
3983
  const line = String(lineRaw || "").trim();
3908
3984
  if (!line) return;
3909
3985
 
@@ -4234,7 +4310,137 @@ async function runProxy(flags) {
4234
4310
  `${JSON.stringify(jsonRpcError(requestObj, -32001, String(err?.message || err)))}\n`,
4235
4311
  );
4236
4312
  }
4313
+ };
4314
+
4315
+ let pendingInputBuffer = Buffer.alloc(0);
4316
+ let messageQueue = Promise.resolve();
4317
+
4318
+ function queueMessage(messageText) {
4319
+ const nextText = String(messageText || "");
4320
+ if (!nextText.trim()) return;
4321
+ messageQueue = messageQueue
4322
+ .then(() => handleIncomingMessage(nextText))
4323
+ .catch((err) => {
4324
+ process.stderr.write(`${String(err?.stack || err)}\n`);
4325
+ });
4326
+ }
4327
+
4328
+ function indexOfBuffer(haystack, needle) {
4329
+ return haystack.indexOf(needle);
4330
+ }
4331
+
4332
+ function startsWithContentLengthHeader(buffer) {
4333
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) return false;
4334
+ let start = 0;
4335
+ while (start < buffer.length && (buffer[start] === 0x0d || buffer[start] === 0x0a)) {
4336
+ start += 1;
4337
+ }
4338
+ const previewLength = Math.min(96, buffer.length - start);
4339
+ if (previewLength <= 0) return false;
4340
+ const preview = buffer.subarray(start, start + previewLength).toString("utf8");
4341
+ return /^\s*content-length\s*:/i.test(preview);
4342
+ }
4343
+
4344
+ function extractFramedMessage(buffer) {
4345
+ const crlfDelimiter = Buffer.from("\r\n\r\n");
4346
+ const lfDelimiter = Buffer.from("\n\n");
4347
+ let headerEnd = indexOfBuffer(buffer, crlfDelimiter);
4348
+ let delimiterLength = crlfDelimiter.length;
4349
+ if (headerEnd < 0) {
4350
+ headerEnd = indexOfBuffer(buffer, lfDelimiter);
4351
+ delimiterLength = lfDelimiter.length;
4352
+ }
4353
+ if (headerEnd < 0) return null;
4354
+
4355
+ const headerText = buffer.subarray(0, headerEnd).toString("utf8");
4356
+ const match = /content-length\s*:\s*(\d+)/i.exec(headerText);
4357
+ if (!match) {
4358
+ // Malformed framed payload; drop header block and continue.
4359
+ return {
4360
+ message: "",
4361
+ remaining: buffer.subarray(headerEnd + delimiterLength),
4362
+ };
4363
+ }
4364
+
4365
+ const bodyLength = Number.parseInt(String(match[1] || "0"), 10);
4366
+ if (!Number.isFinite(bodyLength) || bodyLength < 0) {
4367
+ return {
4368
+ message: "",
4369
+ remaining: buffer.subarray(headerEnd + delimiterLength),
4370
+ };
4371
+ }
4372
+
4373
+ const bodyStart = headerEnd + delimiterLength;
4374
+ const bodyEnd = bodyStart + bodyLength;
4375
+ if (buffer.length < bodyEnd) return null;
4376
+ const message = buffer.subarray(bodyStart, bodyEnd).toString("utf8");
4377
+ return {
4378
+ message,
4379
+ remaining: buffer.subarray(bodyEnd),
4380
+ };
4381
+ }
4382
+
4383
+ function extractLineMessage(buffer) {
4384
+ const newlineIndex = buffer.indexOf(0x0a);
4385
+ if (newlineIndex < 0) return null;
4386
+ let lineBuffer = buffer.subarray(0, newlineIndex);
4387
+ if (lineBuffer.length > 0 && lineBuffer[lineBuffer.length - 1] === 0x0d) {
4388
+ lineBuffer = lineBuffer.subarray(0, lineBuffer.length - 1);
4389
+ }
4390
+ return {
4391
+ message: lineBuffer.toString("utf8"),
4392
+ remaining: buffer.subarray(newlineIndex + 1),
4393
+ };
4394
+ }
4395
+
4396
+ function pumpInputBuffer() {
4397
+ // Supports both MCP framed stdio (Content-Length) and JSONL input.
4398
+ while (pendingInputBuffer.length > 0) {
4399
+ let trimmedLeading = pendingInputBuffer;
4400
+ while (
4401
+ trimmedLeading.length > 0
4402
+ && (trimmedLeading[0] === 0x0d || trimmedLeading[0] === 0x0a)
4403
+ ) {
4404
+ trimmedLeading = trimmedLeading.subarray(1);
4405
+ }
4406
+ pendingInputBuffer = trimmedLeading;
4407
+ if (pendingInputBuffer.length === 0) break;
4408
+
4409
+ if (startsWithContentLengthHeader(pendingInputBuffer)) {
4410
+ const framed = extractFramedMessage(pendingInputBuffer);
4411
+ if (!framed) break;
4412
+ pendingInputBuffer = framed.remaining;
4413
+ if (String(framed.message || "").trim()) {
4414
+ queueMessage(framed.message);
4415
+ }
4416
+ continue;
4417
+ }
4418
+
4419
+ const lineMessage = extractLineMessage(pendingInputBuffer);
4420
+ if (!lineMessage) break;
4421
+ pendingInputBuffer = lineMessage.remaining;
4422
+ if (String(lineMessage.message || "").trim()) {
4423
+ queueMessage(lineMessage.message);
4424
+ }
4425
+ }
4426
+ }
4427
+
4428
+ process.stdin.on("data", (chunk) => {
4429
+ const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4430
+ pendingInputBuffer = pendingInputBuffer.length
4431
+ ? Buffer.concat([pendingInputBuffer, nextChunk])
4432
+ : nextChunk;
4433
+ pumpInputBuffer();
4434
+ });
4435
+
4436
+ process.stdin.on("end", () => {
4437
+ const trailing = String(pendingInputBuffer.toString("utf8") || "").trim();
4438
+ if (trailing) {
4439
+ queueMessage(trailing);
4440
+ }
4237
4441
  });
4442
+
4443
+ process.stdin.resume();
4238
4444
  }
4239
4445
 
4240
4446
  function runCLICommand(cliBin, args, options = {}) {
@@ -4306,7 +4512,7 @@ function tryRegister(cliBin, serverName, proxyArgs, options = {}) {
4306
4512
  if (cliBin === "antigravity") {
4307
4513
  const { filePath, config } = loadAntigravityMcpConfig();
4308
4514
  const nextConfig = safeObject(config);
4309
- const nextServers = safeObject(nextConfig.servers);
4515
+ const nextServers = safeObject(nextConfig.mcpServers ?? nextConfig.servers);
4310
4516
  const existing = safeObject(nextServers[serverName]);
4311
4517
  const args = [selfPath, "proxy", ...proxyArgs];
4312
4518
  const entry = {
@@ -4330,25 +4536,36 @@ function tryRegister(cliBin, serverName, proxyArgs, options = {}) {
4330
4536
  }
4331
4537
  }
4332
4538
  nextServers[serverName] = entry;
4333
- nextConfig.servers = nextServers;
4334
- if (!Array.isArray(nextConfig.inputs)) {
4335
- nextConfig.inputs = [];
4336
- }
4539
+ nextConfig.mcpServers = nextServers;
4540
+ nextConfig.servers = { ...nextServers };
4337
4541
  return saveAntigravityMcpConfig(filePath, nextConfig);
4338
4542
  }
4339
4543
  if (cliBin === "gemini") {
4340
- // Gemini CLI (0.31.x) currently supports:
4341
- // gemini mcp add <name> <commandOrUrl> [args...]
4342
- // It does not expose --env for MCP registration, so pin workspace-dir directly.
4544
+ // Register in both user+project scopes for broader auto-discovery across folders.
4343
4545
  const geminiProxyArgs = workspaceEnv
4344
4546
  ? withWorkspaceDirArg(proxyArgs, workspaceEnv)
4345
4547
  : [...proxyArgs];
4346
- const run = runCLICommand(
4548
+ const geminiEnvArgs = workspaceEnv
4549
+ ? ["-e", `METHEUS_WORKSPACE_DIR=${workspaceEnv}`]
4550
+ : [];
4551
+ const geminiBaseArgs = [serverName, process.execPath, selfPath, "proxy", ...geminiProxyArgs];
4552
+ let ok = false;
4553
+ const scopedAttempts = [
4554
+ ["mcp", "add", "-s", "user", ...geminiEnvArgs, ...geminiBaseArgs],
4555
+ ["mcp", "add", "-s", "project", ...geminiEnvArgs, ...geminiBaseArgs],
4556
+ ];
4557
+ for (const args of scopedAttempts) {
4558
+ const run = runCLICommand(cliBin, args, { stdio: "inherit" });
4559
+ if (run.status === 0) ok = true;
4560
+ }
4561
+ if (ok) return true;
4562
+ // Backward compatibility for older Gemini CLI variants without scope/env flags.
4563
+ const legacyRun = runCLICommand(
4347
4564
  cliBin,
4348
4565
  ["mcp", "add", serverName, process.execPath, selfPath, "proxy", ...geminiProxyArgs],
4349
4566
  { stdio: "inherit" },
4350
4567
  );
4351
- return run.status === 0;
4568
+ return legacyRun.status === 0;
4352
4569
  }
4353
4570
 
4354
4571
  const baseAddArgs = (() => {
@@ -4395,13 +4612,11 @@ function runRemove(cliBin, serverName) {
4395
4612
  if (cliBin === "antigravity") {
4396
4613
  const { filePath, config } = loadAntigravityMcpConfig();
4397
4614
  const nextConfig = safeObject(config);
4398
- const nextServers = safeObject(nextConfig.servers);
4615
+ const nextServers = safeObject(nextConfig.mcpServers ?? nextConfig.servers);
4399
4616
  if (Object.prototype.hasOwnProperty.call(nextServers, serverName)) {
4400
4617
  delete nextServers[serverName];
4401
- nextConfig.servers = nextServers;
4402
- if (!Array.isArray(nextConfig.inputs)) {
4403
- nextConfig.inputs = [];
4404
- }
4618
+ nextConfig.mcpServers = nextServers;
4619
+ nextConfig.servers = { ...nextServers };
4405
4620
  saveAntigravityMcpConfig(filePath, nextConfig);
4406
4621
  }
4407
4622
  return;
@@ -4416,6 +4631,8 @@ function runRemove(cliBin, serverName) {
4416
4631
  return;
4417
4632
  }
4418
4633
  if (cliBin === "gemini") {
4634
+ runCLICommand(cliBin, ["mcp", "remove", "-s", "project", serverName], { stdio: "ignore" });
4635
+ runCLICommand(cliBin, ["mcp", "remove", "-s", "user", serverName], { stdio: "ignore" });
4419
4636
  runCLICommand(cliBin, ["mcp", "remove", serverName], { stdio: "ignore" });
4420
4637
  return;
4421
4638
  }
@@ -4430,6 +4647,9 @@ function isRegistered(cliBin, serverName) {
4430
4647
  return Boolean(getAntigravityServerEntry(serverName));
4431
4648
  }
4432
4649
  if (cliBin === "gemini") {
4650
+ if (getGeminiServerEntry(serverName)) {
4651
+ return true;
4652
+ }
4433
4653
  const listRun = runCLICommand(cliBin, ["mcp", "list"], { stdio: "pipe" });
4434
4654
  if (listRun.status !== 0) return false;
4435
4655
  const text = `${String(listRun.stdout || "")}\n${String(listRun.stderr || "")}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.26",
3
+ "version": "0.2.29",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [