@chrischall/gemini-mcp 1.10.0 → 1.11.1

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/dist/usage.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * What a generation cost, in tokens.
3
+ *
4
+ * Both Gemini APIs already report this and this server used to discard it. The
5
+ * two disagree on more than casing: `generateContent` returns camelCase
6
+ * `usageMetadata` with `promptTokenCount` / `candidatesTokenCount`, while the
7
+ * Interactions API returns snake_case `usage` with `total_input_tokens` /
8
+ * `total_output_tokens` plus cached- and thought-token counts that
9
+ * `generateContent` has no equivalent for. This module is the one place that
10
+ * knows both, so nothing downstream has to.
11
+ *
12
+ * Absence is preserved deliberately. A response with no usage block yields
13
+ * `undefined`, never a zeroed record: "we do not know what this cost" and "this
14
+ * cost nothing" are different claims, and a caller adding up a workflow has to
15
+ * be able to tell them apart.
16
+ */
17
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
18
+ /**
19
+ * Pull one modality's entry out of either API's by-modality list.
20
+ *
21
+ * Modality matters for money, not just curiosity: each is billed at its own
22
+ * rate, and they differ by more than an order of magnitude (text output vs
23
+ * image output is 20-40x on the same model). A modality nobody breaks out is
24
+ * priced as text, which is the cheap end — so missing one understates rather
25
+ * than inventing spend.
26
+ */
27
+ function modalityTokens(list, modality) {
28
+ if (!Array.isArray(list))
29
+ return undefined;
30
+ for (const entry of list) {
31
+ if (!entry || typeof entry !== 'object')
32
+ continue;
33
+ const e = entry;
34
+ // generateContent shouts ("IMAGE"); the Interactions API does not ("image").
35
+ if (typeof e.modality === 'string' && e.modality.toLowerCase() === modality) {
36
+ return num(e.tokenCount) ?? num(e.tokens);
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+ /**
42
+ * Read whichever usage block a response carries, or `undefined` when it
43
+ * carries neither — including the case where the block exists but has no
44
+ * totals in it, which is indistinguishable from absent for our purposes.
45
+ */
46
+ export function readUsage(body) {
47
+ if (!body || typeof body !== 'object')
48
+ return undefined;
49
+ const b = body;
50
+ const meta = b.usageMetadata;
51
+ if (meta) {
52
+ const input = num(meta.promptTokenCount);
53
+ const output = num(meta.candidatesTokenCount);
54
+ const total = num(meta.totalTokenCount);
55
+ if (input !== undefined || output !== undefined || total !== undefined) {
56
+ return compact({
57
+ input_tokens: input ?? 0,
58
+ output_tokens: output ?? 0,
59
+ total_tokens: total ?? (input ?? 0) + (output ?? 0),
60
+ image_tokens: modalityTokens(meta.candidatesTokensDetails, 'image'),
61
+ video_tokens: modalityTokens(meta.candidatesTokensDetails, 'video'),
62
+ audio_tokens: modalityTokens(meta.candidatesTokensDetails, 'audio'),
63
+ cached_tokens: num(meta.cachedContentTokenCount),
64
+ });
65
+ }
66
+ }
67
+ const usage = b.usage;
68
+ if (usage) {
69
+ const input = num(usage.total_input_tokens);
70
+ const output = num(usage.total_output_tokens);
71
+ const total = num(usage.total_tokens);
72
+ if (input !== undefined || output !== undefined || total !== undefined) {
73
+ return compact({
74
+ input_tokens: input ?? 0,
75
+ output_tokens: output ?? 0,
76
+ total_tokens: total ?? (input ?? 0) + (output ?? 0),
77
+ image_tokens: modalityTokens(usage.output_tokens_by_modality, 'image'),
78
+ video_tokens: modalityTokens(usage.output_tokens_by_modality, 'video'),
79
+ audio_tokens: modalityTokens(usage.output_tokens_by_modality, 'audio'),
80
+ cached_tokens: num(usage.total_cached_tokens),
81
+ thought_tokens: num(usage.total_thought_tokens),
82
+ });
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+ /** Drop the optional counts that are absent or zero — a constant 0 is noise. */
88
+ function compact(u) {
89
+ const out = {
90
+ input_tokens: u.input_tokens,
91
+ output_tokens: u.output_tokens,
92
+ total_tokens: u.total_tokens,
93
+ };
94
+ if (u.image_tokens)
95
+ out.image_tokens = u.image_tokens;
96
+ if (u.video_tokens)
97
+ out.video_tokens = u.video_tokens;
98
+ if (u.audio_tokens)
99
+ out.audio_tokens = u.audio_tokens;
100
+ if (u.cached_tokens)
101
+ out.cached_tokens = u.cached_tokens;
102
+ if (u.thought_tokens)
103
+ out.thought_tokens = u.thought_tokens;
104
+ return out;
105
+ }
106
+ /**
107
+ * Add up several calls — `count: N` bills N times, so a tool that reports only
108
+ * the first call understates itself. Gaps are skipped rather than counted as
109
+ * zero; if nothing at all is known the sum is `undefined`, so silence does not
110
+ * turn into a confident total.
111
+ */
112
+ export function sumUsage(parts) {
113
+ const known = parts.filter((p) => p !== undefined);
114
+ if (known.length === 0)
115
+ return undefined;
116
+ return compact(known.reduce((acc, u) => ({
117
+ input_tokens: acc.input_tokens + u.input_tokens,
118
+ output_tokens: acc.output_tokens + u.output_tokens,
119
+ total_tokens: acc.total_tokens + u.total_tokens,
120
+ image_tokens: (acc.image_tokens ?? 0) + (u.image_tokens ?? 0),
121
+ video_tokens: (acc.video_tokens ?? 0) + (u.video_tokens ?? 0),
122
+ audio_tokens: (acc.audio_tokens ?? 0) + (u.audio_tokens ?? 0),
123
+ cached_tokens: (acc.cached_tokens ?? 0) + (u.cached_tokens ?? 0),
124
+ thought_tokens: (acc.thought_tokens ?? 0) + (u.thought_tokens ?? 0),
125
+ }), { input_tokens: 0, output_tokens: 0, total_tokens: 0 }));
126
+ }
127
+ /**
128
+ * Mark a replayed result's usage as already billed.
129
+ *
130
+ * `JobRegistry.dispatch()` can serve a matching `idempotency_key` from the
131
+ * registry without calling upstream — that is what makes a host-timeout retry
132
+ * free. The recorded result still carries the ORIGINAL call's usage, which is
133
+ * correct as a description of the image you are being handed and wrong as a
134
+ * measure of what this call cost: nothing.
135
+ *
136
+ * So the counts stay and gain `usage_billed: false`, which is what lets a
137
+ * workflow total exclude them. Dropping them instead would be worse — the
138
+ * caller could no longer see how the image was produced — and leaving them
139
+ * unmarked would double-count a generation that was paid for once.
140
+ *
141
+ * A result with no usage at all is left untouched: "unknown" must not turn
142
+ * into the positive claim "not billed".
143
+ */
144
+ export function annotateReusedUsage(meta) {
145
+ if (!meta.usage)
146
+ return meta;
147
+ meta.usage_billed = false;
148
+ // The cost estimate needs the same treatment for the same reason, and is
149
+ // worse if it does not get it: a token count someone forgets to exclude is a
150
+ // rounding error in a report, a dollar figure is the report. Marked rather
151
+ // than deleted, so the result still shows what the generation cost when it
152
+ // was actually made.
153
+ if (meta.cost_estimate)
154
+ meta.cost_billed = false;
155
+ return meta;
156
+ }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Single source of the server version. release-please bumps the literal below. */
2
- export const VERSION = '1.10.0'; // x-release-please-version
2
+ export const VERSION = '1.11.1'; // x-release-please-version
package/mint.yaml CHANGED
@@ -44,12 +44,18 @@ egress:
44
44
  # solely in a URL this server BUILDS and returns are excluded.
45
45
  - generativelanguage.googleapis.com
46
46
  #
47
- # NOTE — this server also reaches at least one host determined at
48
- # RUNTIME, which cannot appear as a literal here:
49
- # - a signed upload URL returned by the API (often storage.googleapis.com / s3)
50
- # - a media/CDN host supplied at runtime
51
- # Confirm the concrete host(s) against a live run and add them, or
52
- # the isolated tier will block the call.
47
+ # That one host covers every Gemini call: generateContent, the Files API
48
+ # upload start, the resumable upload session URL it hands back (same
49
+ # host — live-verified, docs/GEMINI-API.md) and the media download, which
50
+ # client.ts refuses to follow to any other host.
51
+ #
52
+ # NOTE — this server also reaches ONE host determined at RUNTIME, which
53
+ # cannot appear as a literal here: the blob store at MCP_BLOB_BASE_URL
54
+ # (src/blob-store.ts PUTs/GETs generated media there). On mcp-host that
55
+ # is the gateway's own public hostname, injected at spawn; add that host
56
+ # for the deployment you register on, or every hosted image save is
57
+ # blocked. Nothing else is dialled at runtime: no GCS/S3 host is ever
58
+ # contacted.
53
59
  #
54
60
  # Also: the images_url argument makes this server fetch an ARBITRARY
55
61
  # caller-supplied https host. That cannot be enumerated in advance, so on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "mcpName": "io.github.chrischall/gemini-mcp",
5
5
  "description": "Gemini image-generation MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -42,7 +42,7 @@
42
42
  "dev": "node dist/index.js",
43
43
  "test": "npm run typecheck && vitest run",
44
44
  "test:watch": "vitest",
45
- "test:coverage": "vitest run --coverage",
45
+ "test:coverage": "npm run typecheck && vitest run --coverage",
46
46
  "typecheck": "tsc -p tsconfig.test.json"
47
47
  },
48
48
  "dependencies": {
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/gemini-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.10.0",
9
+ "version": "1.11.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.10.0",
14
+ "version": "1.11.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },