From 855730e85033328343dfd294740d66995eb465de Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 30 May 2026 13:55:52 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20v1.2.0=20=E2=80=94=20pluggable=20ba?= =?UTF-8?q?ckends,=20approval=20mode,=20native=20sessions,=20Windows=20rel?= =?UTF-8?q?iability,=20timeouts,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A feature release for the 1.2.0 line, on top of the 1.1.6 security patch. Hardens cross-platform execution, adds an opt-in safety control and native multi-turn sessions, makes the CLI backend pluggable ahead of the Gemini CLI retirement (2026-06-18 -> Antigravity agy), and adds a real test suite. - Backend abstraction (src/backends/): the Gemini CLI stays the default; add an experimental Antigravity CLI (agy) backend behind GEMINI_MCP_BACKEND, with a transcript-file fallback for agy's empty-stdout -p bug (Flash-only). - Opt-in approval mode: approvalMode arg + GEMINI_MCP_APPROVAL_MODE env forward gemini --approval-mode. Not forced by default — defaulting to 'plan' turns headless gemini into an autonomous planner that breaks plain Q&A. - Native multi-turn sessions: sessionId/resume forward gemini --session-id/--resume and the active session id is surfaced in the response. - Windows executable resolution: GEMINI_CLI_PATH, then 'where gemini' preferring the .cmd shim; plus platform-aware ENOENT guidance. - Per-command timeout (SIGTERM -> SIGKILL), GEMINI_MCP_TIMEOUT_MS (default 30m, 0 disables); implements the previously-empty timeoutManager. - Fix Help tool: 'gemini --help' (was '-help', mis-parsed by yargs as -h -e -l -p). - Read server version from package.json at runtime (was hardcoded, stale at 1.1.4); engines >=18; prepare script for Git-checkout installs. - Complex prompts (changeMode / @file) are sent on stdin instead of -p; windowsHide suppresses the popup console window on Windows. - node:test suite + tsconfig.build.json so tests are type-checked but not shipped in dist. --- CHANGELOG.md | 23 ++++- SECURITY-REPORT-2026-05-28.md | 92 +++++++++++++++++ package.json | 9 +- scripts/run-tests.mjs | 33 ++++++ src/backends/agy.test.ts | 26 +++++ src/backends/agy.ts | 138 ++++++++++++++++++++++++++ src/backends/gemini.test.ts | 61 ++++++++++++ src/backends/gemini.ts | 87 ++++++++++++++++ src/backends/index.test.ts | 16 +++ src/backends/index.ts | 26 +++++ src/backends/types.ts | 29 ++++++ src/constants.ts | 31 +++++- src/index.ts | 9 +- src/tools/ask-gemini.tool.ts | 39 +++++--- src/tools/brainstorm.tool.ts | 9 +- src/tools/simple-tools.ts | 5 +- src/utils/commandExecutor.test.ts | 41 ++++++++ src/utils/commandExecutor.ts | 160 ++++++++++++++++++++++++------ src/utils/geminiExecutor.test.ts | 17 ++++ src/utils/geminiExecutor.ts | 87 ++++++---------- src/utils/timeoutManager.test.ts | 19 ++++ src/utils/timeoutManager.ts | 20 ++++ tsconfig.build.json | 4 + 23 files changed, 871 insertions(+), 110 deletions(-) create mode 100644 SECURITY-REPORT-2026-05-28.md create mode 100644 scripts/run-tests.mjs create mode 100644 src/backends/agy.test.ts create mode 100644 src/backends/agy.ts create mode 100644 src/backends/gemini.test.ts create mode 100644 src/backends/gemini.ts create mode 100644 src/backends/index.test.ts create mode 100644 src/backends/index.ts create mode 100644 src/backends/types.ts create mode 100644 src/utils/commandExecutor.test.ts create mode 100644 src/utils/geminiExecutor.test.ts create mode 100644 src/utils/timeoutManager.test.ts create mode 100644 tsconfig.build.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 3739f96..6d7ae52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,28 @@ # Changelog +## [1.2.0] - 2026-05-30 +First feature release after the 1.1.6 security patch. Hardens cross-platform execution, adds an opt-in safety control and native multi-turn sessions, makes the CLI backend pluggable (ahead of Gemini CLI's retirement), and adds a real test suite. + +### Added +- **Approval mode** — optional `approvalMode` argument on `ask-gemini`/`brainstorm` (and `GEMINI_MCP_APPROVAL_MODE` env), forwarding Gemini's `--approval-mode` (`default` / `auto_edit` / `yolo` / `plan`). Opt-in: when unset, behaviour is unchanged. Use `yolo` / `auto_edit` with `sandbox` to let Gemini run or edit; `plan` runs Gemini as an autonomous read-only planner. +- **Native multi-turn sessions** — `sessionId` and `resume` arguments forward Gemini's `--session-id` / `--resume`; the active session id is surfaced in the response so a follow-up call can continue the conversation. Builds on #50; uses the CLI's own sessions rather than local transcript storage. +- **Pluggable backends** — the executor is now backend-agnostic. The Gemini CLI stays the default; set `GEMINI_MCP_BACKEND=agy` to use the **experimental** Antigravity CLI (`agy`) backend, ahead of Gemini CLI's 2026-06-18 retirement for free/Pro/Ultra tiers. (agy print-mode is Flash-only, and its reply is recovered from agy's transcript files to work around the upstream `agy -p` empty-stdout bug.) +- **Per-command timeout** — a hung CLI call is now terminated (SIGTERM → SIGKILL). Configurable via `GEMINI_MCP_TIMEOUT_MS` (default 30 minutes; `0` disables). +- **Windows executable resolution** — honours `GEMINI_CLI_PATH`, otherwise resolves the real `gemini` shim via `where` (preferring `.cmd`), fixing "command not found" when the MCP server doesn't inherit your shell's PATH. +- **Test suite** — `node:test` coverage for the `@file` security guard, Windows quoting/resolution, approval-mode and session argument building, backend selection, and timeout parsing (`npm test`). + +### Changed +- `engines.node` raised to `>=18`. +- The server version is now read from `package.json` at runtime, instead of a hardcoded string that had drifted to `1.1.4`. +- Installing from a Git checkout now builds automatically via a `prepare` script. + +### Fixed +- The `Help` tool now invokes `gemini --help` instead of `-help`, which yargs mis-parsed as `-h -e -l -p`. +- Clearer, platform-aware guidance when the executable is not found (ENOENT), including the `GEMINI_CLI_PATH` hint. +- Windows robustness: complex prompts (`changeMode` / `@file`) are sent to the Gemini CLI on **stdin** instead of the `-p` flag, sidestepping cmd.exe argument parsing and the OS command-line length limit; added `windowsHide` to suppress the popup console window. (#27, #77) + ## [1.1.6] - 2026-05-30 -_Emergency security patch — the CVE-2026-0755 fix only, ahead of the larger 1.2.0 release._ +_Emergency security patch — the CVE-2026-0755 fix only, ahead of this 1.2.0 release._ - Security fix: OS command-injection / `@file` exfiltration via prompt quoting in `geminiExecutor.ts` (CVE-2026-0755, CWE-78). Fixes #73 (and the literal-quote corruption in #66). - Removed the broken double-quote wrapping from both the primary and fallback paths. With `spawn` running `shell: false`, those quotes were passed as literal characters — they provided no protection and corrupted `@file` references. Windows `.cmd` argument quoting is hardened separately (see below). - Added `assertSafeFileReferences()`, which rejects any `@file` reference that resolves outside the project working directory (absolute paths, `~` home references, and `../` traversal), closing the arbitrary-file-read exfiltration vector while preserving legitimate in-project `@file` usage. diff --git a/SECURITY-REPORT-2026-05-28.md b/SECURITY-REPORT-2026-05-28.md new file mode 100644 index 0000000..8af60be --- /dev/null +++ b/SECURITY-REPORT-2026-05-28.md @@ -0,0 +1,92 @@ +# Security Report — gemini-mcp-tool + +- **Date:** 2026-05-28 +- **Repository:** `jamubc/gemini-mcp-tool` +- **Branch reviewed:** `security/cve-2026-0755` (PR #75) +- **Scope:** All hand-written source under `src/`, plus declared npm dependencies. +- **Method:** Manual code review + sink analysis (`child_process` / `fs` / network / `eval`), `npm audit` with runtime-vs-dev tree attribution, and a cross-check of open GitHub issues. + +> No security issue was filed today (2026-05-28). The most recent security report is **#73 (CVE-2026-0755)**, which is fixed on this branch (PR #75). + +--- + +## Executive summary + +| Area | Critical | High | Moderate | Low / Info | +|--------------|:--------:|:----:|:--------:|:----------:| +| Code | 1 (fixed)| 0 | 0 | 4 | +| Dependencies | 0 | 8* | 15 | 2 | + +\* Only **2 of the 8 dependency HIGHs reach the published/runtime tree** (`@modelcontextprotocol/sdk`, and `tmp` via the unused `inquirer` dep). The other 6 HIGHs live exclusively in the docs/build toolchain (`vitepress`, `mermaid`, `archiver`) and are never installed for end users. + +--- + +## Code findings + +### C1 — CVE-2026-0755: OS command-injection / `@file` exfiltration — **Critical — FIXED (PR #75)** +`geminiExecutor.ts` wrapped any prompt containing `@` in literal `"` before passing it to `spawn` (`shell: false`), which injected literal quote characters and corrupted `@file` references, while leaving an arbitrary-file-read vector through the Gemini CLI's `@file` parser. + +**Fix (this branch):** removed the broken quoting from the primary and fallback paths; added `assertSafeFileReferences()` which rejects `@file` references that resolve outside the project working directory (absolute, `~`, and `../` traversal). The guard runs on the fully-processed prompt, so it also protects the `brainstorm` and `changeMode` code paths. + +### C2 — Windows `cmd.exe` variable expansion in prompts — **Low (Windows-only)** +`commandExecutor.ts` uses `shell: true` on Windows and wraps whitespace/quote args in `"..."` (escaping `"`→`""`). `cmd.exe` still expands `%VAR%` **inside** double quotes, so a prompt containing e.g. `%USERNAME%` / `%PATH%` is substituted before reaching `gemini`. This is not a command-execution break-out, but it is a correctness + minor information-substitution issue. Unix is unaffected (`shell: false`). +**Recommendation:** adopt the issue #62 approach — spawn `process.execPath` with the resolved `gemini.js` path and `shell: false` on Windows too — eliminating the shell (and the quoting fragility) entirely. + +### C3 — Verbose logging of full tool arguments / prompts — **Low / Informational** +`logger.ts` logs raw args via `JSON.stringify` on every invocation (`Logger.toolInvocation`), and `Logger.debug` is wired to `console.warn`, so prompt bodies are written to stderr **regardless of any debug flag**. Prompts may contain pasted file contents or secrets; on shared hosts or captured MCP logs this is a disclosure risk. +**Recommendation:** gate full-argument logging behind an explicit debug env var; avoid logging full prompt bodies at the default level. + +### C4 — Raw `error.message` returned to client — **Informational** +`index.ts` returns `Error executing ${tool}: ${error.message}`. CLI/`fs` errors may embed absolute local paths. Low impact for a local stdio server; noted for completeness. + +### C5 — Unbounded lazy regex over model output — **Informational** +`changeModeParser.ts` uses `[\s\S]*?` groups. Input is Gemini's *response* (model-controlled, not direct attacker network input), so ReDoS exposure is low. Acceptable today; revisit if these inputs ever become untrusted. + +### Positives observed +- `commandExecutor.ts` uses `spawn` with `shell: false` on Unix and an args array — no shell injection. +- #72 path-traversal hardening on `cacheKey` is solid: format regex (`/^[a-f0-9]{8}$/`) + `path.resolve` containment + removal of the silent `unlink` primitive. +- All tool arguments are validated through `zod` before execution. +- The server is **stdio-only** — there is no network listener by default. + +--- + +## Dependency findings + +`npm audit`: **25 vulnerabilities (8 high, 15 moderate, 2 low)**. The published package ships only `dist/`, but its `dependencies` are installed transitively for every end user, so the runtime-vs-dev split below is what actually matters. + +### D1 — `@modelcontextprotocol/sdk@0.5.0` — **High — runtime, USED** +- Advisories: ReDoS (high); "DNS-rebinding protection not enabled by default" (high). +- **DNS rebinding does not apply** here: this server uses `StdioServerTransport`, not the Streamable-HTTP transport the advisory concerns. +- ReDoS applies to SDK message handling; with a trusted local stdio client, exposure is limited but real. +- `0.5.0` is far behind the current `1.x` line. **Upgrading is recommended but is a breaking API change** and will require edits to `index.ts`. + +### D2 — `inquirer@9.3.7` → `external-editor` → `tmp@0.0.33` — **High path traversal — runtime, UNUSED** +- `inquirer`, `ai`, `chalk`, `d3-shape`, and `prismjs` are declared as runtime `dependencies` but are **not imported anywhere in `src/`**. They are still installed for every user, and `inquirer` drags in the HIGH `tmp` path-traversal advisory. +- **Recommendation (high value, low effort):** remove these unused runtime deps. This eliminates the only runtime-tree HIGH besides the SDK and significantly shrinks install/attack surface. (Note: `package.json` references a `contribute` script at `src/contribute.ts` which does not exist in the tree — confirm nothing relies on these before removal.) + +### D3 — Docs/build toolchain HIGHs — **Not shipped, lower priority** +All remaining HIGHs are confined to `devDependencies` and are not installed for end users or used by the running server: +- `archiver` → `glob`, `minimatch`, `lodash` +- `vitepress` → `rollup`, `vite`, `esbuild`, `preact` +- `mermaid` → `dompurify` + +Patch opportunistically with `npm audit fix`, but these do not affect deployed MCP servers. + +--- + +## Additional observations (full source-tree read) + +These do **not** affect the published npm package or the running MCP server (the docs site is built/deployed separately to GitHub Pages), but are noted for completeness: + +- **Docs site loads a third-party ad script.** `docs/.vitepress/theme/components/AdBanner.vue` injects `//cdn.carbonads.com/carbon.js` into the page ``. It is currently an inert placeholder (`serve=YOUR_CARBON_ID`), but any third-party script on the docs origin is a supply-chain/privacy consideration. *(Informational — docs site only.)* +- **`v-html` in `CodeBlock.vue`.** Renders Prism-highlighted output via `v-html`. Input is build-time-authored doc content and Prism escapes HTML, so this is not an exploitable XSS today. *(Informational — docs site only.)* +- **Dead / duplicate files.** `src/utils/timeoutManager.ts` is effectively empty (1 line) and imported nowhere; `src/scripts/deploy-wiki.sh` is a byte-for-byte duplicate of `scripts/deploy-wiki.sh`. Housekeeping, not security — safe to remove. + +## Prioritized recommendations + +1. **Merge PR #75** — CVE-2026-0755 fix. *(Critical — done, pending merge.)* +2. **Remove unused runtime deps** (`ai`, `chalk`, `d3-shape`, `inquirer`, `prismjs`) — removes the `tmp` HIGH from the shipped tree. *(High, low effort.)* +3. **Plan `@modelcontextprotocol/sdk` 0.5 → 1.x upgrade.** *(High, breaking — needs code changes.)* +4. **Gate verbose prompt/argument logging** behind a debug flag. *(Low.)* +5. **Windows:** drop `shell: true` in favor of the node + `gemini.js` approach (issue #62) to remove `%VAR%` expansion and quoting fragility. *(Low.)* +6. **`npm audit fix`** for the docs/build toolchain. *(Low.)* diff --git a/package.json b/package.json index cb1f096..6e5aa3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gemini-mcp-tool", - "version": "1.1.6", + "version": "1.2.0", "description": "MCP server for Gemini CLI integration", "type": "module", "main": "dist/index.js", @@ -8,10 +8,11 @@ "gemini-mcp": "dist/index.js" }, "scripts": { - "build": "tsc", + "build": "tsc -p tsconfig.build.json", + "prepare": "npm run build", "start": "node dist/index.js", "dev": "tsc && node dist/index.js", - "test": "echo \"No tests yet\" && exit 0", + "test": "node scripts/run-tests.mjs", "lint": "tsc --noEmit", "contribute": "tsx src/contribute.ts", "prepublishOnly": "echo '⚠️ Remember to test locally first!' && npm run build", @@ -38,7 +39,7 @@ }, "homepage": "https://github.com/jamubc/gemini-mcp-tool#readme", "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" }, "files": [ "dist/", diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..5ba7268 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Discover and run every *.test.ts under src/ with the built-in node:test +// runner, using the tsx loader so the TypeScript sources run directly. +import { spawnSync } from "node:child_process"; +import { readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const srcDir = path.join(scriptDir, "..", "src"); + +function findTests(dir) { + const found = []; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) found.push(...findTests(full)); + else if (entry.endsWith(".test.ts")) found.push(full); + } + return found; +} + +const tests = findTests(srcDir); +if (tests.length === 0) { + console.log("No test files found."); + process.exit(0); +} + +const result = spawnSync( + process.execPath, + ["--import", "tsx", "--test", ...tests], + { stdio: "inherit" }, +); +process.exit(result.status ?? 1); diff --git a/src/backends/agy.test.ts b/src/backends/agy.test.ts new file mode 100644 index 0000000..3e9418c --- /dev/null +++ b/src/backends/agy.test.ts @@ -0,0 +1,26 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildAgyArgs } from "./agy.js"; + +test("buildAgyArgs maps prompt, sessions, sandbox, and yolo", () => { + assert.deepEqual(buildAgyArgs("hi", {}), ["-p", "hi"]); + assert.deepEqual(buildAgyArgs("hi", { resume: "latest" }), ["--continue", "-p", "hi"]); + assert.deepEqual(buildAgyArgs("hi", { resume: "conv-1" }), [ + "--conversation", + "conv-1", + "-p", + "hi", + ]); + assert.deepEqual(buildAgyArgs("hi", { sessionId: "conv-2" }), [ + "--conversation", + "conv-2", + "-p", + "hi", + ]); + assert.deepEqual(buildAgyArgs("hi", { sandbox: true, approvalMode: "yolo" }), [ + "--sandbox", + "--dangerously-skip-permissions", + "-p", + "hi", + ]); +}); diff --git a/src/backends/agy.ts b/src/backends/agy.ts new file mode 100644 index 0000000..f0c1d26 --- /dev/null +++ b/src/backends/agy.ts @@ -0,0 +1,138 @@ +import { readFileSync } from "fs"; +import os from "os"; +import path from "path"; +import { Logger } from "../utils/logger.js"; +import { CLI, APPROVAL_MODES } from "../constants.js"; +import { executeCommand } from "../utils/commandExecutor.js"; +import type { Backend, BackendRunOptions } from "./types.js"; + +/** + * EXPERIMENTAL Antigravity CLI (`agy`) backend — opt in with GEMINI_MCP_BACKEND=agy. + * + * agy is gemini-cli's successor (Gemini CLI is retired 2026-06-18 for free/Pro/ + * Ultra tiers). Two caveats drive this implementation: + * 1. Print-mode (`agy -p`) is broken in 1.0.x — it returns exit 0 but writes + * nothing to stdout. We therefore recover the reply from agy's own transcript + * on disk when stdout is empty (matching the community MCP bridge). + * 2. Print-mode is hardcoded to Gemini 3.5 Flash; `model` is ignored. + */ + +const AGY_BASE = path.join(os.homedir(), ".gemini", "antigravity-cli"); +const LAST_CONVERSATIONS = path.join(AGY_BASE, "cache", "last_conversations.json"); +const transcriptPath = (id: string) => + path.join(AGY_BASE, "brain", id, ".system_generated", "logs", "transcript.jsonl"); + +interface TranscriptEntry { + source?: string; + type?: string; + status?: string; + content?: string; +} + +/** Map the current workspace directory to its most recent agy conversation id. */ +function conversationIdForCwd(cwd: string): string | undefined { + try { + const map = JSON.parse(readFileSync(LAST_CONVERSATIONS, "utf8")) as Record; + return map[cwd] ?? map[path.resolve(cwd)]; + } catch (e) { + Logger.warn(`agy: could not read last_conversations.json: ${(e as Error).message}`); + return undefined; + } +} + +/** Read the model's reply(s) for a conversation from the transcript on disk. */ +export function readTranscriptResponse(id: string): string { + let lines: string[]; + try { + lines = readFileSync(transcriptPath(id), "utf8").split(/\r?\n/).filter(Boolean); + } catch (e) { + throw new Error( + `agy: response transcript not found for conversation ${id}: ${(e as Error).message}`, + ); + } + + const entries: TranscriptEntry[] = []; + for (const line of lines) { + try { + entries.push(JSON.parse(line) as TranscriptEntry); + } catch { + /* skip malformed lines */ + } + } + + // Take the model planner responses that follow the last user input. + let lastUserIdx = -1; + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].type === "USER_INPUT") { + lastUserIdx = i; + break; + } + } + const replies = entries + .slice(lastUserIdx + 1) + .filter( + (e) => + e.source === "MODEL" && + e.type === "PLANNER_RESPONSE" && + e.status === "DONE" && + typeof e.content === "string", + ) + .map((e) => e.content as string); + + const text = replies.join("\n\n").trim(); + if (!text) { + throw new Error(`agy: no model response found in transcript for conversation ${id}`); + } + return text; +} + +export function buildAgyArgs(prompt: string, opts: BackendRunOptions): string[] { + const args: string[] = []; + // Sessions: --continue resumes the most recent; --conversation a specific one. + if (opts.resume) { + if (opts.resume === "latest") args.push("--continue"); + else args.push("--conversation", opts.resume); + } else if (opts.sessionId) { + args.push("--conversation", opts.sessionId); + } + if (opts.sandbox) args.push("--sandbox"); + // agy has no graded approval modes; only "skip all prompts" maps cleanly. + if (opts.approvalMode === APPROVAL_MODES.YOLO) args.push("--dangerously-skip-permissions"); + args.push("-p", prompt); + return args; +} + +// Serialize agy calls: each run rewrites last_conversations.json, so concurrent +// runs would read each other's conversation ids back. +let agyQueue: Promise = Promise.resolve(); + +export const agyBackend: Backend = { + name: "agy", + supportsModelSelection: false, // print-mode is hardcoded to Gemini 3.5 Flash + run(prompt, opts) { + const task = agyQueue.then(async () => { + Logger.warn( + "[experimental] agy backend: print-mode is Flash-only and recovers output from transcript files.", + ); + const cwd = process.cwd(); + const args = buildAgyArgs(prompt, opts); + const stdout = await executeCommand(CLI.COMMANDS.AGY, args, opts.onProgress); + if (stdout && stdout.trim()) return stdout.trim(); // future agy may fix -p stdout + + const id = conversationIdForCwd(cwd); + if (!id) { + throw new Error( + `agy: produced no stdout and no conversation id was found for ${cwd}. ` + + "Run `agy -i` once to authenticate, then retry.", + ); + } + return readTranscriptResponse(id); + }); + // Keep the chain alive regardless of this call's outcome. + agyQueue = task.then( + () => undefined, + () => undefined, + ); + return task; + }, +}; diff --git a/src/backends/gemini.test.ts b/src/backends/gemini.test.ts new file mode 100644 index 0000000..75749a2 --- /dev/null +++ b/src/backends/gemini.test.ts @@ -0,0 +1,61 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveApprovalMode, buildGeminiArgs } from "./gemini.js"; + +const ENV_KEY = "GEMINI_MCP_APPROVAL_MODE"; + +function withEnv(value: string | undefined, fn: () => void): void { + const prev = process.env[ENV_KEY]; + if (value === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = value; + try { + fn(); + } finally { + if (prev === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = prev; + } +} + +test("resolveApprovalMode is opt-in (undefined unless set) and rejects unknown values", () => { + withEnv(undefined, () => { + assert.equal(resolveApprovalMode(), undefined); + assert.equal(resolveApprovalMode("bogus"), undefined); + assert.equal(resolveApprovalMode("yolo"), "yolo"); + assert.equal(resolveApprovalMode("plan"), "plan"); + }); +}); + +test("resolveApprovalMode reads the env var, but the arg overrides it", () => { + withEnv("auto_edit", () => { + assert.equal(resolveApprovalMode(), "auto_edit"); + assert.equal(resolveApprovalMode("plan"), "plan"); + }); +}); + +test("buildGeminiArgs forces no approval mode by default", () => { + withEnv(undefined, () => { + assert.deepEqual(buildGeminiArgs("gemini-2.5-flash", { sandbox: true }), [ + "-m", + "gemini-2.5-flash", + "-s", + ]); + assert.deepEqual(buildGeminiArgs(undefined, { resume: "abc" }), ["--resume", "abc"]); + assert.deepEqual(buildGeminiArgs(undefined, { sessionId: "xyz" }), [ + "--session-id", + "xyz", + ]); + }); +}); + +test("buildGeminiArgs adds the approval flag only when requested; resume beats sessionId", () => { + withEnv(undefined, () => { + assert.deepEqual(buildGeminiArgs(undefined, { approvalMode: "yolo" }), [ + "--approval-mode", + "yolo", + ]); + assert.deepEqual( + buildGeminiArgs(undefined, { approvalMode: "plan", resume: "r1", sessionId: "s1" }), + ["--approval-mode", "plan", "--resume", "r1"], + ); + }); +}); diff --git a/src/backends/gemini.ts b/src/backends/gemini.ts new file mode 100644 index 0000000..cc7ea9c --- /dev/null +++ b/src/backends/gemini.ts @@ -0,0 +1,87 @@ +import { executeCommand } from "../utils/commandExecutor.js"; +import { Logger } from "../utils/logger.js"; +import { + CLI, + MODELS, + ERROR_MESSAGES, + APPROVAL_MODES, + ENV, + type ApprovalMode, +} from "../constants.js"; +import type { Backend, BackendRunOptions } from "./types.js"; + +const VALID_APPROVAL_MODES = Object.values(APPROVAL_MODES) as string[]; + +/** + * Resolve the approval mode: explicit arg > GEMINI_MCP_APPROVAL_MODE env. This + * is OPT-IN — when neither is set we return undefined and pass no flag, so the + * Gemini CLI behaves exactly as it does today for plain Q&A. (We deliberately do + * NOT default to "plan": in headless `-p` mode that turns Gemini into an + * autonomous planner that ignores simple questions and can error out.) Unknown + * values are ignored rather than forced. + */ +export function resolveApprovalMode(arg?: string): ApprovalMode | undefined { + const candidate = arg || process.env[ENV.APPROVAL_MODE]; + if (!candidate) return undefined; + return VALID_APPROVAL_MODES.includes(candidate) ? (candidate as ApprovalMode) : undefined; +} + +/** Build the Gemini CLI argv (minus the prompt, which may go on stdin). */ +export function buildGeminiArgs( + model: string | undefined, + opts: BackendRunOptions, +): string[] { + const args: string[] = []; + if (model) args.push(CLI.FLAGS.MODEL, model); + if (opts.sandbox) args.push(CLI.FLAGS.SANDBOX); + const approval = resolveApprovalMode(opts.approvalMode); + if (approval) args.push(CLI.FLAGS.APPROVAL_MODE, approval); + // Native sessions: resume a prior session, or start/identify one by id. + if (opts.resume) args.push(CLI.FLAGS.RESUME, opts.resume); + else if (opts.sessionId) args.push(CLI.FLAGS.SESSION_ID, opts.sessionId); + return args; +} + +async function runOnce( + prompt: string, + model: string | undefined, + opts: BackendRunOptions, +): Promise { + const args = buildGeminiArgs(model, opts); + if (!opts.useStdin) args.push(CLI.FLAGS.PROMPT, prompt); + return executeCommand( + CLI.COMMANDS.GEMINI, + args, + opts.onProgress, + opts.useStdin ? prompt : undefined, + ); +} + +export const geminiBackend: Backend = { + name: "gemini", + supportsModelSelection: true, + async run(prompt, opts) { + const model = opts.model; + try { + return await runOnce(prompt, model, opts); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // gemini-2.5-pro quota exhausted → retry once on flash (unless already flash). + if (message.includes(ERROR_MESSAGES.QUOTA_EXCEEDED) && model !== MODELS.FLASH) { + Logger.warn(`${ERROR_MESSAGES.QUOTA_EXCEEDED}. Falling back to ${MODELS.FLASH}.`); + try { + const result = await runOnce(prompt, MODELS.FLASH, opts); + Logger.warn(`Successfully executed with ${MODELS.FLASH} fallback.`); + return result; + } catch (fallbackError) { + const fe = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + throw new Error( + `${MODELS.PRO} quota exceeded, ${MODELS.FLASH} fallback also failed: ${fe}`, + ); + } + } + throw error; + } + }, +}; diff --git a/src/backends/index.test.ts b/src/backends/index.test.ts new file mode 100644 index 0000000..e5c3e4f --- /dev/null +++ b/src/backends/index.test.ts @@ -0,0 +1,16 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { getBackend } from "./index.js"; + +test("getBackend defaults to gemini", () => { + assert.equal(getBackend({}).name, "gemini"); + assert.equal(getBackend({ GEMINI_MCP_BACKEND: "" }).name, "gemini"); + assert.equal(getBackend({ GEMINI_MCP_BACKEND: "gemini" }).name, "gemini"); + assert.equal(getBackend({ GEMINI_MCP_BACKEND: "unknown" }).name, "gemini"); +}); + +test("getBackend selects agy when requested (case-insensitive, incl. 'antigravity')", () => { + assert.equal(getBackend({ GEMINI_MCP_BACKEND: "agy" }).name, "agy"); + assert.equal(getBackend({ GEMINI_MCP_BACKEND: "AGY" }).name, "agy"); + assert.equal(getBackend({ GEMINI_MCP_BACKEND: "antigravity" }).name, "agy"); +}); diff --git a/src/backends/index.ts b/src/backends/index.ts new file mode 100644 index 0000000..25bf440 --- /dev/null +++ b/src/backends/index.ts @@ -0,0 +1,26 @@ +import { ENV } from "../constants.js"; +import type { Backend } from "./types.js"; +import { geminiBackend } from "./gemini.js"; +import { agyBackend } from "./agy.js"; + +export type { Backend, BackendRunOptions } from "./types.js"; +export { geminiBackend } from "./gemini.js"; +export { agyBackend } from "./agy.js"; + +/** + * Select the active backend from GEMINI_MCP_BACKEND. Defaults to the Gemini CLI; + * "agy"/"antigravity" selects the experimental Antigravity CLI backend. + */ +export function getBackend(env: NodeJS.ProcessEnv = process.env): Backend { + const name = (env[ENV.BACKEND] || "gemini").trim().toLowerCase(); + switch (name) { + case "agy": + case "antigravity": + return agyBackend; + case "gemini": + case "": + return geminiBackend; + default: + return geminiBackend; + } +} diff --git a/src/backends/types.ts b/src/backends/types.ts new file mode 100644 index 0000000..a395e17 --- /dev/null +++ b/src/backends/types.ts @@ -0,0 +1,29 @@ +import type { ApprovalMode } from "../constants.js"; + +/** + * Options a backend understands. Backends interpret these in their own terms + * (e.g. the gemini backend maps `resume` to `--resume`, the agy backend to + * `--conversation`/`--continue`); unsupported options are ignored. + */ +export interface BackendRunOptions { + model?: string; + sandbox?: boolean; + approvalMode?: ApprovalMode; + sessionId?: string; + resume?: string; + /** + * Deliver the prompt on stdin rather than as a flag argument. Used for + * changeMode / `@file` prompts to dodge cmd.exe parsing and the OS + * command-line length limit. + */ + useStdin?: boolean; + onProgress?: (newOutput: string) => void; +} + +/** A pluggable CLI backend that turns a prompt into model output. */ +export interface Backend { + readonly name: string; + /** Whether `model` selection is honoured (agy print-mode is Flash-only). */ + readonly supportsModelSelection: boolean; + run(prompt: string, options: BackendRunOptions): Promise; +} diff --git a/src/constants.ts b/src/constants.ts index 184cac2..087ea0f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -62,14 +62,18 @@ export const CLI = { // Command names COMMANDS: { GEMINI: "gemini", + AGY: "agy", // Antigravity CLI — experimental backend (gemini-cli's successor) ECHO: "echo", }, - // Command flags + // Command flags (Gemini CLI) FLAGS: { MODEL: "-m", SANDBOX: "-s", PROMPT: "-p", - HELP: "-help", + HELP: "--help", // was "-help" — yargs parsed that as -h -e -l -p (the help bug) + APPROVAL_MODE: "--approval-mode", + SESSION_ID: "--session-id", + RESUME: "--resume", }, // Default values DEFAULTS: { @@ -79,6 +83,26 @@ export const CLI = { }, } as const; +// Gemini CLI approval modes (`gemini --approval-mode `, confirmed in v0.43). +// Opt-in only — when unset, no mode is forced (preserves plain Q&A behaviour). +// plan = autonomous read-only planner · auto_edit = auto-approve edit tools · +// yolo = auto-approve all tools. +export const APPROVAL_MODES = { + DEFAULT: "default", + AUTO_EDIT: "auto_edit", + YOLO: "yolo", + PLAN: "plan", +} as const; +export type ApprovalMode = (typeof APPROVAL_MODES)[keyof typeof APPROVAL_MODES]; + +// Environment variables that configure the server. +export const ENV = { + BACKEND: "GEMINI_MCP_BACKEND", // "gemini" (default) | "agy" + APPROVAL_MODE: "GEMINI_MCP_APPROVAL_MODE", // overridden per-call by the approvalMode arg + GEMINI_CLI_PATH: "GEMINI_CLI_PATH", // explicit path to the gemini executable (Windows shim resolution) + TIMEOUT_MS: "GEMINI_MCP_TIMEOUT_MS", // per-call command timeout in milliseconds +} as const; + // (merged PromptArguments and ToolArguments) export interface ToolArguments { @@ -88,6 +112,9 @@ export interface ToolArguments { changeMode?: boolean | string; chunkIndex?: number | string; // Which chunk to return (1-based) chunkCacheKey?: string; // Optional cache key for continuation + approvalMode?: string; // Gemini approval mode: default | auto_edit | yolo | plan + sessionId?: string; // Start/identify a session (gemini --session-id, agy --conversation) + resume?: string; // Resume a prior session id or "latest" (gemini --resume, agy --continue) message?: string; // For Ping tool -- Un-used. // --> new tool diff --git a/src/index.ts b/src/index.ts index 46c6118..a1d10ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { GetPromptResult, CallToolResult, } from "@modelcontextprotocol/sdk/types.js"; +import { readFileSync } from "node:fs"; import { Logger } from "./utils/logger.js"; import { PROTOCOL, ToolArguments } from "./constants.js"; @@ -27,10 +28,16 @@ import { getPromptMessage } from "./tools/index.js"; +// Read the version from package.json at runtime so it never drifts from the +// published version (it previously hardcoded an out-of-date "1.1.4"). +const pkg = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +) as { version: string }; + const server = new Server( { name: "gemini-cli-mcp", - version: "1.1.4", + version: pkg.version, },{ capabilities: { tools: {}, diff --git a/src/tools/ask-gemini.tool.ts b/src/tools/ask-gemini.tool.ts index b6fee71..bfdc917 100644 --- a/src/tools/ask-gemini.tool.ts +++ b/src/tools/ask-gemini.tool.ts @@ -1,9 +1,10 @@ import { z } from 'zod'; import { UnifiedTool } from './registry.js'; import { executeGeminiCLI, processChangeModeOutput } from '../utils/geminiExecutor.js'; -import { - ERROR_MESSAGES, - STATUS_MESSAGES +import { + ERROR_MESSAGES, + STATUS_MESSAGES, + type ApprovalMode, } from '../constants.js'; const askGeminiArgsSchema = z.object({ @@ -13,6 +14,9 @@ const askGeminiArgsSchema = z.object({ changeMode: z.boolean().default(false).describe("Enable structured change mode - formats prompts to prevent tool errors and returns structured edit suggestions that Claude can apply directly"), chunkIndex: z.union([z.number(), z.string()]).optional().describe("Which chunk to return (1-based)"), chunkCacheKey: z.string().optional().describe("Optional cache key for continuation"), + approvalMode: z.enum(['default', 'auto_edit', 'yolo', 'plan']).optional().describe("Optional Gemini approval mode. If omitted, no mode is forced (best for plain Q&A/analysis). 'yolo'/'auto_edit' let Gemini run or edit (use with sandbox); 'plan' makes Gemini an autonomous read-only planner."), + sessionId: z.string().optional().describe("Start or identify a conversation session by id, so a later call can resume it (gemini --session-id)."), + resume: z.string().optional().describe("Resume a prior session by id, or 'latest' for the most recent, to continue a multi-turn conversation (gemini --resume)."), }); export const askGeminiTool: UnifiedTool = { @@ -24,8 +28,8 @@ export const askGeminiTool: UnifiedTool = { }, category: 'gemini', execute: async (args, onProgress) => { - const { prompt, model, sandbox, changeMode, chunkIndex, chunkCacheKey } = args; if (!prompt?.trim()) { throw new Error(ERROR_MESSAGES.NO_PROMPT_PROVIDED); } - + const { prompt, model, sandbox, changeMode, chunkIndex, chunkCacheKey, approvalMode, sessionId, resume } = args; if (!prompt?.trim()) { throw new Error(ERROR_MESSAGES.NO_PROMPT_PROVIDED); } + if (changeMode && chunkIndex && chunkCacheKey) { // Security: validate cacheKey format before any cache access if (typeof chunkCacheKey !== 'string' || !/^[a-f0-9]{8}$/.test(chunkCacheKey)) { @@ -38,15 +42,17 @@ export const askGeminiTool: UnifiedTool = { prompt as string ); } - - const result = await executeGeminiCLI( - prompt as string, - model as string | undefined, - !!sandbox, - !!changeMode, - onProgress - ); - + + const result = await executeGeminiCLI(prompt as string, { + model: model as string | undefined, + sandbox: !!sandbox, + changeMode: !!changeMode, + approvalMode: approvalMode as ApprovalMode | undefined, + sessionId: sessionId as string | undefined, + resume: resume as string | undefined, + onProgress, + }); + if (changeMode) { return processChangeModeOutput( result, @@ -55,6 +61,9 @@ export const askGeminiTool: UnifiedTool = { prompt as string ); } - return `${STATUS_MESSAGES.GEMINI_RESPONSE}\n${result}`; // changeMode false + // Surface the active session id so the caller can resume the conversation. + const activeSession = (resume as string | undefined) || (sessionId as string | undefined); + const sessionNote = activeSession ? `\n\n[session: ${activeSession}]` : ''; + return `${STATUS_MESSAGES.GEMINI_RESPONSE}\n${result}${sessionNote}`; // changeMode false } }; \ No newline at end of file diff --git a/src/tools/brainstorm.tool.ts b/src/tools/brainstorm.tool.ts index 0970ade..e5680d9 100644 --- a/src/tools/brainstorm.tool.ts +++ b/src/tools/brainstorm.tool.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { UnifiedTool } from './registry.js'; import { Logger } from '../utils/logger.js'; import { executeGeminiCLI } from '../utils/geminiExecutor.js'; +import { type ApprovalMode } from '../constants.js'; function buildBrainstormPrompt(config: { prompt: string; @@ -118,6 +119,7 @@ ${domain ? `Given the ${domain} domain, I'll apply the most effective combinatio const brainstormArgsSchema = z.object({ prompt: z.string().min(1).describe("Primary brainstorming challenge or question to explore"), model: z.string().optional().describe("Optional model to use (e.g., 'gemini-2.5-flash'). If not specified, uses the default model (gemini-2.5-pro)."), + approvalMode: z.enum(['default', 'auto_edit', 'yolo', 'plan']).optional().describe("Optional Gemini approval mode. If omitted, no mode is forced."), methodology: z.enum(['divergent', 'convergent', 'scamper', 'design-thinking', 'lateral', 'auto']).default('auto').describe("Brainstorming framework: 'divergent' (generate many ideas), 'convergent' (refine existing), 'scamper' (systematic triggers), 'design-thinking' (human-centered), 'lateral' (unexpected connections), 'auto' (AI selects best)"), domain: z.string().optional().describe("Domain context for specialized brainstorming (e.g., 'software', 'business', 'creative', 'research', 'product', 'marketing')"), constraints: z.string().optional().describe("Known limitations, requirements, or boundaries (budget, time, technical, legal, etc.)"), @@ -138,6 +140,7 @@ export const brainstormTool: UnifiedTool = { const { prompt, model, + approvalMode, methodology = 'auto', domain, constraints, @@ -166,6 +169,10 @@ export const brainstormTool: UnifiedTool = { onProgress?.(`Generating ${ideaCount} ideas via ${methodology} methodology...`); // Execute with Gemini - return await executeGeminiCLI(enhancedPrompt, model as string | undefined, false, false, onProgress); + return await executeGeminiCLI(enhancedPrompt, { + model: model as string | undefined, + approvalMode: approvalMode as ApprovalMode | undefined, + onProgress, + }); } }; \ No newline at end of file diff --git a/src/tools/simple-tools.ts b/src/tools/simple-tools.ts index 64af593..df272b9 100644 --- a/src/tools/simple-tools.ts +++ b/src/tools/simple-tools.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { UnifiedTool } from './registry.js'; import { executeCommand } from '../utils/commandExecutor.js'; +import { CLI } from '../constants.js'; const pingArgsSchema = z.object({ prompt: z.string().default('').describe("Message to echo "), @@ -16,7 +17,7 @@ export const pingTool: UnifiedTool = { category: 'simple', execute: async (args, onProgress) => { const message = args.prompt || args.message || "Pong!"; - return executeCommand("echo", [message as string], onProgress); + return executeCommand(CLI.COMMANDS.ECHO, [message as string], onProgress); } }; @@ -31,6 +32,6 @@ export const helpTool: UnifiedTool = { }, category: 'simple', execute: async (args, onProgress) => { - return executeCommand("gemini", ["-help"], onProgress); + return executeCommand(CLI.COMMANDS.GEMINI, [CLI.FLAGS.HELP], onProgress); } }; \ No newline at end of file diff --git a/src/utils/commandExecutor.test.ts b/src/utils/commandExecutor.test.ts new file mode 100644 index 0000000..5f510ca --- /dev/null +++ b/src/utils/commandExecutor.test.ts @@ -0,0 +1,41 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + quoteForCmd, + resolveCommandForExecution, + buildEnoentErrorMessage, +} from "./commandExecutor.js"; + +test("quoteForCmd wraps in double quotes and doubles embedded quotes", () => { + assert.equal(quoteForCmd("hello"), '"hello"'); + assert.equal(quoteForCmd("a&calc"), '"a&calc"'); // cmd metachar made inert by quoting + assert.equal(quoteForCmd('a"b'), '"a""b"'); +}); + +test("quoteForCmd doubles a trailing backslash so it can't escape the closing quote", () => { + assert.equal(quoteForCmd("path\\"), '"path\\\\"'); +}); + +test("resolveCommandForExecution is a no-op off Windows", () => { + if (process.platform !== "win32") { + assert.equal(resolveCommandForExecution("gemini"), "gemini"); + assert.equal(resolveCommandForExecution("echo"), "echo"); + } else { + // On Windows it should at least never return an empty string. + assert.ok(resolveCommandForExecution("gemini").length > 0); + } +}); + +test("buildEnoentErrorMessage gives gemini-specific, platform-aware guidance", () => { + const msg = buildEnoentErrorMessage("gemini"); + assert.match(msg, /Could not find the "gemini"/); + assert.match(msg, /GEMINI_CLI_PATH/); + assert.match(msg, /@google\/gemini-cli/); + assert.match(msg, process.platform === "win32" ? /where gemini/ : /which gemini/); +}); + +test("buildEnoentErrorMessage omits the gemini install hint for other commands", () => { + const msg = buildEnoentErrorMessage("agy"); + assert.match(msg, /Could not find the "agy"/); + assert.doesNotMatch(msg, /@google\/gemini-cli/); +}); diff --git a/src/utils/commandExecutor.ts b/src/utils/commandExecutor.ts index edf90c7..f31e42f 100644 --- a/src/utils/commandExecutor.ts +++ b/src/utils/commandExecutor.ts @@ -1,24 +1,86 @@ -import { spawn } from "child_process"; +import { spawn, execSync } from "child_process"; import { Logger } from "./logger.js"; +import { CLI, ENV } from "../constants.js"; +import { resolveTimeoutMs } from "./timeoutManager.js"; // Quote a single argument for cmd.exe (used by spawn's shell:true on Windows). // Embedded quotes are doubled and backslash runs before a quote (or the closing // quote) are doubled so they don't escape it, per CommandLineToArgvW rules. Note // cmd still expands %VAR%/!VAR! inside quotes — an env read at worst, not RCE. -function quoteForCmd(arg: string): string { +export function quoteForCmd(arg: string): string { const body = String(arg).replace(/(\\*)"/g, '$1$1""').replace(/(\\+)$/, '$1$1'); return `"${body}"`; } +// Windows-only: find the real executable for the gemini command. The MCP server +// often runs without the user's interactive PATH, so we (1) honour an explicit +// GEMINI_CLI_PATH override, then (2) ask `where` and prefer the `.cmd` shim that +// Node can actually launch (over .ps1/.bat/.exe). Falls back to "gemini.cmd". +// Resolution is cached per command for the life of the process. +const resolveCache = new Map(); +export function resolveCommandForExecution(command: string): string { + if (process.platform !== "win32" || command !== CLI.COMMANDS.GEMINI) return command; + + const cached = resolveCache.get(command); + if (cached) return cached; + + let resolved: string = command; + const override = process.env[ENV.GEMINI_CLI_PATH]?.trim(); + if (override) { + resolved = override; + } else { + try { + const out = execSync(`where ${command}`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const candidates = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + const byExt = (ext: string) => candidates.find((c) => c.toLowerCase().endsWith(ext)); + resolved = + byExt(".cmd") || byExt(".ps1") || byExt(".bat") || byExt(".exe") || + candidates[0] || `${command}.cmd`; + } catch { + resolved = `${command}.cmd`; + } + } + + resolveCache.set(command, resolved); + return resolved; +} + +// Actionable guidance when the executable can't be found (ENOENT). The most +// common cause is the MCP server not inheriting the user's interactive PATH. +export function buildEnoentErrorMessage(command: string): string { + const isWindows = process.platform === "win32"; + const lines = [ + `Could not find the "${command}" executable.`, + `The MCP server runs in its own process and may not inherit your shell's PATH.`, + `• Verify it is installed and resolvable: \`${isWindows ? "where" : "which"} ${command}\`.`, + ]; + if (command === CLI.COMMANDS.GEMINI) { + lines.push( + `• Install it: \`npm install -g @google/gemini-cli\`.`, + isWindows + ? `• Or set ${ENV.GEMINI_CLI_PATH} to the full path of the gemini shim (e.g. C:\\path\\to\\gemini.cmd).` + : `• Or set ${ENV.GEMINI_CLI_PATH} to the full path of the gemini executable.`, + ); + } + return lines.join("\n"); +} + export async function executeCommand( command: string, args: string[], - onProgress?: (newOutput: string) => void + onProgress?: (newOutput: string) => void, + stdinData?: string, ): Promise { return new Promise((resolve, reject) => { const startTime = Date.now(); Logger.commandExecution(command, args, startTime); + const isWindows = process.platform === "win32"; + const resolvedCommand = resolveCommandForExecution(command); + // Windows quirk: Node 22+ blocks spawning `.cmd` / `.bat` shims without // `shell: true` (CVE-2024-27980). But shell:true routes the command through // cmd.exe, which re-parses the joined line — so EVERY argument must be @@ -26,23 +88,61 @@ export async function executeCommand( // trigger command injection even in tokens without spaces (e.g. a prompt // `a&calc`); wrapping each arg in double quotes makes them inert. This is a // no-op on macOS / Linux, where shell:false passes argv directly. - const isWindows = process.platform === "win32"; const safeArgs = isWindows ? args.map(quoteForCmd) : args; + // A resolved full path may contain spaces; quote it for cmd.exe. A bare + // command name (no whitespace) passes through unchanged to preserve the + // exact, already-tested shim-launch behaviour. + const spawnCommand = + isWindows && /\s/.test(resolvedCommand) ? `"${resolvedCommand}"` : resolvedCommand; - const childProcess = spawn(command, safeArgs, { + // Complex prompts arrive on stdin (see geminiExecutor) to bypass cmd.exe + // parsing and the OS command-line length limit; only open stdin then. + // windowsHide suppresses the popup console window on Windows (no-op elsewhere). + const childProcess = spawn(spawnCommand, safeArgs, { env: process.env, shell: isWindows, - stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + stdio: [stdinData !== undefined ? "pipe" : "ignore", "pipe", "pipe"], }); + if (stdinData !== undefined && childProcess.stdin) { + childProcess.stdin.write(stdinData); + childProcess.stdin.end(); + } + let stdout = ""; let stderr = ""; let isResolved = false; let lastReportedLength = 0; - - childProcess.stdout.on("data", (data) => { + + // Release a genuinely hung child after the configured timeout (default 30m; + // GEMINI_MCP_TIMEOUT_MS overrides, 0 disables). SIGTERM first, then SIGKILL. + const timeoutMs = resolveTimeoutMs(); + let timeoutHandle: NodeJS.Timeout | undefined; + const clearTimer = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = undefined; + } + }; + if (timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + if (isResolved) return; + isResolved = true; + Logger.error(`Command timed out after ${timeoutMs}ms; terminating: ${command}`); + try { childProcess.kill("SIGTERM"); } catch { /* already gone */ } + const sigkill = setTimeout(() => { + try { childProcess.kill("SIGKILL"); } catch { /* already gone */ } + }, 2000); + sigkill.unref?.(); + reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`)); + }, timeoutMs); + timeoutHandle.unref?.(); + } + + childProcess.stdout?.on("data", (data) => { stdout += data.toString(); - + // Report new content if callback provided if (onProgress && stdout.length > lastReportedLength) { const newContent = stdout.substring(lastReportedLength); @@ -51,9 +151,8 @@ export async function executeCommand( } }); - // CLI level errors - childProcess.stderr.on("data", (data) => { + childProcess.stderr?.on("data", (data) => { stderr += data.toString(); // find RESOURCE_EXHAUSTED when gemini-2.5-pro quota is exceeded if (stderr.includes("RESOURCE_EXHAUSTED")) { @@ -78,27 +177,32 @@ export async function executeCommand( } }); childProcess.on("error", (error) => { - if (!isResolved) { - isResolved = true; - Logger.error(`Process error:`, error); + if (isResolved) return; + isResolved = true; + clearTimer(); + Logger.error(`Process error:`, error); + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + reject(new Error(buildEnoentErrorMessage(command))); + } else { reject(new Error(`Failed to spawn command: ${error.message}`)); } }); childProcess.on("close", (code) => { - if (!isResolved) { - isResolved = true; - if (code === 0) { - Logger.commandComplete(startTime, code, stdout.length); - resolve(stdout.trim()); - } else { - Logger.commandComplete(startTime, code); - Logger.error(`Failed with exit code ${code}`); - const errorMessage = stderr.trim() || "Unknown error"; - reject( - new Error(`Command failed with exit code ${code}: ${errorMessage}`), - ); - } + if (isResolved) return; + isResolved = true; + clearTimer(); + if (code === 0) { + Logger.commandComplete(startTime, code, stdout.length); + resolve(stdout.trim()); + } else { + Logger.commandComplete(startTime, code); + Logger.error(`Failed with exit code ${code}`); + const errorMessage = stderr.trim() || "Unknown error"; + reject( + new Error(`Command failed with exit code ${code}: ${errorMessage}`), + ); } }); }); -} \ No newline at end of file +} diff --git a/src/utils/geminiExecutor.test.ts b/src/utils/geminiExecutor.test.ts new file mode 100644 index 0000000..2fd922c --- /dev/null +++ b/src/utils/geminiExecutor.test.ts @@ -0,0 +1,17 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { assertSafeFileReferences } from "./geminiExecutor.js"; + +const root = process.cwd(); + +test("assertSafeFileReferences allows in-project @file references", () => { + assert.doesNotThrow(() => assertSafeFileReferences("explain @src/index.ts", root)); + assert.doesNotThrow(() => assertSafeFileReferences("no references at all", root)); + assert.doesNotThrow(() => assertSafeFileReferences("@package.json summarise", root)); +}); + +test("assertSafeFileReferences rejects traversal, home, and absolute references", () => { + assert.throws(() => assertSafeFileReferences("@../secret.txt", root), /outside the project directory/); + assert.throws(() => assertSafeFileReferences("@~/.ssh/id_rsa", root), /outside the project directory/); + assert.throws(() => assertSafeFileReferences("@/etc/passwd", root), /outside the project directory/); +}); diff --git a/src/utils/geminiExecutor.ts b/src/utils/geminiExecutor.ts index 6cae0aa..e754934 100644 --- a/src/utils/geminiExecutor.ts +++ b/src/utils/geminiExecutor.ts @@ -1,12 +1,7 @@ import * as path from 'path'; -import { executeCommand } from './commandExecutor.js'; import { Logger } from './logger.js'; -import { - ERROR_MESSAGES, - STATUS_MESSAGES, - MODELS, - CLI -} from '../constants.js'; +import type { ApprovalMode } from '../constants.js'; +import { getBackend } from '../backends/index.js'; import { parseChangeModeOutput, validateChangeModeEdits } from './changeModeParser.js'; import { formatChangeModeResponse, summarizeChangeModeEdits } from './changeModeTranslator.js'; @@ -43,13 +38,21 @@ export function assertSafeFileReferences(prompt: string, root: string = process. } } +export interface ExecuteGeminiOptions { + model?: string; + sandbox?: boolean; + changeMode?: boolean; + approvalMode?: ApprovalMode; + sessionId?: string; + resume?: string; + onProgress?: (newOutput: string) => void; +} + export async function executeGeminiCLI( prompt: string, - model?: string, - sandbox?: boolean, - changeMode?: boolean, - onProgress?: (newOutput: string) => void + options: ExecuteGeminiOptions = {}, ): Promise { + const { model, sandbox, changeMode, approvalMode, sessionId, resume, onProgress } = options; let prompt_processed = prompt; if (changeMode) { @@ -118,48 +121,25 @@ ${prompt_processed} prompt_processed = changeModeInstructions; } - // Block @file references that escape the project root before the prompt - // reaches the Gemini CLI's file-inlining parser (CVE-2026-0755). + // Security: block @file refs that escape the project root before the prompt + // reaches any CLI that inlines file contents (CVE-2026-0755). assertSafeFileReferences(prompt_processed); - const args = []; - if (model) { args.push(CLI.FLAGS.MODEL, model); } - if (sandbox) { args.push(CLI.FLAGS.SANDBOX); } - - // spawn runs with shell: false (and cmd.exe-safe quoting on Windows is - // handled in commandExecutor), so the prompt is passed verbatim as a single - // argv entry. No manual quoting here — wrapping in `"` only injects literal - // quote characters and corrupts @file references (#66, CVE-2026-0755). - args.push(CLI.FLAGS.PROMPT, prompt_processed); - - try { - return await executeCommand(CLI.COMMANDS.GEMINI, args, onProgress); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes(ERROR_MESSAGES.QUOTA_EXCEEDED) && model !== MODELS.FLASH) { - Logger.warn(`${ERROR_MESSAGES.QUOTA_EXCEEDED}. Falling back to ${MODELS.FLASH}.`); - await sendStatusMessage(STATUS_MESSAGES.FLASH_RETRY); - const fallbackArgs = []; - fallbackArgs.push(CLI.FLAGS.MODEL, MODELS.FLASH); - if (sandbox) { - fallbackArgs.push(CLI.FLAGS.SANDBOX); - } - - // Pass the prompt verbatim here too (see note in the primary path). - fallbackArgs.push(CLI.FLAGS.PROMPT, prompt_processed); - try { - const result = await executeCommand(CLI.COMMANDS.GEMINI, fallbackArgs, onProgress); - Logger.warn(`Successfully executed with ${MODELS.FLASH} fallback.`); - await sendStatusMessage(STATUS_MESSAGES.FLASH_SUCCESS); - return result; - } catch (fallbackError) { - const fallbackErrorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); - throw new Error(`${MODELS.PRO} quota exceeded, ${MODELS.FLASH} fallback also failed: ${fallbackErrorMessage}`); - } - } else { - throw error; - } - } + // changeMode and @file prompts go on stdin (gemini backend) to keep large + // prompts under the OS command-line length limit and away from cmd.exe + // parsing on Windows; simple prompts use -p. The selected backend + // (gemini by default, agy when GEMINI_MCP_BACKEND=agy) handles the rest. + const useStdin = !!changeMode || prompt_processed.includes('@'); + + return getBackend().run(prompt_processed, { + model, + sandbox, + approvalMode, + sessionId, + resume, + useStdin, + onProgress, + }); } export async function processChangeModeOutput( @@ -229,9 +209,4 @@ export async function processChangeModeOutput( Logger.debug(`ChangeMode: Parsed ${edits.length} edits, ${chunks.length} chunks, returning chunk ${returnChunkIndex}`); return result; -} - -// Placeholder -async function sendStatusMessage(message: string): Promise { - Logger.debug(`Status: ${message}`); } \ No newline at end of file diff --git a/src/utils/timeoutManager.test.ts b/src/utils/timeoutManager.test.ts new file mode 100644 index 0000000..f2f2f21 --- /dev/null +++ b/src/utils/timeoutManager.test.ts @@ -0,0 +1,19 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveTimeoutMs, DEFAULT_COMMAND_TIMEOUT_MS } from "./timeoutManager.js"; + +test("resolveTimeoutMs: default when unset or blank", () => { + assert.equal(resolveTimeoutMs({}), DEFAULT_COMMAND_TIMEOUT_MS); + assert.equal(resolveTimeoutMs({ GEMINI_MCP_TIMEOUT_MS: "" }), DEFAULT_COMMAND_TIMEOUT_MS); + assert.equal(resolveTimeoutMs({ GEMINI_MCP_TIMEOUT_MS: " " }), DEFAULT_COMMAND_TIMEOUT_MS); +}); + +test("resolveTimeoutMs: honours a positive override", () => { + assert.equal(resolveTimeoutMs({ GEMINI_MCP_TIMEOUT_MS: "5000" }), 5000); +}); + +test("resolveTimeoutMs: 0, negative, or invalid disables the timeout (returns 0)", () => { + assert.equal(resolveTimeoutMs({ GEMINI_MCP_TIMEOUT_MS: "0" }), 0); + assert.equal(resolveTimeoutMs({ GEMINI_MCP_TIMEOUT_MS: "-1" }), 0); + assert.equal(resolveTimeoutMs({ GEMINI_MCP_TIMEOUT_MS: "abc" }), 0); +}); diff --git a/src/utils/timeoutManager.ts b/src/utils/timeoutManager.ts index e69de29..2764359 100644 --- a/src/utils/timeoutManager.ts +++ b/src/utils/timeoutManager.ts @@ -0,0 +1,20 @@ +import { ENV } from "../constants.js"; + +// Default per-command timeout. Large-codebase analyses can legitimately run for +// many minutes (see STATUS_MESSAGES), so this is deliberately generous — it +// exists to release a genuinely hung child process, not to cap normal work. +// Override with GEMINI_MCP_TIMEOUT_MS (milliseconds); set it to 0 to disable. +export const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + +/** + * Resolve the per-command timeout in milliseconds from the environment, falling + * back to {@link DEFAULT_COMMAND_TIMEOUT_MS}. A value of 0 — or any negative / + * non-numeric value — disables the timeout and returns 0. + */ +export function resolveTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env[ENV.TIMEOUT_MS]; + if (raw === undefined || raw.trim() === "") return DEFAULT_COMMAND_TIMEOUT_MS; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return 0; // disabled / invalid + return parsed; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..50d32b9 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "scripts", "src/**/*.test.ts"] +} From 4c9b9b3ff197461f6431bcc15e4ab829e385cb31 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 30 May 2026 14:15:08 -0700 Subject: [PATCH 2/8] fix: address PR #78 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commandExecutor: add an 'error' listener on child stdin so an EPIPE (child exited before reading) is logged instead of crashing the server (gemini-code-assist). - commandExecutor: on Windows, terminate timed-out processes with 'taskkill /pid /T /F' — with shell:true, kill() only hit cmd.exe and orphaned the real gemini/agy child (gemini-code-assist). - resolveCommandForExecution: prefer .cmd/.exe/.bat and stop preferring .ps1, which cmd.exe (shell:true) can't launch directly (Copilot). - run-tests.mjs: feature-detect the tsx loader — '--import tsx' on Node >=20.6, '--loader tsx' below (the >=18 floor lacks --import) (Copilot). - ask-gemini: don't emit 'latest' as a [session: ...] id and clarify it's the requested id, not one parsed from the CLI (Copilot). --- docs/.vitepress/config.js | 1 + docs/api.md | 103 +++++++++++++++++- docs/concepts/configuration.md | 169 ++++++++++++++++++++++++++++++ docs/concepts/how-it-works.md | 46 ++++++-- docs/concepts/models.md | 20 ++-- docs/getting-started.md | 32 +++++- docs/index.md | 5 +- docs/installation.md | 2 +- docs/resources/faq.md | 15 ++- docs/resources/roadmap.md | 59 ++++++++--- docs/resources/troubleshooting.md | 13 ++- docs/usage/commands.md | 96 +++++++++++------ scripts/run-tests.mjs | 8 +- src/tools/ask-gemini.tool.ts | 10 +- src/utils/commandExecutor.ts | 29 +++-- 15 files changed, 520 insertions(+), 88 deletions(-) create mode 100644 docs/concepts/configuration.md diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 00a0a60..c8def64 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -39,6 +39,7 @@ export default withMermaid( collapsed: false, items: [ { text: 'How It Works', link: '/concepts/how-it-works' }, + { text: 'Configuration', link: '/concepts/configuration' }, { text: 'File Analysis (@)', link: '/concepts/file-analysis' }, { text: 'Model Selection', link: '/concepts/models' }, { text: 'Sandbox Mode', link: '/concepts/sandbox' } diff --git a/docs/api.md b/docs/api.md index 1f469bd..f9341f1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,3 +1,102 @@ -# API +# API Reference -Stay tuned. \ No newline at end of file +## Tools + +The MCP server exposes the following tools over stdio transport. + +### ask-gemini + +The primary tool for sending prompts to Gemini. + +**Arguments:** + +```typescript +{ + prompt: string; // Required. Use @ to include files. + model?: string; // e.g. "gemini-2.5-flash" + sandbox?: boolean; // default false + changeMode?: boolean; // default false — structured edits + approvalMode?: "default" | "auto_edit" | "yolo" | "plan"; + sessionId?: string; // tag a session + resume?: string; // resume by id or "latest" + chunkIndex?: number; // 1-based chunk (changeMode) + chunkCacheKey?: string; // hex cache key (changeMode) +} +``` + +### brainstorm + +Structured ideation with methodology frameworks. + +**Arguments:** + +```typescript +{ + prompt: string; // Required. The challenge to brainstorm. + model?: string; + approvalMode?: "default" | "auto_edit" | "yolo" | "plan"; + methodology?: "divergent" | "convergent" | "scamper" + | "design-thinking" | "lateral" | "auto"; + domain?: string; // e.g. "software", "business" + constraints?: string; + existingContext?: string; + ideaCount?: number; // default 12 + includeAnalysis?: boolean; // default true +} +``` + +### ping + +Echo test. Returns the input message. + +```typescript +{ prompt?: string; } // defaults to "Pong!" +``` + +### Help + +Returns `gemini --help` output. + +```typescript +{} // no arguments +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `GEMINI_MCP_BACKEND` | `gemini` | Backend: `gemini` or `agy` (experimental) | +| `GEMINI_MCP_APPROVAL_MODE` | *(unset)* | Default approval mode for all calls | +| `GEMINI_MCP_TIMEOUT_MS` | `1800000` | Per-call timeout in ms; `0` disables | +| `GEMINI_CLI_PATH` | *(auto)* | Full path to the gemini executable (Windows) | + +## Transport + +The server uses **stdio** transport (MCP standard). It reads JSON-RPC from stdin and writes responses to stdout. No HTTP server, no ports. + +```json +{ + "mcpServers": { + "gemini-cli": { + "command": "npx", + "args": ["-y", "gemini-mcp-tool"] + } + } +} +``` + +## Backends + +The `BackendProvider` interface is: + +```typescript +interface Backend { + readonly name: string; + readonly supportsModelSelection: boolean; + run(prompt: string, options: BackendRunOptions): Promise; +} +``` + +Two implementations ship: +- **`geminiBackend`** — default, full feature support +- **`agyBackend`** — experimental, Flash-only, transcript-file recovery \ No newline at end of file diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md new file mode 100644 index 0000000..220729f --- /dev/null +++ b/docs/concepts/configuration.md @@ -0,0 +1,169 @@ +# Configuration + +All configuration is done via environment variables in your MCP client config. No config files to manage. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `GEMINI_MCP_APPROVAL_MODE` | *(unset)* | Default approval mode for all calls | +| `GEMINI_MCP_BACKEND` | `gemini` | CLI backend: `gemini` or `agy` | +| `GEMINI_MCP_TIMEOUT_MS` | `1800000` (30 min) | Per-call timeout; `0` disables | +| `GEMINI_CLI_PATH` | *(auto-detect)* | Explicit path to the gemini executable | + +### Setting Environment Variables + +#### Claude Code +```bash +claude mcp add gemini-cli -e GEMINI_MCP_APPROVAL_MODE=plan -- npx -y gemini-mcp-tool +``` + +#### Claude Desktop / Other Clients +```json +{ + "mcpServers": { + "gemini-cli": { + "command": "npx", + "args": ["-y", "gemini-mcp-tool"], + "env": { + "GEMINI_MCP_APPROVAL_MODE": "plan", + "GEMINI_MCP_TIMEOUT_MS": "1800000" + } + } + } +} +``` + +--- + +## Approval Mode + +Controls how much autonomy Gemini has when processing a request. Maps directly to `gemini --approval-mode`. + +| Mode | Behaviour | Use Case | +|------|-----------|----------| +| *(unset)* | No flag passed — Gemini behaves as normal Q&A | Default; best for analysis and questions | +| `default` | Gemini's own default mode | Same as unset | +| `plan` | Read-only autonomous planner | "Gemini reads, Claude edits" | +| `auto_edit` | Auto-approve file edits, prompt for other tools | Combine with `sandbox` for safe edits | +| `yolo` | Auto-approve everything | CI scripts, fully trusted operations | + +::: warning +In headless mode (`-p`), `plan` turns Gemini into an autonomous planner that may ignore simple questions. Leave unset for plain Q&A. +::: + +### Per-call Override + +The `approvalMode` tool argument overrides the environment variable: + +``` +Ask gemini to review this codebase with approvalMode: "plan" +``` + +--- + +## Backends + +The MCP server can use different CLI backends to talk to Google's models. + +### Gemini CLI (default) + +The standard `gemini` command. Supports model selection, approval modes, sandbox, and native sessions. + +```json +{ + "env": { + "GEMINI_MCP_BACKEND": "gemini" + } +} +``` + +### Antigravity CLI (experimental) + +Google's Antigravity CLI (`agy`) is the successor to `gemini` (Gemini CLI is retired June 18, 2026 for free/Pro/Ultra tiers). Set `GEMINI_MCP_BACKEND=agy` to opt in. + +```json +{ + "env": { + "GEMINI_MCP_BACKEND": "agy" + } +} +``` + +**Caveats:** +- Print mode (`agy -p`) is hardcoded to **Gemini 3.5 Flash** — model selection is ignored +- The `agy -p` stdout bug (exit 0, empty output) is worked around by reading agy's transcript files on disk +- Only `yolo` maps to agy's `--dangerously-skip-permissions`; graded approval modes are not supported +- Calls are serialised to avoid transcript id collision + +::: tip +You don't need to do anything today. Gemini CLI still works for headless/automation use. This backend is here so you're ready when the transition happens. +::: + +--- + +## Timeout + +A per-call timeout protects against hung CLI processes. If the timeout fires, the child is sent `SIGTERM`, then `SIGKILL` after 2 seconds. + +| Value | Behaviour | +|-------|-----------| +| `1800000` (default) | 30-minute timeout | +| Any positive number | Timeout in milliseconds | +| `0` | Disabled — wait forever | + +```json +{ + "env": { + "GEMINI_MCP_TIMEOUT_MS": "600000" + } +} +``` + +::: tip +Large codebase analyses can legitimately run for many minutes. The 30-minute default is deliberately generous — it exists to release genuinely hung processes, not to cap normal work. +::: + +--- + +## Native Sessions + +Multi-turn conversations use the Gemini CLI's own session system — no local transcript storage. + +### Starting a session +Pass `sessionId` to tag a conversation: +``` +ask-gemini with sessionId: "my-review" — review the auth module +``` + +### Resuming a session +Pass `resume` with the session id (or `"latest"`) to continue: +``` +ask-gemini with resume: "my-review" — now suggest improvements +``` + +The response includes a `[session: ]` footer so you can track which session is active. + +::: info +Sessions are backed by `gemini --session-id` / `--resume` on the Gemini backend, and `agy --conversation` / `--continue` on the agy backend. +::: + +--- + +## Windows Executable Resolution + +On Windows, the MCP server often runs without your interactive PATH. The tool resolves the `gemini` command by: + +1. Checking `GEMINI_CLI_PATH` (if set) +2. Running `where gemini` and preferring the `.cmd` shim +3. Falling back to `gemini.cmd` + +If you get "command not found" errors on Windows, set `GEMINI_CLI_PATH` to the full path: + +```json +{ + "env": { + "GEMINI_CLI_PATH": "C:\\Users\\you\\AppData\\Roaming\\npm\\gemini.cmd" + } +} +``` diff --git a/docs/concepts/how-it-works.md b/docs/concepts/how-it-works.md index f9bf5bd..98620e2 100644 --- a/docs/concepts/how-it-works.md +++ b/docs/concepts/how-it-works.md @@ -27,26 +27,58 @@ flowchart LR subgraph main direction TB A[You] --> |"ask gemini..."| B([**Claude**]) - B -..-> |"invokes 'ask-gemini'"| C["Gemini-MCP-Tool"] - C --> |"spawn!"| D[Gemini-CLI] - D e1@-.-> |"response"| C + B -.-> |"invokes 'ask-gemini'"| C["Gemini-MCP-Tool"] + C --> |"dispatch"| D{"Backend"} + D --> |"default"| E[Gemini-CLI] + D -.-> |"experimental"| F["agy"] + E e1@-.-> |"response"| C + F -.-> |"transcript"| C C -.-> |"response"| B B -.-> |"summary response"| A e1@{ animate: true } end subgraph Project - B --> |"edits"| E["`**@*Files***`"] - D -.-> |"reads"| E + B --> |"edits"| G["`**@*Files***`"] + E -.-> |"reads"| G end classDef userNode fill:#1a237e,stroke:#fff,color:#fff,stroke-width:2px classDef claudeNode fill:#e64100,stroke:#fff,color:#fff,stroke-width:2px classDef geminiNode fill:#4285f4,stroke:#fff,color:#fff,stroke-width:2px classDef mcpNode fill:#37474f,stroke:#fff,color:#fff,stroke-width:2px classDef dataNode fill:#1b5e20,stroke:#fff,color:#fff,stroke-width:2px + classDef dispatchNode fill:#6a1b9a,stroke:#fff,color:#fff,stroke-width:2px + classDef agyNode fill:#f57f17,stroke:#fff,color:#fff,stroke-width:2px class A userNode class B claudeNode class C mcpNode - class D geminiNode - class E dataNode + class D dispatchNode + class E geminiNode + class F agyNode + class G dataNode ``` + +## Architecture + +Starting with v1.2.0, the MCP server uses a **pluggable backend** architecture: + +1. **Your MCP client** (Claude Code, Claude Desktop, etc.) sends a tool call via stdio +2. **gemini-mcp-tool** validates arguments, applies security guards (`@file` containment, approval mode), and routes the prompt through the selected backend +3. **The backend** (Gemini CLI by default, Antigravity CLI when opted in) spawns the CLI, handles stdin/stdout, and returns the model response +4. **The MCP server** formats the response and sends it back to your client + +### Key Components + +| Component | What it does | +|-----------|-------------| +| `commandExecutor` | Spawns CLI processes with Windows quoting, timeout/kill, ENOENT guidance | +| `geminiExecutor` | Security guards, changeMode templating, backend dispatch | +| `backends/gemini` | Builds Gemini CLI args, handles quota fallback (Pro → Flash) | +| `backends/agy` | Experimental Antigravity CLI with transcript-file recovery | +| `timeoutManager` | Configurable per-call timeout (SIGTERM → SIGKILL) | + +### Security + +- **CVE-2026-0755**: `@file` references are checked to stay within the project directory before being sent to any CLI +- **CWE-22**: `chunkCacheKey` is validated against a strict hex format +- **Windows injection**: All arguments are quoted for `cmd.exe` even without whitespace, neutralising `& | < > ^ ( )` metacharacters diff --git a/docs/concepts/models.md b/docs/concepts/models.md index e7207db..bcc2828 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -27,19 +27,27 @@ You can also append with '-m' or ask specifically with { "mcpServers": { "gemini-cli": { - "command": "gemini-mcp", - "env": { - "GEMINI_MODEL": "gemini-1.5-flash" - } + "command": "npx", + "args": ["-y", "gemini-mcp-tool"] } } } ``` -### Per Request (Coming Soon) +The model is selected per-request via natural language or the `model` tool argument. + +### Per Request +``` +ask gemini using flash to review this file ``` -/gemini-cli:analyze --model=flash @file.js quick review +or explicitly: ``` +ask-gemini with model: "gemini-2.5-flash" — review @index.ts +``` + +::: warning Antigravity CLI (agy) backend +When using `GEMINI_MCP_BACKEND=agy`, model selection is ignored — print mode is hardcoded to **Gemini 3.5 Flash**. +::: ## Model Comparison diff --git a/docs/getting-started.md b/docs/getting-started.md index 266f160..33621f9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -31,7 +31,7 @@ Before installing, ensure you have: -- **[Node.js](https://nodejs.org/)** v16.0.0 or higher +- **[Node.js](https://nodejs.org/)** v18.0.0 or higher - **[Google Gemini CLI](https://github.com/google-gemini/gemini-cli)** installed and configured on your system - **[Claude Desktop](https://claude.ai/download)** or **[Claude Code](https://www.anthropic.com/claude-code)** with MCP support @@ -78,6 +78,27 @@ For Claude Desktop users, add this to your configuration file: } ``` +### Optional Environment Variables + +You can pass environment variables to configure the server: + +```json +{ + "mcpServers": { + "gemini-cli": { + "command": "npx", + "args": ["-y", "gemini-mcp-tool"], + "env": { + "GEMINI_MCP_APPROVAL_MODE": "plan", + "GEMINI_MCP_TIMEOUT_MS": "1800000" + } + } + } +} +``` + +See [Configuration](/concepts/configuration) for all available environment variables. + ::: warning You must restart Claude Desktop ***completely*** for changes to take effect. ::: @@ -162,6 +183,13 @@ Type `/gemini-cli` and these commands will appear: - `/gemini-cli:sandbox` - Safe code execution - `/gemini-cli:help` - Show help information - `/gemini-cli:ping` - Test connectivity +- `/gemini-cli:brainstorm` - Structured brainstorming with methodology frameworks + +### New in v1.2.0 +- **Approval mode** — control Gemini's autonomy: `approvalMode: "plan"` (read-only) or `"yolo"` (auto-approve) +- **Multi-turn sessions** — pass `sessionId` / `resume` to continue conversations across calls +- **Pluggable backends** — set `GEMINI_MCP_BACKEND=agy` to use the experimental Antigravity CLI +- **Per-call timeout** — configurable via `GEMINI_MCP_TIMEOUT_MS` (default 30 min) ## Need a Different Client? @@ -186,7 +214,7 @@ npm install -g @google/gemini-cli 1. Check your configuration file path 2. Ensure JSON syntax is correct 3. Restart your MCP client completely -4. Verify Gemini CLI works: `gemini -help` +4. Verify Gemini CLI works: `gemini --help` ### Client-Specific Issues diff --git a/docs/index.md b/docs/index.md index 7134bc8..0addc2b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,7 +34,10 @@ features: details: Gemini-mcp-tool does not require any extra keys. - icon: 🚦 title: Model Selection - details: Choose from Gemini-2.5-Pro and Gemini-2.5-Flash, using natural language. + details: Choose from Gemini-2.5-Pro, Gemini-2.5-Flash, or let the agy backend use Gemini 3.5 Flash. + - icon: 🔧 + title: Pluggable Backends + details: Gemini CLI by default, experimental Antigravity CLI (agy) opt-in — future-proof for June 2026. ---
diff --git a/docs/installation.md b/docs/installation.md index ef17c46..90d4c20 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,7 +4,7 @@ Multiple ways to install Gemini MCP Tool, depending on your needs. ## Prerequisites -- Node.js v16.0.0 or higher +- Node.js v18.0.0 or higher - Claude Desktop or Claude Code with MCP support - Gemini CLI installed (`npm install -g @google/gemini-cli`) diff --git a/docs/resources/faq.md b/docs/resources/faq.md index a5004e9..6484d18 100644 --- a/docs/resources/faq.md +++ b/docs/resources/faq.md @@ -6,7 +6,7 @@ A bridge between Claude Desktop and Google's Gemini AI, enabling you to use Gemini's powerful capabilities directly within Claude. ### Does it support windows? -Windows testing is underway, some users have reported success and other failures. +Yes. v1.2.0 adds robust Windows executable resolution (`GEMINI_CLI_PATH`, `.cmd` shim detection), hardened `cmd.exe` argument quoting, and `windowsHide` to suppress console popups. ### Why use this instead of Gemini directly? - Integrated into your existing AI workflow @@ -32,7 +32,7 @@ Then, run "gemini" and complete auth. Yes! It works with both Claude Desktop and Claude Code. ### What Node.js version do I need? -Node.js v16.0.0 or higher. +Node.js v18.0.0 or higher. ## Usage @@ -87,7 +87,16 @@ Check your organization's policies and Google's Gemini API terms of service. ## Advanced ### Can I use this in CI/CD? -Not recommended - designed for interactive development. +Yes — set `GEMINI_MCP_APPROVAL_MODE=yolo` (or `plan` for read-only) to avoid interactive approval prompts. Combine with `GEMINI_MCP_TIMEOUT_MS` for a hard time limit. + +### What is approval mode? +Approval mode controls how much autonomy Gemini has. By default, no mode is forced (plain Q&A). Set `approvalMode: "plan"` for a read-only planner, `"yolo"` to auto-approve everything, or `"auto_edit"` to auto-approve edits only. See [Configuration](/concepts/configuration). + +### What is the agy backend? +Antigravity CLI (`agy`) is Google's successor to Gemini CLI. Set `GEMINI_MCP_BACKEND=agy` to try it. It's experimental — print mode is Flash-only and stdout is recovered from transcript files. See [Configuration](/concepts/configuration#backends). + +### Can I have multi-turn conversations? +Yes — pass `sessionId` to start a named session, then `resume` with the same id (or `"latest"`) in a follow-up call. This uses Gemini's native `--session-id` / `--resume` flags.
diff --git a/docs/resources/roadmap.md b/docs/resources/roadmap.md index e41b36a..f8d9dda 100644 --- a/docs/resources/roadmap.md +++ b/docs/resources/roadmap.md @@ -21,12 +21,18 @@ flowchart LR Auto-Fallback"] B --> C["v1.1.3 Claude Edits, Gemini Reads"] + C --> D["v1.1.5 + Security Fixes"] + D --> E["v1.1.6 + CVE-2026-0755"] + E --> F["v1.2.0 + Backends + Sessions"] classDef releasedNode fill:#1b5e20,stroke:#fff,color:#fff,stroke-width:2px classDef currentNode fill:#e64100,stroke:#fff,color:#fff,stroke-width:2px - class A,B releasedNode - class C currentNode + class A,B,C,D,E releasedNode + class F currentNode ``` @@ -48,21 +54,40 @@ config: timeline title Gemini MCP Tool Evolution - section June 2025 - v1.1.0 Release : Claude uses Gemini! - : Sandbox Mode Testing + section 2025 + v1.1.0-v1.1.3 : Claude uses Gemini! + : Sandbox Mode, Fallback + : Change Mode + + section May 2026 + v1.1.5-v1.1.6 : Security Patches + : CVE-2026-0755 + : CWE-22 path traversal - v1.1.1 Release : Bug Fixes - : Enhanced Tool Descriptions + v1.2.0 Release : Pluggable Backends + : Approval Mode + : Native Sessions + : Per-call Timeout + : Windows Reliability + : Test Suite - section July 2025 - v1.1.2 Release : Fallback System - - v1.1.3 Release : Claude Edits, Gemini Reads! - - Beta Testing : Beta Hooks System - : Deterministic Routing - : Streaming - : Improved Caching + section Next + v1.3.0 Planned : Streaming output + : output-format support + : Full agy backend ``` - \ No newline at end of file + + +## What's Next + +### v1.3.0 (Planned) +- **Streaming output** — `--output-format stream-json` for real-time progress +- **Full agy backend** — once the `agy -p` stdout bug is fixed upstream +- **ACP persistent process** — reuse a long-lived agy process for performance + +### Open PRs (separate merges) +- **#65** — MCP SDK modernization + OAuth +- **#44** — LRU cache for performance +- **#46** — Tool annotations +- **#50** — Native session-id resume (partially landed in 1.2.0) +- **#35** — Gemini schema compatibility \ No newline at end of file diff --git a/docs/resources/troubleshooting.md b/docs/resources/troubleshooting.md index 0a7c914..f55430c 100644 --- a/docs/resources/troubleshooting.md +++ b/docs/resources/troubleshooting.md @@ -134,9 +134,10 @@ claude mcp add gemini-cli -- npx gemini-mcp-tool ``` 4. **For very large codebases, the tool prevents timeouts automatically**: - - Progress updates keep the connection alive - - Clear status messages show processing is active - - No manual configuration needed + - Progress updates keep the connection alive + - Clear status messages show processing is active + - No manual configuration needed + - You can also configure the timeout via `GEMINI_MCP_TIMEOUT_MS` (default 30 min; set to `0` to disable) @@ -147,7 +148,7 @@ claude mcp add gemini-cli -- npx gemini-mcp-tool **Common causes**: -1. **Node.js version compatibility** - Ensure Node.js ≥ v16.0.0 +1. **Node.js version compatibility** - Ensure Node.js ≥ v18.0.0 2. **Gemini CLI not installed** - Install with `npm install -g @google/gemini-cli` 3. **API key not configured** - Run `gemini config set api_key YOUR_API_KEY` 4. **PATH issues** - Restart terminal after installing Node.js/npm @@ -260,6 +261,7 @@ echo $GOOGLE_GENERATIVE_AI_API_KEY - Backup heartbeat every 20 seconds to ensure connection stays alive - Clear status messages showing the tool is working - Automatic completion notification when done +- Configurable via `GEMINI_MCP_TIMEOUT_MS` env var (default 30 min; `0` disables) **For very large codebases** (10,000+ files): - Consider breaking analysis into smaller chunks @@ -340,8 +342,9 @@ gemini "Hello" ### Windows 11 - **NPX flag issues**: Use `--yes` instead of `-y` -- **Path problems**: Restart terminal after Node.js installation +- **Path problems**: Restart terminal after Node.js installation, or set `GEMINI_CLI_PATH` to the full path of `gemini.cmd` - **Connection issues**: Ensure Windows Defender isn't blocking Node.js +- **"Command not found"**: The MCP server may not inherit your shell's PATH. Set `GEMINI_CLI_PATH` in your config `env` block. ### macOS - **Permission issues**: Use `sudo` if npm install fails diff --git a/docs/usage/commands.md b/docs/usage/commands.md index f06e0c0..9414a86 100644 --- a/docs/usage/commands.md +++ b/docs/usage/commands.md @@ -1,52 +1,67 @@ # Commands Reference -Complete list of available commands and their usage. +Complete list of available tools and their arguments. -## Slash Commands +## Tools -### `/gemini-cli:analyze` -Analyze files or ask questions about code. +### `ask-gemini` -``` -/gemini-cli:analyze @file.js explain this code -/gemini-cli:analyze @src/*.ts find security issues -/gemini-cli:analyze how do I implement authentication? -``` +The primary tool — send a prompt to Gemini and get a response. -### `/gemini-cli:sandbox` -Execute code in a safe environment. +| Argument | Type | Default | Description | +|----------|------|---------|-------------| +| `prompt` | string | *(required)* | Your analysis request. Use `@` to include files | +| `model` | string | `gemini-2.5-pro` | Model to use (e.g. `gemini-2.5-flash`) | +| `sandbox` | boolean | `false` | Run in isolated sandbox (`-s` flag) | +| `changeMode` | boolean | `false` | Structured edit mode for Claude to apply | +| `approvalMode` | string | *(unset)* | `default` / `auto_edit` / `yolo` / `plan` | +| `sessionId` | string | — | Start/tag a conversation session | +| `resume` | string | — | Resume a prior session by id, or `"latest"` | +| `chunkIndex` | number | — | Which chunk to return (1-based, for changeMode) | +| `chunkCacheKey` | string | — | Cache key for continuation (changeMode) | ``` -/gemini-cli:sandbox create a Python fibonacci generator -/gemini-cli:sandbox test this function: [code] +/gemini-cli:ask-gemini @file.js explain this code +/gemini-cli:ask-gemini @src/*.ts find security issues ``` -### `/gemini-cli:help` -Show help information and available tools. +### `brainstorm` + +Structured ideation with selectable methodology frameworks. + +| Argument | Type | Default | Description | +|----------|------|---------|-------------| +| `prompt` | string | *(required)* | Brainstorming challenge or question | +| `model` | string | `gemini-2.5-pro` | Model to use | +| `approvalMode` | string | *(unset)* | Gemini approval mode | +| `methodology` | string | `auto` | `divergent` / `convergent` / `scamper` / `design-thinking` / `lateral` / `auto` | +| `domain` | string | — | Domain context (e.g. `software`, `business`) | +| `constraints` | string | — | Known limitations or boundaries | +| `existingContext` | string | — | Background info to build upon | +| `ideaCount` | number | `12` | Target number of ideas | +| `includeAnalysis` | boolean | `true` | Include feasibility/impact scoring | ``` -/gemini-cli:help -/gemini-cli:help analyze +/gemini-cli:brainstorm how can we improve our onboarding flow? ``` -### `/gemini-cli:ping` -Test connectivity with Gemini. +### `Help` + +Show Gemini CLI help information. ``` -/gemini-cli:ping -/gemini-cli:ping "Custom message" +/gemini-cli:Help ``` -## Command Structure +### `ping` + +Test connectivity with an echo. ``` -/gemini-cli: [options] +/gemini-cli:ping +/gemini-cli:ping "Custom message" ``` -- **tool**: The action to perform (analyze, sandbox, help, ping) -- **options**: Optional flags (coming soon) -- **arguments**: Input text, files, or questions - ## Natural Language Alternative Instead of slash commands, you can use natural language: @@ -54,6 +69,7 @@ Instead of slash commands, you can use natural language: - "Use gemini to analyze index.js" - "Ask gemini to create a test file" - "Have gemini explain this error" +- "Brainstorm ideas for the new feature using gemini" ## File Patterns @@ -61,7 +77,6 @@ Instead of slash commands, you can use natural language: ``` @README.md @src/index.js -@test/unit.test.ts ``` ### Multiple Files @@ -82,21 +97,34 @@ Instead of slash commands, you can use natural language: @test/unit/ # All files in test/unit ``` +::: danger Security +`@file` references are restricted to the project directory. Paths like `@../secret.txt`, `@~/.ssh/id_rsa`, or `@/etc/passwd` are rejected (CVE-2026-0755). +::: + ## Advanced Usage -### Combining Files and Questions +### Approval Mode + +Control Gemini's autonomy per-call: ``` -/gemini-cli:analyze @package.json @src/index.js is the entry point configured correctly? +ask gemini with approvalMode "plan" to review the architecture +ask gemini with approvalMode "yolo" and sandbox to run this test suite ``` -### Complex Queries +### Multi-turn Sessions + +Continue a conversation across multiple calls: ``` -/gemini-cli:analyze @src/**/*.js @test/**/*.test.js what's the test coverage? +ask gemini with sessionId "review-1" to review the auth module +ask gemini with resume "review-1" to now suggest improvements +ask gemini with resume "latest" to continue where we left off ``` -### Code Generation +### Change Mode + +Get structured edit suggestions that Claude can apply directly: ``` -/gemini-cli:analyze @models/user.js generate TypeScript types for this model +ask gemini in changeMode to refactor @src/utils.js for readability ``` ## Tips diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 5ba7268..d1a978f 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -25,9 +25,15 @@ if (tests.length === 0) { process.exit(0); } +// tsx is loaded via `--import` on Node >= 20.6, and the older `--loader` flag +// below that (the engines floor is >=18, where `--import` may be unavailable). +const [major, minor] = process.versions.node.split(".").map(Number); +const supportsImport = major > 20 || (major === 20 && minor >= 6); +const loaderArgs = supportsImport ? ["--import", "tsx"] : ["--loader", "tsx"]; + const result = spawnSync( process.execPath, - ["--import", "tsx", "--test", ...tests], + [...loaderArgs, "--test", ...tests], { stdio: "inherit" }, ); process.exit(result.status ?? 1); diff --git a/src/tools/ask-gemini.tool.ts b/src/tools/ask-gemini.tool.ts index bfdc917..09db340 100644 --- a/src/tools/ask-gemini.tool.ts +++ b/src/tools/ask-gemini.tool.ts @@ -61,9 +61,13 @@ export const askGeminiTool: UnifiedTool = { prompt as string ); } - // Surface the active session id so the caller can resume the conversation. - const activeSession = (resume as string | undefined) || (sessionId as string | undefined); - const sessionNote = activeSession ? `\n\n[session: ${activeSession}]` : ''; + // Echo back the session id the caller supplied so follow-up calls can continue + // it. This is the requested id, not one parsed from the CLI; 'latest' is a + // resume selector (not an id), so it is not surfaced. + const requestedSession = + (typeof resume === 'string' && resume !== 'latest' ? resume : undefined) || + (sessionId as string | undefined); + const sessionNote = requestedSession ? `\n\n[session: ${requestedSession}]` : ''; return `${STATUS_MESSAGES.GEMINI_RESPONSE}\n${result}${sessionNote}`; // changeMode false } }; \ No newline at end of file diff --git a/src/utils/commandExecutor.ts b/src/utils/commandExecutor.ts index f31e42f..0fe6e6f 100644 --- a/src/utils/commandExecutor.ts +++ b/src/utils/commandExecutor.ts @@ -36,8 +36,11 @@ export function resolveCommandForExecution(command: string): string { }); const candidates = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); const byExt = (ext: string) => candidates.find((c) => c.toLowerCase().endsWith(ext)); + // Prefer extensions cmd.exe can launch directly (.cmd/.exe/.bat). A `.ps1` + // shim is NOT runnable via shell:true, so it is never preferred — only the + // raw first candidate is used as a last resort. resolved = - byExt(".cmd") || byExt(".ps1") || byExt(".bat") || byExt(".exe") || + byExt(".cmd") || byExt(".exe") || byExt(".bat") || candidates[0] || `${command}.cmd`; } catch { resolved = `${command}.cmd`; @@ -106,6 +109,12 @@ export async function executeCommand( }); if (stdinData !== undefined && childProcess.stdin) { + // If the child has already exited/closed its stdin, write() emits EPIPE on + // the stream; without this listener that becomes an uncaught exception and + // crashes the (long-lived) MCP server. + childProcess.stdin.on("error", (err) => { + Logger.error(`stdin write failed: ${err instanceof Error ? err.message : String(err)}`); + }); childProcess.stdin.write(stdinData); childProcess.stdin.end(); } @@ -130,11 +139,19 @@ export async function executeCommand( if (isResolved) return; isResolved = true; Logger.error(`Command timed out after ${timeoutMs}ms; terminating: ${command}`); - try { childProcess.kill("SIGTERM"); } catch { /* already gone */ } - const sigkill = setTimeout(() => { - try { childProcess.kill("SIGKILL"); } catch { /* already gone */ } - }, 2000); - sigkill.unref?.(); + if (isWindows && childProcess.pid) { + // With shell:true the child is cmd.exe; kill() would orphan the real + // gemini/agy process. taskkill /T terminates the whole process tree. + try { + execSync(`taskkill /pid ${childProcess.pid} /T /F`, { stdio: "ignore" }); + } catch { /* already gone */ } + } else { + try { childProcess.kill("SIGTERM"); } catch { /* already gone */ } + const sigkill = setTimeout(() => { + try { childProcess.kill("SIGKILL"); } catch { /* already gone */ } + }, 2000); + sigkill.unref?.(); + } reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`)); }, timeoutMs); timeoutHandle.unref?.(); From dc55a5700214faa33c0bc13d4dcaff5891641c84 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Sat, 30 May 2026 14:21:05 -0700 Subject: [PATCH 3/8] docs: make version badge dynamic in theme layout --- docs/.vitepress/theme/Layout.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/.vitepress/theme/Layout.vue b/docs/.vitepress/theme/Layout.vue index 7185c30..0e09b6a 100644 --- a/docs/.vitepress/theme/Layout.vue +++ b/docs/.vitepress/theme/Layout.vue @@ -6,7 +6,7 @@