UNPKG

@dondonudonjp/vertexai-imagen-mcp-server

Version:

[DEPRECATED] MCP Server for Vertex AI Imagen image generation. Imagen endpoints shut down 2026-06-30; migrate to the nanoBanana MCP Server (Gemini image models).

304 lines 14.2 kB
import axios from 'axios'; import fs from 'fs/promises'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { normalizeAndValidatePath, getDisplayPath, generateMultipleFilePaths, } from '../utils/path.js'; import { getProjectId, getImagenApiUrl, getAuthHeaders } from '../utils/auth.js'; import { resolveImageSource, createImageResponse, createUriImageResponse, createMultiUriImageResponse, } from '../utils/image.js'; import { generateImageUUID, calculateParamsHash, embedMetadata, isMetadataEmbeddingEnabled, } from '../utils/metadata.js'; import { vertexAIRateLimiter } from '../utils/rateLimiter.js'; import { GOOGLE_IMAGEN_EDIT_MODEL } from '../config/constants.js'; export async function editImage(context, args) { const { prompt, reference_image_base64, reference_image_path, mask_image_base64, mask_image_path, mask_mode, mask_classes, mask_dilation = 0.01, edit_mode = 'inpaint_insertion', base_steps, output_path = 'edited_image.png', return_base64 = false, include_thumbnail, guidance_scale, sample_count = 1, negative_prompt, model = GOOGLE_IMAGEN_EDIT_MODEL, region, sample_image_size, } = args; const { auth, resourceManager, historyDb } = context; if (!prompt || typeof prompt !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'prompt is required and must be a string'); } if (sample_count < 1 || sample_count > 4) { throw new McpError(ErrorCode.InvalidParams, 'sample_count must be between 1 and 4'); } if (sample_image_size === '2K') { throw new McpError(ErrorCode.InvalidParams, '2K resolution is only supported by imagen-4.0-generate-001 and imagen-4.0-ultra-generate-001. ' + 'The edit_image tool uses Imagen-3 capability models which do not support 2K. Please use "1K" or omit sample_image_size.'); } const normalizedPath = return_base64 ? undefined : await normalizeAndValidatePath(output_path); if (mask_mode === 'semantic' && (!mask_classes || mask_classes.length === 0)) { throw new McpError(ErrorCode.InvalidParams, "mask_classes array is required and must not be empty when mask_mode is 'semantic'"); } if (mask_mode === 'user_provided' && !mask_image_base64 && !mask_image_path) { if (process.env.DEBUG) { console.error(`[DEBUG] mask_mode is 'user_provided' but no mask image provided - proceeding without mask`); } } if ((mask_image_base64 || mask_image_path) && mask_mode !== 'user_provided' && mask_mode !== undefined) { throw new McpError(ErrorCode.InvalidParams, "mask_image_base64/mask_image_path can only be used when mask_mode is 'user_provided'"); } if (mask_mode && mask_mode !== 'mask_free' && (mask_dilation < 0 || mask_dilation > 1)) { throw new McpError(ErrorCode.InvalidParams, 'mask_dilation must be between 0 and 1'); } const baseImage = await resolveImageSource({ base64Value: reference_image_base64, pathValue: reference_image_path, label: 'Reference image', required: true, }); if (!baseImage) { throw new McpError(ErrorCode.InvalidParams, 'Reference image could not be resolved'); } const referenceImages = [ { referenceType: 'REFERENCE_TYPE_RAW', referenceId: 0, referenceImage: { bytesBase64Encoded: baseImage.base64, ...(baseImage.mimeType ? { mimeType: baseImage.mimeType } : {}), }, }, ]; if (mask_mode === 'user_provided') { const maskImage = await resolveImageSource({ base64Value: mask_image_base64, pathValue: mask_image_path, label: 'Mask image', }); if (maskImage) { referenceImages.push({ referenceType: 'REFERENCE_TYPE_MASK', referenceId: 1, referenceImage: { bytesBase64Encoded: maskImage.base64, ...(maskImage.mimeType ? { mimeType: maskImage.mimeType } : {}), }, maskImageConfig: { maskMode: 'MASK_MODE_USER_PROVIDED', dilation: mask_dilation, }, }); } } else if (mask_mode && ['background', 'foreground', 'semantic'].includes(mask_mode)) { const maskModeMap = { background: 'MASK_MODE_BACKGROUND', foreground: 'MASK_MODE_FOREGROUND', semantic: 'MASK_MODE_SEMANTIC', }; const maskConfig = { referenceType: 'REFERENCE_TYPE_MASK', referenceId: 1, maskImageConfig: { maskMode: maskModeMap[mask_mode], dilation: mask_dilation, }, }; if (mask_mode === 'semantic' && mask_classes) { maskConfig.maskImageConfig.maskClasses = mask_classes; } else if (mask_mode === 'semantic' && !mask_classes) { throw new McpError(ErrorCode.InvalidParams, "mask_classes is required when mask_mode is 'semantic'"); } referenceImages.push(maskConfig); } else if (!mask_mode || mask_mode === 'mask_free') { if (process.env.DEBUG) { console.error('[DEBUG] Mask-free editing mode - no mask will be applied'); } } const editModeMap = { inpaint_removal: 'EDIT_MODE_INPAINT_REMOVAL', inpaint_insertion: 'EDIT_MODE_INPAINT_INSERTION', bgswap: 'EDIT_MODE_BGSWAP', outpainting: 'EDIT_MODE_OUTPAINT', mask_free: 'EDIT_MODE_DEFAULT', }; let apiEditMode; if (!mask_mode || mask_mode === 'mask_free') { apiEditMode = 'EDIT_MODE_DEFAULT'; } else { apiEditMode = editModeMap[edit_mode] || 'EDIT_MODE_DEFAULT'; } const requestBody = { instances: [ { prompt, referenceImages, }, ], parameters: { editMode: apiEditMode, }, }; if (base_steps !== undefined) { if (typeof base_steps !== 'number' || Number.isNaN(base_steps) || base_steps < 1) { throw new McpError(ErrorCode.InvalidParams, 'base_steps must be a positive number'); } requestBody.parameters.editConfig = { baseSteps: base_steps, }; } if (guidance_scale !== undefined) { if (typeof guidance_scale !== 'number' || Number.isNaN(guidance_scale)) { throw new McpError(ErrorCode.InvalidParams, 'guidance_scale must be a number'); } requestBody.parameters.guidanceScale = guidance_scale; } if (negative_prompt) { if (typeof negative_prompt !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'negative_prompt must be a string'); } requestBody.parameters.negativePrompt = negative_prompt; } if (sample_count) { requestBody.parameters.sampleCount = sample_count; } if (sample_image_size) { requestBody.parameters.sampleImageSize = sample_image_size; } if (process.env.DEBUG) { console.error(`[DEBUG] edit_image: model=${model}, edit=${edit_mode}, mask=${mask_mode}, refs=${referenceImages.length}`); } // パラメータハッシュの計算(履歴管理用) const params = { prompt, model, edit_mode, mask_mode: mask_mode || 'mask_free', mask_dilation, base_steps: base_steps || undefined, guidance_scale: guidance_scale || undefined, sample_count, sample_image_size: sample_image_size || undefined, negative_prompt: negative_prompt || undefined, }; const paramsHash = calculateParamsHash(params); // UUID発行(各画像ごと) const imageUUIDs = []; for (let i = 0; i < sample_count; i++) { imageUUIDs.push(generateImageUUID()); } const metadataEmbeddingEnabled = isMetadataEmbeddingEnabled(); if (process.env.DEBUG && metadataEmbeddingEnabled) { console.error(`[DEBUG] Metadata embedding enabled. UUIDs generated: ${imageUUIDs.length}`); } try { const projectId = await getProjectId(auth); const apiUrl = getImagenApiUrl(projectId, model, region); const authHeaders = await getAuthHeaders(auth); const response = await vertexAIRateLimiter.execute(() => axios.post(apiUrl, requestBody, { headers: { 'Content-Type': 'application/json', ...authHeaders, }, timeout: 45000, })); if (!response.data.predictions || response.data.predictions.length === 0) { throw new Error('Editing failed - no output received'); } const predictions = response.data.predictions; const maskApplied = referenceImages.length > 1 ? 'yes' : 'no'; const baseInfoText = `Image edited successfully!\n\nPrompt: ${prompt}\nModel: ${model}\nEdit mode: ${edit_mode}\nMask mode: ${mask_mode}\nMask applied: ${maskApplied}`; if (return_base64) { console.error('[WARNING] return_base64=true is deprecated and consumes ~1,500 tokens. Use file save mode (default) instead.'); if (predictions.length > 1) { console.error(`[WARNING] return_base64 mode only returns the first image. ${predictions.length - 1} additional images were discarded.`); } const editedImage = predictions[0]; const imageBuffer = Buffer.from(editedImage.bytesBase64Encoded, 'base64'); return createImageResponse(imageBuffer, editedImage.mimeType, undefined, baseInfoText); } if (!normalizedPath) { throw new Error('Normalized path is required for file save mode'); } const filePaths = await generateMultipleFilePaths(normalizedPath, sample_count); if (process.env.DEBUG) { console.error(`[DEBUG] Saving ${predictions.length} edited image(s)`); } const imageInfos = []; for (let i = 0; i < predictions.length; i++) { const prediction = predictions[i]; let imageBuffer = Buffer.from(prediction.bytesBase64Encoded, 'base64'); const absoluteFilePath = filePaths[i]; const uuid = imageUUIDs[i]; // メタデータ埋め込み if (metadataEmbeddingEnabled) { const metadata = { vertexai_imagen_uuid: uuid, params_hash: paramsHash, tool_name: 'edit_image', model, created_at: new Date().toISOString(), sample_image_size: sample_image_size || undefined, }; try { imageBuffer = (await embedMetadata(imageBuffer, metadata)); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error(`[WARNING] Failed to embed metadata for ${uuid}: ${errorMsg}`); } } await fs.writeFile(absoluteFilePath, imageBuffer); const displayPath = getDisplayPath(absoluteFilePath); const fileUri = resourceManager.getFileUri(absoluteFilePath); imageInfos.push({ uri: fileUri, mimeType: prediction.mimeType, fileSize: imageBuffer.length, filePath: displayPath, absoluteFilePath, }); // データベースに履歴記録 try { historyDb.createImageHistory({ uuid, filePath: absoluteFilePath, toolName: 'edit_image', prompt, model, sampleCount: sample_count, sampleImageSize: sample_image_size || undefined, parameters: JSON.stringify(params), paramsHash, success: true, fileSize: imageBuffer.length, mimeType: prediction.mimeType, }); if (process.env.DEBUG) { console.error(`[DEBUG] Image history recorded: ${uuid}`); } } catch (dbError) { const errorMsg = dbError instanceof Error ? dbError.message : String(dbError); console.error(`[WARNING] Failed to record image history for ${uuid}: ${errorMsg}`); } } const shouldIncludeThumbnail = include_thumbnail !== undefined ? include_thumbnail : process.env.VERTEXAI_IMAGEN_THUMBNAIL === 'true'; if (sample_count === 1) { const info = imageInfos[0]; return await createUriImageResponse(info.uri, info.mimeType, info.fileSize, info.filePath, info.absoluteFilePath, baseInfoText, shouldIncludeThumbnail); } return await createMultiUriImageResponse(imageInfos, baseInfoText, shouldIncludeThumbnail); } catch (error) { if (axios.isAxiosError(error)) { const errorMessage = error.response?.data?.error?.message || error.message; const errorCode = error.response?.status; if (process.env.DEBUG) { console.error(`[DEBUG] API Error ${errorCode}: ${errorMessage}`); } if (errorCode === 401 || errorCode === 403) { throw new McpError(ErrorCode.InvalidRequest, `Google Imagen API authentication error: ${errorMessage}`); } if (errorCode === 400) { throw new McpError(ErrorCode.InvalidParams, `Google Imagen API invalid parameter error: ${errorMessage}`); } if (errorCode && errorCode >= 500) { throw new McpError(ErrorCode.InternalError, `Google Imagen API server error: ${errorMessage}`); } throw new McpError(ErrorCode.InternalError, `Google Imagen API error: ${errorMessage}`); } throw error; } } //# sourceMappingURL=editImage.js.map