@chrischall/gemini-mcp 1.8.0 → 1.9.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
10
- "version": "1.8.0"
10
+ "version": "1.9.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Gemini Images",
16
16
  "source": "./",
17
17
  "description": "Generate and edit images with Google Gemini image models (Nano Banana / Nano Banana Pro) via natural language prompts",
18
- "version": "1.8.0",
18
+ "version": "1.9.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gemini-mcp",
3
3
  "displayName": "Gemini Images",
4
- "version": "1.8.0",
4
+ "version": "1.9.0",
5
5
  "description": "MCP server for Google Gemini image generation and editing — generate and edit images via the Gemini API",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/bundle.js CHANGED
@@ -32099,59 +32099,6 @@ var init_stdio2 = __esm({
32099
32099
  }
32100
32100
  });
32101
32101
 
32102
- // node_modules/@chrischall/mcp-utils/dist/server/index.js
32103
- async function createMcpServer(opts) {
32104
- const server = new McpServer({ name: opts.name, version: opts.version });
32105
- if (opts.banner !== void 0) {
32106
- console.error(opts.banner);
32107
- }
32108
- const deps = opts.deps;
32109
- for (const register of opts.tools) {
32110
- await register(server, deps);
32111
- }
32112
- return server;
32113
- }
32114
- function withGracefulShutdown(server, opts = {}) {
32115
- const shouldExit = opts.exit ?? true;
32116
- let shuttingDown = false;
32117
- const handler = (signal) => {
32118
- if (shuttingDown)
32119
- return;
32120
- shuttingDown = true;
32121
- void (async () => {
32122
- try {
32123
- if (opts.onSignal)
32124
- await opts.onSignal(signal);
32125
- await server.close();
32126
- } catch (err) {
32127
- console.error(`[mcp-utils] error during graceful shutdown on ${signal}: ${err instanceof Error ? err.message : String(err)}`);
32128
- } finally {
32129
- if (shouldExit)
32130
- process.exit(0);
32131
- }
32132
- })();
32133
- };
32134
- process.on("SIGINT", () => handler("SIGINT"));
32135
- process.on("SIGTERM", () => handler("SIGTERM"));
32136
- }
32137
- async function runMcp(opts) {
32138
- const server = await createMcpServer(opts);
32139
- const shutdown = opts.shutdown ?? true;
32140
- if (shutdown !== false) {
32141
- withGracefulShutdown(server, shutdown === true ? {} : shutdown);
32142
- }
32143
- const spec = opts.transport ?? "stdio";
32144
- const transport = spec === "stdio" ? new StdioServerTransport() : spec;
32145
- await server.connect(transport);
32146
- return server;
32147
- }
32148
- var init_server3 = __esm({
32149
- "node_modules/@chrischall/mcp-utils/dist/server/index.js"() {
32150
- init_mcp();
32151
- init_stdio2();
32152
- }
32153
- });
32154
-
32155
32102
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
32156
32103
  function redactSecrets(text) {
32157
32104
  return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
@@ -32211,12 +32158,95 @@ function textResult(data) {
32211
32158
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
32212
32159
  };
32213
32160
  }
32161
+ function errorResult(message) {
32162
+ return {
32163
+ content: [{ type: "text", text: redactSecrets(message) }],
32164
+ isError: true
32165
+ };
32166
+ }
32214
32167
  var init_response = __esm({
32215
32168
  "node_modules/@chrischall/mcp-utils/dist/response/index.js"() {
32216
32169
  init_errors4();
32217
32170
  }
32218
32171
  });
32219
32172
 
