@chrischall/gemini-mcp 1.9.0 → 1.11.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.
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { PRICED_AT } from '../pricing.js';
4
+ /**
5
+ * What this session has spent, in tokens.
6
+ *
7
+ * This exists because the obvious thing — read the account balance before and
8
+ * after — cannot be built. The Generative Language API's discovery document
9
+ * has no billing, quota, usage, account or credit resource at all, and Cloud
10
+ * Billing v1 has no notion of a balance either (its schema never mentions the
11
+ * word): Google Cloud is post-paid, so there is no wallet to read. Even with a
12
+ * service account and the Cloud Billing scopes, actual spend arrives through
13
+ * BigQuery export hours later — far too late to attribute to one call.
14
+ *
15
+ * Tokens do the job better anyway. They are exact, they are per-call, they
16
+ * arrive with the response, and they attribute: a before/after balance diff
17
+ * cannot separate your generation from anything else billing the account in
18
+ * the same window, and this can.
19
+ *
20
+ * Money IS reported, with two safeguards. Image output turned out to be billed
21
+ * per token like everything else — just at 20-40x the text-output rate — so
22
+ * the published per-image prices are exactly the token arithmetic, and an
23
+ * estimate is arithmetic rather than guesswork (see src/pricing.ts). The
24
+ * safeguards are that every figure carries the date its rates were read, and
25
+ * `GEMINI_RATE_CARD` overrides them without waiting on a release.
26
+ *
27
+ * The session's cost is accumulated PER CALL, not derived from the token total
28
+ * at the end: the total spans models, and pricing a Pro call's tokens at a
29
+ * Lite rate would be wrong by 4x.
30
+ */
31
+ export function registerUsageTools(server, client) {
32
+ server.registerTool('gemini_token_usage', {
33
+ description: 'Token usage for this session so far — what every generation has cost in tokens, added up. ' +
34
+ 'Call it before and after a workflow and subtract to get that workflow\'s cost; call it after a ' +
35
+ 'single generation for that call\'s. Reports tokens AND an estimated USD cost, priced per call against each call\'s own model and stamped with the date its rates were read (override with GEMINI_RATE_CARD). Note there is no account-balance endpoint to query — Google Cloud is post-paid and its billing data lags by hours — so this is the accurate way to attribute spend to a call.',
36
+ annotations: toolAnnotations({ readOnly: true }),
37
+ inputSchema: {
38
+ reset: z
39
+ .boolean()
40
+ .optional()
41
+ .describe('Zero the running total after reporting it, so the next call measures from here. Use it to bracket a workflow without arithmetic.'),
42
+ },
43
+ }, async (args) => {
44
+ const usage = client.session.usageTotal;
45
+ const calls = client.session.billedCalls;
46
+ const costUsd = client.session.costUsd;
47
+ const priced = client.session.pricedCalls;
48
+ if (args.reset) {
49
+ client.session.usageTotal = undefined;
50
+ client.session.billedCalls = 0;
51
+ client.session.costUsd = undefined;
52
+ client.session.pricedCalls = 0;
53
+ }
54
+ return textResult({
55
+ usage: usage ?? null,
56
+ billed_calls: calls,
57
+ estimated_cost_usd: costUsd ?? null,
58
+ priced_calls: priced,
59
+ rates_published: PRICED_AT,
60
+ ...(args.reset ? { reset: true } : {}),
61
+ note: usage === undefined
62
+ ? 'No generation has run in this session yet, so nothing has been billed through it.'
63
+ : priced < calls
64
+ ? `Estimate covers ${priced} of ${calls} billed calls — the rest used a model that is not in the rate card. Add it with GEMINI_RATE_CARD to include it.`
65
+ : 'Estimate priced per call against each call\'s own model, then added up.',
66
+ rates: `Published rates read on ${PRICED_AT} (paid tier, USD). Override or extend with GEMINI_RATE_CARD ` +
67
+ 'if they have moved — an estimate is only as current as its card.',
68
+ scope: 'This session only. A restart starts from zero, and a hosted deployment counts each authenticated session separately — these are your tokens, not the account\'s.',
69
+ });
70
+ });
71
+ }
@@ -3,8 +3,9 @@ import { McpToolError } from '@chrischall/mcp-utils';
3
3
  import { slugify, baseName } from '../images.js';
4
4
  import { resolveImageInputs } from '../inputs.js';
5
5
  import { DEFAULT_VIDEO_MODEL } from '../models.js';
