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.
- gemini_cli_mcp_fast-1.0.0/.github/workflows/ci.yml +33 -0
- gemini_cli_mcp_fast-1.0.0/.github/workflows/publish.yml +29 -0
- gemini_cli_mcp_fast-1.0.0/.gitignore +4 -0
- gemini_cli_mcp_fast-1.0.0/Dockerfile +30 -0
- gemini_cli_mcp_fast-1.0.0/PKG-INFO +172 -0
- gemini_cli_mcp_fast-1.0.0/README.md +147 -0
- gemini_cli_mcp_fast-1.0.0/pyproject.toml +46 -0
- gemini_cli_mcp_fast-1.0.0/requirements.txt +2 -0
- gemini_cli_mcp_fast-1.0.0/server.py +263 -0
- gemini_cli_mcp_fast-1.0.0/src/gemini_mcp_server/__init__.py +5 -0
- gemini_cli_mcp_fast-1.0.0/src/gemini_mcp_server/__main__.py +6 -0
- gemini_cli_mcp_fast-1.0.0/src/gemini_mcp_server/server.py +263 -0
- gemini_cli_mcp_fast-1.0.0/tests/test_server.py +193 -0
|
@@ -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,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
|
+
[](https://github.com/jxsprt/gemini-mcp-server/actions/workflows/ci.yml)
|
|
29
|
+
[](https://pypi.org/project/gemini-mcp-server/)
|
|
30
|
+
[](LICENSE)
|
|
31
|
+
[](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
|
+
[](https://github.com/jxsprt/gemini-mcp-server/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/gemini-mcp-server/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](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,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,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
|