@brobertoblanko/gemini-grounding-mcp 1.1.0
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.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/citations.js +147 -0
- package/cli.js +212 -0
- package/config.js +87 -0
- package/gemini.js +309 -0
- package/index.js +195 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Broberto Blanko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# @brobertoblanko/gemini-grounding-mcp
|
|
2
|
+
|
|
3
|
+
A small, self-contained MCP server that gives Claude Code - or any other MCP
|
|
4
|
+
client - access to Google web search through the Gemini API with grounding.
|
|
5
|
+
It uses nothing but the official `@google/genai` and `@modelcontextprotocol/sdk`
|
|
6
|
+
packages: no community wrapper, no version silently changing under you.
|
|
7
|
+
|
|
8
|
+
**Scope of use:** research queries only. Not intended for production use or for
|
|
9
|
+
connecting to sensitive systems.
|
|
10
|
+
|
|
11
|
+
Architecture and design decisions: see [specs.md](./docs/specs.md) (German).
|
|
12
|
+
Working rules for Claude Code in this repository: see [CLAUDE.md](./CLAUDE.md)
|
|
13
|
+
(German).
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- **Node.js 20 or newer** - required by `@google/genai` and the MCP SDK.
|
|
18
|
+
Check with `node -v`.
|
|
19
|
+
- **A Gemini API key**, available for free at
|
|
20
|
+
[Google AI Studio](https://aistudio.google.com/apikey).
|
|
21
|
+
- **Claude Code** or any other MCP-capable client
|
|
22
|
+
([Model Context Protocol](https://modelcontextprotocol.io)).
|
|
23
|
+
|
|
24
|
+
**A note on cost:** Gemini API calls are not free in every case. There is a free
|
|
25
|
+
tier with rate limits; beyond that you are billed per token, and Google Search
|
|
26
|
+
grounding may be charged separately depending on model and plan. The official
|
|
27
|
+
[pricing](https://ai.google.dev/gemini-api/docs/pricing) and
|
|
28
|
+
[rate limit](https://ai.google.dev/gemini-api/docs/rate-limits) pages are
|
|
29
|
+
authoritative - both change regularly, which is why no concrete figures appear
|
|
30
|
+
here. The token footer under every answer makes the cost of each individual call
|
|
31
|
+
visible.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Nothing to install up front if you use `npx` - the registration command below
|
|
36
|
+
fetches the package on demand. To install it permanently instead:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g @brobertoblanko/gemini-grounding-mcp
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This provides two commands: `gemini-grounding-mcp` starts the MCP server over
|
|
43
|
+
stdio, and `gemini-grounding` is the command line tool described further down.
|
|
44
|
+
|
|
45
|
+
To work from the source instead:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
git clone https://github.com/srzsn22q6d-sys/gemini-grounding-mcp.git
|
|
49
|
+
cd gemini-grounding-mcp
|
|
50
|
+
npm install
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Providing the API key
|
|
54
|
+
|
|
55
|
+
The API key is passed exclusively through the `GEMINI_API_KEY` environment
|
|
56
|
+
variable - never in the code, never in the config file. It must be set
|
|
57
|
+
**persistently** before the client starts the server.
|
|
58
|
+
|
|
59
|
+
**Windows (PowerShell, user scope, once):**
|
|
60
|
+
|
|
61
|
+
```powershell
|
|
62
|
+
[Environment]::SetEnvironmentVariable('GEMINI_API_KEY', '<your-api-key>', 'User')
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Reopen the shell afterwards so the variable is available.
|
|
66
|
+
|
|
67
|
+
**macOS / Linux** - add to `~/.zshrc`, `~/.bashrc` or equivalent:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
export GEMINI_API_KEY='<your-api-key>'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Registering with Claude Code
|
|
74
|
+
|
|
75
|
+
**Windows (PowerShell):**
|
|
76
|
+
|
|
77
|
+
```powershell
|
|
78
|
+
claude mcp add gemini-grounding -s user `
|
|
79
|
+
-e 'GEMINI_API_KEY=${GEMINI_API_KEY}' `
|
|
80
|
+
-- npx -y @brobertoblanko/gemini-grounding-mcp
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**macOS / Linux (bash / zsh):**
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
claude mcp add gemini-grounding -s user \
|
|
87
|
+
-e 'GEMINI_API_KEY=${GEMINI_API_KEY}' \
|
|
88
|
+
-- npx -y @brobertoblanko/gemini-grounding-mcp
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Write `${GEMINI_API_KEY}` in **single** quotes so your shell does not expand it
|
|
92
|
+
itself. Claude Code resolves it later, when it loads its configuration, from the
|
|
93
|
+
environment variable you already set - that way only the placeholder ends up in
|
|
94
|
+
`~/.claude.json`, not the key in plain text.
|
|
95
|
+
|
|
96
|
+
If you installed globally with `npm install -g`, replace
|
|
97
|
+
`npx -y @brobertoblanko/gemini-grounding-mcp` with `gemini-grounding-mcp`.
|
|
98
|
+
If you cloned the repository, use `node <path-to-repo>/index.js` instead -
|
|
99
|
+
`claude mcp add` needs a concrete absolute path that resolves on the machine
|
|
100
|
+
in question.
|
|
101
|
+
|
|
102
|
+
Verify that the server is running:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
claude mcp list
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Command line tool
|
|
109
|
+
|
|
110
|
+
The server can also be driven without an MCP client - useful for checking that
|
|
111
|
+
your API key and model choice work before registering it, and for testing a
|
|
112
|
+
change during development without restarting the client.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
gemini-grounding config
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Working from a clone, use `node cli.js` in place of `gemini-grounding`, or run
|
|
119
|
+
`npm link` once to make the command available system-wide. `npm link` places a
|
|
120
|
+
link to `cli.js` in the global npm directory (a `.cmd`/`.ps1` pair on Windows, a
|
|
121
|
+
symlink elsewhere); because it is a link and not a copy, edits to `cli.js` take
|
|
122
|
+
effect immediately. Undo it with
|
|
123
|
+
`npm unlink -g @brobertoblanko/gemini-grounding-mcp` - npm expects the package
|
|
124
|
+
name there, not the command name `gemini-grounding`. Given the command name it
|
|
125
|
+
merely reports `up to date` and removes nothing.
|
|
126
|
+
|
|
127
|
+
| Command | Effect |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `gemini-grounding "<query>"` | Search using the saved defaults; prints the answer including source list and token footer |
|
|
130
|
+
| `gemini-grounding config` | Shows the saved model, thinking level, whether an API key is present, and where the config file lives |
|
|
131
|
+
| `gemini-grounding models [--all]` | Lists the models usable with this server and their token limits; `--all` lists every one |
|
|
132
|
+
| `gemini-grounding set-model <id>` | Persists the default model |
|
|
133
|
+
| `gemini-grounding set-thinking <level>` | Persists the default thinking level (`minimal`, `low`, `medium`, `high`) |
|
|
134
|
+
| `gemini-grounding help` | Short help |
|
|
135
|
+
|
|
136
|
+
Anything that is not a known subcommand is treated as a search query - as
|
|
137
|
+
**exactly one argument**. A question containing spaces therefore has to be
|
|
138
|
+
quoted. Unquoted, the call aborts with a message rather than sending a query
|
|
139
|
+
that option parsing has cut individual words out of (`… what does --thinking
|
|
140
|
+
high mean …` would otherwise have run as `what does mean …`). An unknown option
|
|
141
|
+
(`--al` instead of `--all`) and surplus arguments are errors with exit code 1 as
|
|
142
|
+
well - none of it is silently ignored.
|
|
143
|
+
|
|
144
|
+
**Per-call overrides.** For a single call, model and thinking level can be set
|
|
145
|
+
differently without touching the saved defaults:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
gemini-grounding "query" --model gemini-3-pro-preview --thinking minimal
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Which values were actually used is shown in the footer under every answer.
|
|
152
|
+
|
|
153
|
+
**Shared configuration.** The CLI and the MCP server read and write the same
|
|
154
|
+
config file. A `set-model` in the terminal therefore also changes what the MCP
|
|
155
|
+
server uses on its next call - intentionally so, because it makes a model switch
|
|
156
|
+
possible without having to ask the client to do it.
|
|
157
|
+
|
|
158
|
+
**Errors stay fully visible.** Unlike the MCP server, which has to condense
|
|
159
|
+
every error into a single line for the client, the CLI prints the full stack
|
|
160
|
+
trace including the original Google API error and exits with code 1. When
|
|
161
|
+
testing, that is exactly what you want: a message like
|
|
162
|
+
`ApiError: {"error":{"code":503, ...}}` says the request did not get through to
|
|
163
|
+
Google - not that your installation is broken. A plain usage error (wrong
|
|
164
|
+
argument, unknown option, empty query) prints only the one explanatory line,
|
|
165
|
+
also with exit code 1 - nobody needs a stack trace for a typo.
|
|
166
|
+
|
|
167
|
+
`config` only checks whether a key arrives in the environment at all and prints
|
|
168
|
+
its length - **never the value itself**. Whether the key is valid is something
|
|
169
|
+
only a real request can tell you.
|
|
170
|
+
|
|
171
|
+
## Tools
|
|
172
|
+
|
|
173
|
+
- **`gemini-search`** - research via Google Search, URL Context and Code
|
|
174
|
+
Execution in one call. The answer contains inline citation markers, a source
|
|
175
|
+
list and a token footer. If Gemini executed code, the code and its result
|
|
176
|
+
appear under `Code execution:` after the answer text - the calculation is
|
|
177
|
+
evidence, so it belongs where the sources are. If the answer did not finish
|
|
178
|
+
normally - blocked, or cut off at the token limit - a line marked ⚠️ says so
|
|
179
|
+
along with the reason, instead of letting an empty or half answer look like a
|
|
180
|
+
success.
|
|
181
|
+
- **`gemini-list-models`** - lists the models available for your API key with
|
|
182
|
+
their token limits. By default only those usable with this server (see below);
|
|
183
|
+
with `all: true`, every one.
|
|
184
|
+
- **`gemini-set-model`** - persists the default model and/or default thinking
|
|
185
|
+
level (only those two values, never the API key).
|
|
186
|
+
|
|
187
|
+
### Citation markers
|
|
188
|
+
|
|
189
|
+
Markers such as `[1]` or `[1][3]` are placed in the answer text at the positions
|
|
190
|
+
for which the API reports a source, using the same numbering as the source list
|
|
191
|
+
at the end. Their point is less *which* source backs a sentence than *whether*
|
|
192
|
+
it is backed at all: a sentence without a marker may well come from the model's
|
|
193
|
+
own memory rather than from the search - and that is precisely the kind of
|
|
194
|
+
sentence you would not want to write code against unchecked.
|
|
195
|
+
|
|
196
|
+
The guarantee runs one way only. A marker that is present is reliable; a marker
|
|
197
|
+
that is missing is an indication, not proof. Markers are verified against the
|
|
198
|
+
text segment the API supplies and are dropped rather than guessed if they do not
|
|
199
|
+
match, and they are never placed inside code spans or fenced blocks. Whenever
|
|
200
|
+
markers were dropped, the footer says so. See
|
|
201
|
+
[specs.md](./docs/specs.md) for the details.
|
|
202
|
+
|
|
203
|
+
### Where the configuration is stored
|
|
204
|
+
|
|
205
|
+
`gemini-set-model` and the CLI's `set-*` commands write the default model and
|
|
206
|
+
thinking level to:
|
|
207
|
+
|
|
208
|
+
| Platform | Location |
|
|
209
|
+
|---|---|
|
|
210
|
+
| Linux, macOS | `~/.config/gemini-grounding-mcp/config.json` |
|
|
211
|
+
| Windows | `%APPDATA%\gemini-grounding-mcp\config.json` |
|
|
212
|
+
| Any, if `XDG_CONFIG_HOME` is set | `$XDG_CONFIG_HOME/gemini-grounding-mcp/config.json` |
|
|
213
|
+
|
|
214
|
+
Neither the file nor its directory is created until you save a setting for the
|
|
215
|
+
first time. Delete the file to return to the built-in defaults
|
|
216
|
+
(`gemini-flash-latest`, thinking level `medium`). It holds nothing but those two
|
|
217
|
+
values - never the API key. Run `gemini-grounding config` to see the exact path
|
|
218
|
+
on your machine.
|
|
219
|
+
|
|
220
|
+
**Coming from a clone older than 1.1.0:** back then the file lived next to
|
|
221
|
+
`index.js` inside the repository. It is no longer read and nothing is migrated
|
|
222
|
+
for you - run `gemini-grounding config` to see where the file belongs now, then
|
|
223
|
+
either move the old one there or simply set both values again. The leftover copy
|
|
224
|
+
in the clone can be deleted.
|
|
225
|
+
|
|
226
|
+
### Which models are usable
|
|
227
|
+
|
|
228
|
+
Your API key exposes considerably more models than will work here. The model
|
|
229
|
+
list therefore shows, by default, only those meeting two conditions - both taken
|
|
230
|
+
from what the API reports about each model, not from its name:
|
|
231
|
+
|
|
232
|
+
- **`generateContent`** in `supportedActions` - the model produces text at all.
|
|
233
|
+
This rules out embedding, image (Imagen), video (Veo) and live/audio models.
|
|
234
|
+
- **`thinking: true`** - the model accepts a thinking level. Since every search
|
|
235
|
+
sends one, the API would otherwise answer with
|
|
236
|
+
`400 Thinking level is not supported for this model.`
|
|
237
|
+
|
|
238
|
+
Filtering by name patterns would be unreliable: Google assigns code names that
|
|
239
|
+
say nothing about capabilities - `nano-banana-pro-preview` is an image model.
|
|
240
|
+
|
|
241
|
+
Two limitations remain:
|
|
242
|
+
|
|
243
|
+
- The conditions say what runs **technically**, not what is sensible for
|
|
244
|
+
research. Some image, speech and robotics models meet them too and show up in
|
|
245
|
+
the list.
|
|
246
|
+
- **Listed does not mean available.** Retired models stay in the list and answer
|
|
247
|
+
with `404 ... is no longer available`. No field announces this in advance -
|
|
248
|
+
only a real call gives you certainty.
|
|
249
|
+
|
|
250
|
+
This is why `--all` / `all: true` hides nothing but instead shows the complete
|
|
251
|
+
list with a status column.
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
[MIT](./LICENSE)
|
package/citations.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Belegmarker ([1], [2]) fuer den Antworttext. Die Gemini-API weist ueber
|
|
2
|
+
// groundingSupports aus, welche Textstelle durch welche Quelle gestuetzt ist;
|
|
3
|
+
// diese Datei setzt daraus Marker in den Text. Sichtbar werden soll dabei
|
|
4
|
+
// weniger, WELCHE Quelle einen Satz stuetzt, als vielmehr, OB er ueberhaupt
|
|
5
|
+
// belegt ist - gemessen an einer realen Antwort waren 27 % des Textes durch
|
|
6
|
+
// keinen einzigen Support gedeckt, ohne dass man es der Antwort ansah.
|
|
7
|
+
//
|
|
8
|
+
// Die Datei greift bewusst weder auf die API noch auf die Konfiguration zu:
|
|
9
|
+
// Sie bekommt Text und Metadaten uebergeben und liefert Text zurueck. Damit
|
|
10
|
+
// ist sie ohne Netzwerk und ohne API-Key pruefbar (test/citations.test.js).
|
|
11
|
+
//
|
|
12
|
+
// Gerechnet wird durchgehend in BYTES, nicht in Zeichen: startIndex und
|
|
13
|
+
// endIndex sind laut Typdefinition "measured in bytes". Bei einer deutschen
|
|
14
|
+
// Testantwort stimmte keine einzige von 28 Positionen zeichenbasiert, alle 28
|
|
15
|
+
// bytebasiert; Text und Bytes liefen am Ende um 44 Stellen auseinander. Google
|
|
16
|
+
// selbst hatte diesen Fehler in der Gemini CLI (PR google-gemini/
|
|
17
|
+
// gemini-cli#5956, aufgefallen an japanischem Text).
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Findet die Bereiche, in die kein Marker gesetzt werden darf: Markdown-Code
|
|
21
|
+
* im Fliesstext. Ein Marker mitten in einem Codebeispiel macht aus
|
|
22
|
+
* `copy.replace(obj, x=1)` ein `copy.replace(obj[3], x=1)` - syntaktisch
|
|
23
|
+
* gueltig, inhaltlich falsch. Da gegen die Antworten dieses Servers Code
|
|
24
|
+
* geschrieben wird, wiegt das schwerer als ein fehlender Marker.
|
|
25
|
+
*
|
|
26
|
+
* Gemeint ist NUR Code, den das Modell selbst in seinen Fliesstext schreibt.
|
|
27
|
+
* Die Bloecke aus Code Execution sind eigene Parts, werden von buildText()
|
|
28
|
+
* erst nach dem Einfuegen angehaengt und koennen hier nicht auftauchen.
|
|
29
|
+
*
|
|
30
|
+
* Gesucht wird in EINEM Durchlauf. Der Scan laeuft von links nach rechts, und
|
|
31
|
+
* weil die umzaeunten Bloecke in der Alternation vorn stehen, verschluckt ein
|
|
32
|
+
* Zaun alles, was in ihm steht - auch einzelne Backticks, die sonst als
|
|
33
|
+
* Inline-Code gelesen wuerden. Eine zweite Suche mit Ueberlappungspruefung
|
|
34
|
+
* braucht es dadurch nicht.
|
|
35
|
+
*
|
|
36
|
+
* Verworfen wurde die einfachere Variante, die Backticks vor der Zielposition
|
|
37
|
+
* zu zaehlen und bei ungerader Anzahl zu verwerfen: Ein einzelner unpaariger
|
|
38
|
+
* Backtick im Text kippt diese Zaehlung fuer den gesamten Rest, ein mit vier
|
|
39
|
+
* Backticks geschlossener Block ebenso, und innerhalb einer Backtick-Sequenz
|
|
40
|
+
* oszilliert sie bedeutungslos.
|
|
41
|
+
*
|
|
42
|
+
* Nicht erkannt werden eingerueckte Codebloecke (vier Leerzeichen). Sie sind
|
|
43
|
+
* die einzige bekannte Luecke; nachruestbar als dritte Regel, falls sie in
|
|
44
|
+
* echten Antworten auftauchen.
|
|
45
|
+
*
|
|
46
|
+
* Rueckgabe sind BYTE-Bereiche, passend zu den Offsets der API.
|
|
47
|
+
*/
|
|
48
|
+
function findCodeRanges(text) {
|
|
49
|
+
const toByte = (charIndex) => Buffer.byteLength(text.slice(0, charIndex), "utf8");
|
|
50
|
+
|
|
51
|
+
return [...text.matchAll(/```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]+`/g)].map((match) => [
|
|
52
|
+
toByte(match.index),
|
|
53
|
+
toByte(match.index + match[0].length),
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Setzt Belegmarker in den Text EINES Parts.
|
|
59
|
+
*
|
|
60
|
+
* - text: der Rohtext genau eines Text-Parts
|
|
61
|
+
* - supports: die groundingSupports, die zu DIESEM Part gehoeren
|
|
62
|
+
* - chunkNumbers: Map vom Index in groundingChunks auf die Nummer in der
|
|
63
|
+
* ausgegebenen Quellenliste (siehe buildSourceList)
|
|
64
|
+
*
|
|
65
|
+
* Liefert den Text mit Markern und die Zahl der verworfenen Marker. Verworfen
|
|
66
|
+
* wird bewusst grosszuegig: Ein fehlender Marker laesst eine belegte Aussage
|
|
67
|
+
* unbelegt wirken und loest damit nur zusaetzliche Vorsicht aus. Ein falsch
|
|
68
|
+
* gesetzter Marker verweist auf eine Quelle, die die Aussage nicht stuetzt -
|
|
69
|
+
* oder zerstoert Code. Deshalb im Zweifel immer gegen den Marker.
|
|
70
|
+
*/
|
|
71
|
+
export function insertCitations({ text, supports, chunkNumbers }) {
|
|
72
|
+
if (!text || supports.length === 0) return { text, dropped: 0 };
|
|
73
|
+
|
|
74
|
+
const bytes = Buffer.from(text, "utf8");
|
|
75
|
+
const codeRanges = findCodeRanges(text);
|
|
76
|
+
const insertions = [];
|
|
77
|
+
let dropped = 0;
|
|
78
|
+
|
|
79
|
+
for (const support of supports) {
|
|
80
|
+
const segment = support.segment ?? {};
|
|
81
|
+
const end = segment.endIndex;
|
|
82
|
+
if (typeof end !== "number") continue;
|
|
83
|
+
|
|
84
|
+
// Protobuf laesst Defaultwerte weg: startIndex fehlt im JSON, wenn er 0
|
|
85
|
+
// ist. Ohne ?? 0 ergaebe die Pruefung darunter NaN und schluege immer fehl.
|
|
86
|
+
const start = segment.startIndex ?? 0;
|
|
87
|
+
|
|
88
|
+
// Die einzige Absicherung gegen eine stille Aenderung der
|
|
89
|
+
// Offset-Semantik: Die API liefert den erwarteten Ausschnitt in
|
|
90
|
+
// segment.text mit. Passt er nicht zur berechneten Position, wird nicht
|
|
91
|
+
// geraten, sondern der Marker weggelassen. Damit kann ein Marker nie an
|
|
92
|
+
// der falschen Stelle landen - er kann nur fehlen.
|
|
93
|
+
if (segment.text !== undefined && bytes.subarray(start, end).toString("utf8") !== segment.text) {
|
|
94
|
+
dropped++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (codeRanges.some(([from, to]) => end > from && end < to)) {
|
|
99
|
+
dropped++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// groundingChunkIndices verweist auf die UNDEDUPLIZIERTE Trefferliste der
|
|
104
|
+
// API - gemessen 14 Treffer bei nur 4 eindeutigen URLs. Ein naives
|
|
105
|
+
// index + 1 schriebe damit Nummern bis [14] in eine Liste mit vier
|
|
106
|
+
// Eintraegen. chunkNumbers uebersetzt; Treffer, die es nicht in die Liste
|
|
107
|
+
// geschafft haben, erzeugen keinen Marker.
|
|
108
|
+
const numbers = [
|
|
109
|
+
...new Set(
|
|
110
|
+
(support.groundingChunkIndices ?? [])
|
|
111
|
+
.map((index) => chunkNumbers.get(index))
|
|
112
|
+
.filter((number) => number !== undefined),
|
|
113
|
+
),
|
|
114
|
+
].sort((a, b) => a - b);
|
|
115
|
+
|
|
116
|
+
// Kein dropped++: Hier gab es nichts zu setzen, nichts ging verloren.
|
|
117
|
+
if (numbers.length === 0) continue;
|
|
118
|
+
|
|
119
|
+
insertions.push({ index: end, marker: numbers.map((n) => `[${n}]`).join("") });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (insertions.length === 0) return { text, dropped };
|
|
123
|
+
|
|
124
|
+
// Von hinten nach vorn, damit bereits eingesetzte Zeichen die noch
|
|
125
|
+
// ausstehenden Positionen nicht verschieben.
|
|
126
|
+
insertions.sort((a, b) => b.index - a.index);
|
|
127
|
+
|
|
128
|
+
// Die Bytestuecke werden gesammelt und EINMAL zusammengesetzt, statt bei
|
|
129
|
+
// jedem Marker einen neuen Puffer zu bauen (Muster aus Googles
|
|
130
|
+
// Referenzimplementierung). Buffer statt TextEncoder/Uint8Array: identische
|
|
131
|
+
// Byte-Semantik, aber kuerzer - der Server laeuft ausschliesslich unter
|
|
132
|
+
// Node, die Portabilitaet, wegen der Google dort TextEncoder nutzt, wird
|
|
133
|
+
// hier nicht gebraucht.
|
|
134
|
+
const chunks = [];
|
|
135
|
+
let lastIndex = bytes.length;
|
|
136
|
+
for (const { index, marker } of insertions) {
|
|
137
|
+
// Faengt einen Offset ab, der ueber das Textende hinausweist - sonst
|
|
138
|
+
// entstuende ein leeres subarray und der Marker landete am falschen Ort.
|
|
139
|
+
const position = Math.min(index, lastIndex);
|
|
140
|
+
chunks.unshift(bytes.subarray(position, lastIndex));
|
|
141
|
+
chunks.unshift(Buffer.from(marker, "utf8"));
|
|
142
|
+
lastIndex = position;
|
|
143
|
+
}
|
|
144
|
+
chunks.unshift(bytes.subarray(0, lastIndex));
|
|
145
|
+
|
|
146
|
+
return { text: Buffer.concat(chunks).toString("utf8"), dropped };
|
|
147
|
+
}
|
package/cli.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Kommandozeilen-Frontend auf denselben Kern, den auch index.js nutzt:
|
|
3
|
+
// gemini.js fuer die API-Aufrufe, config.js fuer die gespeicherten Defaults.
|
|
4
|
+
// Die relativen Imports loesen in ES-Modulen relativ zu DIESER Datei auf,
|
|
5
|
+
// nicht zum Arbeitsverzeichnis - die CLI funktioniert daher aus jedem Ordner.
|
|
6
|
+
|
|
7
|
+
import { runSearch, listModels } from "./gemini.js";
|
|
8
|
+
import {
|
|
9
|
+
CONFIG_PATH,
|
|
10
|
+
getSavedModel,
|
|
11
|
+
getSavedThinkingLevel,
|
|
12
|
+
setSavedConfig,
|
|
13
|
+
THINKING_LEVELS,
|
|
14
|
+
} from "./config.js";
|
|
15
|
+
|
|
16
|
+
const HELP = `gemini-grounding - CLI for the Gemini grounding MCP server
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
gemini-grounding "<query>" [--model <id>] [--thinking <level>]
|
|
20
|
+
gemini-grounding <command> [argument]
|
|
21
|
+
|
|
22
|
+
Commands:
|
|
23
|
+
config Show saved model, thinking level and API key status
|
|
24
|
+
models [--all] List models usable with this server; --all lists every
|
|
25
|
+
model the API key exposes, including unusable ones
|
|
26
|
+
set-model <id> Persist the default model
|
|
27
|
+
set-thinking <level> Persist the default thinking level
|
|
28
|
+
help Show this help
|
|
29
|
+
|
|
30
|
+
Options (search only, never persisted):
|
|
31
|
+
--model <id> Use this model for this call only
|
|
32
|
+
--thinking <level> Use this thinking level for this call only
|
|
33
|
+
|
|
34
|
+
Thinking levels: ${THINKING_LEVELS.join(", ")}
|
|
35
|
+
|
|
36
|
+
Anything that is not a known command is treated as a search query. The query
|
|
37
|
+
must be a single argument - put it in quotes if it contains spaces.
|
|
38
|
+
The API key is read from the GEMINI_API_KEY environment variable.
|
|
39
|
+
The saved defaults are shared with the MCP server; "config" prints their location.`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bedienfehler - falsche Argumente, unbekannte Option, leere Anfrage. Wird
|
|
43
|
+
* anders behandelt als ein echter Laufzeitfehler: nur die Meldung, kein
|
|
44
|
+
* Stacktrace, weil ein Tippfehler auf der Kommandozeile keinen braucht.
|
|
45
|
+
*/
|
|
46
|
+
class UsageError extends Error {}
|
|
47
|
+
|
|
48
|
+
function fail(message) {
|
|
49
|
+
throw new UsageError(message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireThinkingLevel(value, origin) {
|
|
53
|
+
if (!THINKING_LEVELS.includes(value)) {
|
|
54
|
+
fail(`Invalid thinking level "${value}" for ${origin}. Allowed: ${THINKING_LEVELS.join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Holt "--name <wert>" aus der Argumentliste und ENTFERNT beides daraus.
|
|
61
|
+
* Dadurch ist die Position der Flags beliebig und der uebrig bleibende Rest
|
|
62
|
+
* ist sauber Unterbefehl bzw. Suchanfrage.
|
|
63
|
+
*/
|
|
64
|
+
function takeFlag(args, name) {
|
|
65
|
+
const index = args.indexOf(`--${name}`);
|
|
66
|
+
if (index === -1) return undefined;
|
|
67
|
+
const value = args[index + 1];
|
|
68
|
+
if (value === undefined || value.startsWith("--")) {
|
|
69
|
+
fail(`Missing value for --${name}`);
|
|
70
|
+
}
|
|
71
|
+
args.splice(index, 2);
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Wie takeFlag, aber fuer Schalter ohne Wert. */
|
|
76
|
+
function takeSwitch(args, name) {
|
|
77
|
+
const index = args.indexOf(`--${name}`);
|
|
78
|
+
if (index === -1) return false;
|
|
79
|
+
args.splice(index, 1);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function main() {
|
|
84
|
+
// argv[0] ist der Node-Interpreter, argv[1] das Skript selbst - erst ab
|
|
85
|
+
// Index 2 stehen die vom Benutzer uebergebenen Argumente.
|
|
86
|
+
const args = process.argv.slice(2);
|
|
87
|
+
|
|
88
|
+
const modelFlag = takeFlag(args, "model");
|
|
89
|
+
const thinkingFlag = takeFlag(args, "thinking");
|
|
90
|
+
const allFlag = takeSwitch(args, "all");
|
|
91
|
+
if (thinkingFlag !== undefined) requireThinkingLevel(thinkingFlag, "--thinking");
|
|
92
|
+
|
|
93
|
+
// Was jetzt noch wie eine Option aussieht, kennt die CLI nicht. Ohne diese
|
|
94
|
+
// Pruefung bliebe ein Tippfehler folgenlos liegen: "models --al" haette
|
|
95
|
+
// stillschweigend die gefilterte Liste gezeigt, die man fuer die
|
|
96
|
+
// vollstaendige haelt. Bewusst nur "--", damit eine Anfrage wie
|
|
97
|
+
// "-5 Grad in Fahrenheit" weiterhin durchgeht; "--help" ist ausgenommen,
|
|
98
|
+
// weil es unten als Unterbefehl behandelt wird.
|
|
99
|
+
const unknownOption = args.find((arg) => arg.startsWith("--") && arg !== "--help");
|
|
100
|
+
if (unknownOption) fail(`Unknown option "${unknownOption}".`);
|
|
101
|
+
|
|
102
|
+
const [command, ...rest] = args;
|
|
103
|
+
|
|
104
|
+
// Jeder Zweig prueft, dass nichts Ueberzaehliges uebrig bleibt - sonst
|
|
105
|
+
// wuerde "set-thinking low unsinn" klaglos speichern und den Rest verwerfen.
|
|
106
|
+
const requireNoArgs = () => {
|
|
107
|
+
if (rest.length > 0) fail(`"${command}" takes no arguments.`);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
switch (command) {
|
|
111
|
+
case undefined:
|
|
112
|
+
// Aufruf ohne Argumente ist ein Bedienfehler: Hilfe nach stderr, Exit 1.
|
|
113
|
+
fail(HELP);
|
|
114
|
+
break;
|
|
115
|
+
|
|
116
|
+
case "help":
|
|
117
|
+
case "--help":
|
|
118
|
+
case "-h":
|
|
119
|
+
requireNoArgs();
|
|
120
|
+
console.log(HELP);
|
|
121
|
+
break;
|
|
122
|
+
|
|
123
|
+
case "config": {
|
|
124
|
+
requireNoArgs();
|
|
125
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
126
|
+
// Der Wert des Keys wird nie ausgegeben, auch nicht gekuerzt - nur seine
|
|
127
|
+
// Laenge, weil sich daran ein abgeschnittenes Einfuegen erkennen laesst.
|
|
128
|
+
const keyStatus = apiKey
|
|
129
|
+
? `set (${apiKey.length} chars)`
|
|
130
|
+
: "NOT SET - set the GEMINI_API_KEY environment variable";
|
|
131
|
+
console.log(`${"Model:".padEnd(16)}${getSavedModel()}`);
|
|
132
|
+
console.log(`${"Thinking level:".padEnd(16)}${getSavedThinkingLevel()}`);
|
|
133
|
+
console.log(`${"API key:".padEnd(16)}${keyStatus}`);
|
|
134
|
+
// Der Pfad wird immer genannt, auch wenn die Datei noch gar nicht
|
|
135
|
+
// existiert - dann steht dort, wo sie beim ersten set-model entstehen
|
|
136
|
+
// wird, und die obigen Werte sind die eingebauten Defaults.
|
|
137
|
+
console.log(`${"Config file:".padEnd(16)}${CONFIG_PATH}`);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case "models":
|
|
142
|
+
requireNoArgs();
|
|
143
|
+
console.log(await listModels({ all: allFlag }));
|
|
144
|
+
break;
|
|
145
|
+
|
|
146
|
+
case "set-model": {
|
|
147
|
+
if (rest.length !== 1) fail("Usage: gemini-grounding set-model <model-id>");
|
|
148
|
+
const model = rest[0];
|
|
149
|
+
setSavedConfig({ model });
|
|
150
|
+
console.log(`Saved - Model: ${model}`);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case "set-thinking": {
|
|
155
|
+
if (rest.length !== 1) {
|
|
156
|
+
fail(`Usage: gemini-grounding set-thinking <${THINKING_LEVELS.join("|")}>`);
|
|
157
|
+
}
|
|
158
|
+
const level = requireThinkingLevel(rest[0], "set-thinking");
|
|
159
|
+
setSavedConfig({ thinkingLevel: level });
|
|
160
|
+
console.log(`Saved - Thinking level: ${level}`);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
default: {
|
|
165
|
+
// Alles, was kein bekannter Unterbefehl ist, gilt als Suchanfrage - und
|
|
166
|
+
// zwar als genau ein Argument. Eine unquotiert getippte Frage wieder
|
|
167
|
+
// zusammenzusetzen waere truegerisch: takeFlag hat vorher ein
|
|
168
|
+
// "--thinking high" mitten aus ihr herausgeschnitten, sodass eine
|
|
169
|
+
// sinnentstellte Anfrage abgeschickt wuerde, ohne dass es auffaellt.
|
|
170
|
+
if (rest.length > 0) {
|
|
171
|
+
fail("The query must be a single argument - put it in quotes.");
|
|
172
|
+
}
|
|
173
|
+
const query = command.trim();
|
|
174
|
+
// Ohne diese Pruefung ginge ein leeres Argument - etwa aus einer nicht
|
|
175
|
+
// gesetzten Shell-Variablen - als Anfrage an die API und kostet Tokens.
|
|
176
|
+
if (query === "") fail("The query is empty.");
|
|
177
|
+
|
|
178
|
+
// Gleiches Muster wie im MCP-Handler (index.js): ein Flag gilt nur fuer
|
|
179
|
+
// diesen Aufruf, sonst greift der gespeicherte Standard. config.json wird
|
|
180
|
+
// dabei nicht angefasst.
|
|
181
|
+
const text = await runSearch({
|
|
182
|
+
query,
|
|
183
|
+
model: modelFlag ?? getSavedModel(),
|
|
184
|
+
thinkingLevel: thinkingFlag ?? getSavedThinkingLevel(),
|
|
185
|
+
});
|
|
186
|
+
console.log(text);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await main();
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// Bei einem echten Laufzeitfehler gibt console.error(error) denselben
|
|
195
|
+
// vollstaendigen Stacktrace aus, den Node bei einem unbehandelten Fehler
|
|
196
|
+
// zeigen wuerde - beim Testen ist genau das gewollt, im Gegensatz zu
|
|
197
|
+
// index.js, das den Fehler fuer den Client auf eine Zeile verdichten muss.
|
|
198
|
+
// Ein Bedienfehler braucht dagegen keinen Stacktrace, nur die Meldung.
|
|
199
|
+
//
|
|
200
|
+
// Gefangen wird er trotzdem, weil Node den Prozess bei einer unbehandelten
|
|
201
|
+
// Rejection hart beendet: haengt dabei noch eine offene Netzwerkverbindung,
|
|
202
|
+
// bricht libuv unter Windows mit "Assertion failed ... src\win\async.c" ab
|
|
203
|
+
// und der Prozess endet mit 0xC0000409 statt mit dem vereinbarten Code 1.
|
|
204
|
+
//
|
|
205
|
+
// process.exitCode statt process.exit(), damit Node regulaer herunterfaehrt -
|
|
206
|
+
// das gilt ausnahmslos, deshalb wirft auch fail() nur einen UsageError,
|
|
207
|
+
// statt selbst zu beenden: stdout und stderr sind unter Windows auf einem
|
|
208
|
+
// TTY asynchron, sodass process.exit() eine laengere Ausgabe wie HELP
|
|
209
|
+
// abschneiden koennte.
|
|
210
|
+
console.error(error instanceof UsageError ? error.message : error);
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
}
|
package/config.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
// Plattformueblicher Ort fuer Nutzer-State, NICHT "./config.json" - das
|
|
6
|
+
// Arbeitsverzeichnis eines per stdio gestarteten MCP-Servers ist nicht
|
|
7
|
+
// garantiert der Projektordner. Auch nicht scriptrelativ: bei "npm install -g"
|
|
8
|
+
// liegt das Script in einem Verzeichnis, das der Paketmanager verwaltet und
|
|
9
|
+
// beim Update neu schreibt, bei "npx" in einem Cache, dessen Hash sich mit
|
|
10
|
+
// jeder Version aendert - die Einstellung waere dort praktisch fluechtig.
|
|
11
|
+
//
|
|
12
|
+
// Reihenfolge: XDG_CONFIG_HOME gewinnt, wenn gesetzt (Linux-Konvention und
|
|
13
|
+
// zugleich das Ventil fuer alle, die den Standardort nicht wollen). Sonst
|
|
14
|
+
// unter Windows %APPDATA%, wo Nutzer-State hingehoert - nicht ~/.config.
|
|
15
|
+
// macOS wird bewusst wie Linux behandelt: Der Apple-Standard waere
|
|
16
|
+
// ~/Library/Application Support/, aber dies ist ein Terminal-Werkzeug, und im
|
|
17
|
+
// Terminal sucht niemand in einem im Finder ausgeblendeten Ordner.
|
|
18
|
+
const CONFIG_DIR = path.join(
|
|
19
|
+
process.env.XDG_CONFIG_HOME ??
|
|
20
|
+
(process.platform === "win32"
|
|
21
|
+
? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"))
|
|
22
|
+
: path.join(os.homedir(), ".config")),
|
|
23
|
+
"gemini-grounding-mcp",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Vollstaendiger Pfad der Konfigurationsdatei. Exportiert, weil
|
|
28
|
+
* Auffindbarkeit ueber die Ausgabe entsteht und nicht ueber den Ort: MCP-Server
|
|
29
|
+
* und CLI nennen den Pfad, damit niemand raten muss, wo die Einstellung liegt.
|
|
30
|
+
*/
|
|
31
|
+
export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
32
|
+
|
|
33
|
+
const FALLBACK_MODEL = "gemini-flash-latest";
|
|
34
|
+
// Bewusst "medium" und nicht "high": der Fallback greift bei jedem neuen
|
|
35
|
+
// Nutzer ohne config.json, und ein hoeheres Level kostet ungefragt mehr
|
|
36
|
+
// Thinking-Tokens. Wer mehr will, setzt es per gemini-set-model dauerhaft
|
|
37
|
+
// oder pro Aufruf.
|
|
38
|
+
const FALLBACK_THINKING_LEVEL = "medium";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Die von der Gemini-API akzeptierten Thinking-Level - einzige Quelle fuer
|
|
42
|
+
* MCP-Server und CLI, damit beide dieselben Werte zulassen. index.js macht
|
|
43
|
+
* daraus die Zod-Schemas (z.enum nimmt dieses Array direkt), cli.js prueft
|
|
44
|
+
* damit seine Kommandozeilenargumente.
|
|
45
|
+
*/
|
|
46
|
+
export const THINKING_LEVELS = ["minimal", "low", "medium", "high"];
|
|
47
|
+
|
|
48
|
+
function readConfig() {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
51
|
+
} catch {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Liest das dauerhaft gespeicherte Standardmodell.
|
|
58
|
+
* Liefert FALLBACK_MODEL, falls keine oder eine kaputte config.json existiert.
|
|
59
|
+
*/
|
|
60
|
+
export function getSavedModel() {
|
|
61
|
+
return readConfig().model ?? FALLBACK_MODEL;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Liest das dauerhaft gespeicherte Standard-Thinking-Level.
|
|
66
|
+
* Liefert FALLBACK_THINKING_LEVEL, falls keine oder eine kaputte config.json existiert.
|
|
67
|
+
*/
|
|
68
|
+
export function getSavedThinkingLevel() {
|
|
69
|
+
return readConfig().thinkingLevel ?? FALLBACK_THINKING_LEVEL;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Speichert Modell und/oder Thinking-Level dauerhaft, ohne den jeweils
|
|
74
|
+
* anderen gespeicherten Wert zu ueberschreiben (undefined-Felder bleiben
|
|
75
|
+
* unangetastet). Enthaelt ausschliesslich diese beiden Werte, niemals den
|
|
76
|
+
* API-Key oder andere sensible Daten.
|
|
77
|
+
*/
|
|
78
|
+
export function setSavedConfig({ model, thinkingLevel }) {
|
|
79
|
+
const config = readConfig();
|
|
80
|
+
if (model !== undefined) config.model = model;
|
|
81
|
+
if (thinkingLevel !== undefined) config.thinkingLevel = thinkingLevel;
|
|
82
|
+
// Nur hier im Schreibpfad angelegt, damit das Paket nichts ungefragt
|
|
83
|
+
// erzeugt: Solange niemand das Modell setzt, entsteht weder Verzeichnis noch
|
|
84
|
+
// Datei - readConfig() faengt die fehlende Datei bereits ab.
|
|
85
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
86
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
87
|
+
}
|
package/gemini.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { GoogleGenAI } from "@google/genai";
|
|
2
|
+
import { insertCitations } from "./citations.js";
|
|
3
|
+
|
|
4
|
+
function getClient() {
|
|
5
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
6
|
+
if (!apiKey) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
"GEMINI_API_KEY is not set. The API key must be provided via environment " +
|
|
9
|
+
"variable (never hardcoded).",
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return new GoogleGenAI({ apiKey });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Baut die Quellenliste aus zwei getrennten Metadaten-Quellen der Gemini-API:
|
|
17
|
+
* - groundingChunks: Treffer der Google-Suche
|
|
18
|
+
* - urlContextMetadata: Seiten, die Gemini gezielt per URL Context gelesen hat
|
|
19
|
+
* Beide Listen werden zusammengefuehrt und nach URL entduplifiziert.
|
|
20
|
+
*
|
|
21
|
+
* Liefert zusaetzlich chunkNumbers: die Zuordnung vom Index in
|
|
22
|
+
* groundingChunks auf die Nummer in der AUSGEGEBENEN Liste. Beide Zaehlungen
|
|
23
|
+
* laufen auseinander, weil groundingChunks Suchtreffer abbildet und nicht
|
|
24
|
+
* Quellen - gemessen 17 Treffer bei 14 eindeutigen URLs. Ohne diese Zuordnung
|
|
25
|
+
* verwiesen die Marker im Text auf Nummern, die es in der Liste nicht gibt.
|
|
26
|
+
*/
|
|
27
|
+
function buildSourceList(candidate) {
|
|
28
|
+
const searchChunks = candidate?.groundingMetadata?.groundingChunks ?? [];
|
|
29
|
+
const urlContextEntries = candidate?.urlContextMetadata?.urlMetadata ?? [];
|
|
30
|
+
|
|
31
|
+
const numberByUri = new Map();
|
|
32
|
+
const chunkNumbers = new Map();
|
|
33
|
+
const sources = [];
|
|
34
|
+
|
|
35
|
+
const addSource = (title, uri) => {
|
|
36
|
+
if (!numberByUri.has(uri)) {
|
|
37
|
+
sources.push({ title, uri });
|
|
38
|
+
numberByUri.set(uri, sources.length);
|
|
39
|
+
}
|
|
40
|
+
return numberByUri.get(uri);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
searchChunks.forEach((chunk, index) => {
|
|
44
|
+
const uri = chunk.web?.uri;
|
|
45
|
+
if (!uri) return;
|
|
46
|
+
chunkNumbers.set(index, addSource(chunk.web?.title ?? uri, uri));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// URL-Context-Quellen stehen hinter den Suchtreffern und beeinflussen die
|
|
50
|
+
// Nummerierung der Marker deshalb nicht.
|
|
51
|
+
//
|
|
52
|
+
// Gemessen an einer Anfrage mit konkreter URL: Die gelesene Seite stand
|
|
53
|
+
// zusaetzlich als groundingChunk in der Antwort - mit echtem Seitentitel,
|
|
54
|
+
// direkter URL und eigenen Supports. Sie kam ueber die Deduplizierung hier
|
|
55
|
+
// also gar nicht mehr an und bekam trotzdem Marker. Hier landet nur eine
|
|
56
|
+
// Seite, die NICHT zugleich Chunk ist; die bleibt dann ohne Marker, weil es
|
|
57
|
+
// zu ihr keine Supports gibt.
|
|
58
|
+
for (const entry of urlContextEntries) {
|
|
59
|
+
if (entry.retrievedUrl) addSource(entry.retrievedUrl, entry.retrievedUrl);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { sources, chunkNumbers };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatSourcesBlock(sources) {
|
|
66
|
+
if (sources.length === 0) return "";
|
|
67
|
+
const list = sources
|
|
68
|
+
.map((s, i) => `[${i + 1}] ${s.title} - ${s.uri}`)
|
|
69
|
+
.join("\n");
|
|
70
|
+
return `\n\nSources:\n${list}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Baut den Antworttext aus den Parts der Antwort, statt `response.text` zu
|
|
75
|
+
* nutzen. Der `.text`-Getter des SDK verwirft alles, was kein Textteil ist -
|
|
76
|
+
* bei aktiviertem Code Execution also gerade den ausgefuehrten Code und dessen
|
|
77
|
+
* Ergebnis - und schreibt dabei pro Aufruf eine Warnung nach stderr. Hier
|
|
78
|
+
* kommen beide als Codebloecke mit in die Antwort, damit nachvollziehbar
|
|
79
|
+
* bleibt, wie eine berechnete Zahl zustande gekommen ist.
|
|
80
|
+
*
|
|
81
|
+
* Code und Ergebnis kommen dabei ans ENDE, hinter den Antworttext. Die API
|
|
82
|
+
* liefert die Parts in Ausfuehrungsreihenfolge, sodass die Antwort sonst mit
|
|
83
|
+
* einem Codeblock beginnt und die eigentliche Auskunft erst darunter steht.
|
|
84
|
+
* Der Rechenweg ist ein Beleg und gehoert damit dorthin, wo auch die
|
|
85
|
+
* Quellenliste steht: hinter die Antwort, nicht davor.
|
|
86
|
+
*
|
|
87
|
+
* Hier werden ausserdem die Belegmarker gesetzt (siehe citations.js) -
|
|
88
|
+
* bewusst an dieser Stelle, weil die Parts nur hier noch einzeln vorliegen:
|
|
89
|
+
* Die Offsets der API zaehlen ab dem Anfang JEDES Parts, nach dem
|
|
90
|
+
* join("\n\n") waeren sie ab dem zweiten Part um zwei Bytes verschoben.
|
|
91
|
+
*/
|
|
92
|
+
function buildText(candidate, { supports, chunkNumbers }) {
|
|
93
|
+
const textBlocks = [];
|
|
94
|
+
const codeBlocks = [];
|
|
95
|
+
let dropped = 0;
|
|
96
|
+
|
|
97
|
+
// forEach statt for...of: Der Schleifenindex IST der partIndex, auf den sich
|
|
98
|
+
// segment.partIndex bezieht. Denk-Parts werden zwar uebersprungen, zaehlen
|
|
99
|
+
// dabei aber mit - partIndex zaehlt ueber ALLE Parts des Kandidaten.
|
|
100
|
+
(candidate?.content?.parts ?? []).forEach((part, partIndex) => {
|
|
101
|
+
// Denk-Parts gehoeren nicht in die Ausgabe - ihr Umfang steht bereits als
|
|
102
|
+
// Thinking-Tokens im Footer.
|
|
103
|
+
if (part.thought) return;
|
|
104
|
+
|
|
105
|
+
if (part.text) {
|
|
106
|
+
// partIndex fehlt im JSON, wenn er 0 ist (Protobuf-Default), daher ?? 0.
|
|
107
|
+
const result = insertCitations({
|
|
108
|
+
text: part.text,
|
|
109
|
+
supports: supports.filter((s) => (s.segment?.partIndex ?? 0) === partIndex),
|
|
110
|
+
chunkNumbers,
|
|
111
|
+
});
|
|
112
|
+
textBlocks.push(result.text);
|
|
113
|
+
dropped += result.dropped;
|
|
114
|
+
} else if (part.executableCode?.code) {
|
|
115
|
+
// language ist das Language-Enum ("PYTHON"); LANGUAGE_UNSPECIFIED ergibt
|
|
116
|
+
// keine sinnvolle Sprachangabe fuer den Codeblock.
|
|
117
|
+
const language = (part.executableCode.language ?? "").toLowerCase();
|
|
118
|
+
const fence = language.includes("unspecified") ? "" : language;
|
|
119
|
+
// trimEnd, weil Code und Ausgabe mit einem Zeilenumbruch enden - sonst
|
|
120
|
+
// steht eine Leerzeile vor dem schliessenden Codeblock.
|
|
121
|
+
codeBlocks.push(`\`\`\`${fence}\n${part.executableCode.code.trimEnd()}\n\`\`\``);
|
|
122
|
+
} else if (part.codeExecutionResult) {
|
|
123
|
+
// outcome ist "OUTCOME_OK", "OUTCOME_FAILED", ... - das Praefix traegt
|
|
124
|
+
// keine Information.
|
|
125
|
+
const outcome =
|
|
126
|
+
(part.codeExecutionResult.outcome ?? "").replace(/^OUTCOME_/, "") || "UNKNOWN";
|
|
127
|
+
const output = (part.codeExecutionResult.output ?? "").trimEnd();
|
|
128
|
+
codeBlocks.push(`Result (${outcome}):\n\`\`\`\n${output}\n\`\`\``);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Ueberschrift wie bei der Quellenliste, damit der nachgestellte Rechenweg
|
|
133
|
+
// nicht als Fortsetzung des Antworttextes gelesen wird.
|
|
134
|
+
if (codeBlocks.length > 0) codeBlocks.unshift("Code execution:");
|
|
135
|
+
|
|
136
|
+
return { text: [...textBlocks, ...codeBlocks].join("\n\n"), dropped };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Weist auf eine Antwort hin, die nicht regulaer zu Ende gelaufen ist. Ohne
|
|
141
|
+
* diesen Hinweis saehe eine blockierte oder abgeschnittene Antwort wie ein
|
|
142
|
+
* Erfolg aus: der Text fehlt oder bricht mitten im Satz ab, Quellenliste und
|
|
143
|
+
* Footer stehen trotzdem unveraendert darunter.
|
|
144
|
+
*/
|
|
145
|
+
function formatNotice({ text, candidate, promptFeedback }) {
|
|
146
|
+
const blockReason = promptFeedback?.blockReason;
|
|
147
|
+
if (blockReason) {
|
|
148
|
+
return `\n\n⚠️ Request blocked by the API - blockReason: ${blockReason}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const finishReason = candidate?.finishReason;
|
|
152
|
+
if (text === "") {
|
|
153
|
+
return `\n\n⚠️ The response contained no text - finishReason: ${finishReason ?? "unknown"}`;
|
|
154
|
+
}
|
|
155
|
+
// STOP ist der regulaere Abschluss. Alles andere - vor allem MAX_TOKENS -
|
|
156
|
+
// bedeutet eine abgeschnittene Antwort, die sonst vollstaendig wirkt.
|
|
157
|
+
if (finishReason && finishReason !== "STOP") {
|
|
158
|
+
return `\n\n⚠️ The response is incomplete - finishReason: ${finishReason}`;
|
|
159
|
+
}
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatFooter({ usageMetadata, model, thinkingLevel, sourceCount, dropped }) {
|
|
164
|
+
const inputTokens = usageMetadata?.promptTokenCount ?? 0;
|
|
165
|
+
const outputTokens = usageMetadata?.candidatesTokenCount ?? 0;
|
|
166
|
+
const thinkingTokens = usageMetadata?.thoughtsTokenCount ?? 0;
|
|
167
|
+
|
|
168
|
+
// Verworfene Marker gehoeren in den Footer, weil sie die Aussagekraft der
|
|
169
|
+
// Antwort veraendern: Fehlt ein Marker, kann die Stelle ungegroundet sein -
|
|
170
|
+
// oder die Pruefung hat ihn verworfen. Nur sichtbar, wenn es welche gab,
|
|
171
|
+
// damit der Normalfall den Footer nicht verlaengert.
|
|
172
|
+
const droppedNote = dropped > 0 ? ` | ⚠️ ${dropped} markers dropped` : "";
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
`\n\n---\n🔢 ${inputTokens} input / ${outputTokens} output / ${thinkingTokens} thinking tokens ` +
|
|
176
|
+
`| 🔍 ${sourceCount} sources | 🤖 ${model} (thinking: ${thinkingLevel})${droppedNote}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Fuehrt eine Gemini-Recherche mit allen drei Built-in-Tools durch
|
|
182
|
+
* (Google Search, URL Context, Code Execution), setzt die Belegmarker in den
|
|
183
|
+
* Antworttext und haengt Quellenliste sowie Token-Footer an.
|
|
184
|
+
*/
|
|
185
|
+
export async function runSearch({ query, model, thinkingLevel }) {
|
|
186
|
+
const ai = getClient();
|
|
187
|
+
|
|
188
|
+
const response = await ai.models.generateContent({
|
|
189
|
+
model,
|
|
190
|
+
contents: query,
|
|
191
|
+
config: {
|
|
192
|
+
tools: [{ googleSearch: {} }, { urlContext: {} }, { codeExecution: {} }],
|
|
193
|
+
thinkingConfig: { thinkingLevel },
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const candidate = response.candidates?.[0];
|
|
198
|
+
const { sources, chunkNumbers } = buildSourceList(candidate);
|
|
199
|
+
|
|
200
|
+
// Das ?? [] ist die Absicherung gegen eine Antwort ohne groundingMetadata -
|
|
201
|
+
// dann laeuft alles unveraendert durch, nur ohne Marker.
|
|
202
|
+
const { text, dropped } = buildText(candidate, {
|
|
203
|
+
supports: candidate?.groundingMetadata?.groundingSupports ?? [],
|
|
204
|
+
chunkNumbers,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const notice = formatNotice({
|
|
208
|
+
text,
|
|
209
|
+
candidate,
|
|
210
|
+
promptFeedback: response.promptFeedback,
|
|
211
|
+
});
|
|
212
|
+
const sourcesBlock = formatSourcesBlock(sources);
|
|
213
|
+
const footer = formatFooter({
|
|
214
|
+
usageMetadata: response.usageMetadata,
|
|
215
|
+
model,
|
|
216
|
+
thinkingLevel,
|
|
217
|
+
sourceCount: sources.length,
|
|
218
|
+
dropped,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Der Footer bleibt in jedem Fall der letzte Bestandteil der Antwort.
|
|
222
|
+
return text + notice + sourcesBlock + footer;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Kuerzt eine Tokenzahl lesbar ab: 1048576 -> 1M, 65536 -> 64k. */
|
|
226
|
+
function formatTokenLimit(limit) {
|
|
227
|
+
if (typeof limit !== "number") return "?";
|
|
228
|
+
if (limit >= 1024 * 1024) return `${Math.round(limit / (1024 * 1024))}M`;
|
|
229
|
+
if (limit >= 1024) return `${Math.round(limit / 1024)}k`;
|
|
230
|
+
return String(limit);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Ob ein Modell mit DIESEM Server funktioniert. Zwei Bedingungen, beide aus
|
|
235
|
+
* den Angaben der API selbst statt aus dem Modellnamen - ein Namensmuster
|
|
236
|
+
* wuerde bei jeder neuen Modellfamilie brechen (Codenamen wie
|
|
237
|
+
* "nano-banana-pro-preview" verraten nichts ueber die Faehigkeiten):
|
|
238
|
+
* - generateContent: erzeugt ueberhaupt Text (schliesst Embeddings, Imagen,
|
|
239
|
+
* Veo und die Live-/Audio-Modelle aus)
|
|
240
|
+
* - thinking: akzeptiert ein Thinking-Level. runSearch schickt immer eines
|
|
241
|
+
* mit, andernfalls antwortet die API mit
|
|
242
|
+
* 400 "Thinking level is not supported for this model."
|
|
243
|
+
*/
|
|
244
|
+
function isUsableModel(model) {
|
|
245
|
+
return (model.supportedActions ?? []).includes("generateContent") && model.thinking === true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Warum ein Modell nicht in der Standardliste steht - nur fuer die --all-Ansicht. */
|
|
249
|
+
function modelStatus(model) {
|
|
250
|
+
const actions = model.supportedActions ?? [];
|
|
251
|
+
if (!actions.includes("generateContent")) return actions[0] ?? "no generateContent";
|
|
252
|
+
return model.thinking === true ? "thinking" : "no thinking";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Listet die fuer den aktuellen API-Key verfuegbaren Modelle mit Token-Limits.
|
|
257
|
+
* Standardmaessig nur die, die mit diesem Server nutzbar sind; mit all=true
|
|
258
|
+
* die vollstaendige Liste inkl. Statusspalte.
|
|
259
|
+
*
|
|
260
|
+
* Die Liste sagt nichts ueber die Verfuegbarkeit aus: abgekuendigte Modelle
|
|
261
|
+
* erscheinen weiterhin, antworten aber mit 404. Ein Feld dafuer gibt es nicht.
|
|
262
|
+
*/
|
|
263
|
+
export async function listModels({ all = false } = {}) {
|
|
264
|
+
const ai = getClient();
|
|
265
|
+
const pager = await ai.models.list({ config: { pageSize: 50 } });
|
|
266
|
+
|
|
267
|
+
const models = [];
|
|
268
|
+
for await (const model of pager) models.push(model);
|
|
269
|
+
if (models.length === 0) return "No models available for this API key.";
|
|
270
|
+
|
|
271
|
+
const usable = models.filter(isUsableModel);
|
|
272
|
+
|
|
273
|
+
// Sicherung gegen ein leeres Ergebnis, falls die API die ausgewerteten
|
|
274
|
+
// Felder einmal nicht mehr liefert: dann lieber die volle Liste zeigen als
|
|
275
|
+
// gar keine.
|
|
276
|
+
const filterFailed = usable.length === 0;
|
|
277
|
+
const showAll = all || filterFailed;
|
|
278
|
+
const shown = [...(showAll ? models : usable)];
|
|
279
|
+
|
|
280
|
+
const name = (model) => (model.name ?? "?").replace(/^models\//, "");
|
|
281
|
+
shown.sort((a, b) => name(a).localeCompare(name(b)));
|
|
282
|
+
const width = Math.max(...shown.map((model) => name(model).length));
|
|
283
|
+
|
|
284
|
+
const lines = shown.map((model) => {
|
|
285
|
+
const limits =
|
|
286
|
+
`${formatTokenLimit(model.inputTokenLimit).padStart(4)} in / ` +
|
|
287
|
+
`${formatTokenLimit(model.outputTokenLimit).padStart(4)} out`;
|
|
288
|
+
const status = showAll ? ` ${modelStatus(model)}` : "";
|
|
289
|
+
return `${name(model).padEnd(width)} ${limits}${status}`;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
let note;
|
|
293
|
+
if (filterFailed) {
|
|
294
|
+
note =
|
|
295
|
+
`All ${models.length} models - the usability filter matched nothing, so ` +
|
|
296
|
+
"nothing is hidden. Check whether the API still reports supportedActions and thinking.";
|
|
297
|
+
} else if (all) {
|
|
298
|
+
note =
|
|
299
|
+
`All ${models.length} models. Only the ${usable.length} marked "thinking" work with ` +
|
|
300
|
+
"this server, which always sends a thinking level. Being listed is no guarantee " +
|
|
301
|
+
"a model still answers - retired ones stay in this list and return 404.";
|
|
302
|
+
} else {
|
|
303
|
+
note =
|
|
304
|
+
`${usable.length} of ${models.length} models usable with this server (text generation ` +
|
|
305
|
+
"plus thinking level support). Request all models to see the rest.";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return `${lines.join("\n")}\n\n${note}`;
|
|
309
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Der Shebang macht die Datei als bin-Eintrag direkt ausfuehrbar - noetig,
|
|
3
|
+
// damit "npx @brobertoblanko/gemini-grounding-mcp" den Server startet.
|
|
4
|
+
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
import { runSearch, listModels } from "./gemini.js";
|
|
12
|
+
import {
|
|
13
|
+
CONFIG_PATH,
|
|
14
|
+
getSavedModel,
|
|
15
|
+
getSavedThinkingLevel,
|
|
16
|
+
setSavedConfig,
|
|
17
|
+
THINKING_LEVELS,
|
|
18
|
+
} from "./config.js";
|
|
19
|
+
|
|
20
|
+
// Die Version kommt aus der package.json, damit sie nur an einer Stelle
|
|
21
|
+
// gepflegt wird - eine zweite, von Hand nachgezogene Zahl meldet dem Client
|
|
22
|
+
// frueher oder spaeter eine Fassung, die nicht der ausgelieferten entspricht.
|
|
23
|
+
// createRequire statt import ... with { type: "json" }: Import Attributes gibt
|
|
24
|
+
// es erst ab Node 20.10, engines erlaubt aber jedes 20.x - dort waere es ein
|
|
25
|
+
// Syntaxfehler, den niemand abfangen kann, waehrend createRequire seit jeher
|
|
26
|
+
// laeuft. Die package.json liegt neben dieser Datei, im Klon wie im
|
|
27
|
+
// installierten Paket: npm packt sie immer mit, unabhaengig von files.
|
|
28
|
+
const { version } = createRequire(import.meta.url)("./package.json");
|
|
29
|
+
|
|
30
|
+
const server = new McpServer(
|
|
31
|
+
{
|
|
32
|
+
name: "gemini-grounding",
|
|
33
|
+
title: "Gemini Web Search",
|
|
34
|
+
version,
|
|
35
|
+
description:
|
|
36
|
+
"Web search and research via Google's Gemini API with grounding - " +
|
|
37
|
+
"current, cited information for library APIs, software behavior, and recent events.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
instructions:
|
|
41
|
+
"Search and research the web through Google's Gemini API with grounding. " +
|
|
42
|
+
"Use this server to retrieve current, cited information - library APIs, " +
|
|
43
|
+
"software behavior, recent events, or any fact that may be newer than a " +
|
|
44
|
+
"model's training data.\n" +
|
|
45
|
+
"- gemini-search: the model and thinking level actually used are always shown in the " +
|
|
46
|
+
"response footer, so the user can see them without guessing.\n" +
|
|
47
|
+
"- On errors, report them clearly - never silently fall back to another model.\n" +
|
|
48
|
+
"- Before changing the default model, list what's available with gemini-list-models; " +
|
|
49
|
+
"use gemini-set-model only when explicitly asked.\n" +
|
|
50
|
+
"- Every answer has a source list and a token-usage footer appended - keep them intact.",
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
server.registerTool(
|
|
55
|
+
"gemini-search",
|
|
56
|
+
{
|
|
57
|
+
title: "Gemini web search with grounding",
|
|
58
|
+
description:
|
|
59
|
+
"Run a web search / research query through the Gemini API. Combines " +
|
|
60
|
+
"Google Search, URL context, and code execution in a single call. " +
|
|
61
|
+
"Uses the saved default model and thinking level unless the user " +
|
|
62
|
+
"explicitly requests a different one for this call - the default " +
|
|
63
|
+
"can be changed anytime via gemini-set-model. " +
|
|
64
|
+
"The response includes a source list and a token-usage footer.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
query: z.string().describe("The search or research query"),
|
|
67
|
+
model: z
|
|
68
|
+
.string()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe(
|
|
71
|
+
"Gemini model, e.g. gemini-flash-latest. Omit to use the saved " +
|
|
72
|
+
"default; only set when the user explicitly asks for a specific model.",
|
|
73
|
+
),
|
|
74
|
+
thinkingLevel: z
|
|
75
|
+
.enum(THINKING_LEVELS)
|
|
76
|
+
.optional()
|
|
77
|
+
.describe(
|
|
78
|
+
"Model reasoning depth. Omit to use the saved default; only set " +
|
|
79
|
+
"when the user explicitly asks for a specific thinking level.",
|
|
80
|
+
),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
// Die Defaults werden hier im Handler aufgeloest, NICHT als Zod-.default() im
|
|
84
|
+
// Schema: ein Schema-Default wird einmal beim Registrieren ausgewertet und
|
|
85
|
+
// eingefroren, sodass gemini-set-model erst nach einem Serverneustart wirken
|
|
86
|
+
// wuerde. Der Footer zeigt dadurch immer die tatsaechlich genutzten Werte.
|
|
87
|
+
async ({ query, model, thinkingLevel }) => {
|
|
88
|
+
try {
|
|
89
|
+
const text = await runSearch({
|
|
90
|
+
query,
|
|
91
|
+
model: model ?? getSavedModel(),
|
|
92
|
+
thinkingLevel: thinkingLevel ?? getSavedThinkingLevel(),
|
|
93
|
+
});
|
|
94
|
+
return { content: [{ type: "text", text }] };
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: "text", text: `Error in gemini-search: ${error.message}` }],
|
|
98
|
+
isError: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
server.registerTool(
|
|
105
|
+
"gemini-list-models",
|
|
106
|
+
{
|
|
107
|
+
title: "List available Gemini models",
|
|
108
|
+
description:
|
|
109
|
+
"List the Gemini models available for the current API key with their token " +
|
|
110
|
+
"limits. By default only models usable with this server - they generate text " +
|
|
111
|
+
"and accept a thinking level, which gemini-search always sends. Being listed " +
|
|
112
|
+
"is no guarantee a model still answers: retired models remain in the list and " +
|
|
113
|
+
"return 404 on use.",
|
|
114
|
+
inputSchema: {
|
|
115
|
+
all: z
|
|
116
|
+
.boolean()
|
|
117
|
+
.optional()
|
|
118
|
+
.describe(
|
|
119
|
+
"List every model the API key exposes, including those that cannot be " +
|
|
120
|
+
"used here (embedding, image, video, audio models). Off by default.",
|
|
121
|
+
),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
async ({ all }) => {
|
|
125
|
+
try {
|
|
126
|
+
const text = await listModels({ all });
|
|
127
|
+
return { content: [{ type: "text", text }] };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: "text", text: `Error in gemini-list-models: ${error.message}` }],
|
|
131
|
+
isError: true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
server.registerTool(
|
|
138
|
+
"gemini-set-model",
|
|
139
|
+
{
|
|
140
|
+
title: "Set default model and/or thinking level",
|
|
141
|
+
description:
|
|
142
|
+
"Persist the default model ID and/or thinking level to the user config file. " +
|
|
143
|
+
"Used by gemini-search as the default from the next call on, unless the call " +
|
|
144
|
+
"specifies otherwise. Check availability with gemini-list-models first. At " +
|
|
145
|
+
"least one of the two parameters is required.",
|
|
146
|
+
inputSchema: {
|
|
147
|
+
model: z
|
|
148
|
+
.string()
|
|
149
|
+
.optional()
|
|
150
|
+
.describe(
|
|
151
|
+
"Model ID, e.g. gemini-flash-latest or a pinned model like gemini-3.5-flash",
|
|
152
|
+
),
|
|
153
|
+
thinkingLevel: z.enum(THINKING_LEVELS).optional().describe("Model reasoning depth"),
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
async ({ model, thinkingLevel }) => {
|
|
157
|
+
if (model === undefined && thinkingLevel === undefined) {
|
|
158
|
+
return {
|
|
159
|
+
content: [
|
|
160
|
+
{
|
|
161
|
+
type: "text",
|
|
162
|
+
text: "Error in gemini-set-model: model or thinkingLevel must be provided.",
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
isError: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// Schreibfehler sind hier real moeglich - das Zielverzeichnis wird beim
|
|
169
|
+
// Speichern erst angelegt und kann je nach Rechten oder verschobenem
|
|
170
|
+
// %APPDATA% unbeschreibbar sein. Ohne catch liefe der Fehler ungefangen aus
|
|
171
|
+
// dem Handler; mit catch kommt er als saubere isError-Antwort samt Pfad an.
|
|
172
|
+
try {
|
|
173
|
+
setSavedConfig({ model, thinkingLevel });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return {
|
|
176
|
+
content: [
|
|
177
|
+
{
|
|
178
|
+
type: "text",
|
|
179
|
+
text: `Error in gemini-set-model: could not write ${CONFIG_PATH} - ${error.message}`,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
isError: true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const parts = [];
|
|
186
|
+
if (model !== undefined) parts.push(`Model: ${model}`);
|
|
187
|
+
if (thinkingLevel !== undefined) parts.push(`Thinking level: ${thinkingLevel}`);
|
|
188
|
+
return {
|
|
189
|
+
content: [{ type: "text", text: `Saved to ${CONFIG_PATH} - ${parts.join(", ")}` }],
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const transport = new StdioServerTransport();
|
|
195
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@brobertoblanko/gemini-grounding-mcp",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Minimal MCP server for Google web search via the Gemini API with grounding, inline citation markers and a token footer.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"mcp-server",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"claude",
|
|
11
|
+
"claude-code",
|
|
12
|
+
"anthropic",
|
|
13
|
+
"gemini",
|
|
14
|
+
"gemini-api",
|
|
15
|
+
"google-gemini",
|
|
16
|
+
"grounding",
|
|
17
|
+
"web-search",
|
|
18
|
+
"google-search",
|
|
19
|
+
"citations",
|
|
20
|
+
"research",
|
|
21
|
+
"llm",
|
|
22
|
+
"nodejs"
|
|
23
|
+
],
|
|
24
|
+
"homepage": "https://github.com/srzsn22q6d-sys/gemini-grounding-mcp#readme",
|
|
25
|
+
"bugs": "https://github.com/srzsn22q6d-sys/gemini-grounding-mcp/issues",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/srzsn22q6d-sys/gemini-grounding-mcp.git"
|
|
29
|
+
},
|
|
30
|
+
"author": "Broberto Blanko <246734967+srzsn22q6d-sys@users.noreply.github.com>",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"bin": {
|
|
33
|
+
"gemini-grounding-mcp": "index.js",
|
|
34
|
+
"gemini-grounding": "cli.js"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"index.js",
|
|
38
|
+
"cli.js",
|
|
39
|
+
"config.js",
|
|
40
|
+
"gemini.js",
|
|
41
|
+
"citations.js",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"start": "node index.js",
|
|
50
|
+
"test": "node --test"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=20"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@google/genai": "2.13.0",
|
|
57
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
58
|
+
"zod": "4.4.3"
|
|
59
|
+
}
|
|
60
|
+
}
|