6
- import { emitMedia, resolveAspectRatio, orientationSchema, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
+ import { emitMedia, reportShape, resolveAspectRatio, orientationSchema, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
+ import { attachCost } from '../pricing.js';
8
9
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
10
  /** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
10
11
  const VIDEO_ASPECT_RATIOS = ['16:9', '9:16'];
@@ -100,9 +101,14 @@ export function registerVideoTools(server, client) {
100
101
  onInteractionStarted: ctx.reportInteraction,
101
102
  }));
102
103
  client.session.lastVideoInteractionId = r.id;
103
- const meta = { model, interaction_id: r.id };
104
+ // The RESOLVED ratio: omni takes only 16:9/9:16, so a caller who asked
105
+ // in words can see which of the two their request became.
106
+ const meta = reportShape({ model, interaction_id: r.id }, { ...args, aspect_ratio: aspectRatio });
104
107
  if (previousInteractionId)
105
108
  meta.previous_interaction_id = previousInteractionId;
109
+ if (r.usage)
110
+ meta.usage = r.usage;
111
+ attachCost(meta, model, r.usage);
106
112
  if (r.text)
107
113
  meta.text = r.text;
108
114
  if (report)
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.9.0'; // x-release-please-version
2
+ export const VERSION = '1.11.0'; // x-release-please-version
package/mint.yaml ADDED
@@ -0,0 +1,57 @@
1
+ version: 1
2
+ name: Gemini Images
3
+ slug: gemini
4
+ summary: >-
5
+ Generate and edit images with Google Gemini image models (Nano Banana /
6
+ Nano Banana Pro) via natural language prompts
7
+ env:
8
+ - name: GEMINI_API_KEY
9
+ secret: true
10
+ required: true
11
+ help: >-
12
+ Your Google Gemini API key (aistudio.google.com/apikey)
13
+ - name: GEMINI_IMAGE_MODEL
14
+ required: false
15
+ help: >-
16
+ Override the default image model (default: gemini-3.1-flash-image)
17
+ - name: GEMINI_OUTPUT_DIR
18
+ required: false
19
+ help: >-
20
+ Default directory for generated images (default: current working
21
+ directory)
22
+ - name: GEMINI_INPUT_DIR
23
+ required: false
24
+ help: >-
25
+ Directory to resolve bare input-image filenames against
26
+ - name: GEMINI_TIMEOUT_MS
27
+ required: false
28
+ help: >-
29
+ Upstream request timeout in ms (default: 60000, or 120000 for 4K output)
30
+ - name: GEMINI_CHAIN_RETRY_MS
31
+ required: false
32
+ help: >-
33
+ Retry budget in milliseconds for a chained request that 404s while the
34
+ upstream result settles (default 120000).
35
+ - name: GEMINI_HEARTBEAT_MS
36
+ required: false
37
+ help: >-
38
+ How often, in milliseconds, to emit a progress notification while a
39
+ generation is in flight. Hosts reset their tools/call timeout on progress,
40
+ so this lets a long 4K/Pro generation outlive the default budget.
41
+ egress:
42
+ allow:
43
+ # Only hosts the SERVER process actually fetches. Hosts that appear
44
+ # solely in a URL this server BUILDS and returns are excluded.
45
+ - generativelanguage.googleapis.com
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.
53
+ #
54
+ # Also: the images_url argument makes this server fetch an ARBITRARY
55
+ # caller-supplied https host. That cannot be enumerated in advance, so on
56
+ # the isolated tier either allow the hosts you intend to pass, or expect
57
+ # image-by-URL to be blocked there.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
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>",
@@ -33,7 +33,8 @@
33
33
  ".claude-plugin",
34
34
  "skills/",
35
35
  ".mcp.json",
36
- "server.json"
36
+ "server.json",
37
+ "mint.yaml"
37
38
  ],
38
39
  "scripts": {
39
40
  "build": "tsc && npm run bundle",
@@ -41,7 +42,7 @@
41
42
  "dev": "node dist/index.js",
42
43
  "test": "npm run typecheck && vitest run",
43
44
  "test:watch": "vitest",
44
- "test:coverage": "vitest run --coverage",
45
+ "test:coverage": "npm run typecheck && vitest run --coverage",
45
46
  "typecheck": "tsc -p tsconfig.test.json"
46
47
  },
47
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.9.0",
9
+ "version": "1.11.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.9.0",
14
+ "version": "1.11.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },