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).

340 lines 16.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 customizeImage(context, args) { const { prompt, control_image_base64, control_image_path, control_type, enable_control_computation = true, subject_images, subject_description, subject_type, style_image_base64, style_image_path, style_description, output_path = 'customized_image.png', aspect_ratio = '1:1', return_base64 = false, include_thumbnail, safety_level = 'BLOCK_MEDIUM_AND_ABOVE', person_generation = 'DONT_ALLOW', language = 'auto', negative_prompt, sample_count = 1, 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 customize_image tool uses Imagen-3 capability models which do not support 2K. Please use "1K" or omit sample_image_size.'); } const hasControl = control_image_base64 || control_image_path; const hasSubject = subject_images && subject_images.length > 0; const hasStyle = style_image_base64 || style_image_path; if (!hasControl && !hasSubject && !hasStyle) { throw new McpError(ErrorCode.InvalidParams, 'At least one reference image type must be provided (control, subject, or style)'); } if (hasControl && !control_type) { throw new McpError(ErrorCode.InvalidParams, 'control_type is required when control image is provided'); } if (hasSubject) { if (!subject_description) { throw new McpError(ErrorCode.InvalidParams, 'subject_description is required when subject_images is provided'); } if (!subject_type) { throw new McpError(ErrorCode.InvalidParams, 'subject_type is required when subject_images is provided'); } } const normalizedPath = return_base64 ? undefined : await normalizeAndValidatePath(output_path); const refTypeCount = (hasControl ? 1 : 0) + (hasSubject ? 1 : 0) + (hasStyle ? 1 : 0); if (refTypeCount > 2 && aspect_ratio !== '1:1') { throw new McpError(ErrorCode.InvalidParams, 'API limitation: Cannot use more than 2 reference image types with non-square aspect ratio. ' + `You are using ${refTypeCount} types (${hasControl ? 'control ' : ''}${hasSubject ? 'subject ' : ''}${hasStyle ? 'style' : ''}) ` + `with aspect ratio ${aspect_ratio}. Please either: ` + '1) Use aspect_ratio="1:1" (square) to enable 3 reference types, or ' + '2) Reduce to 2 or fewer reference image types.'); } if (process.env.DEBUG) { console.error(`[DEBUG] customize_image: model=${model}, ctrl=${hasControl}, subj=${hasSubject}, style=${hasStyle}`); } const referenceImages = []; let currentRefId = 1; if (hasControl) { const controlImage = await resolveImageSource({ base64Value: control_image_base64, pathValue: control_image_path, label: 'Control image', required: true, }); if (!controlImage) { throw new McpError(ErrorCode.InvalidParams, 'Control image could not be resolved'); } const controlTypeMap = { face_mesh: 'CONTROL_TYPE_FACE_MESH', canny: 'CONTROL_TYPE_CANNY', scribble: 'CONTROL_TYPE_SCRIBBLE', }; const resolvedControlType = controlTypeMap[control_type] || control_type; referenceImages.push({ referenceType: 'REFERENCE_TYPE_CONTROL', referenceId: currentRefId++, referenceImage: { bytesBase64Encoded: controlImage.base64, ...(controlImage.mimeType ? { mimeType: controlImage.mimeType } : {}), }, controlImageConfig: { controlType: resolvedControlType, enableControlImageComputation: enable_control_computation, }, }); } if (hasSubject && subject_images) { const subjectTypeMap = { person: 'SUBJECT_TYPE_PERSON', animal: 'SUBJECT_TYPE_ANIMAL', product: 'SUBJECT_TYPE_PRODUCT', default: 'SUBJECT_TYPE_DEFAULT', }; const resolvedSubjectType = subjectTypeMap[subject_type] || 'SUBJECT_TYPE_DEFAULT'; const subjectRefId = currentRefId; for (const subjectImage of subject_images) { const { image_base64, image_path } = subjectImage; const subjectSource = await resolveImageSource({ base64Value: image_base64, pathValue: image_path, label: 'Subject image', required: true, }); if (!subjectSource) { throw new McpError(ErrorCode.InvalidParams, 'Subject image could not be resolved'); } referenceImages.push({ referenceType: 'REFERENCE_TYPE_SUBJECT', referenceId: subjectRefId, referenceImage: { bytesBase64Encoded: subjectSource.base64, ...(subjectSource.mimeType ? { mimeType: subjectSource.mimeType } : {}), }, subjectImageConfig: { subjectType: resolvedSubjectType, subjectDescription: subject_description, }, }); } currentRefId++; } if (hasStyle) { const styleImage = await resolveImageSource({ base64Value: style_image_base64, pathValue: style_image_path, label: 'Style image', required: true, }); if (!styleImage) { throw new McpError(ErrorCode.InvalidParams, 'Style image could not be resolved'); } referenceImages.push({ referenceType: 'REFERENCE_TYPE_STYLE', referenceId: currentRefId++, referenceImage: { bytesBase64Encoded: styleImage.base64, ...(styleImage.mimeType ? { mimeType: styleImage.mimeType } : {}), }, styleImageConfig: { ...(style_description ? { styleDescription: style_description } : {}), }, }); } const requestBody = { instances: [ { prompt, referenceImages, }, ], parameters: { sampleCount: sample_count, aspectRatio: aspect_ratio, safetySettings: [ { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: safety_level, }, { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: safety_level, }, { category: 'HARM_CATEGORY_HARASSMENT', threshold: safety_level, }, { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: safety_level, }, ], personGeneration: person_generation, language, }, }; if (negative_prompt) { requestBody.parameters.negativePrompt = negative_prompt; } if (sample_image_size) { requestBody.parameters.sampleImageSize = sample_image_size; } if (process.env.DEBUG) { console.error('[DEBUG] Reference images structure:'); referenceImages.forEach((ref, idx) => { console.error(`[DEBUG] Ref ${idx}: type=${ref.referenceType}, id=${ref.referenceId}`); }); } // パラメータハッシュの計算(履歴管理用) const params = { prompt, model, aspect_ratio, safety_level, person_generation, language, sample_count, sample_image_size: sample_image_size || undefined, negative_prompt: negative_prompt || undefined, has_control: hasControl, has_subject: hasSubject, has_style: hasStyle, control_type: hasControl ? control_type : undefined, subject_type: hasSubject ? subject_type : undefined, subject_description: hasSubject ? subject_description : undefined, style_description: hasStyle ? style_description : 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('No images were generated'); } const predictions = response.data.predictions; const baseInfoText = `Image customized successfully!\n\nPrompt: ${prompt}\nAspect ratio: ${aspect_ratio}\nModel: ${model}\nReference types: ${hasControl ? 'control ' : ''}${hasSubject ? 'subject ' : ''}${hasStyle ? 'style' : ''}`; 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 generatedImage = predictions[0]; const imageBuffer = Buffer.from(generatedImage.bytesBase64Encoded, 'base64'); return createImageResponse(imageBuffer, generatedImage.mimeType, undefined, baseInfoText); } if (!normalizedPath) { throw new Error('Normalized path is required for file save mode.\n' + 'This is an internal error - please report this issue.'); } const filePaths = await generateMultipleFilePaths(normalizedPath, sample_count); if (process.env.DEBUG) { console.error(`[DEBUG] Saving ${predictions.length} customized 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: 'customize_image', model, created_at: new Date().toISOString(), aspect_ratio, 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: 'customize_image', prompt, model, aspectRatio: aspect_ratio, sampleCount: sample_count, sampleImageSize: sample_image_size || undefined, safetyLevel: safety_level, personGeneration: person_generation, language, 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=customizeImage.js.map