@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -0
- package/dist/bundle.js +346 -11
- package/dist/client.js +23 -4
- package/dist/jobs.js +4 -0
- package/dist/pricing.js +201 -0
- package/dist/registrars.js +2 -0
- package/dist/session.js +52 -0
- package/dist/tools/generate.js +13 -0
- package/dist/tools/interact.js +8 -2
- package/dist/tools/music.js +4 -0
- package/dist/tools/set.js +17 -2
- package/dist/tools/shared.js +19 -5
- package/dist/tools/usage.js +71 -0
- package/dist/tools/video.js +8 -2
- package/dist/usage.js +156 -0
- package/dist/version.js +1 -1
- package/mint.yaml +57 -0
- package/package.json +4 -3
- package/server.json +2 -2
|
@@ -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
|
+
}
|
package/dist/tools/video.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
"version": "1.11.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@chrischall/gemini-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.11.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|