#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { GoogleGenAI } from "@google/genai";
import * as fs from "node:fs";
import * as path from "node:path";
// Configuration
const MODEL_NAME = "gemini-3-pro-image-preview";
const OUTPUT_DIR = process.env.OUTPUT_DIR || "/output";
// State for session
let lastGeneratedImagePath: string | null = null;
let conversationHistory: Array<{role: string; parts: Array<{text?: string; inlineData?: {mimeType: string; data: string}}>}> = [];
// Initialize Gemini client
function getClient(): GoogleGenAI {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY environment variable is not set");
}
return new GoogleGenAI({ apiKey });
}
// Ensure output directory exists
function ensureOutputDir(): void {
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
}
// Generate unique filename with correct extension based on mime type
function generateFilename(prefix: string = "image", mimeType: string = "image/png"): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const extensionMap: Record<string, string> = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
};
const ext = extensionMap[mimeType] || ".jpg"; // Default to .jpg since Gemini often returns JPEG
return `${prefix}-${timestamp}${ext}`;
}
// Save image from base64
function saveImage(base64Data: string, filename: string): string {
ensureOutputDir();
const filepath = path.join(OUTPUT_DIR, filename);
const buffer = Buffer.from(base64Data, "base64");
fs.writeFileSync(filepath, buffer);
return filepath;
}
// Read image as base64
function readImageAsBase64(imagePath: string): string {
const imageData = fs.readFileSync(imagePath);
return imageData.toString("base64");
}
// Get MIME type from file extension
function getMimeType(filepath: string): string {
const ext = path.extname(filepath).toLowerCase();
const mimeTypes: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
};
return mimeTypes[ext] || "image/png";
}
// Process response and extract image
function processResponse(response: any): { imagePath: string | null; text: string } {
let resultText = "";
let imagePath: string | null = null;
if (response.candidates && response.candidates[0]?.content?.parts) {
for (const part of response.candidates[0].content.parts) {
if (part.thought) continue; // Skip thinking parts
if (part.text) {
resultText += part.text + "\n";
} else if (part.inlineData) {
// Use the actual MIME type from Gemini's response for correct file extension
const mimeType = part.inlineData.mimeType || "image/jpeg";
const filename = generateFilename("generated", mimeType);
imagePath = saveImage(part.inlineData.data, filename);
lastGeneratedImagePath = imagePath;
}
}
}
return { imagePath, text: resultText.trim() };
}
// Define tools
const tools: Tool[] = [
{
name: "generate_image",
description: "Generate a NEW image from text prompt using Gemini 3 Pro. Use this for creating completely new images from scratch. Supports high-resolution output (1K, 2K, 4K) and various aspect ratios.",
inputSchema: {
type: "object",
properties: {
prompt: {
type: "string",
description: "Text prompt describing the image to create. Be descriptive and specific for best results.",
},
aspectRatio: {
type: "string",
enum: ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"],
description: "Aspect ratio for the generated image. Default is 1:1.",
},
resolution: {
type: "string",
enum: ["1K", "2K", "4K"],
description: "Output resolution. Default is 1K. Higher resolutions take longer.",
},
useGoogleSearch: {
type: "boolean",
description: "Enable Google Search grounding for real-time information (weather, news, etc.).",
},
},
required: ["prompt"],
},
},
{
name: "edit_image",
description: "Edit an existing image file using text instructions. Can add/remove elements, change styles, transfer styles from reference images, and more.",
inputSchema: {
type: "object",
properties: {
imagePath: {
type: "string",
description: "Full file path to the image to edit.",
},
prompt: {
type: "string",
description: "Text describing the modifications to make to the image.",
},
referenceImages: {
type: "array",
items: { type: "string" },
description: "Optional array of file paths to reference images (for style transfer, adding elements, etc.). Up to 14 images supported.",
},
aspectRatio: {
type: "string",
enum: ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"],
description: "Aspect ratio for the output image.",
},
resolution: {
type: "string",
enum: ["1K", "2K", "4K"],
description: "Output resolution.",
},
},
required: ["imagePath", "prompt"],
},
},
{
name: "continue_editing",
description: "Continue editing the last generated/edited image in this session. Use for iterative improvements without specifying the file path.",
inputSchema: {
type: "object",
properties: {
prompt: {
type: "string",
description: "Text describing the modifications to make to the last image.",
},
referenceImages: {
type: "array",
items: { type: "string" },
description: "Optional array of file paths to reference images.",
},
aspectRatio: {
type: "string",
enum: ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"],
description: "Aspect ratio for the output image.",
},
resolution: {
type: "string",
enum: ["1K", "2K", "4K"],
description: "Output resolution.",
},
},
required: ["prompt"],
},
},
{
name: "get_last_image_info",
description: "Get information about the last generated/edited image in this session.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "compose_images",
description: "Combine multiple images into a new composition. Perfect for product mockups, character consistency, and creative collages. Supports up to 14 reference images.",
inputSchema: {
type: "object",
properties: {
images: {
type: "array",
items: { type: "string" },
description: "Array of file paths to images to combine (up to 14 images).",
},
prompt: {
type: "string",
description: "Text describing how to combine the images and what to create.",
},
aspectRatio: {
type: "string",
enum: ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"],
description: "Aspect ratio for the output image.",
},
resolution: {
type: "string",
enum: ["1K", "2K", "4K"],
description: "Output resolution.",
},
},
required: ["images", "prompt"],
},
},
{
name: "get_configuration_status",
description: "Check if the Gemini API key is configured and ready to use.",
inputSchema: {
type: "object",
properties: {},
},
},
];
// Tool handlers
async function handleGenerateImage(args: {
prompt: string;
aspectRatio?: string;
resolution?: string;
useGoogleSearch?: boolean;
}): Promise<string> {
const ai = getClient();
const config: any = {
responseModalities: ["TEXT", "IMAGE"],
imageConfig: {
aspectRatio: args.aspectRatio || "1:1",
imageSize: args.resolution || "1K",
},
};
if (args.useGoogleSearch) {
config.tools = [{ googleSearch: {} }];
}
const response = await ai.models.generateContent({
model: MODEL_NAME,
contents: args.prompt,
config,
});
const result = processResponse(response);
// Reset conversation for new generation
conversationHistory = [];
let output = "";
if (result.imagePath) {
output += `Image saved to: ${result.imagePath}\n`;
}
if (result.text) {
output += `Model response: ${result.text}`;
}
return output || "Image generation completed but no output was produced.";
}
async function handleEditImage(args: {
imagePath: string;
prompt: string;
referenceImages?: string[];
aspectRatio?: string;
resolution?: string;
}): Promise<string> {
const ai = getClient();
if (!fs.existsSync(args.imagePath)) {
throw new Error(`Image file not found: ${args.imagePath}`);
}
const contents: Array<{text?: string; inlineData?: {mimeType: string; data: string}}> = [];
// Add main image
contents.push({
inlineData: {
mimeType: getMimeType(args.imagePath),
data: readImageAsBase64(args.imagePath),
},
});
// Add reference images if provided
if (args.referenceImages) {
for (const refPath of args.referenceImages) {
if (fs.existsSync(refPath)) {
contents.push({
inlineData: {
mimeType: getMimeType(refPath),
data: readImageAsBase64(refPath),
},
});
}
}
}
// Add prompt
contents.push({ text: args.prompt });
const response = await ai.models.generateContent({
model: MODEL_NAME,
contents,
config: {
responseModalities: ["TEXT", "IMAGE"],
imageConfig: {
aspectRatio: args.aspectRatio || "1:1",
imageSize: args.resolution || "1K",
},
},
});
const result = processResponse(response);
let output = "";
if (result.imagePath) {
output += `Edited image saved to: ${result.imagePath}\n`;
}
if (result.text) {
output += `Model response: ${result.text}`;
}
return output || "Image editing completed but no output was produced.";
}
async function handleContinueEditing(args: {
prompt: string;
referenceImages?: string[];
aspectRatio?: string;
resolution?: string;
}): Promise<string> {
if (!lastGeneratedImagePath) {
throw new Error("No previous image in session. Use generate_image or edit_image first.");
}
return handleEditImage({
imagePath: lastGeneratedImagePath,
prompt: args.prompt,
referenceImages: args.referenceImages,
aspectRatio: args.aspectRatio,
resolution: args.resolution,
});
}
async function handleGetLastImageInfo(): Promise<string> {
if (!lastGeneratedImagePath) {
return "No image has been generated or edited in this session yet.";
}
if (!fs.existsSync(lastGeneratedImagePath)) {
return `Last image path was: ${lastGeneratedImagePath}, but the file no longer exists.`;
}
const stats = fs.statSync(lastGeneratedImagePath);
return JSON.stringify({
path: lastGeneratedImagePath,
size: `${(stats.size / 1024).toFixed(2)} KB`,
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
}, null, 2);
}
async function handleComposeImages(args: {
images: string[];
prompt: string;
aspectRatio?: string;
resolution?: string;
}): Promise<string> {
const ai = getClient();
if (args.images.length === 0) {
throw new Error("At least one image is required for composition.");
}
if (args.images.length > 14) {
throw new Error("Maximum 14 images are supported for composition.");
}
const contents: Array<{text?: string; inlineData?: {mimeType: string; data: string}}> = [];
// Add all images
for (const imgPath of args.images) {
if (!fs.existsSync(imgPath)) {
throw new Error(`Image file not found: ${imgPath}`);
}
contents.push({
inlineData: {
mimeType: getMimeType(imgPath),
data: readImageAsBase64(imgPath),
},
});
}
// Add prompt
contents.push({ text: args.prompt });
const response = await ai.models.generateContent({
model: MODEL_NAME,
contents,
config: {
responseModalities: ["TEXT", "IMAGE"],
imageConfig: {
aspectRatio: args.aspectRatio || "1:1",
imageSize: args.resolution || "1K",
},
},
});
const result = processResponse(response);
let output = "";
if (result.imagePath) {
output += `Composed image saved to: ${result.imagePath}\n`;
}
if (result.text) {
output += `Model response: ${result.text}`;
}
return output || "Image composition completed but no output was produced.";
}
function handleGetConfigurationStatus(): string {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
return JSON.stringify({
configured: false,
message: "GEMINI_API_KEY environment variable is not set.",
model: MODEL_NAME,
outputDirectory: OUTPUT_DIR,
}, null, 2);
}
return JSON.stringify({
configured: true,
message: "Gemini API key is configured and ready to use.",
model: MODEL_NAME,
outputDirectory: OUTPUT_DIR,
keyPreview: `${apiKey.substring(0, 8)}...${apiKey.substring(apiKey.length - 4)}`,
}, null, 2);
}
// Create and run server
const server = new Server(
{
name: "nano-banana-pro-mcp",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Register tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools,
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
let result: string;
switch (name) {
case "generate_image":
result = await handleGenerateImage(args as any);
break;
case "edit_image":
result = await handleEditImage(args as any);
break;
case "continue_editing":
result = await handleContinueEditing(args as any);
break;
case "get_last_image_info":
result = await handleGetLastImageInfo();
break;
case "compose_images":
result = await handleComposeImages(args as any);
break;
case "get_configuration_status":
result = handleGetConfigurationStatus();
break;
default:
throw new Error(`Unknown tool: ${name}`);
}
return {
content: [{ type: "text", text: result }],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Error: ${errorMessage}` }],
isError: true,
};
}
});
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Nano Banana Pro MCP server running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});