gemini-cli-mcp-fast 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.11", "3.12"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Set up Python ${{ matrix.python-version }}
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+
24
+ - name: Install dependencies
25
+ run: |
26
+ python -m pip install --upgrade pip
27
+ pip install -e ".[dev]"
28
+
29
+ - name: Run tests
30
+ run: python -m pytest tests/ -v
31
+
32
+ - name: Verify import
33
+ run: python -c "from gemini_mcp_server import gemini_version; print('Import OK')"
@@ -0,0 +1,29 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch: # manual trigger
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write # for trusted publishing
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Python
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.11"
21
+
22
+ - name: Install build tools
23
+ run: python -m pip install build
24
+
25
+ - name: Build package
26
+ run: python -m build
27
+
28
+ - name: Publish to PyPI
29
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,4 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .DS_Store
@@ -0,0 +1,30 @@
1
+ FROM python:3.11-slim
2
+
3
+ LABEL org.opencontainers.image.source="https://github.com/jxsprt/gemini-mcp-server"
4
+ LABEL org.opencontainers.image.description="FastMCP server wrapping Google's Gemini CLI"
5
+ LABEL org.opencontainers.image.license="MIT"
6
+
7
+ # Install Node.js for Gemini CLI
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ nodejs \
10
+ npm \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Install Gemini CLI
14
+ RUN npm install -g @google/gemini-cli
15
+
16
+ # Copy and install the Python package
17
+ COPY . /app
18
+ WORKDIR /app
19
+ RUN pip install --no-cache-dir -e ".[dev]"
20
+
21
+ # Verify installs
22
+ RUN gemini --version && python -c "from gemini_mcp_server import mcp; print('Server OK')"
23
+
24
+ # Gemini CLI credentials mount point
25
+ VOLUME /root/.gemini
26
+
27
+ # MCP stdio transport — runs as subprocess of host MCP client
28
+ ENTRYPOINT ["python", "-m", "gemini_mcp_server"]
29
+
30
+ # For HTTP transport, override: docker run -e GEMINI_MCP_HTTP=1 -p 8000:8000 ...
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: gemini-cli-mcp-fast
3
+ Version: 1.0.0
4
+ Summary: FastMCP server wrapping Google's Gemini CLI — use Gemini models from any MCP client
5
+ Project-URL: Homepage, https://github.com/jxsprt/gemini-mcp-server
6
+ Project-URL: Repository, https://github.com/jxsprt/gemini-mcp-server
7
+ Project-URL: Issues, https://github.com/jxsprt/gemini-mcp-server/issues
8
+ Author-email: Jaspreet Singh <jaspreetsinghintp@gmail.com>
9
+ License: MIT
10
+ Keywords: fastmcp,gemini,gemini-cli,mcp,mcp-server
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: fastmcp>=3.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Gemini CLI MCP Server
27
+
28
+ [![CI](https://github.com/jxsprt/gemini-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/jxsprt/gemini-mcp-server/actions/workflows/ci.yml)
29
+ [![PyPI](https://img.shields.io/badge/PyPI-pip%20install%20gemini--mcp--server-blue)](https://pypi.org/project/gemini-mcp-server/)
30
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
31
+ [![PR to Hermes Agent](https://img.shields.io/badge/PR-Hermes%20Agent-%23NouResearch?label=PR)](https://github.com/NousResearch/hermes-agent/pull/22878)
32
+
33
+ A lightweight [FastMCP](https://gofastmcp.com) server that wraps Google's [Gemini CLI](https://github.com/google-gemini/gemini-cli) as MCP tools. Works with any MCP client — Hermes Agent, Claude Code, Claude Desktop, Cursor, etc.
34
+
35
+ ## Prerequisites
36
+
37
+ - **Node.js** — for the Gemini CLI
38
+ - **Python 3.11+** — for the MCP server
39
+ - **Gemini CLI** — installed and authenticated
40
+
41
+ ```bash
42
+ npm install -g @google/gemini-cli
43
+ gemini --version # Verify install
44
+ gemini # Run once to complete OAuth login
45
+ ```
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ # Clone the repo
51
+ git clone https://github.com/jxsprt/gemini-mcp-server.git
52
+ cd gemini-mcp-server
53
+
54
+ # Create venv and install deps
55
+ python3 -m venv .venv
56
+ .venv/bin/pip install -r requirements.txt
57
+ ```
58
+
59
+ ## Configuration
60
+
61
+ Add to your MCP client's config:
62
+
63
+ ### Hermes Agent (`~/.hermes/config.yaml`)
64
+
65
+ ```yaml
66
+ mcp_servers:
67
+ gemini-cli:
68
+ command: "/path/to/gemini-mcp-server/.venv/bin/python3"
69
+ args: ["/path/to/gemini-mcp-server/server.py"]
70
+ timeout: 240
71
+ ```
72
+
73
+ Restart your Hermes gateway. Tools will be available as `mcp_gemini_cli_*`.
74
+
75
+ ### Claude Desktop (`claude_desktop_config.json`)
76
+
77
+ ```json
78
+ {
79
+ "mcpServers": {
80
+ "gemini-cli": {
81
+ "command": "/path/to/gemini-mcp-server/.venv/bin/python3",
82
+ "args": ["/path/to/gemini-mcp-server/server.py"]
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ ### Claude Code
89
+
90
+ ```bash
91
+ fastmcp install claude-code /path/to/gemini-mcp-server/server.py
92
+ ```
93
+
94
+ ### Cursor
95
+
96
+ ```bash
97
+ fastmcp install cursor /path/to/gemini-mcp-server/server.py -e .
98
+ ```
99
+
100
+ ## Tools
101
+
102
+ ### `gemini_prompt`
103
+
104
+ Send any prompt to Gemini CLI non-interactively. Supports model selection and JSON output.
105
+
106
+ **Safe by default** — uses `--approval-mode auto-edit` (auto-approves file edits, prompts for shell). Pass `dangerous=true` for full `yolo` mode.
107
+
108
+ ```python
109
+ gemini_prompt(prompt="Explain TCP handshake", model="gemini-2.5-flash")
110
+ gemini_prompt(prompt="Refactor this module", dangerous=True) # full auto-approval
111
+ ```
112
+
113
+ | Parameter | Type | Required | Default | Description |
114
+ |-----------|------|----------|---------|-------------|
115
+ | `prompt` | string | ✅ | — | Prompt text (max ~100K chars) |
116
+ | `model` | string | ❌ | CLI default | Model name (e.g., `gemini-3-flash-preview`) |
117
+ | `output_format` | string | ❌ | `text` | `text`, `json`, or `stream-json` |
118
+ | `dangerous` | bool | ❌ | `false` | Use `--approval-mode yolo` (auto-approves shell) |
119
+
120
+ ### `gemini_plan`
121
+
122
+ Read-only audit and review mode. Uses `--approval-mode plan` — **guarantees no file mutations or shell execution**. Safe for whole-repo analysis and code reviews.
123
+
124
+ ```python
125
+ gemini_plan(prompt="Review this auth module for security issues")
126
+ ```
127
+
128
+ | Parameter | Type | Required | Default | Description |
129
+ |-----------|------|----------|---------|-------------|
130
+ | `prompt` | string | ✅ | — | Analysis question or review request |
131
+ | `model` | string | ❌ | CLI default | Gemini model |
132
+ | `include_directories` | string | ❌ | — | Comma-separated additional dirs |
133
+
134
+ ### `gemini_version`
135
+
136
+ Returns the installed Gemini CLI version.
137
+
138
+ ```python
139
+ gemini_version() # → "0.41.2"
140
+ ```
141
+
142
+ ## Verification
143
+
144
+ ```bash
145
+ cd /path/to/gemini-mcp-server
146
+ .venv/bin/python -c "
147
+ import server
148
+ print('Version:', server.gemini_version())
149
+ print('Prompt:', server.gemini_prompt('Say hello in one word'))
150
+ print('Plan:', server.gemini_plan('What tools does this server have?'))
151
+ "
152
+ ```
153
+
154
+ ## How It Works
155
+
156
+ The server shells out to `gemini -p "prompt" --approval-mode yolo` for each tool call. It does NOT use the Google Gen AI Python SDK — it goes through the Gemini CLI binary (OAuth-authenticated).
157
+
158
+ **Key design decisions:**
159
+ - **Stdio transport** — runs as a subprocess of your MCP client
160
+ - **No hardcoded paths** — discovers `gemini` on PATH
161
+ - **No API keys in config** — uses the CLI's existing OAuth session
162
+ - **Retry-friendly** — generous timeouts (120s default, 240s for plan mode) to handle Gemini's capacity retries
163
+
164
+ ## Notes
165
+
166
+ - **Capacity errors**: Gemini's reasoning models can hit rate limits. The CLI retries up to 7 times with backoff. If it fails, try again later or use a different model.
167
+ - **Plan mode is safe**: `--approval-mode plan` explicitly prevents the agent from writing files or executing shell commands. Read-only, guaranteed.
168
+ - **1M token context**: Gemini CLI supports the full 1M token context window of Gemini models.
169
+
170
+ ## License
171
+
172
+ MIT
@@ -0,0 +1,147 @@
1
+ # Gemini CLI MCP Server
2
+
3
+ [![CI](https://github.com/jxsprt/gemini-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/jxsprt/gemini-mcp-server/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/badge/PyPI-pip%20install%20gemini--mcp--server-blue)](https://pypi.org/project/gemini-mcp-server/)
5
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
6
+ [![PR to Hermes Agent](https://img.shields.io/badge/PR-Hermes%20Agent-%23NouResearch?label=PR)](https://github.com/NousResearch/hermes-agent/pull/22878)
7
+
8
+ A lightweight [FastMCP](https://gofastmcp.com) server that wraps Google's [Gemini CLI](https://github.com/google-gemini/gemini-cli) as MCP tools. Works with any MCP client — Hermes Agent, Claude Code, Claude Desktop, Cursor, etc.
9
+
10
+ ## Prerequisites
11
+
12
+ - **Node.js** — for the Gemini CLI
13
+ - **Python 3.11+** — for the MCP server
14
+ - **Gemini CLI** — installed and authenticated
15
+
16
+ ```bash
17
+ npm install -g @google/gemini-cli
18
+ gemini --version # Verify install
19
+ gemini # Run once to complete OAuth login
20
+ ```
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ # Clone the repo
26
+ git clone https://github.com/jxsprt/gemini-mcp-server.git
27
+ cd gemini-mcp-server
28
+
29
+ # Create venv and install deps
30
+ python3 -m venv .venv
31
+ .venv/bin/pip install -r requirements.txt
32
+ ```
33
+
34
+ ## Configuration
35
+
36
+ Add to your MCP client's config:
37
+
38
+ ### Hermes Agent (`~/.hermes/config.yaml`)
39
+
40
+ ```yaml
41
+ mcp_servers:
42
+ gemini-cli:
43
+ command: "/path/to/gemini-mcp-server/.venv/bin/python3"
44
+ args: ["/path/to/gemini-mcp-server/server.py"]
45
+ timeout: 240
46
+ ```
47
+
48
+ Restart your Hermes gateway. Tools will be available as `mcp_gemini_cli_*`.
49
+
50
+ ### Claude Desktop (`claude_desktop_config.json`)
51
+
52
+ ```json
53
+ {
54
+ "mcpServers": {
55
+ "gemini-cli": {
56
+ "command": "/path/to/gemini-mcp-server/.venv/bin/python3",
57
+ "args": ["/path/to/gemini-mcp-server/server.py"]
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ ### Claude Code
64
+
65
+ ```bash
66
+ fastmcp install claude-code /path/to/gemini-mcp-server/server.py
67
+ ```
68
+
69
+ ### Cursor
70
+
71
+ ```bash
72
+ fastmcp install cursor /path/to/gemini-mcp-server/server.py -e .
73
+ ```
74
+
75
+ ## Tools
76
+
77
+ ### `gemini_prompt`
78
+
79
+ Send any prompt to Gemini CLI non-interactively. Supports model selection and JSON output.
80
+
81
+ **Safe by default** — uses `--approval-mode auto-edit` (auto-approves file edits, prompts for shell). Pass `dangerous=true` for full `yolo` mode.
82
+
83
+ ```python
84
+ gemini_prompt(prompt="Explain TCP handshake", model="gemini-2.5-flash")
85
+ gemini_prompt(prompt="Refactor this module", dangerous=True) # full auto-approval
86
+ ```
87
+
88
+ | Parameter | Type | Required | Default | Description |
89
+ |-----------|------|----------|---------|-------------|
90
+ | `prompt` | string | ✅ | — | Prompt text (max ~100K chars) |
91
+ | `model` | string | ❌ | CLI default | Model name (e.g., `gemini-3-flash-preview`) |
92
+ | `output_format` | string | ❌ | `text` | `text`, `json`, or `stream-json` |
93
+ | `dangerous` | bool | ❌ | `false` | Use `--approval-mode yolo` (auto-approves shell) |
94
+
95
+ ### `gemini_plan`
96
+
97
+ Read-only audit and review mode. Uses `--approval-mode plan` — **guarantees no file mutations or shell execution**. Safe for whole-repo analysis and code reviews.
98
+
99
+ ```python
100
+ gemini_plan(prompt="Review this auth module for security issues")
101
+ ```
102
+
103
+ | Parameter | Type | Required | Default | Description |
104
+ |-----------|------|----------|---------|-------------|
105
+ | `prompt` | string | ✅ | — | Analysis question or review request |
106
+ | `model` | string | ❌ | CLI default | Gemini model |
107
+ | `include_directories` | string | ❌ | — | Comma-separated additional dirs |
108
+
109
+ ### `gemini_version`
110
+
111
+ Returns the installed Gemini CLI version.
112
+
113
+ ```python
114
+ gemini_version() # → "0.41.2"
115
+ ```
116
+
117
+ ## Verification
118
+
119
+ ```bash
120
+ cd /path/to/gemini-mcp-server
121
+ .venv/bin/python -c "
122
+ import server
123
+ print('Version:', server.gemini_version())
124
+ print('Prompt:', server.gemini_prompt('Say hello in one word'))
125
+ print('Plan:', server.gemini_plan('What tools does this server have?'))
126
+ "
127
+ ```
128
+
129
+ ## How It Works
130
+
131
+ The server shells out to `gemini -p "prompt" --approval-mode yolo` for each tool call. It does NOT use the Google Gen AI Python SDK — it goes through the Gemini CLI binary (OAuth-authenticated).
132
+
133
+ **Key design decisions:**
134
+ - **Stdio transport** — runs as a subprocess of your MCP client
135
+ - **No hardcoded paths** — discovers `gemini` on PATH
136
+ - **No API keys in config** — uses the CLI's existing OAuth session
137
+ - **Retry-friendly** — generous timeouts (120s default, 240s for plan mode) to handle Gemini's capacity retries
138
+
139
+ ## Notes
140
+
141
+ - **Capacity errors**: Gemini's reasoning models can hit rate limits. The CLI retries up to 7 times with backoff. If it fails, try again later or use a different model.
142
+ - **Plan mode is safe**: `--approval-mode plan` explicitly prevents the agent from writing files or executing shell commands. Read-only, guaranteed.
143
+ - **1M token context**: Gemini CLI supports the full 1M token context window of Gemini models.
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "gemini-cli-mcp-fast"
7
+ version = "1.0.0"
8
+ description = "FastMCP server wrapping Google's Gemini CLI — use Gemini models from any MCP client"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ authors = [
12
+ {name = "Jaspreet Singh", email = "jaspreetsinghintp@gmail.com"},
13
+ ]
14
+ keywords = ["mcp", "gemini", "fastmcp", "gemini-cli", "mcp-server"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+
27
+ requires-python = ">=3.11"
28
+ dependencies = [
29
+ "fastmcp>=3.0.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=8.0.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/jxsprt/gemini-mcp-server"
39
+ Repository = "https://github.com/jxsprt/gemini-mcp-server"
40
+ Issues = "https://github.com/jxsprt/gemini-mcp-server/issues"
41
+
42
+ [project.scripts]
43
+ gemini-mcp = "gemini_mcp_server:main"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/gemini_mcp_server"]
@@ -0,0 +1,2 @@
1
+ fastmcp>=3.0.0
2
+ mcp>=1.0.0
@@ -0,0 +1,263 @@
1
+ """
2
+ gemini-mcp-server — FastMCP server wrapping Google's Gemini CLI.
3
+
4
+ Exposes Gemini CLI as MCP tools consumable by any MCP client
5
+ (Hermes Agent, Claude Code, Claude Desktop, Cursor, etc.).
6
+
7
+ Powered by FastMCP (https://gofastmcp.com).
8
+
9
+ Tools:
10
+ - gemini_prompt → Send a prompt to Gemini CLI (non-interactive, full power)
11
+ - gemini_plan → Read-only audit/review mode (--approval-mode plan)
12
+ - gemini_version → Get Gemini CLI version
13
+ """
14
+
15
+ import logging
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ from typing import Optional
20
+
21
+ from fastmcp import FastMCP
22
+
23
+ # ── Logging ────────────────────────────────────────────────────────────────
24
+
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format="%(asctime)s [%(levelname)s] %(message)s",
28
+ stream=__import__("sys").stderr,
29
+ )
30
+ logger = logging.getLogger("gemini-mcp")
31
+
32
+ # ── Server ─────────────────────────────────────────────────────────────────
33
+
34
+ mcp = FastMCP("Gemini CLI MCP Server")
35
+
36
+ # ── Configuration (env vars with sensible defaults) ────────────────────────
37
+
38
+ _GEMINI_CLI_PATH = os.environ.get("GEMINI_CLI_PATH")
39
+ DEFAULT_TIMEOUT = int(os.environ.get("GEMINI_TIMEOUT", "120"))
40
+ PLAN_TIMEOUT = int(os.environ.get("GEMINI_PLAN_TIMEOUT", "240"))
41
+ MAX_PROMPT_LENGTH = int(os.environ.get("GEMINI_MAX_PROMPT", "100000"))
42
+ HTTP_ENABLED = os.environ.get("GEMINI_MCP_HTTP", "").lower() in ("1", "true", "yes")
43
+
44
+
45
+ def _get_gemini_bin() -> str:
46
+ """Locate the gemini CLI binary. Returns full path or raises RuntimeError."""
47
+ if _GEMINI_CLI_PATH:
48
+ path = _GEMINI_CLI_PATH
49
+ if os.path.isfile(path) and os.access(path, os.X_OK):
50
+ return path
51
+ raise RuntimeError(
52
+ f"GEMINI_CLI_PATH set to '{path}' but file not found or not executable. "
53
+ "Fix the path or unset it to auto-discover."
54
+ )
55
+ path = shutil.which("gemini")
56
+ if path is None:
57
+ raise RuntimeError(
58
+ "Gemini CLI not found on PATH. Install it with: npm install -g @google/gemini-cli, "
59
+ "or set the GEMINI_CLI_PATH environment variable."
60
+ )
61
+ return path
62
+
63
+
64
+ # ── Helper ─────────────────────────────────────────────────────────────────
65
+
66
+
67
+ def _run_gemini(args: list[str], timeout: int = DEFAULT_TIMEOUT) -> dict:
68
+ """Run `gemini <args>` and return structured result.
69
+
70
+ Returns dict with keys: success, output, error.
71
+ """
72
+ gemini_bin = _get_gemini_bin()
73
+ cmd = [gemini_bin] + args
74
+ logger.info("Running: %s", " ".join(cmd))
75
+
76
+ try:
77
+ result = subprocess.run(
78
+ cmd,
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=timeout,
82
+ )
83
+ stdout = result.stdout.strip()
84
+ stderr = result.stderr.strip()
85
+
86
+ if result.returncode != 0:
87
+ logger.warning("Non-zero exit %d: %s", result.returncode, stderr[:200])
88
+ return {
89
+ "success": False,
90
+ "output": stdout,
91
+ "error": stderr or f"Exit code {result.returncode}",
92
+ }
93
+
94
+ logger.info("Success (%d chars)", len(result.stdout))
95
+ return {"success": True, "output": stdout, "error": None}
96
+
97
+ except subprocess.TimeoutExpired:
98
+ logger.warning("Timed out after %ds", timeout)
99
+ return {
100
+ "success": False,
101
+ "output": "",
102
+ "error": f"Command timed out after {timeout}s",
103
+ }
104
+ except FileNotFoundError:
105
+ msg = f"Gemini CLI not found at {gemini_bin}"
106
+ logger.error(msg)
107
+ return {"success": False, "output": "", "error": msg}
108
+ except Exception as e:
109
+ logger.exception("Unexpected error")
110
+ return {"success": False, "output": "", "error": str(e)}
111
+
112
+
113
+ # ── Tools ──────────────────────────────────────────────────────────────────
114
+
115
+
116
+ @mcp.tool(
117
+ name="gemini_prompt",
118
+ description=(
119
+ "Send a prompt to Gemini CLI in non-interactive mode. "
120
+ "The full power of Gemini models — coding, research, Q&A, analysis. "
121
+ "Supports optional model selection and output format."
122
+ ),
123
+ )
124
+ def gemini_prompt(
125
+ prompt: str,
126
+ model: Optional[str] = None,
127
+ output_format: Optional[str] = None,
128
+ dangerous: bool = False,
129
+ ) -> str:
130
+ """Execute a prompt via Gemini CLI.
131
+
132
+ By default uses --approval-mode auto-edit (safe for file edits in working dir).
133
+ Pass dangerous=True to use --approval-mode yolo (auto-approves ALL actions
134
+ including arbitrary shell commands).
135
+
136
+ Args:
137
+ prompt: The prompt text to send (max ~100K chars).
138
+ model: Gemini model to use (e.g., gemini-3-flash-preview, gemini-2.5-pro).
139
+ Defaults to gemini CLI's built-in default.
140
+ output_format: 'text' (default), 'json', or 'stream-json'.
141
+ JSON includes token/cost stats in the envelope.
142
+ dangerous: If True, uses --approval-mode yolo (auto-approves shell cmds).
143
+ Defaults to False (--approval-mode auto-edit).
144
+
145
+ Returns:
146
+ Gemini CLI output as a string.
147
+ """
148
+ if len(prompt) > MAX_PROMPT_LENGTH:
149
+ prompt = prompt[:MAX_PROMPT_LENGTH]
150
+
151
+ approval = "yolo" if dangerous else "auto_edit"
152
+ args = ["-p", prompt, "--approval-mode", approval]
153
+
154
+ if model:
155
+ args += ["-m", model]
156
+ if output_format:
157
+ args += ["-o", output_format]
158
+
159
+ result = _run_gemini(args, timeout=DEFAULT_TIMEOUT)
160
+
161
+ if result["success"]:
162
+ return result["output"] or "(empty response)"
163
+ else:
164
+ error_msg = result["error"] or "unknown error"
165
+ return f"Error: {error_msg}"
166
+
167
+
168
+ @mcp.tool(
169
+ name="gemini_plan",
170
+ description=(
171
+ "Read-only audit, code review, and research via Gemini CLI. "
172
+ "Uses --approval-mode plan which guarantees NO file mutations or shell execution. "
173
+ "Safe for whole-repo analysis, code reviews, vulnerability assessments, "
174
+ "architecture audits, and deep research."
175
+ ),
176
+ )
177
+ def gemini_plan(
178
+ prompt: str,
179
+ model: Optional[str] = None,
180
+ include_directories: Optional[str] = None,
181
+ ) -> str:
182
+ """Run Gemini CLI in read-only plan mode — no mutations possible.
183
+
184
+ Args:
185
+ prompt: Analysis question or review request.
186
+ model: Gemini model (optional, defaults to gemini CLI default).
187
+ include_directories: Comma-separated additional dirs to include in workspace.
188
+ Only includes subdirectories of the current working directory
189
+ for safety.
190
+
191
+ Returns:
192
+ Gemini CLI's analysis/plan output.
193
+ """
194
+ if len(prompt) > MAX_PROMPT_LENGTH:
195
+ prompt = prompt[:MAX_PROMPT_LENGTH]
196
+
197
+ args = ["-p", prompt, "--approval-mode", "plan"]
198
+
199
+ if model:
200
+ args += ["-m", model]
201
+
202
+ # Validate include_directories — restrict to CWD subdirectories
203
+ if include_directories:
204
+ cwd = os.getcwd()
205
+ validated = []
206
+ for d in include_directories.split(","):
207
+ d = d.strip()
208
+ abs_d = os.path.abspath(os.path.join(cwd, d)) if not os.path.isabs(d) else d
209
+ if abs_d.startswith(cwd.rstrip("/") + "/") or abs_d == cwd:
210
+ validated.append(d)
211
+ else:
212
+ logger.warning("Ignored out-of-workspace path: %s", d)
213
+ if validated:
214
+ args += ["--include-directories", ",".join(validated)]
215
+
216
+ result = _run_gemini(args, timeout=PLAN_TIMEOUT)
217
+
218
+ if result["success"]:
219
+ return result["output"] or "(empty response)"
220
+ else:
221
+ error_msg = result["error"] or "unknown error"
222
+ return f"Error: {error_msg}"
223
+
224
+
225
+ @mcp.tool(
226
+ name="gemini_version",
227
+ description="Get the installed Gemini CLI version.",
228
+ )
229
+ def gemini_version() -> str:
230
+ """Get Gemini CLI version info."""
231
+ try:
232
+ gemini_bin = _get_gemini_bin()
233
+ result = subprocess.run(
234
+ [gemini_bin, "--version"],
235
+ capture_output=True,
236
+ text=True,
237
+ timeout=10,
238
+ )
239
+ output = (result.stdout or result.stderr or "").strip()
240
+ return output or "(no version info)"
241
+ except subprocess.TimeoutExpired:
242
+ return "Error: Command timed out after 10s"
243
+ except RuntimeError as e:
244
+ return f"Error: {e}"
245
+ except Exception as e:
246
+ return f"Error: {e}"
247
+
248
+
249
+ # ── Entrypoint ─────────────────────────────────────────────────────────────
250
+
251
+
252
+ def main() -> None:
253
+ """CLI entry point for pip-installed package."""
254
+ if HTTP_ENABLED:
255
+ logger.info("Starting HTTP transport on port 8000")
256
+ mcp.run(transport="http")
257
+ else:
258
+ logger.info("Starting stdio transport")
259
+ mcp.run()
260
+
261
+
262
+ if __name__ == "__main__":
263
+ main()
@@ -0,0 +1,5 @@
1
+ """gemini-mcp-server — FastMCP server wrapping Google's Gemini CLI."""
2
+
3
+ from .server import mcp, gemini_prompt, gemini_plan, gemini_version, main
4
+
5
+ __all__ = ["mcp", "gemini_prompt", "gemini_plan", "gemini_version", "main"]
@@ -0,0 +1,6 @@
1
+ """Allow running as: python -m gemini_mcp_server"""
2
+
3
+ from gemini_mcp_server import mcp
4
+
5
+ if __name__ == "__main__":
6
+ mcp.run()
@@ -0,0 +1,263 @@
1
+ """
2
+ gemini-mcp-server — FastMCP server wrapping Google's Gemini CLI.
3
+
4
+ Exposes Gemini CLI as MCP tools consumable by any MCP client
5
+ (Hermes Agent, Claude Code, Claude Desktop, Cursor, etc.).
6
+
7
+ Powered by FastMCP (https://gofastmcp.com).
8
+
9
+ Tools:
10
+ - gemini_prompt → Send a prompt to Gemini CLI (non-interactive, full power)
11
+ - gemini_plan → Read-only audit/review mode (--approval-mode plan)
12
+ - gemini_version → Get Gemini CLI version
13
+ """
14
+
15
+ import logging
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ from typing import Optional
20
+
21
+ from fastmcp import FastMCP
22
+
23
+ # ── Logging ────────────────────────────────────────────────────────────────
24
+
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format="%(asctime)s [%(levelname)s] %(message)s",
28
+ stream=__import__("sys").stderr,
29
+ )
30
+ logger = logging.getLogger("gemini-mcp")
31
+
32
+ # ── Server ─────────────────────────────────────────────────────────────────
33
+
34
+ mcp = FastMCP("Gemini CLI MCP Server")
35
+
36
+ # ── Configuration (env vars with sensible defaults) ────────────────────────
37
+
38
+ _GEMINI_CLI_PATH = os.environ.get("GEMINI_CLI_PATH")
39
+ DEFAULT_TIMEOUT = int(os.environ.get("GEMINI_TIMEOUT", "120"))
40
+ PLAN_TIMEOUT = int(os.environ.get("GEMINI_PLAN_TIMEOUT", "240"))
41
+ MAX_PROMPT_LENGTH = int(os.environ.get("GEMINI_MAX_PROMPT", "100000"))
42
+ HTTP_ENABLED = os.environ.get("GEMINI_MCP_HTTP", "").lower() in ("1", "true", "yes")
43
+
44
+
45
+ def _get_gemini_bin() -> str:
46
+ """Locate the gemini CLI binary. Returns full path or raises RuntimeError."""
47
+ if _GEMINI_CLI_PATH:
48
+ path = _GEMINI_CLI_PATH
49
+ if os.path.isfile(path) and os.access(path, os.X_OK):
50
+ return path
51
+ raise RuntimeError(
52
+ f"GEMINI_CLI_PATH set to '{path}' but file not found or not executable. "
53
+ "Fix the path or unset it to auto-discover."
54
+ )
55
+ path = shutil.which("gemini")
56
+ if path is None:
57
+ raise RuntimeError(
58
+ "Gemini CLI not found on PATH. Install it with: npm install -g @google/gemini-cli, "
59
+ "or set the GEMINI_CLI_PATH environment variable."
60
+ )
61
+ return path
62
+
63
+
64
+ # ── Helper ─────────────────────────────────────────────────────────────────
65
+
66
+
67
+ def _run_gemini(args: list[str], timeout: int = DEFAULT_TIMEOUT) -> dict:
68
+ """Run `gemini <args>` and return structured result.
69
+
70
+ Returns dict with keys: success, output, error.
71
+ """
72
+ gemini_bin = _get_gemini_bin()
73
+ cmd = [gemini_bin] + args
74
+ logger.info("Running: %s", " ".join(cmd))
75
+
76
+ try:
77
+ result = subprocess.run(
78
+ cmd,
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=timeout,
82
+ )
83
+ stdout = result.stdout.strip()
84
+ stderr = result.stderr.strip()
85
+
86
+ if result.returncode != 0:
87
+ logger.warning("Non-zero exit %d: %s", result.returncode, stderr[:200])
88
+ return {
89
+ "success": False,
90
+ "output": stdout,
91
+ "error": stderr or f"Exit code {result.returncode}",
92
+ }
93
+
94
+ logger.info("Success (%d chars)", len(result.stdout))
95
+ return {"success": True, "output": stdout, "error": None}
96
+
97
+ except subprocess.TimeoutExpired:
98
+ logger.warning("Timed out after %ds", timeout)
99
+ return {
100
+ "success": False,
101
+ "output": "",
102
+ "error": f"Command timed out after {timeout}s",
103
+ }
104
+ except FileNotFoundError:
105
+ msg = f"Gemini CLI not found at {gemini_bin}"
106
+ logger.error(msg)
107
+ return {"success": False, "output": "", "error": msg}
108
+ except Exception as e:
109
+ logger.exception("Unexpected error")
110
+ return {"success": False, "output": "", "error": str(e)}
111
+
112
+
113
+ # ── Tools ──────────────────────────────────────────────────────────────────
114
+
115
+
116
+ @mcp.tool(
117
+ name="gemini_prompt",
118
+ description=(
119
+ "Send a prompt to Gemini CLI in non-interactive mode. "
120
+ "The full power of Gemini models — coding, research, Q&A, analysis. "
121
+ "Supports optional model selection and output format."
122
+ ),
123
+ )
124
+ def gemini_prompt(
125
+ prompt: str,
126
+ model: Optional[str] = None,
127
+ output_format: Optional[str] = None,
128
+ dangerous: bool = False,
129
+ ) -> str:
130
+ """Execute a prompt via Gemini CLI.
131
+
132
+ By default uses --approval-mode auto-edit (safe for file edits in working dir).
133
+ Pass dangerous=True to use --approval-mode yolo (auto-approves ALL actions
134
+ including arbitrary shell commands).
135
+
136
+ Args:
137
+ prompt: The prompt text to send (max ~100K chars).
138
+ model: Gemini model to use (e.g., gemini-3-flash-preview, gemini-2.5-pro).
139
+ Defaults to gemini CLI's built-in default.
140
+ output_format: 'text' (default), 'json', or 'stream-json'.
141
+ JSON includes token/cost stats in the envelope.
142
+ dangerous: If True, uses --approval-mode yolo (auto-approves shell cmds).
143
+ Defaults to False (--approval-mode auto-edit).
144
+
145
+ Returns:
146
+ Gemini CLI output as a string.
147
+ """
148
+ if len(prompt) > MAX_PROMPT_LENGTH:
149
+ prompt = prompt[:MAX_PROMPT_LENGTH]
150
+
151
+ approval = "yolo" if dangerous else "auto_edit"
152
+ args = ["-p", prompt, "--approval-mode", approval]
153
+
154
+ if model:
155
+ args += ["-m", model]
156
+ if output_format:
157
+ args += ["-o", output_format]
158
+
159
+ result = _run_gemini(args, timeout=DEFAULT_TIMEOUT)
160
+
161
+ if result["success"]:
162
+ return result["output"] or "(empty response)"
163
+ else:
164
+ error_msg = result["error"] or "unknown error"
165
+ return f"Error: {error_msg}"
166
+
167
+
168
+ @mcp.tool(
169
+ name="gemini_plan",
170
+ description=(
171
+ "Read-only audit, code review, and research via Gemini CLI. "
172
+ "Uses --approval-mode plan which guarantees NO file mutations or shell execution. "
173
+ "Safe for whole-repo analysis, code reviews, vulnerability assessments, "
174
+ "architecture audits, and deep research."
175
+ ),
176
+ )
177
+ def gemini_plan(
178
+ prompt: str,
179
+ model: Optional[str] = None,
180
+ include_directories: Optional[str] = None,
181
+ ) -> str:
182
+ """Run Gemini CLI in read-only plan mode — no mutations possible.
183
+
184
+ Args:
185
+ prompt: Analysis question or review request.
186
+ model: Gemini model (optional, defaults to gemini CLI default).
187
+ include_directories: Comma-separated additional dirs to include in workspace.
188
+ Only includes subdirectories of the current working directory
189
+ for safety.
190
+
191
+ Returns:
192
+ Gemini CLI's analysis/plan output.
193
+ """
194
+ if len(prompt) > MAX_PROMPT_LENGTH:
195
+ prompt = prompt[:MAX_PROMPT_LENGTH]
196
+
197
+ args = ["-p", prompt, "--approval-mode", "plan"]
198
+
199
+ if model:
200
+ args += ["-m", model]
201
+
202
+ # Validate include_directories — restrict to CWD subdirectories
203
+ if include_directories:
204
+ cwd = os.getcwd()
205
+ validated = []
206
+ for d in include_directories.split(","):
207
+ d = d.strip()
208
+ abs_d = os.path.abspath(os.path.join(cwd, d)) if not os.path.isabs(d) else d
209
+ if abs_d.startswith(cwd.rstrip("/") + "/") or abs_d == cwd:
210
+ validated.append(d)
211
+ else:
212
+ logger.warning("Ignored out-of-workspace path: %s", d)
213
+ if validated:
214
+ args += ["--include-directories", ",".join(validated)]
215
+
216
+ result = _run_gemini(args, timeout=PLAN_TIMEOUT)
217
+
218
+ if result["success"]:
219
+ return result["output"] or "(empty response)"
220
+ else:
221
+ error_msg = result["error"] or "unknown error"
222
+ return f"Error: {error_msg}"
223
+
224
+
225
+ @mcp.tool(
226
+ name="gemini_version",
227
+ description="Get the installed Gemini CLI version.",
228
+ )
229
+ def gemini_version() -> str:
230
+ """Get Gemini CLI version info."""
231
+ try:
232
+ gemini_bin = _get_gemini_bin()
233
+ result = subprocess.run(
234
+ [gemini_bin, "--version"],
235
+ capture_output=True,
236
+ text=True,
237
+ timeout=10,
238
+ )
239
+ output = (result.stdout or result.stderr or "").strip()
240
+ return output or "(no version info)"
241
+ except subprocess.TimeoutExpired:
242
+ return "Error: Command timed out after 10s"
243
+ except RuntimeError as e:
244
+ return f"Error: {e}"
245
+ except Exception as e:
246
+ return f"Error: {e}"
247
+
248
+
249
+ # ── Entrypoint ─────────────────────────────────────────────────────────────
250
+
251
+
252
+ def main() -> None:
253
+ """CLI entry point for pip-installed package."""
254
+ if HTTP_ENABLED:
255
+ logger.info("Starting HTTP transport on port 8000")
256
+ mcp.run(transport="http")
257
+ else:
258
+ logger.info("Starting stdio transport")
259
+ mcp.run()
260
+
261
+
262
+ if __name__ == "__main__":
263
+ main()
@@ -0,0 +1,193 @@
1
+ """Tests for gemini-mcp-server."""
2
+
3
+ import os
4
+ import subprocess
5
+ from unittest.mock import ANY, MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ # Point to the source tree so we test the installed package
10
+ from gemini_mcp_server import gemini_prompt, gemini_plan, gemini_version
11
+
12
+
13
+ # ── Fixtures ────────────────────────────────────────────────────────────────
14
+
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def _reset_env():
18
+ """Ensure env vars don't leak between tests."""
19
+ keys = ["GEMINI_CLI_PATH", "GEMINI_TIMEOUT", "GEMINI_PLAN_TIMEOUT", "GEMINI_MAX_PROMPT"]
20
+ saved = {k: os.environ.pop(k, None) for k in keys}
21
+ yield
22
+ for k, v in saved.items():
23
+ if v is not None:
24
+ os.environ[k] = v
25
+
26
+
27
+ @pytest.fixture
28
+ def mock_subprocess():
29
+ """Mock subprocess.run to return a successful result."""
30
+ with patch.object(subprocess, "run") as mock:
31
+ mock.return_value = MagicMock(
32
+ returncode=0,
33
+ stdout="mock output\n",
34
+ stderr="",
35
+ )
36
+ yield mock
37
+
38
+
39
+ @pytest.fixture
40
+ def mock_subprocess_fail():
41
+ """Mock subprocess.run to return a failure."""
42
+ with patch.object(subprocess, "run") as mock:
43
+ mock.return_value = MagicMock(
44
+ returncode=1,
45
+ stdout="",
46
+ stderr="something went wrong",
47
+ )
48
+ yield mock
49
+
50
+
51
+ @pytest.fixture
52
+ def mock_subprocess_timeout():
53
+ """Mock subprocess.run to raise TimeoutExpired."""
54
+ with patch.object(subprocess, "run") as mock:
55
+ mock.side_effect = subprocess.TimeoutExpired(cmd="gemini", timeout=10)
56
+ yield mock
57
+
58
+
59
+ @pytest.fixture
60
+ def mock_bin():
61
+ """Ensure shutil.which finds a fake gemini binary."""
62
+ with patch("server.shutil.which", return_value="/usr/local/bin/gemini"):
63
+ yield
64
+
65
+
66
+ # ── gemini_version ──────────────────────────────────────────────────────────
67
+
68
+
69
+ class TestGeminiVersion:
70
+ def test_returns_version(self, mock_subprocess, mock_bin):
71
+ mock_subprocess.return_value = MagicMock(
72
+ returncode=0, stdout="0.41.2\n", stderr=""
73
+ )
74
+ result = gemini_version()
75
+ assert result == "0.41.2"
76
+
77
+ def test_timeout(self, mock_subprocess_timeout, mock_bin):
78
+ result = gemini_version()
79
+ assert "Error" in result
80
+ assert "timed out" in result
81
+
82
+ def test_binary_not_found(self):
83
+ with patch("server.shutil.which", return_value=None):
84
+ result = gemini_version()
85
+ assert "Error" in result
86
+ assert "not found" in result
87
+
88
+
89
+ # ── gemini_prompt ────────────────────────────────────────────────────────────
90
+
91
+
92
+ class TestGeminiPrompt:
93
+ def test_basic_prompt(self, mock_subprocess, mock_bin):
94
+ result = gemini_prompt("Say hello")
95
+ assert result == "mock output"
96
+ # Verify it used auto_edit (safe default)
97
+ args = mock_subprocess.call_args[0][0]
98
+ assert "--approval-mode" in args
99
+ assert "auto_edit" in args
100
+
101
+ def test_with_model(self, mock_subprocess, mock_bin):
102
+ gemini_prompt("Hi", model="gemini-2.5-pro")
103
+ args = mock_subprocess.call_args[0][0]
104
+ assert "-m" in args
105
+ assert "gemini-2.5-pro" in args
106
+
107
+ def test_with_output_format(self, mock_subprocess, mock_bin):
108
+ gemini_prompt("Hi", output_format="json")
109
+ args = mock_subprocess.call_args[0][0]
110
+ assert "-o" in args
111
+ assert "json" in args
112
+
113
+ def test_dangerous_mode(self, mock_subprocess, mock_bin):
114
+ gemini_prompt("Hi", dangerous=True)
115
+ args = mock_subprocess.call_args[0][0]
116
+ assert "yolo" in args # dangerous=True → yolo
117
+
118
+ def test_safe_default(self, mock_subprocess, mock_bin):
119
+ gemini_prompt("Hi")
120
+ args = mock_subprocess.call_args[0][0]
121
+ assert "yolo" not in args # not dangerous by default
122
+
123
+ def test_prompt_truncation(self, mock_subprocess, mock_bin):
124
+ long_prompt = "x" * 200_000
125
+ result = gemini_prompt(long_prompt)
126
+ assert result == "mock output" # didn't crash
127
+ args = mock_subprocess.call_args[0][0]
128
+ prompt_arg = args[args.index("-p") + 1]
129
+ assert len(prompt_arg) <= 100_000 # was truncated
130
+
131
+ def test_timeout(self, mock_subprocess_timeout, mock_bin):
132
+ result = gemini_prompt("Hi")
133
+ assert "Error" in result
134
+ assert "timed out" in result
135
+
136
+ def test_subprocess_failure(self, mock_subprocess_fail, mock_bin):
137
+ result = gemini_prompt("Hi")
138
+ assert "Error" in result
139
+
140
+ def test_empty_response(self, mock_subprocess, mock_bin):
141
+ mock_subprocess.return_value = MagicMock(
142
+ returncode=0, stdout="\n \n", stderr=""
143
+ )
144
+ result = gemini_prompt("Hi")
145
+ assert result == "(empty response)"
146
+
147
+
148
+ # ── gemini_plan ──────────────────────────────────────────────────────────────
149
+
150
+
151
+ class TestGeminiPlan:
152
+ def test_basic_plan(self, mock_subprocess, mock_bin):
153
+ result = gemini_plan("Review this code")
154
+ assert result == "mock output"
155
+ # Verify it used plan mode
156
+ args = mock_subprocess.call_args[0][0]
157
+ assert "--approval-mode" in args
158
+ assert "plan" in args
159
+
160
+ def test_with_model(self, mock_subprocess, mock_bin):
161
+ gemini_plan("Review", model="gemini-2.5-pro")
162
+ args = mock_subprocess.call_args[0][0]
163
+ assert "-m" in args
164
+ assert "gemini-2.5-pro" in args
165
+
166
+ def test_include_directories(self, mock_subprocess, mock_bin):
167
+ with patch("server.os.getcwd", return_value="/home/user/project"):
168
+ gemini_plan("Review", include_directories="src,tests")
169
+ args = mock_subprocess.call_args[0][0]
170
+ assert "--include-directories" in args
171
+ idx = args.index("--include-directories")
172
+ dirs = args[idx + 1]
173
+ assert "src" in dirs
174
+ assert "tests" in dirs
175
+
176
+ def test_out_of_workspace_path_rejected(self, mock_subprocess, mock_bin):
177
+ with patch("server.os.getcwd", return_value="/home/user/project"):
178
+ with patch("server.logger.warning") as mock_log:
179
+ gemini_plan("Review", include_directories="/etc")
180
+ args = mock_subprocess.call_args[0][0]
181
+ # /etc should NOT appear in the args
182
+ if "--include-directories" in args:
183
+ idx = args.index("--include-directories")
184
+ dirs = args[idx + 1]
185
+ assert "/etc" not in dirs
186
+ mock_log.assert_called_once_with(
187
+ "Ignored out-of-workspace path: %s", "/etc"
188
+ )
189
+
190
+ def test_timeout(self, mock_subprocess_timeout, mock_bin):
191
+ result = gemini_plan("Review")
192
+ assert "Error" in result
193
+ assert "timed out" in result