@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 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
+ }