32173
+ // node_modules/@chrischall/mcp-utils/dist/server/index.js
32174
+ function hintResultOrRethrow(err) {
32175
+ if (err instanceof McpToolError && err.hint) {
32176
+ return errorResult(`${err.message}
32177
+
32178
+ Hint: ${err.hint}`);
32179
+ }
32180
+ throw err;
32181
+ }
32182
+ function surfaceToolHints(server) {
32183
+ const register = server.registerTool.bind(server);
32184
+ server.registerTool = (name, config2, cb) => register(name, config2, (...args) => {
32185
+ let result;
32186
+ try {
32187
+ result = cb(...args);
32188
+ } catch (err) {
32189
+ return hintResultOrRethrow(err);
32190
+ }
32191
+ return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
32192
+ });
32193
+ }
32194
+ async function createMcpServer(opts) {
32195
+ const server = new McpServer({ name: opts.name, version: opts.version });
32196
+ if (opts.surfaceHints !== false)
32197
+ surfaceToolHints(server);
32198
+ if (opts.banner !== void 0) {
32199
+ console.error(opts.banner);
32200
+ }
32201
+ const deps = opts.deps;
32202
+ for (const register of opts.tools) {
32203
+ await register(server, deps);
32204
+ }
32205
+ return server;
32206
+ }
32207
+ function withGracefulShutdown(server, opts = {}) {
32208
+ const shouldExit = opts.exit ?? true;
32209
+ let shuttingDown = false;
32210
+ const handler = (signal) => {
32211
+ if (shuttingDown)
32212
+ return;
32213
+ shuttingDown = true;
32214
+ void (async () => {
32215
+ try {
32216
+ if (opts.onSignal)
32217
+ await opts.onSignal(signal);
32218
+ await server.close();
32219
+ } catch (err) {
32220
+ console.error(`[mcp-utils] error during graceful shutdown on ${signal}: ${err instanceof Error ? err.message : String(err)}`);
32221
+ } finally {
32222
+ if (shouldExit)
32223
+ process.exit(0);
32224
+ }
32225
+ })();
32226
+ };
32227
+ process.on("SIGINT", () => handler("SIGINT"));
32228
+ process.on("SIGTERM", () => handler("SIGTERM"));
32229
+ }
32230
+ async function runMcp(opts) {
32231
+ const server = await createMcpServer(opts);
32232
+ const shutdown = opts.shutdown ?? true;
32233
+ if (shutdown !== false) {
32234
+ withGracefulShutdown(server, shutdown === true ? {} : shutdown);
32235
+ }
32236
+ const spec = opts.transport ?? "stdio";
32237
+ const transport = spec === "stdio" ? new StdioServerTransport() : spec;
32238
+ await server.connect(transport);
32239
+ return server;
32240
+ }
32241
+ var init_server3 = __esm({
32242
+ "node_modules/@chrischall/mcp-utils/dist/server/index.js"() {
32243
+ init_mcp();
32244
+ init_stdio2();
32245
+ init_errors4();
32246
+ init_response();
32247
+ }
32248
+ });
32249
+
32220
32250
  // node_modules/@chrischall/mcp-utils/dist/config/index.js
