commit
175c1ea35e
13 changed files with 3381 additions and 0 deletions
@ -0,0 +1,6 @@
|
||||
- Project type: TypeScript Node.js MCP server using the official MCP TypeScript SDK and stdio transport. |
||||
- Primary references: https://github.com/modelcontextprotocol/typescript-sdk and https://ai.google.dev/gemini-api/docs/image-generation. |
||||
- Keep stdout reserved for MCP traffic. Send operational logs only to stderr. |
||||
- Persist local configuration in the hidden home file ~/.bigbananamcp.json and never print raw API keys. |
||||
- The local HTTP configuration service must stay bound to localhost by default. |
||||
- The main MCP tool should support text-to-image, image editing, multi-image fusion, search-grounded prompting, and context preservation with reference images. |
||||
@ -0,0 +1,4 @@
|
||||
node_modules/ |
||||
dist/ |
||||
.DS_Store |
||||
npm-debug.log* |
||||
@ -0,0 +1,11 @@
|
||||
{ |
||||
"servers": { |
||||
"google-nano-banana": { |
||||
"type": "stdio", |
||||
"command": "node", |
||||
"args": [ |
||||
"dist/index.js" |
||||
] |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,111 @@
|
||||
# Big Banana MCP |
||||
|
||||
 |
||||
|
||||
Server MCP in TypeScript per usare i modelli Google Nano Banana tramite Gemini API, con supporto per: |
||||
|
||||
- generazione da testo |
||||
- editing da una o piu immagini di riferimento |
||||
- fusione di piu immagini |
||||
- grounding opzionale con Google Search |
||||
- controlli su aspect ratio, risoluzione e thinking |
||||
|
||||
Il server salva la configurazione in un file nascosto nella home utente: |
||||
|
||||
- `~/.bigbananamcp.json` |
||||
|
||||
Se la chiave API non e configurata, i tool MCP falliscono in modo esplicito e indicano l'endpoint HTTP locale da usare per configurarla. |
||||
|
||||
## Requisiti |
||||
|
||||
- Node.js 20+ |
||||
- una Google API key valida per Gemini API |
||||
|
||||
## Installazione |
||||
|
||||
```bash |
||||
npm install |
||||
npm run build |
||||
``` |
||||
|
||||
## Avvio |
||||
|
||||
Per uso MCP via stdio: |
||||
|
||||
```bash |
||||
npm run start |
||||
``` |
||||
|
||||
Per sviluppo: |
||||
|
||||
```bash |
||||
npm run dev |
||||
``` |
||||
|
||||
Il server avvia anche un endpoint HTTP locale di configurazione, di default su `http://127.0.0.1:3210`. |
||||
|
||||
## Endpoint di configurazione |
||||
|
||||
### `GET /health` |
||||
|
||||
Ritorna lo stato del servizio. |
||||
|
||||
### `GET /config` |
||||
|
||||
Ritorna la configurazione corrente senza esporre la chiave completa. |
||||
|
||||
### `PUT /config` |
||||
|
||||
Aggiorna il file `~/.bigbananamcp.json`. |
||||
|
||||
Esempio: |
||||
|
||||
```bash |
||||
curl -X PUT http://127.0.0.1:3210/config \ |
||||
-H 'Content-Type: application/json' \ |
||||
-d '{ |
||||
"apiKey": "YOUR_GOOGLE_API_KEY", |
||||
"defaultModel": "gemini-3.1-flash-image-preview", |
||||
"defaultAspectRatio": "16:9", |
||||
"defaultImageSize": "2K", |
||||
"defaultThinkingLevel": "dynamic", |
||||
"enableGoogleSearchByDefault": false |
||||
}' |
||||
``` |
||||
|
||||
### `POST /config/validate` |
||||
|
||||
Controlla se la configurazione contiene una chiave API presente e formalmente valida. |
||||
|
||||
## Tool MCP esposti |
||||
|
||||
### `nano_banana_generate` |
||||
|
||||
Tool principale per generazione, editing e multimodalita. |
||||
|
||||
Supporta: |
||||
|
||||
- solo prompt testo |
||||
- prompt + immagine base |
||||
- prompt + immagini multiple |
||||
- grounding opzionale con Google Search |
||||
- salvataggio opzionale dei risultati su disco |
||||
|
||||
### `nano_banana_models` |
||||
|
||||
Elenca modelli consigliati e relative capacita operative. |
||||
|
||||
### `nano_banana_config_status` |
||||
|
||||
Ritorna stato configurazione, path del file nascosto e URL del servizio HTTP locale. |
||||
|
||||
## Note progettuali |
||||
|
||||
- Il modello predefinito e `gemini-3.1-flash-image-preview`, cioe Nano Banana 2. |
||||
- Sono supportati anche `gemini-2.5-flash-image` e `gemini-3-pro-image-preview`. |
||||
- Le immagini vengono inviate come `inlineData` in base64, in linea con l'SDK ufficiale `@google/genai`. |
||||
- Tutti i log runtime vanno su stderr per non interferire con il trasporto stdio MCP. |
||||
|
||||
## Debug in VS Code |
||||
|
||||
Il file `.vscode/mcp.json` e gia pronto. Dopo `npm run build`, VS Code puo avviare e debuggare questo server MCP usando `node dist/index.js`. |
||||
|
After Width: | Height: | Size: 1.2 MiB |
@ -0,0 +1,26 @@
|
||||
{ |
||||
"name": "big-banana-mcp", |
||||
"version": "0.1.0", |
||||
"private": true, |
||||
"type": "module", |
||||
"description": "Big Banana MCP server for Google Nano Banana and Gemini image workflows.", |
||||
"scripts": { |
||||
"build": "tsc -p tsconfig.json", |
||||
"dev": "tsx watch src/index.ts", |
||||
"start": "node dist/index.js", |
||||
"check": "tsc --noEmit -p tsconfig.json" |
||||
}, |
||||
"dependencies": { |
||||
"@google/genai": "^1.21.0", |
||||
"@modelcontextprotocol/sdk": "^1.27.1", |
||||
"zod": "^4.0.17" |
||||
}, |
||||
"devDependencies": { |
||||
"@types/node": "^24.0.10", |
||||
"tsx": "^4.20.3", |
||||
"typescript": "^5.9.2" |
||||
}, |
||||
"engines": { |
||||
"node": ">=20.0.0" |
||||
} |
||||
} |
||||
@ -0,0 +1,46 @@
|
||||
import { mkdir } from 'node:fs/promises'; |
||||
import { resolve } from 'node:path'; |
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; |
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; |
||||
|
||||
const outputDirectory = resolve(process.cwd(), 'generated'); |
||||
await mkdir(outputDirectory, { recursive: true }); |
||||
|
||||
const client = new Client({ name: 'big-banana-mcp-smoke-test', version: '0.1.0' }); |
||||
const transport = new StdioClientTransport({ |
||||
command: 'node', |
||||
args: ['dist/index.js'], |
||||
cwd: process.cwd(), |
||||
stderr: 'inherit', |
||||
}); |
||||
|
||||
try { |
||||
await client.connect(transport); |
||||
|
||||
const tools = await client.listTools(); |
||||
console.log(JSON.stringify({ tools: tools.tools.map(tool => tool.name) }, null, 2)); |
||||
|
||||
const configStatus = await client.callTool({ name: 'nano_banana_config_status', arguments: {} }); |
||||
console.log(JSON.stringify({ configStatus }, null, 2)); |
||||
|
||||
console.log('Starting nano_banana_generate...'); |
||||
const result = await client.callTool({ |
||||
name: 'nano_banana_generate', |
||||
arguments: { |
||||
prompt: |
||||
'Design a square logo for a project called Big Banana. Show a single anthropomorphic banana character, extremely muscular, confident, heroic, and stylish in a chad pose, but still clean and logo-friendly. Background in mature comic-book style with bold halftone texture, dynamic burst shapes, inked shadows, warm yellow-orange-red palette, and strong black linework. Keep it visually punchy, iconic, and readable at small sizes. Center composition. No extra characters. Include subtle room for branding, but do not render readable text. Make it feel like a premium modern mascot logo rather than a generic illustration.', |
||||
model: 'gemini-2.5-flash-image', |
||||
aspectRatio: '1:1', |
||||
imageSize: '1K', |
||||
candidateCount: 1, |
||||
outputDirectory, |
||||
outputPrefix: 'big-banana-logo', |
||||
}, |
||||
}); |
||||
|
||||
console.log('Finished nano_banana_generate.'); |
||||
console.log(JSON.stringify({ result }, null, 2)); |
||||
} finally { |
||||
await transport.close(); |
||||
} |
||||
@ -0,0 +1,88 @@
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; |
||||
import { dirname, join } from 'node:path'; |
||||
import os from 'node:os'; |
||||
|
||||
export const CONFIG_FILE_NAME = '.bigbananamcp.json'; |
||||
export const DEFAULT_CONFIG_SERVER_HOST = '127.0.0.1'; |
||||
export const DEFAULT_CONFIG_SERVER_PORT = 3210; |
||||
export const DEFAULT_MODEL = 'gemini-3.1-flash-image-preview'; |
||||
|
||||
export type ThinkingLevel = 'minimal' | 'dynamic' | 'high'; |
||||
|
||||
export type AppConfig = { |
||||
apiKey?: string; |
||||
defaultModel: string; |
||||
defaultAspectRatio: string; |
||||
defaultImageSize: string; |
||||
defaultThinkingLevel: ThinkingLevel; |
||||
enableGoogleSearchByDefault: boolean; |
||||
configServerHost: string; |
||||
configServerPort: number; |
||||
}; |
||||
|
||||
export const DEFAULT_CONFIG: AppConfig = { |
||||
defaultModel: DEFAULT_MODEL, |
||||
defaultAspectRatio: '1:1', |
||||
defaultImageSize: '1K', |
||||
defaultThinkingLevel: 'dynamic', |
||||
enableGoogleSearchByDefault: false, |
||||
configServerHost: DEFAULT_CONFIG_SERVER_HOST, |
||||
configServerPort: DEFAULT_CONFIG_SERVER_PORT, |
||||
}; |
||||
|
||||
export function getConfigPath(): string { |
||||
return join(os.homedir(), CONFIG_FILE_NAME); |
||||
} |
||||
|
||||
export async function loadConfig(): Promise<AppConfig> { |
||||
const configPath = getConfigPath(); |
||||
|
||||
try { |
||||
const raw = await readFile(configPath, 'utf8'); |
||||
const parsed = JSON.parse(raw) as Partial<AppConfig>; |
||||
return { |
||||
...DEFAULT_CONFIG, |
||||
...parsed, |
||||
}; |
||||
} catch (error) { |
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { |
||||
return { ...DEFAULT_CONFIG }; |
||||
} |
||||
|
||||
throw new Error(`Unable to read config file ${configPath}: ${(error as Error).message}`); |
||||
} |
||||
} |
||||
|
||||
export async function saveConfig(partialConfig: Partial<AppConfig>): Promise<AppConfig> { |
||||
const configPath = getConfigPath(); |
||||
const nextConfig = { |
||||
...(await loadConfig()), |
||||
...partialConfig, |
||||
} satisfies AppConfig; |
||||
|
||||
await mkdir(dirname(configPath), { recursive: true }); |
||||
await writeFile(configPath, JSON.stringify(nextConfig, null, 2), 'utf8'); |
||||
await chmod(configPath, 0o600); |
||||
|
||||
return nextConfig; |
||||
} |
||||
|
||||
export function sanitizeConfig(config: AppConfig): Record<string, unknown> { |
||||
return { |
||||
...config, |
||||
apiKey: config.apiKey ? `${config.apiKey.slice(0, 4)}...${config.apiKey.slice(-4)}` : null, |
||||
hasApiKey: Boolean(config.apiKey), |
||||
}; |
||||
} |
||||
|
||||
export function assertApiKey(config: AppConfig): string { |
||||
const apiKey = config.apiKey?.trim(); |
||||
|
||||
if (!apiKey) { |
||||
throw new Error( |
||||
`Google API key missing. Configure it with PUT http://${config.configServerHost}:${config.configServerPort}/config or by writing ${getConfigPath()}.`, |
||||
); |
||||
} |
||||
|
||||
return apiKey; |
||||
} |
||||
@ -0,0 +1,194 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'; |
||||
import { extname, isAbsolute, join, resolve } from 'node:path'; |
||||
|
||||
import { GoogleGenAI } from '@google/genai'; |
||||
|
||||
import type { AppConfig, ThinkingLevel } from './config.js'; |
||||
|
||||
export const SUPPORTED_MODELS = [ |
||||
{ |
||||
id: 'gemini-2.5-flash-image', |
||||
label: 'Nano Banana', |
||||
strengths: ['low latency', 'image editing', 'multi-image fusion'], |
||||
}, |
||||
{ |
||||
id: 'gemini-3.1-flash-image-preview', |
||||
label: 'Nano Banana 2', |
||||
strengths: ['faster iteration', 'search grounding', '512px to 4K pipeline'], |
||||
}, |
||||
{ |
||||
id: 'gemini-3-pro-image-preview', |
||||
label: 'Nano Banana Pro', |
||||
strengths: ['best text rendering', 'higher fidelity', 'richer reasoning'], |
||||
}, |
||||
] as const; |
||||
|
||||
export type InputImage = { |
||||
data: string; |
||||
mimeType?: string; |
||||
}; |
||||
|
||||
export type GenerateRequest = { |
||||
prompt: string; |
||||
model?: string; |
||||
systemInstruction?: string; |
||||
inputImages?: InputImage[]; |
||||
useGoogleSearch?: boolean; |
||||
aspectRatio?: string; |
||||
imageSize?: string; |
||||
personGeneration?: 'ALLOW_ALL' | 'ALLOW_ADULT' | 'ALLOW_NONE'; |
||||
thinkingLevel?: ThinkingLevel; |
||||
thinkingBudget?: number; |
||||
includeThoughts?: boolean; |
||||
candidateCount?: number; |
||||
temperature?: number; |
||||
outputDirectory?: string; |
||||
outputPrefix?: string; |
||||
}; |
||||
|
||||
export type GeneratedImage = { |
||||
mimeType: string; |
||||
data: string; |
||||
savedPath?: string; |
||||
}; |
||||
|
||||
export type GenerateResult = { |
||||
model: string; |
||||
prompt: string; |
||||
textParts: string[]; |
||||
images: GeneratedImage[]; |
||||
usedGoogleSearch: boolean; |
||||
usageMetadata?: unknown; |
||||
}; |
||||
|
||||
function cleanBase64(data: string): { data: string; mimeType?: string } { |
||||
const trimmed = data.trim(); |
||||
const match = /^data:([^;]+);base64,(.+)$/s.exec(trimmed); |
||||
|
||||
if (!match) { |
||||
return { data: trimmed }; |
||||
} |
||||
|
||||
return { |
||||
mimeType: match[1], |
||||
data: match[2], |
||||
}; |
||||
} |
||||
|
||||
function buildThinkingConfig(level: ThinkingLevel | undefined, budget: number | undefined, includeThoughts: boolean | undefined) { |
||||
const thinkingBudget = budget ?? (level === 'high' ? 24576 : level === 'minimal' ? 0 : undefined); |
||||
|
||||
if (thinkingBudget === undefined && includeThoughts === undefined) { |
||||
return undefined; |
||||
} |
||||
|
||||
return { |
||||
...(thinkingBudget !== undefined ? { thinkingBudget } : {}), |
||||
...(includeThoughts !== undefined ? { includeThoughts } : {}), |
||||
}; |
||||
} |
||||
|
||||
function resolveOutputDirectory(outputDirectory: string): string { |
||||
return isAbsolute(outputDirectory) ? outputDirectory : resolve(process.cwd(), outputDirectory); |
||||
} |
||||
|
||||
function extensionForMimeType(mimeType: string): string { |
||||
const direct = mimeType.split('/')[1]?.toLowerCase(); |
||||
if (!direct) { |
||||
return '.bin'; |
||||
} |
||||
|
||||
if (direct === 'jpeg') { |
||||
return '.jpg'; |
||||
} |
||||
|
||||
return extname(`file.${direct}`) || '.bin'; |
||||
} |
||||
|
||||
async function saveImages(images: GeneratedImage[], outputDirectory: string, outputPrefix: string): Promise<GeneratedImage[]> { |
||||
const directory = resolveOutputDirectory(outputDirectory); |
||||
await mkdir(directory, { recursive: true }); |
||||
|
||||
return Promise.all( |
||||
images.map(async (image, index) => { |
||||
const fileName = `${outputPrefix}-${index + 1}${extensionForMimeType(image.mimeType)}`; |
||||
const savedPath = join(directory, fileName); |
||||
await writeFile(savedPath, Buffer.from(image.data, 'base64')); |
||||
return { |
||||
...image, |
||||
savedPath, |
||||
}; |
||||
}), |
||||
); |
||||
} |
||||
|
||||
export async function generateWithNanoBanana(apiKey: string, config: AppConfig, request: GenerateRequest): Promise<GenerateResult> { |
||||
const model = request.model ?? config.defaultModel; |
||||
const ai = new GoogleGenAI({ apiKey }); |
||||
|
||||
const parts: Array<Record<string, unknown>> = [{ text: request.prompt }]; |
||||
|
||||
for (const image of request.inputImages ?? []) { |
||||
const normalized = cleanBase64(image.data); |
||||
parts.push({ |
||||
inlineData: { |
||||
data: normalized.data, |
||||
mimeType: image.mimeType ?? normalized.mimeType ?? 'image/png', |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
const usedGoogleSearch = request.useGoogleSearch ?? config.enableGoogleSearchByDefault; |
||||
|
||||
const response = await ai.models.generateContent({ |
||||
model, |
||||
contents: [ |
||||
{ |
||||
role: 'user', |
||||
parts, |
||||
}, |
||||
], |
||||
config: { |
||||
...(request.systemInstruction ? { systemInstruction: request.systemInstruction } : {}), |
||||
...(request.temperature !== undefined ? { temperature: request.temperature } : {}), |
||||
...(request.candidateCount !== undefined ? { candidateCount: request.candidateCount } : {}), |
||||
responseModalities: ['TEXT', 'IMAGE'], |
||||
imageConfig: { |
||||
aspectRatio: request.aspectRatio ?? config.defaultAspectRatio, |
||||
imageSize: request.imageSize ?? config.defaultImageSize, |
||||
}, |
||||
...(buildThinkingConfig(request.thinkingLevel, request.thinkingBudget, request.includeThoughts) |
||||
? { thinkingConfig: buildThinkingConfig(request.thinkingLevel, request.thinkingBudget, request.includeThoughts) } |
||||
: {}), |
||||
...(usedGoogleSearch ? { tools: [{ googleSearch: {} }] } : {}), |
||||
}, |
||||
}); |
||||
|
||||
const firstCandidate = response.candidates?.[0]; |
||||
const responseParts = firstCandidate?.content?.parts ?? []; |
||||
|
||||
let images: GeneratedImage[] = responseParts |
||||
.filter((part): part is { inlineData: { mimeType?: string; data?: string } } => Boolean((part as { inlineData?: unknown }).inlineData)) |
||||
.map(part => ({ |
||||
mimeType: part.inlineData.mimeType ?? 'image/png', |
||||
data: part.inlineData.data ?? '', |
||||
})) |
||||
.filter(image => image.data.length > 0); |
||||
|
||||
if (request.outputDirectory) { |
||||
images = await saveImages(images, request.outputDirectory, request.outputPrefix ?? 'nano-banana-output'); |
||||
} |
||||
|
||||
const textParts = responseParts |
||||
.filter((part): part is { text: string; thought?: boolean } => typeof (part as { text?: unknown }).text === 'string') |
||||
.map(part => (part.thought ? `[thought] ${part.text}` : part.text)); |
||||
|
||||
return { |
||||
model, |
||||
prompt: request.prompt, |
||||
textParts, |
||||
images, |
||||
usedGoogleSearch, |
||||
usageMetadata: response.usageMetadata, |
||||
}; |
||||
} |
||||
@ -0,0 +1,111 @@
|
||||
import http from 'node:http'; |
||||
|
||||
import { loadConfig, sanitizeConfig, saveConfig, type AppConfig } from './config.js'; |
||||
|
||||
type ConfigServerHandle = { |
||||
close: () => Promise<void>; |
||||
}; |
||||
|
||||
function jsonResponse(response: http.ServerResponse, statusCode: number, payload: unknown): void { |
||||
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); |
||||
response.end(JSON.stringify(payload, null, 2)); |
||||
} |
||||
|
||||
async function readJsonBody(request: http.IncomingMessage): Promise<Record<string, unknown>> { |
||||
const chunks: Buffer[] = []; |
||||
|
||||
for await (const chunk of request) { |
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); |
||||
} |
||||
|
||||
if (chunks.length === 0) { |
||||
return {}; |
||||
} |
||||
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>; |
||||
} |
||||
|
||||
export async function startConfigHttpServer(initialConfig: AppConfig): Promise<ConfigServerHandle> { |
||||
const server = http.createServer(async (request, response) => { |
||||
try { |
||||
const url = new URL(request.url ?? '/', `http://${initialConfig.configServerHost}:${initialConfig.configServerPort}`); |
||||
|
||||
if (request.method === 'GET' && url.pathname === '/health') { |
||||
jsonResponse(response, 200, { ok: true, service: 'big-banana-mcp-config' }); |
||||
return; |
||||
} |
||||
|
||||
if (request.method === 'GET' && url.pathname === '/config') { |
||||
const currentConfig = await loadConfig(); |
||||
jsonResponse(response, 200, { |
||||
config: sanitizeConfig(currentConfig), |
||||
instructions: { |
||||
update: 'PUT /config', |
||||
validate: 'POST /config/validate', |
||||
}, |
||||
}); |
||||
return; |
||||
} |
||||
|
||||
if (request.method === 'PUT' && url.pathname === '/config') { |
||||
const body = await readJsonBody(request); |
||||
const nextConfig = await saveConfig({ |
||||
...(typeof body.apiKey === 'string' ? { apiKey: body.apiKey } : {}), |
||||
...(typeof body.defaultModel === 'string' ? { defaultModel: body.defaultModel } : {}), |
||||
...(typeof body.defaultAspectRatio === 'string' ? { defaultAspectRatio: body.defaultAspectRatio } : {}), |
||||
...(typeof body.defaultImageSize === 'string' ? { defaultImageSize: body.defaultImageSize } : {}), |
||||
...(body.defaultThinkingLevel === 'minimal' || body.defaultThinkingLevel === 'dynamic' || body.defaultThinkingLevel === 'high' |
||||
? { defaultThinkingLevel: body.defaultThinkingLevel } |
||||
: {}), |
||||
...(typeof body.enableGoogleSearchByDefault === 'boolean' |
||||
? { enableGoogleSearchByDefault: body.enableGoogleSearchByDefault } |
||||
: {}), |
||||
...(typeof body.configServerHost === 'string' ? { configServerHost: body.configServerHost } : {}), |
||||
...(typeof body.configServerPort === 'number' ? { configServerPort: body.configServerPort } : {}), |
||||
}); |
||||
jsonResponse(response, 200, { ok: true, config: sanitizeConfig(nextConfig) }); |
||||
return; |
||||
} |
||||
|
||||
if (request.method === 'POST' && url.pathname === '/config/validate') { |
||||
const currentConfig = await loadConfig(); |
||||
jsonResponse(response, 200, { |
||||
ok: Boolean(currentConfig.apiKey?.trim()), |
||||
hasApiKey: Boolean(currentConfig.apiKey?.trim()), |
||||
config: sanitizeConfig(currentConfig), |
||||
}); |
||||
return; |
||||
} |
||||
|
||||
jsonResponse(response, 404, { error: 'Not found' }); |
||||
} catch (error) { |
||||
jsonResponse(response, 500, { error: (error as Error).message }); |
||||
} |
||||
}); |
||||
|
||||
await new Promise<void>((resolve, reject) => { |
||||
server.once('error', reject); |
||||
server.listen(initialConfig.configServerPort, initialConfig.configServerHost, () => { |
||||
server.off('error', reject); |
||||
resolve(); |
||||
}); |
||||
}); |
||||
|
||||
console.error( |
||||
`Configuration endpoint listening on http://${initialConfig.configServerHost}:${initialConfig.configServerPort}`, |
||||
); |
||||
|
||||
return { |
||||
close: async () => |
||||
new Promise<void>((resolve, reject) => { |
||||
server.close(error => { |
||||
if (error) { |
||||
reject(error); |
||||
return; |
||||
} |
||||
|
||||
resolve(); |
||||
}); |
||||
}), |
||||
}; |
||||
} |
||||
@ -0,0 +1,190 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; |
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; |
||||
import * as z from 'zod/v4'; |
||||
|
||||
import { assertApiKey, getConfigPath, loadConfig, sanitizeConfig } from './config.js'; |
||||
import { generateWithNanoBanana, SUPPORTED_MODELS } from './google.js'; |
||||
import { startConfigHttpServer } from './httpConfigServer.js'; |
||||
|
||||
async function createServer(): Promise<McpServer> { |
||||
const currentConfig = await loadConfig(); |
||||
|
||||
const server = new McpServer( |
||||
{ |
||||
name: 'big-banana-mcp', |
||||
version: '0.1.0', |
||||
}, |
||||
{ |
||||
capabilities: { |
||||
logging: {}, |
||||
}, |
||||
}, |
||||
); |
||||
|
||||
server.registerResource( |
||||
'config-status', |
||||
'config://status', |
||||
{ |
||||
title: 'Big Banana Config Status', |
||||
description: 'Current sanitized configuration and config endpoint metadata.', |
||||
mimeType: 'application/json', |
||||
}, |
||||
async uri => { |
||||
const config = await loadConfig(); |
||||
return { |
||||
contents: [ |
||||
{ |
||||
uri: uri.href, |
||||
mimeType: 'application/json', |
||||
text: JSON.stringify( |
||||
{ |
||||
configFile: getConfigPath(), |
||||
config: sanitizeConfig(config), |
||||
configEndpoint: `http://${config.configServerHost}:${config.configServerPort}`, |
||||
}, |
||||
null, |
||||
2, |
||||
), |
||||
}, |
||||
], |
||||
}; |
||||
}, |
||||
); |
||||
|
||||
server.registerTool( |
||||
'nano_banana_models', |
||||
{ |
||||
title: 'Nano Banana Models', |
||||
description: 'List the supported Google Nano Banana and Gemini image models exposed by this server.', |
||||
}, |
||||
async () => ({ |
||||
content: [ |
||||
{ |
||||
type: 'text', |
||||
text: JSON.stringify(SUPPORTED_MODELS, null, 2), |
||||
}, |
||||
], |
||||
structuredContent: { |
||||
models: SUPPORTED_MODELS, |
||||
}, |
||||
}), |
||||
); |
||||
|
||||
server.registerTool( |
||||
'nano_banana_config_status', |
||||
{ |
||||
title: 'Nano Banana Config Status', |
||||
description: 'Return config file location, config endpoint, and whether an API key is configured.', |
||||
}, |
||||
async () => { |
||||
const config = await loadConfig(); |
||||
const payload = { |
||||
configFile: getConfigPath(), |
||||
configEndpoint: `http://${config.configServerHost}:${config.configServerPort}`, |
||||
config: sanitizeConfig(config), |
||||
}; |
||||
|
||||
return { |
||||
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], |
||||
structuredContent: payload, |
||||
}; |
||||
}, |
||||
); |
||||
|
||||
server.registerTool( |
||||
'nano_banana_generate', |
||||
{ |
||||
title: 'Nano Banana Generate', |
||||
description: 'Generate or edit images with Google Nano Banana models. Supports text-to-image, multimodal editing, multi-image fusion, and optional Google Search grounding.', |
||||
inputSchema: z.object({ |
||||
prompt: z.string().min(1).describe('Main instruction for the image workflow.'), |
||||
model: z.string().optional().describe('Optional model override, for example gemini-3.1-flash-image-preview.'), |
||||
systemInstruction: z.string().optional().describe('Optional system instruction sent to Gemini.'), |
||||
inputImages: z |
||||
.array( |
||||
z.object({ |
||||
data: z.string().describe('Base64 image data or a data URL.'), |
||||
mimeType: z.string().optional().describe('Explicit image MIME type if not embedded in the data URL.'), |
||||
}), |
||||
) |
||||
.max(14) |
||||
.optional() |
||||
.describe('Reference images used for editing, fusion, or context preservation.'), |
||||
useGoogleSearch: z.boolean().optional().describe('Enable grounding with Google Search when supported by the model.'), |
||||
aspectRatio: z |
||||
.enum(['1:1', '2:3', '3:2', '3:4', '4:3', '9:16', '16:9', '21:9']) |
||||
.optional() |
||||
.describe('Output aspect ratio.'), |
||||
imageSize: z.enum(['1K', '2K', '4K']).optional().describe('Output image size.'), |
||||
personGeneration: z |
||||
.enum(['ALLOW_ALL', 'ALLOW_ADULT', 'ALLOW_NONE']) |
||||
.optional() |
||||
.describe('People generation policy.'), |
||||
thinkingLevel: z.enum(['minimal', 'dynamic', 'high']).optional().describe('High-level reasoning preset.'), |
||||
thinkingBudget: z.number().int().min(0).optional().describe('Explicit thinking budget override.'), |
||||
includeThoughts: z.boolean().optional().describe('Ask the model to include thought parts when supported.'), |
||||
candidateCount: z.number().int().min(1).max(4).optional().describe('How many candidates to request.'), |
||||
temperature: z.number().min(0).max(2).optional().describe('Sampling temperature.'), |
||||
outputDirectory: z.string().optional().describe('Optional directory where generated images should be written.'), |
||||
outputPrefix: z.string().optional().describe('Optional filename prefix used when saving images.'), |
||||
}), |
||||
}, |
||||
async args => { |
||||
const config = await loadConfig(); |
||||
const apiKey = assertApiKey(config); |
||||
const result = await generateWithNanoBanana(apiKey, config, args); |
||||
|
||||
const summary = { |
||||
model: result.model, |
||||
prompt: result.prompt, |
||||
usedGoogleSearch: result.usedGoogleSearch, |
||||
imageCount: result.images.length, |
||||
savedPaths: result.images.map(image => image.savedPath).filter(Boolean), |
||||
}; |
||||
|
||||
return { |
||||
content: [ |
||||
{ |
||||
type: 'text', |
||||
text: JSON.stringify( |
||||
{ |
||||
...summary, |
||||
textParts: result.textParts, |
||||
}, |
||||
null, |
||||
2, |
||||
), |
||||
}, |
||||
], |
||||
structuredContent: result, |
||||
}; |
||||
}, |
||||
); |
||||
|
||||
return server; |
||||
} |
||||
|
||||
async function main(): Promise<void> { |
||||
const config = await loadConfig(); |
||||
const configServer = await startConfigHttpServer(config); |
||||
const server = await createServer(); |
||||
const transport = new StdioServerTransport(); |
||||
|
||||
process.on('SIGINT', async () => { |
||||
await configServer.close(); |
||||
process.exit(0); |
||||
}); |
||||
|
||||
process.on('SIGTERM', async () => { |
||||
await configServer.close(); |
||||
process.exit(0); |
||||
}); |
||||
|
||||
await server.connect(transport); |
||||
console.error('Big Banana MCP server running on stdio'); |
||||
} |
||||
|
||||
main().catch(error => { |
||||
console.error(`Fatal server error: ${(error as Error).stack ?? (error as Error).message}`); |
||||
process.exit(1); |
||||
}); |
||||
@ -0,0 +1,18 @@
|
||||
{ |
||||
"compilerOptions": { |
||||
"target": "ES2022", |
||||
"module": "NodeNext", |
||||
"moduleResolution": "NodeNext", |
||||
"outDir": "dist", |
||||
"rootDir": "src", |
||||
"strict": true, |
||||
"esModuleInterop": true, |
||||
"forceConsistentCasingInFileNames": true, |
||||
"skipLibCheck": true, |
||||
"resolveJsonModule": true, |
||||
"declaration": false, |
||||
"sourceMap": true, |
||||
"types": ["node"] |
||||
}, |
||||
"include": ["src/**/*.ts"] |
||||
} |
||||
Loading…
Reference in new issue