32221
32251
  function readEnvVar(key, opts = {}) {
32222
32252
  const env = opts.env ?? process.env;
@@ -32838,7 +32868,7 @@ async function loadStdioDotenv() {
32838
32868
  }
32839
32869
 
32840
32870
  // src/version.ts
32841
- var VERSION = "1.8.0";
32871
+ var VERSION = "1.9.0";
32842
32872
 
32843
32873
  // src/errors.ts
32844
32874
  init_dist();
@@ -33583,6 +33613,27 @@ function buildZip(entries, modifiedAt = new Date(Date.UTC(1980, 0, 1))) {
33583
33613
  var DEFAULT_SINK = createDiskSink();
33584
33614
  var ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "1:4", "4:1", "1:8", "8:1"];
33585
33615
  var IMAGE_SIZES = ["512", "1K", "2K", "4K"];
33616
+ var ORIENTATIONS = ["landscape", "portrait", "square"];
33617
+ var ORIENTATION_RATIO = {
33618
+ landscape: "16:9",
33619
+ portrait: "9:16",
33620
+ square: "1:1"
33621
+ };
33622
+ function resolveAspectRatio(opts, supported) {
33623
+ if (opts.aspect_ratio) return opts.aspect_ratio;
33624
+ if (!opts.orientation) return void 0;
33625
+ const ratio = ORIENTATION_RATIO[opts.orientation];
33626
+ if (!supported.includes(ratio)) {
33627
+ throw new McpToolError(
33628
+ `orientation "${opts.orientation}" (${ratio}) is not available for this model, which supports ${supported.join(" and ")}. Ask for ${supported.map((r) => `"${r}"`).join(" or ")} via aspect_ratio, or choose another orientation.`,
33629
+ { hint: `Supported aspect ratios: ${supported.join(", ")}.` }
33630
+ );
33631
+ }
33632
+ return ratio;
33633
+ }
33634
+ var orientationSchema = external_exports.enum(ORIENTATIONS).optional().describe(
33635
+ 'Shape of the output, in plain terms: "landscape" (wide, 16:9), "portrait" (tall, 9:16) or "square" (1:1). Use this for a request phrased as landscape/portrait/vertical/horizontal. For any other proportion \u2014 35mm photo (3:2), print (4:3), social (4:5), cinematic (21:9) \u2014 name it with aspect_ratio instead, which overrides this when both are given.'
33636
+ );
33586
33637
  var MODEL_CHOICE_GUIDE = "gemini-3.1-flash-image (Nano Banana 2) is the versatile generalist workhorse \u2014 balances speed with state-of-the-art 4K generation, world knowledge, and reliable text rendering; excels at multi-reference-image processing and consistency. gemini-3-pro-image (Nano Banana Pro) is the premium choice for the most complex visual tasks \u2014 highest world knowledge, advanced localization, accurate brand consistency, precision creative control. gemini-3.1-flash-lite-image (Nano Banana 2 Lite) is the fastest/cheapest for simple tasks (1K only, no search grounding).";
33587
33638
  var timeoutMsSchema = external_exports.number().int().positive().optional().describe(
33588
33639
  "Upstream request timeout in ms for this call (default: $GEMINI_TIMEOUT_MS, else 60000 \u2014 or 120000 when image_size is 4K, which routinely runs past 60s)"
@@ -33679,7 +33730,8 @@ var sharedImageSchema = {
33679
33730
  // (`/models/${model}:generateContent`), so slashes/colons/queries must be
33680
33731
  // rejected to keep a crafted value from escaping the path segment.
33681
33732
  model: external_exports.string().regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")').optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
33682
- aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
33733
+ aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Exact output aspect ratio. For a plain landscape/portrait/square request, `orientation` is the shorthand; this wins if both are given."),
33734
+ orientation: orientationSchema,
33683
33735
  image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
33684
33736
  output_dir: external_exports.string().optional().describe("Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)"),
33685
33737
  inline: external_exports.boolean().optional().describe("Return base64 images inline instead of writing to disk"),
@@ -33763,6 +33815,7 @@ function timeoutRiskHint(opts) {
33763
33815
  function buildMeta(model, seed, opts) {
33764
33816
  const meta3 = { model, seed };
33765
33817
  if (opts.aspect_ratio) meta3.aspect_ratio = opts.aspect_ratio;
33818
+ if (opts.orientation) meta3.orientation = opts.orientation;
33766
33819
  if (opts.image_size) meta3.image_size = opts.image_size;
33767
33820
  return meta3;
33768
33821
  }
@@ -34437,10 +34490,11 @@ function registerGenerateTools(server, client2) {
34437
34490
  if (gate) return gate;
34438
34491
  const count = args.count ?? 1;
34439
34492
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
34493
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
34440
34494
  const fingerprint = fingerprintRequest("gemini_image_generate", {
34441
34495
  model,
34442
34496
  prompt: args.prompt,
34443
- aspect_ratio: args.aspect_ratio,
34497
+ aspect_ratio: aspectRatio,
34444
34498
  image_size: args.image_size,
34445
34499
  thinking_level: args.thinking_level,
34446
34500
  google_search: args.google_search,
@@ -34476,7 +34530,7 @@ function registerGenerateTools(server, client2) {
34476
34530
  prompt,
34477
34531
  images: refInputs.length > 0 ? refInputs : void 0,
34478
34532
  model: args.model,
34479
- aspectRatio: args.aspect_ratio,
34533
+ aspectRatio,
34480
34534
  imageSize: args.image_size,
34481
34535
  seed: seed + i,
34482
34536
  thinkingLevel: args.thinking_level,
@@ -34496,7 +34550,7 @@ function registerGenerateTools(server, client2) {
34496
34550
  }
34497
34551
  });
34498
34552
  if (named.length === 0) throw firstError;
34499
- const meta3 = buildMeta(model, seed, args);
34553
+ const meta3 = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
34500
34554
  if (capturedText) meta3.text = capturedText;
34501
34555
  if (capturedGrounding) meta3.grounding = capturedGrounding;
34502
34556
  if (report) meta3.image_inputs = report;
@@ -34538,10 +34592,11 @@ function registerGenerateTools(server, client2) {
34538
34592
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, SEND_LOCAL_ACTION, GENERATE_ENDPOINT, args.images);
34539
34593
  if (gate) return gate;
34540
34594
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
34595
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
34541
34596
  const fingerprint = fingerprintRequest("gemini_image_edit", {
34542
34597
  model,
34543
34598
  prompt: args.prompt,
34544
- aspect_ratio: args.aspect_ratio,
34599
+ aspect_ratio: aspectRatio,
34545
34600
  image_size: args.image_size,
34546
34601
  thinking_level: args.thinking_level,
34547
34602
  google_search: args.google_search,
@@ -34565,7 +34620,7 @@ function registerGenerateTools(server, client2) {
34565
34620
  prompt,
34566
34621
  images: inputs,
34567
34622
  model: args.model,
34568
- aspectRatio: args.aspect_ratio,
34623
+ aspectRatio,
34569
34624
  imageSize: args.image_size,
34570
34625
  seed,
34571
34626
  thinkingLevel: args.thinking_level,
@@ -34573,7 +34628,7 @@ function registerGenerateTools(server, client2) {
34573
34628
  timeoutMs: args.timeout_ms
34574
34629
  }));
34575
34630
  const { images: [img], text } = result;
34576
- const meta3 = buildMeta(model, seed, args);
34631
+ const meta3 = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
34577
34632
  if (text) meta3.text = text;
34578
34633
  if (result.grounding) meta3.grounding = result.grounding;
34579
34634
  if (report) meta3.image_inputs = report;
@@ -34622,13 +34677,14 @@ function registerSetTools(server, client2) {
34622
34677
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local master image input(s) to the Gemini API", "/v1beta/models/{model}:generateContent", args.master_images);
34623
34678
  if (gate) return gate;
34624
34679
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
34680
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
34625
34681
  const fingerprint = fingerprintRequest("gemini_image_set", {
34626
34682
  model,
34627
34683
  master_prompt: args.master_prompt,
34628
34684
  scenes: args.scenes,
34629
34685
  count: args.count,
34630
34686
  reference_mode: args.reference_mode,
34631
- aspect_ratio: args.aspect_ratio,
34687
+ aspect_ratio: aspectRatio,
34632
34688
  image_size: args.image_size,
34633
34689
  thinking_level: args.thinking_level,
34634
34690
  google_search: args.google_search,
@@ -34643,7 +34699,7 @@ function registerSetTools(server, client2) {
34643
34699
  });
34644
34700
  return client2.session.jobs.dispatch({ toolName: "gemini_image_set", fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
34645
34701
  const seed = pickSeed(args.seed);
34646
- const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
34702
+ const cfg = { model: args.model, aspectRatio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
34647
34703
  const slug = args.basename ? baseName(args.basename) : slugify2(args.master_prompt);
34648
34704
  const charRefs = await resolveCharacterRefs(client2, args.characters, args.style);
34649
34705
  const masterPrompt = composePrompt(args.master_prompt, charRefs);
@@ -34703,7 +34759,7 @@ function registerSetTools(server, client2) {
34703
34759
  return result;
34704
34760
  });
34705
34761
  const masterText = masterResult.text;
34706
- const meta3 = buildMeta(model, seed, args);
34762
+ const meta3 = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
34707
34763
  if (masterText) meta3.text = masterText;
34708
34764
  if (masterResult.grounding) meta3.grounding = masterResult.grounding;
34709
34765
  if (report) meta3.image_inputs = report;
@@ -36028,7 +36084,8 @@ function registerInteractTools(server, client2) {
36028
36084
  `Reference images as base64 strings or data URIs. Last resort: prefer images_url or images_file_uris, which keep image bytes out of the conversation. ${NEW_REFERENCES_ONLY}`
36029
36085
  ),
36030
36086
  model: external_exports.string().optional().describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
36031
- aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Output aspect ratio"),
36087
+ aspect_ratio: external_exports.enum(ASPECT_RATIOS).optional().describe("Exact output aspect ratio. For a plain landscape/portrait/square request, `orientation` is the shorthand; this wins if both are given."),
36088
+ orientation: orientationSchema,
36032
36089
  image_size: external_exports.enum(IMAGE_SIZES).optional().describe("Output resolution (512 = 0.5K, Flash-only)"),
36033
36090
  thinking_level: external_exports.enum(["minimal", "high"]).optional().describe("Reasoning depth; higher can help complex/structural edits"),
36034
36091
  filename: external_exports.string().optional().describe("Base filename for the output image (extension stripped; default: slugified input)"),
@@ -36073,10 +36130,11 @@ function registerInteractTools(server, client2) {
36073
36130
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local file input(s) to the Gemini Interactions API", "/v1beta/interactions", images, args.video_path);
36074
36131
  if (gate) return gate;
36075
36132
  const model = resolveModel(args.model, readEnvVar("GEMINI_IMAGE_MODEL"));
36133
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
36076
36134
  const fingerprint = fingerprintRequest("gemini_interact", {
36077
36135
  model,
36078
36136
  input: args.input,
36079
- aspect_ratio: args.aspect_ratio,
36137
+ aspect_ratio: aspectRatio,
36080
36138
  image_size: args.image_size,
36081
36139
  thinking_level: args.thinking_level,
36082
36140
  previous_interaction_id: previousInteractionId,
@@ -36096,7 +36154,7 @@ function registerInteractTools(server, client2) {
36096
36154
  const callOpts = {
36097
36155
  input: args.input,
36098
36156
  model: args.model,
36099
- aspectRatio: args.aspect_ratio,
36157
+ aspectRatio,
36100
36158
  imageSize: args.image_size,
36101
36159
  thinkingLevel: args.thinking_level,
36102
36160
  googleSearch: args.google_search,
@@ -36181,7 +36239,8 @@ function registerVideoTools(server, client2) {
36181
36239
  annotations: { readOnlyHint: false, openWorldHint: true },
36182
36240
  inputSchema: {
36183
36241
  prompt: external_exports.string().min(1).describe("Description of the video to generate (or the edit instruction when task=edit)"),
36184
- aspect_ratio: external_exports.enum(VIDEO_ASPECT_RATIOS).optional().describe("Output aspect ratio (omni: 16:9 or 9:16)"),
36242
+ aspect_ratio: external_exports.enum(VIDEO_ASPECT_RATIOS).optional().describe("Exact output aspect ratio (omni: 16:9 or 9:16). `orientation` is the plain-language shorthand; this wins if both are given."),
36243
+ orientation: orientationSchema,
36185
36244
  task: external_exports.enum(VIDEO_TASKS).optional().describe("text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)"),
36186
36245
  images: external_exports.array(external_exports.string().min(1)).optional().describe("Reference image path(s) for image_to_video / reference_to_video"),
36187
36246
  images_url: imagesUrlSchema("Reference stills"),
@@ -36216,10 +36275,11 @@ function registerVideoTools(server, client2) {
36216
36275
  const gate = await previewLocalInputsUnlessConfirmed(args.confirm, "Send local image input(s) to the Gemini omni API", "/v1beta/interactions", args.images);
36217
36276
  if (gate) return gate;
36218
36277
  const model = args.model?.trim() || DEFAULT_VIDEO_MODEL;
36278
+ const aspectRatio = resolveAspectRatio(args, VIDEO_ASPECT_RATIOS);
36219
36279
  const fingerprint = fingerprintRequest("gemini_video_generate", {
36220
36280
  model,
36221
36281
  prompt: args.prompt,
36222
- aspect_ratio: args.aspect_ratio,
36282
+ aspect_ratio: aspectRatio,
36223
36283
  task: args.task,
36224
36284
  images: args.images,
36225
36285
  images_base64: args.images_base64,
@@ -36234,7 +36294,7 @@ function registerVideoTools(server, client2) {
36234
36294
  input: args.prompt,
36235
36295
  images: inputs.length ? inputs : void 0,
36236
36296
  model: args.model,
36237
- aspectRatio: args.aspect_ratio,
36297
+ aspectRatio,
36238
36298
  task: args.task,
36239
36299
  previousInteractionId,
36240
36300
  timeoutMs: args.timeout_ms,
@@ -3,7 +3,7 @@ import { readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { slugify, baseName } from '../images.js';
5
5
  import { resolveImageInputs, requireImageInput } from '../inputs.js';
6
- import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt } from './shared.js';
6
+ import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, resolveVideoInput, videoPathSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
9
  const GENERATE_ENDPOINT = '/v1beta/models/{model}:generateContent';
@@ -46,10 +46,15 @@ export function registerGenerateTools(server, client) {
46
46
  return gate;
47
47
  const count = args.count ?? 1;
48
48
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
49
+ // Resolve shape BEFORE fingerprinting: `orientation: 'portrait'` and
50
+ // `aspect_ratio: '9:16'` are the same upstream request, so they must
51
+ // dedup to the same job rather than billing twice (hence the resolved
52
+ // ratio in the fingerprint below, and no `orientation` alongside it).
53
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
49
54
  // Fingerprint the request identity (excl. server-picked seed / output path)
50
55
  // so a blind re-issue after a host timeout dedups instead of re-billing.
51
56
  const fingerprint = fingerprintRequest('gemini_image_generate', {
52
- model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
57
+ model, prompt: args.prompt, aspect_ratio: aspectRatio, image_size: args.image_size,
53
58
  thinking_level: args.thinking_level, google_search: args.google_search, count,
54
59
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
55
60
  images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
@@ -92,7 +97,7 @@ export function registerGenerateTools(server, client) {
92
97
  prompt,
93
98
  images: refInputs.length > 0 ? refInputs : undefined,
94
99
  model: args.model,
95
- aspectRatio: args.aspect_ratio,
100
+ aspectRatio,
96
101
  imageSize: args.image_size,
97
102
  seed: seed + i,
98
103
  thinkingLevel: args.thinking_level,
@@ -116,7 +121,7 @@ export function registerGenerateTools(server, client) {
116
121
  });
117
122
  if (named.length === 0)
118
123
  throw firstError;
119
- const meta = buildMeta(model, seed, args);
124
+ const meta = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
120
125
  if (capturedText)
121
126
  meta.text = capturedText;
122
127
  if (capturedGrounding)
@@ -171,8 +176,13 @@ export function registerGenerateTools(server, client) {
171
176
  if (gate)
172
177
  return gate;
173
178
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
179
+ // Resolve shape BEFORE fingerprinting: `orientation: 'portrait'` and
180
+ // `aspect_ratio: '9:16'` are the same upstream request, so they must
181
+ // dedup to the same job rather than billing twice (hence the resolved
182
+ // ratio in the fingerprint below, and no `orientation` alongside it).
183
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
174
184
  const fingerprint = fingerprintRequest('gemini_image_edit', {
175
- model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
185
+ model, prompt: args.prompt, aspect_ratio: aspectRatio, image_size: args.image_size,
176
186
  thinking_level: args.thinking_level, google_search: args.google_search,
177
187
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
178
188
  images_url: args.images_url, images_file_uris: args.images_file_uris, images_r2_keys: args.images_r2_keys,
@@ -189,7 +199,7 @@ export function registerGenerateTools(server, client) {
189
199
  prompt,
190
200
  images: inputs,
191
201
  model: args.model,
192
- aspectRatio: args.aspect_ratio,
202
+ aspectRatio,
193
203
  imageSize: args.image_size,
194
204
  seed,
195
205
  thinkingLevel: args.thinking_level,
@@ -197,7 +207,7 @@ export function registerGenerateTools(server, client) {
197
207
  timeoutMs: args.timeout_ms,
198
208
  }));
199
209
  const { images: [img], text } = result;
200
- const meta = buildMeta(model, seed, args);
210
+ const meta = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
201
211
  if (text)
202
212
  meta.text = text;
203
213
  if (result.grounding)
@@ -5,7 +5,7 @@ import { ChainedRequest404Error } from '../client.js';
5
5
  import { slugify, baseName, resolveImagePath, writeSidecar, resolveOutputDir, readImageAsInline } from '../images.js';
6
6
  import { resolveImageInputs } from '../inputs.js';
7
7
  import { findInteractionImages, latestInteractionId } from '../sidecar.js';
8
- import { emit, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
8
+ import { emit, resolveAspectRatio, orientationSchema, ASPECT_RATIOS, IMAGE_SIZES, MODEL_CHOICE_GUIDE, resolveVideoInput, videoPathSchema, timeoutMsSchema, timeoutRiskHint, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
9
9
  import { fingerprintRequest } from '../jobs.js';
10
10
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
11
11
  // The last-interaction id and the written-outputs set used to live here, at
@@ -87,7 +87,11 @@ export function registerInteractTools(server, client) {
87
87
  .string()
88
88
  .optional()
89
89
  .describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
90
- aspect_ratio: z.enum(ASPECT_RATIOS).optional().describe('Output aspect ratio'),
90
+ aspect_ratio: z
91
+ .enum(ASPECT_RATIOS)
92
+ .optional()
93
+ .describe('Exact output aspect ratio. For a plain landscape/portrait/square request, `orientation` is the shorthand; this wins if both are given.'),
94
+ orientation: orientationSchema,
91
95
  image_size: z
92
96
  .enum(IMAGE_SIZES)
93
97
  .optional()
@@ -173,8 +177,10 @@ export function registerInteractTools(server, client) {
173
177
  if (gate)
174
178
  return gate;
175
179
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
180
+ // Resolved before the fingerprint so "portrait" and "9:16" are one job.
181
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
176
182
  const fingerprint = fingerprintRequest('gemini_interact', {
177
- model, input: args.input, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
183
+ model, input: args.input, aspect_ratio: aspectRatio, image_size: args.image_size,
178
184
  thinking_level: args.thinking_level, previous_interaction_id: previousInteractionId,
179
185
  google_search: args.google_search, search_types: args.search_types,
180
186
  images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
@@ -187,7 +193,7 @@ export function registerInteractTools(server, client) {
187
193
  const callOpts = {
188
194
  input: args.input,
189
195
  model: args.model,
190
- aspectRatio: args.aspect_ratio,
196
+ aspectRatio,
191
197
  imageSize: args.image_size,
192
198
  thinkingLevel: args.thinking_level,
193
199
  googleSearch: args.google_search,
package/dist/tools/set.js CHANGED
@@ -3,7 +3,7 @@ import { McpToolError, readEnvVar } from '@chrischall/mcp-utils';
3
3
  import { resolveModel } from '../models.js';
4
4
  import { slugify, baseName } from '../images.js';
5
5
  import { resolveImageInputs } from '../inputs.js';
6
- import { emit, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt, persistBundle, SET_URL_TTL_MS } from './shared.js';
6
+ import { emit, resolveAspectRatio, ASPECT_RATIOS, sharedImageSchema, pickSeed, buildMeta, timeoutRiskHint, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema, imagesR2KeysSchema, charactersSchema, styleSchema, resolveCharacterRefs, composePrompt, persistBundle, SET_URL_TTL_MS } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
9
  export function registerSetTools(server, client) {
@@ -39,9 +39,14 @@ export function registerSetTools(server, client) {
39
39
  if (gate)
40
40
  return gate;
41
41
  const model = resolveModel(args.model, readEnvVar('GEMINI_IMAGE_MODEL'));
42
+ // Resolve shape BEFORE fingerprinting: `orientation: 'portrait'` and
43
+ // `aspect_ratio: '9:16'` are the same upstream request, so they must
44
+ // dedup to the same job rather than billing twice (hence the resolved
45
+ // ratio in the fingerprint below, and no `orientation` alongside it).
46
+ const aspectRatio = resolveAspectRatio(args, ASPECT_RATIOS);
42
47
  const fingerprint = fingerprintRequest('gemini_image_set', {
43
48
  model, master_prompt: args.master_prompt, scenes: args.scenes, count: args.count,
44
- reference_mode: args.reference_mode, aspect_ratio: args.aspect_ratio, image_size: args.image_size,
49
+ reference_mode: args.reference_mode, aspect_ratio: aspectRatio, image_size: args.image_size,
45
50
  thinking_level: args.thinking_level, google_search: args.google_search,
46
51
  master_images: args.master_images, master_images_base64: args.master_images_base64, from_clipboard: args.from_clipboard,
47
52
  master_images_url: args.master_images_url, master_images_file_uris: args.master_images_file_uris,
@@ -49,7 +54,7 @@ export function registerSetTools(server, client) {
49
54
  });
50
55
  return client.session.jobs.dispatch({ toolName: 'gemini_image_set', fingerprint, idempotencyKey: args.idempotency_key, async: args.async, waitMs: args.max_wait_ms }, async () => {
51
56
  const seed = pickSeed(args.seed);
52
- const cfg = { model: args.model, aspectRatio: args.aspect_ratio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
57
+ const cfg = { model: args.model, aspectRatio, imageSize: args.image_size, thinkingLevel: args.thinking_level, googleSearch: args.google_search, timeoutMs: args.timeout_ms };
53
58
  const slug = args.basename ? baseName(args.basename) : slugify(args.master_prompt);
54
59
  // 1. master (optionally seeded from reference images).
55
60
  //
@@ -141,7 +146,7 @@ export function registerSetTools(server, client) {
141
146
  return result;
142
147
  });
143
148
  const masterText = masterResult.text;
144
- const meta = buildMeta(model, seed, args);
149
+ const meta = buildMeta(model, seed, { ...args, aspect_ratio: aspectRatio });
145
150
  if (masterText)
146
151
  meta.text = masterText;
147
152
  if (masterResult.grounding)
@@ -16,6 +16,65 @@ const DEFAULT_SINK = createDiskSink();
16
16
  export const ASPECT_RATIOS = ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9', '1:4', '4:1', '1:8', '8:1'];
17
17
  /** Supported output resolutions. `512` is Flash-only (0.5K); `1K`/`2K`/`4K` work on all image models. */
18
18
  export const IMAGE_SIZES = ['512', '1K', '2K', '4K'];
19
+ /**
20
+ * The words people actually use for shape, as a shorthand over
21
+ * {@link ASPECT_RATIOS}.
22
+ *
23
+ * Landscape and portrait output was always *possible* — every generation tool
24
+ * has taken `aspect_ratio` since the beginning. But that parameter is a
25
+ * 14-value enum described as "Output aspect ratio", with nothing linking
26
+ * "portrait" to `9:16`, so a request phrased the way a person phrases it did
27
+ * not reliably reach the API. A capability that cannot be addressed is, from
28
+ * the caller's side, a missing one.
29
+ */
30
+ export const ORIENTATIONS = ['landscape', 'portrait', 'square'];
31
+ /**
32
+ * One mapping, deliberately shared by images and video.
33
+ *
34
+ * `16:9`/`9:16` rather than a photo ratio like `3:2` so that "portrait" means
35
+ * the same shape whatever is being generated — omni offers only these two, and
36
+ * an orientation that silently meant something different per tool would be a
37
+ * worse shorthand than no shorthand. Anyone wanting 35mm or print proportions
38
+ * still names the ratio outright, which always wins (see
39
+ * {@link resolveAspectRatio}).
40
+ */
41
+ const ORIENTATION_RATIO = {
42
+ landscape: '16:9',
43
+ portrait: '9:16',
44
+ square: '1:1',
45
+ };
46
+ /**
47
+ * Settle `aspect_ratio` and `orientation` into the one value sent upstream.
48
+ *
49
+ * An explicit ratio wins: it is the more specific request, and a caller who
50
+ * named `21:9` while also saying "landscape" meant `21:9`. `supported` is the
51
+ * calling tool's own enum — the image tools accept all of
52
+ * {@link ASPECT_RATIOS}, omni accepts two — and an orientation outside it is
53
+ * REFUSED rather than rounded to the nearest shape. Quietly substituting
54
+ * `16:9` for an unsupported `square` would hand back a picture the caller
55
+ * never asked for and give them no way to tell it had been swapped.
56
+ */
57
+ export function resolveAspectRatio(opts, supported) {
58
+ if (opts.aspect_ratio)
59
+ return opts.aspect_ratio;
60
+ if (!opts.orientation)
61
+ return undefined;
62
+ const ratio = ORIENTATION_RATIO[opts.orientation];
63
+ if (!supported.includes(ratio)) {
64
+ // Remediation belongs in the MESSAGE — the host drops `hint`.
65
+ throw new McpToolError(`orientation "${opts.orientation}" (${ratio}) is not available for this model, which supports ${supported.join(' and ')}. ` +
66
+ `Ask for ${supported.map((r) => `"${r}"`).join(' or ')} via aspect_ratio, or choose another orientation.`, { hint: `Supported aspect ratios: ${supported.join(', ')}.` });
67
+ }
68
+ return ratio;
69
+ }
70
+ /** The `orientation` parameter, worded so the shorthand is discoverable. */
71
+ export const orientationSchema = z
72
+ .enum(ORIENTATIONS)
73
+ .optional()
74
+ .describe('Shape of the output, in plain terms: "landscape" (wide, 16:9), "portrait" (tall, 9:16) or "square" (1:1). ' +
75
+ 'Use this for a request phrased as landscape/portrait/vertical/horizontal. For any other proportion — ' +
76
+ '35mm photo (3:2), print (4:3), social (4:5), cinematic (21:9) — name it with aspect_ratio instead, ' +
77
+ 'which overrides this when both are given.');
19
78
  /**
20
79
  * When to pick which Nano Banana model — shared by every tool's `model` param
21
80
  * so the guidance can't drift between them.
@@ -228,7 +287,11 @@ export const sharedImageSchema = {
228
287
  .regex(/^[\w.-]+$/, 'must be a bare model id (letters, digits, ".", "_", "-")')
229
288
  .optional()
230
289
  .describe(`Model id override (default: server default; see gemini_list_models). ${MODEL_CHOICE_GUIDE}`),
231
- aspect_ratio: z.enum(ASPECT_RATIOS).optional().describe('Output aspect ratio'),
290
+ aspect_ratio: z
291
+ .enum(ASPECT_RATIOS)
292
+ .optional()
293
+ .describe('Exact output aspect ratio. For a plain landscape/portrait/square request, `orientation` is the shorthand; this wins if both are given.'),
294
+ orientation: orientationSchema,
232
295
  image_size: z.enum(IMAGE_SIZES).optional().describe('Output resolution (512 = 0.5K, Flash-only)'),
233
296
  output_dir: z.string().optional().describe('Directory to write images to (default: $GEMINI_OUTPUT_DIR or cwd)'),
234
297
  inline: z.boolean().optional().describe('Return base64 images inline instead of writing to disk'),
@@ -384,8 +447,12 @@ export function timeoutRiskHint(opts) {
384
447
  /** Build the result metadata echoed to the caller (omitting unset optionals). */
385
448
  export function buildMeta(model, seed, opts) {
386
449
  const meta = { model, seed };
450
+ // The RESOLVED ratio, and the orientation only when one was asked for — so a
451
+ // caller who said "portrait" can see both what they asked and what it became.
387
452
  if (opts.aspect_ratio)
388
453
  meta.aspect_ratio = opts.aspect_ratio;
454
+ if (opts.orientation)
455
+ meta.orientation = opts.orientation;
389
456
  if (opts.image_size)
390
457
  meta.image_size = opts.image_size;
391
458
  return meta;
@@ -3,7 +3,7 @@ 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, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
6
+ import { emitMedia, resolveAspectRatio, orientationSchema, timeoutMsSchema, idempotencyKeySchema, asyncSchema, maxWaitMsSchema, withProgressHeartbeat, assertLocalInputsAvailable, imagesUrlSchema, imagesFileUrisSchema } from './shared.js';
7
7
  import { fingerprintRequest } from '../jobs.js';
8
8
  import { previewLocalInputsUnlessConfirmed, schemaConfirm } from './_confirm.js';
9
9
  /** omni's own aspect-ratio enum — NOT the image tools' ASPECT_RATIOS. */
@@ -30,7 +30,8 @@ export function registerVideoTools(server, client) {
30
30
  annotations: { readOnlyHint: false, openWorldHint: true },
31
31
  inputSchema: {
32
32
  prompt: z.string().min(1).describe('Description of the video to generate (or the edit instruction when task=edit)'),
33
- aspect_ratio: z.enum(VIDEO_ASPECT_RATIOS).optional().describe('Output aspect ratio (omni: 16:9 or 9:16)'),
33
+ aspect_ratio: z.enum(VIDEO_ASPECT_RATIOS).optional().describe('Exact output aspect ratio (omni: 16:9 or 9:16). `orientation` is the plain-language shorthand; this wins if both are given.'),
34
+ orientation: orientationSchema,
34
35
  task: z.enum(VIDEO_TASKS).optional().describe('text_to_video (default), image_to_video / reference_to_video (need image input), or edit (needs previous_interaction_id)'),
35
36
  images: z.array(z.string().min(1)).optional().describe('Reference image path(s) for image_to_video / reference_to_video'),
36
37
  images_url: imagesUrlSchema('Reference stills'),
@@ -71,8 +72,12 @@ export function registerVideoTools(server, client) {
71
72
  if (gate)
72
73
  return gate;
73
74
  const model = args.model?.trim() || DEFAULT_VIDEO_MODEL;
75
+ // omni has no square, so `orientation: 'square'` is refused here rather
76
+ // than rounded to 16:9 — see resolveAspectRatio. Resolved before the
77
+ // fingerprint so "portrait" and "9:16" dedup to one billable job.
78
+ const aspectRatio = resolveAspectRatio(args, VIDEO_ASPECT_RATIOS);
74
79
  const fingerprint = fingerprintRequest('gemini_video_generate', {
75
- model, prompt: args.prompt, aspect_ratio: args.aspect_ratio, task: args.task,
80
+ model, prompt: args.prompt, aspect_ratio: aspectRatio, task: args.task,
76
81
  images: args.images, images_base64: args.images_base64, from_clipboard: args.from_clipboard,
77
82
  images_url: args.images_url, images_file_uris: args.images_file_uris,
78
83
  previous_interaction_id: previousInteractionId,
@@ -83,7 +88,7 @@ export function registerVideoTools(server, client) {
83
88
  input: args.prompt,
84
89
  images: inputs.length ? inputs : undefined,
85
90
  model: args.model,
86
- aspectRatio: args.aspect_ratio,
91
+ aspectRatio,
87
92
  task: args.task,
88
93
  previousInteractionId,
89
94
  timeoutMs: args.timeout_ms,
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.8.0'; // x-release-please-version
2
+ export const VERSION = '1.9.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrischall/gemini-mcp",
3
- "version": "1.8.0",
3
+ "version": "1.9.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>",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc -p tsconfig.test.json"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^0.14.0",
48
+ "@chrischall/mcp-utils": "^0.15.0",
49
49
  "@modelcontextprotocol/sdk": "^1.29.0",
50
50
  "dotenv": "^17.4.0",
51
51
  "zod": "^4.4.2"
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.8.0",
9
+ "version": "1.9.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@chrischall/gemini-mcp",
14
- "version": "1.8.0",
14
+ "version": "1.9.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },