@mindstone-engineering/mcp-server-nano-banana 0.1.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.
- package/dist/auth.d.ts +21 -0
- package/dist/auth.js +29 -0
- package/dist/bridge.d.ts +16 -0
- package/dist/bridge.js +43 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.js +73 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +25 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +13 -0
- package/dist/tools/configure.d.ts +3 -0
- package/dist/tools/configure.js +47 -0
- package/dist/tools/edit.d.ts +3 -0
- package/dist/tools/edit.js +197 -0
- package/dist/tools/generate.d.ts +3 -0
- package/dist/tools/generate.js +144 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/path-safety.d.ts +28 -0
- package/dist/tools/path-safety.js +78 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +46 -0
- package/dist/utils.d.ts +14 -0
- package/dist/utils.js +42 -0
- package/package.json +48 -0
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nano Banana authentication module.
|
|
3
|
+
*
|
|
4
|
+
* Simple API key management — stored via env var (GEMINI_API_KEY)
|
|
5
|
+
* or configured at runtime via the configure_nano_banana_api_key tool.
|
|
6
|
+
*
|
|
7
|
+
* Auth: Gemini API key as query parameter (?key=...) on all API requests (NOT header).
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Get the current API key.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getApiKey(): string;
|
|
13
|
+
/**
|
|
14
|
+
* Set the API key at runtime (from configure tool).
|
|
15
|
+
*/
|
|
16
|
+
export declare function setApiKey(key: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* Check if an API key is configured.
|
|
19
|
+
*/
|
|
20
|
+
export declare function hasApiKey(): boolean;
|
|
21
|
+
//# sourceMappingURL=auth.d.ts.map
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nano Banana authentication module.
|
|
3
|
+
*
|
|
4
|
+
* Simple API key management — stored via env var (GEMINI_API_KEY)
|
|
5
|
+
* or configured at runtime via the configure_nano_banana_api_key tool.
|
|
6
|
+
*
|
|
7
|
+
* Auth: Gemini API key as query parameter (?key=...) on all API requests (NOT header).
|
|
8
|
+
*/
|
|
9
|
+
/** Runtime API key — starts from env, can be updated via configure tool. */
|
|
10
|
+
let apiKey = process.env.GEMINI_API_KEY ?? '';
|
|
11
|
+
/**
|
|
12
|
+
* Get the current API key.
|
|
13
|
+
*/
|
|
14
|
+
export function getApiKey() {
|
|
15
|
+
return apiKey;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Set the API key at runtime (from configure tool).
|
|
19
|
+
*/
|
|
20
|
+
export function setApiKey(key) {
|
|
21
|
+
apiKey = key;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Check if an API key is configured.
|
|
25
|
+
*/
|
|
26
|
+
export function hasApiKey() {
|
|
27
|
+
return apiKey.trim().length > 0;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=auth.js.map
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path to bridge state file, supporting both current and legacy env vars.
|
|
3
|
+
*/
|
|
4
|
+
export declare const BRIDGE_STATE_PATH: string;
|
|
5
|
+
/**
|
|
6
|
+
* Send a request to the host app bridge.
|
|
7
|
+
*
|
|
8
|
+
* The bridge is an HTTP server running inside the host app (e.g. Rebel)
|
|
9
|
+
* that handles credential management and other cross-process operations.
|
|
10
|
+
*/
|
|
11
|
+
export declare const bridgeRequest: (urlPath: string, body: Record<string, unknown>) => Promise<{
|
|
12
|
+
success: boolean;
|
|
13
|
+
warning?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
}>;
|
|
16
|
+
//# sourceMappingURL=bridge.d.ts.map
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { REQUEST_TIMEOUT_MS } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Path to bridge state file, supporting both current and legacy env vars.
|
|
5
|
+
*/
|
|
6
|
+
export const BRIDGE_STATE_PATH = process.env.MCP_HOST_BRIDGE_STATE || process.env.MINDSTONE_REBEL_BRIDGE_STATE || '';
|
|
7
|
+
const loadBridgeState = () => {
|
|
8
|
+
if (!BRIDGE_STATE_PATH)
|
|
9
|
+
return null;
|
|
10
|
+
try {
|
|
11
|
+
const raw = fs.readFileSync(BRIDGE_STATE_PATH, 'utf8');
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Send a request to the host app bridge.
|
|
20
|
+
*
|
|
21
|
+
* The bridge is an HTTP server running inside the host app (e.g. Rebel)
|
|
22
|
+
* that handles credential management and other cross-process operations.
|
|
23
|
+
*/
|
|
24
|
+
export const bridgeRequest = async (urlPath, body) => {
|
|
25
|
+
const bridge = loadBridgeState();
|
|
26
|
+
if (!bridge) {
|
|
27
|
+
return { success: false, error: 'Bridge not available' };
|
|
28
|
+
}
|
|
29
|
+
const response = await fetch(`http://127.0.0.1:${bridge.port}${urlPath}`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
32
|
+
headers: {
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
Authorization: `Bearer ${bridge.token}`,
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
});
|
|
38
|
+
if (response.status === 401 || response.status === 403) {
|
|
39
|
+
return { success: false, error: `Bridge returned ${response.status}: unauthorized. Check host app authentication.` };
|
|
40
|
+
}
|
|
41
|
+
return response.json();
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=bridge.js.map
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nano Banana / Gemini API HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Centralises query-param auth injection, error handling, rate-limit
|
|
5
|
+
* messaging, and timeout handling for all Gemini API calls.
|
|
6
|
+
*
|
|
7
|
+
* Auth: ?key={GEMINI_API_KEY} query parameter (NOT header)
|
|
8
|
+
* Base URL: https://generativelanguage.googleapis.com/v1beta
|
|
9
|
+
*/
|
|
10
|
+
import { type GeminiResponse } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* Make an authenticated request to the Gemini API.
|
|
13
|
+
* The API key is injected as a query parameter.
|
|
14
|
+
* Returns the parsed GeminiResponse.
|
|
15
|
+
*/
|
|
16
|
+
export declare function geminiFetch(apiKey: string, modelPath: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<GeminiResponse>;
|
|
17
|
+
//# sourceMappingURL=client.d.ts.map
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nano Banana / Gemini API HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Centralises query-param auth injection, error handling, rate-limit
|
|
5
|
+
* messaging, and timeout handling for all Gemini API calls.
|
|
6
|
+
*
|
|
7
|
+
* Auth: ?key={GEMINI_API_KEY} query parameter (NOT header)
|
|
8
|
+
* Base URL: https://generativelanguage.googleapis.com/v1beta
|
|
9
|
+
*/
|
|
10
|
+
import { NanoBananaError, REQUEST_TIMEOUT_MS, getErrorResolution, } from './types.js';
|
|
11
|
+
const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta';
|
|
12
|
+
/**
|
|
13
|
+
* Make an authenticated request to the Gemini API.
|
|
14
|
+
* The API key is injected as a query parameter.
|
|
15
|
+
* Returns the parsed GeminiResponse.
|
|
16
|
+
*/
|
|
17
|
+
export async function geminiFetch(apiKey, modelPath, body, signal) {
|
|
18
|
+
if (!apiKey || apiKey.trim().length === 0) {
|
|
19
|
+
throw new NanoBananaError('Gemini API key not configured', 'AUTH_REQUIRED', 'Configure your Gemini API key. Get one at https://aistudio.google.com/api-keys');
|
|
20
|
+
}
|
|
21
|
+
const url = `${GEMINI_API_BASE}/models/${modelPath}:generateContent?key=${apiKey}`;
|
|
22
|
+
// Log URL without key
|
|
23
|
+
console.error(`[NanoBanana API] POST ${GEMINI_API_BASE}/models/${modelPath}:generateContent`);
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await fetch(url, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
signal: signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
29
|
+
headers: {
|
|
30
|
+
'Content-Type': 'application/json',
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify(body),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
37
|
+
throw new NanoBananaError('Request to Gemini API timed out', 'TIMEOUT', 'The request took too long. Try again or check if the Gemini API is available.');
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
// Handle rate limiting
|
|
42
|
+
if (response.status === 429) {
|
|
43
|
+
throw new NanoBananaError('Rate limited. Please wait a moment before retrying.', 'RATE_LIMITED', getErrorResolution(429));
|
|
44
|
+
}
|
|
45
|
+
// Handle auth errors
|
|
46
|
+
if (response.status === 401 || response.status === 403) {
|
|
47
|
+
let detail = '';
|
|
48
|
+
try {
|
|
49
|
+
const errBody = await response.clone().json();
|
|
50
|
+
detail = errBody.error?.message || '';
|
|
51
|
+
}
|
|
52
|
+
catch { /* not JSON */ }
|
|
53
|
+
throw new NanoBananaError('Authentication failed', 'AUTH_FAILED', getErrorResolution(response.status, detail));
|
|
54
|
+
}
|
|
55
|
+
// Handle other HTTP errors
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
let detail = '';
|
|
58
|
+
try {
|
|
59
|
+
const errBody = await response.clone().json();
|
|
60
|
+
detail = errBody.error?.message || '';
|
|
61
|
+
}
|
|
62
|
+
catch { /* not JSON */ }
|
|
63
|
+
throw new NanoBananaError(`Gemini API error (HTTP ${response.status}): ${detail || response.statusText}`, `HTTP_${response.status}`, getErrorResolution(response.status, detail));
|
|
64
|
+
}
|
|
65
|
+
// Parse response
|
|
66
|
+
try {
|
|
67
|
+
return (await response.json());
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new NanoBananaError('Failed to parse Gemini API response', 'PARSE_ERROR', 'The API returned an unparseable response. Try again.');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=client.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Nano Banana MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Provides Google Gemini image generation and editing via Model Context Protocol.
|
|
6
|
+
* Generate images from text, edit existing images with AI.
|
|
7
|
+
*
|
|
8
|
+
* Environment variables:
|
|
9
|
+
* - GEMINI_API_KEY: Gemini API key (required, get from https://aistudio.google.com/api-keys)
|
|
10
|
+
* - MCP_HOST_BRIDGE_STATE: Path to host app bridge state file (optional)
|
|
11
|
+
* - MINDSTONE_REBEL_BRIDGE_STATE: Legacy bridge state path (optional)
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Nano Banana MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Provides Google Gemini image generation and editing via Model Context Protocol.
|
|
6
|
+
* Generate images from text, edit existing images with AI.
|
|
7
|
+
*
|
|
8
|
+
* Environment variables:
|
|
9
|
+
* - GEMINI_API_KEY: Gemini API key (required, get from https://aistudio.google.com/api-keys)
|
|
10
|
+
* - MCP_HOST_BRIDGE_STATE: Path to host app bridge state file (optional)
|
|
11
|
+
* - MINDSTONE_REBEL_BRIDGE_STATE: Legacy bridge state path (optional)
|
|
12
|
+
*/
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import { createServer } from './server.js';
|
|
15
|
+
async function main() {
|
|
16
|
+
const server = createServer();
|
|
17
|
+
const transport = new StdioServerTransport();
|
|
18
|
+
await server.connect(transport);
|
|
19
|
+
console.error('NanoBanana MCP server running on stdio');
|
|
20
|
+
}
|
|
21
|
+
main().catch((error) => {
|
|
22
|
+
console.error('Fatal error:', error);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
});
|
|
25
|
+
//# sourceMappingURL=index.js.map
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { registerConfigureTools, registerGenerateTools, registerEditTools, } from './tools/index.js';
|
|
3
|
+
export function createServer() {
|
|
4
|
+
const server = new McpServer({
|
|
5
|
+
name: 'nano-banana-mcp-server',
|
|
6
|
+
version: '0.1.0',
|
|
7
|
+
});
|
|
8
|
+
registerConfigureTools(server);
|
|
9
|
+
registerGenerateTools(server);
|
|
10
|
+
registerEditTools(server);
|
|
11
|
+
return server;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { setApiKey } from '../auth.js';
|
|
3
|
+
import { bridgeRequest, BRIDGE_STATE_PATH } from '../bridge.js';
|
|
4
|
+
import { NanoBananaError } from '../types.js';
|
|
5
|
+
import { withErrorHandling } from '../utils.js';
|
|
6
|
+
export function registerConfigureTools(server) {
|
|
7
|
+
server.registerTool('configure_nano_banana_api_key', {
|
|
8
|
+
title: 'Configure NanoBanana API Key',
|
|
9
|
+
description: 'Save your Gemini API key for NanoBanana image generation. Call this when the user provides their key. ' +
|
|
10
|
+
'WHERE TO GET A KEY: Go to https://aistudio.google.com/api-keys → Create new API key → Copy the key. ' +
|
|
11
|
+
'FREE TIER: Generous free usage for Gemini API. Supports image generation and editing.',
|
|
12
|
+
inputSchema: z.object({
|
|
13
|
+
api_key: z.string().min(1).describe('Gemini API key from https://aistudio.google.com/api-keys'),
|
|
14
|
+
}),
|
|
15
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
16
|
+
}, withErrorHandling(async (args) => {
|
|
17
|
+
const key = args.api_key.trim();
|
|
18
|
+
// If bridge is available, persist via bridge
|
|
19
|
+
if (BRIDGE_STATE_PATH) {
|
|
20
|
+
try {
|
|
21
|
+
const result = await bridgeRequest('/bundled/nanobanana/configure', { apiKey: key });
|
|
22
|
+
if (result.success) {
|
|
23
|
+
setApiKey(key);
|
|
24
|
+
const message = result.warning
|
|
25
|
+
? `Gemini API key configured successfully. Note: ${result.warning}`
|
|
26
|
+
: 'Gemini API key configured successfully! You can now use nano_banana_generate and nano_banana_edit to create and edit images.';
|
|
27
|
+
return JSON.stringify({ ok: true, message });
|
|
28
|
+
}
|
|
29
|
+
// Bridge returned failure — surface as error, do NOT fall through
|
|
30
|
+
throw new NanoBananaError(result.error || 'Bridge configuration failed', 'BRIDGE_ERROR', 'The host app bridge rejected the configuration request. Check the host app logs.');
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error instanceof NanoBananaError)
|
|
34
|
+
throw error;
|
|
35
|
+
// Bridge request failed (network, timeout, etc.) — surface as error
|
|
36
|
+
throw new NanoBananaError(`Bridge request failed: ${error instanceof Error ? error.message : String(error)}`, 'BRIDGE_ERROR', 'Could not reach the host app bridge. Ensure the host app is running.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// No bridge — store in-memory
|
|
40
|
+
setApiKey(key);
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
ok: true,
|
|
43
|
+
message: 'Gemini API key configured successfully! You can now use nano_banana_generate and nano_banana_edit to create and edit images.',
|
|
44
|
+
});
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=configure.js.map
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { getApiKey, hasApiKey } from '../auth.js';
|
|
6
|
+
import { geminiFetch } from '../client.js';
|
|
7
|
+
import { NanoBananaError, SUPPORTED_MODELS, DEFAULT_MODEL, SUPPORTED_ASPECT_RATIOS, SUPPORTED_IMAGE_EXTENSIONS, } from '../types.js';
|
|
8
|
+
import { resolveSavePath } from './path-safety.js';
|
|
9
|
+
const MODEL_DESCRIPTION = 'Model to use: "gemini-3.1-flash-image-preview" (Nano Banana 2, default — pro-quality at flash speed, 4K), ' +
|
|
10
|
+
'"gemini-3-pro-image-preview" (Nano Banana Pro — highest quality), or ' +
|
|
11
|
+
'"gemini-2.5-flash-image" (original Nano Banana — fast, legacy)';
|
|
12
|
+
/**
|
|
13
|
+
* Expand ~ to home directory.
|
|
14
|
+
*/
|
|
15
|
+
function expandPath(filePath) {
|
|
16
|
+
return filePath.replace(/^~/, os.homedir());
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Detect MIME type from file extension.
|
|
20
|
+
* Returns null for unsupported formats.
|
|
21
|
+
*/
|
|
22
|
+
function getMimeTypeFromPath(filePath) {
|
|
23
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
24
|
+
return SUPPORTED_IMAGE_EXTENSIONS[ext] || null;
|
|
25
|
+
}
|
|
26
|
+
export function registerEditTools(server) {
|
|
27
|
+
server.registerTool('nano_banana_edit', {
|
|
28
|
+
title: 'Edit Image (Nano Banana)',
|
|
29
|
+
description: `Edit an existing image using Google Gemini's image editing capabilities.\n\n` +
|
|
30
|
+
`Use this when the user wants to modify, edit, or transform an existing image using AI.\n\n` +
|
|
31
|
+
`Provide a source image file path and edit instructions. The edited image will appear inline in the conversation.\n\n` +
|
|
32
|
+
`Examples of edit prompts:\n` +
|
|
33
|
+
`- "Remove the background and make it transparent"\n` +
|
|
34
|
+
`- "Change the color of the car to red"\n` +
|
|
35
|
+
`- "Add a sunset sky in the background"\n` +
|
|
36
|
+
`- "Make this photo look like a watercolor painting"\n` +
|
|
37
|
+
`- "Remove the person on the right side"`,
|
|
38
|
+
inputSchema: z.object({
|
|
39
|
+
source_image_path: z.string().min(1).describe('Path to the image file to edit (supports ~ for home directory)'),
|
|
40
|
+
prompt: z.string().min(1).describe('Instructions for how to edit the image'),
|
|
41
|
+
model: z.enum(SUPPORTED_MODELS).optional().describe(MODEL_DESCRIPTION),
|
|
42
|
+
aspect_ratio: z.enum(SUPPORTED_ASPECT_RATIOS).optional().describe('Aspect ratio for the edited image'),
|
|
43
|
+
save_path: z.string().optional().describe('Optional file path to save the edited image. IMPORTANT: Must be inside the workspace directory so the image can be displayed inline.'),
|
|
44
|
+
}),
|
|
45
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
46
|
+
}, async (input) => {
|
|
47
|
+
if (!hasApiKey()) {
|
|
48
|
+
return {
|
|
49
|
+
content: [{
|
|
50
|
+
type: 'text',
|
|
51
|
+
text: JSON.stringify({
|
|
52
|
+
ok: false,
|
|
53
|
+
error: 'Gemini API key not configured',
|
|
54
|
+
code: 'AUTH_REQUIRED',
|
|
55
|
+
resolution: 'Configure your Gemini API key. Get one at https://aistudio.google.com/api-keys',
|
|
56
|
+
next_step: {
|
|
57
|
+
action: 'Ask the user for their Gemini API key, then call configure_nano_banana_api_key',
|
|
58
|
+
tool_to_call: 'configure_nano_banana_api_key',
|
|
59
|
+
tool_parameters: { api_key: '<user_provided_key>' },
|
|
60
|
+
get_key_from: 'https://aistudio.google.com/api-keys',
|
|
61
|
+
},
|
|
62
|
+
}, null, 2),
|
|
63
|
+
}],
|
|
64
|
+
isError: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const sourcePath = expandPath(input.source_image_path);
|
|
68
|
+
const model = input.model ?? DEFAULT_MODEL;
|
|
69
|
+
// Check file format before reading
|
|
70
|
+
const sourceMimeType = getMimeTypeFromPath(sourcePath);
|
|
71
|
+
if (!sourceMimeType) {
|
|
72
|
+
const ext = path.extname(sourcePath).toLowerCase() || '(no extension)';
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: 'text', text: `Unsupported image format: ${ext}. Supported formats: PNG, JPEG, WebP.` }],
|
|
75
|
+
isError: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// Read and encode source image
|
|
79
|
+
let imageBuffer;
|
|
80
|
+
try {
|
|
81
|
+
if (!fs.existsSync(sourcePath)) {
|
|
82
|
+
return {
|
|
83
|
+
content: [{ type: 'text', text: `File not found: ${sourcePath}` }],
|
|
84
|
+
isError: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
imageBuffer = fs.readFileSync(sourcePath);
|
|
88
|
+
console.error(`[NanoBanana] Read source image: ${imageBuffer.length} bytes, type: ${sourceMimeType}`);
|
|
89
|
+
}
|
|
90
|
+
catch (readError) {
|
|
91
|
+
const errMsg = readError instanceof Error ? readError.message : String(readError);
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: 'text', text: `Failed to read image file: ${errMsg}` }],
|
|
94
|
+
isError: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const sourceBase64 = imageBuffer.toString('base64');
|
|
98
|
+
const generationConfig = { responseModalities: ['TEXT', 'IMAGE'] };
|
|
99
|
+
if (input.aspect_ratio) {
|
|
100
|
+
generationConfig.imageConfig = { aspectRatio: input.aspect_ratio };
|
|
101
|
+
}
|
|
102
|
+
const requestBody = {
|
|
103
|
+
contents: [{
|
|
104
|
+
parts: [
|
|
105
|
+
{ text: `Edit this image: ${input.prompt}` },
|
|
106
|
+
{ inlineData: { mimeType: sourceMimeType, data: sourceBase64 } },
|
|
107
|
+
],
|
|
108
|
+
}],
|
|
109
|
+
generationConfig,
|
|
110
|
+
};
|
|
111
|
+
let data;
|
|
112
|
+
try {
|
|
113
|
+
data = await geminiFetch(getApiKey(), model, requestBody);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error instanceof NanoBananaError) {
|
|
117
|
+
return {
|
|
118
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, error: error.message, code: error.code, resolution: error.resolution }) }],
|
|
119
|
+
isError: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
123
|
+
return {
|
|
124
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, error: `Network error: ${errMsg}` }) }],
|
|
125
|
+
isError: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// Check for prompt blocks
|
|
129
|
+
const candidates = data.candidates;
|
|
130
|
+
if (!candidates || !candidates.length) {
|
|
131
|
+
const blockReason = data.promptFeedback?.blockReason;
|
|
132
|
+
if (blockReason) {
|
|
133
|
+
return {
|
|
134
|
+
content: [{ type: 'text', text: `Prompt was blocked: ${blockReason}. Please try a different prompt.` }],
|
|
135
|
+
isError: true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
content: [{ type: 'text', text: 'No edited image generated — API returned no results' }],
|
|
140
|
+
isError: true,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// Extract edited image from response
|
|
144
|
+
const parts = candidates[0]?.content?.parts || [];
|
|
145
|
+
let imageData = null;
|
|
146
|
+
let imageMimeType = 'image/png';
|
|
147
|
+
let textResponse = null;
|
|
148
|
+
for (const part of parts) {
|
|
149
|
+
if (part.inlineData?.data) {
|
|
150
|
+
imageData = part.inlineData.data;
|
|
151
|
+
imageMimeType = part.inlineData.mimeType || 'image/png';
|
|
152
|
+
}
|
|
153
|
+
else if (part.text) {
|
|
154
|
+
textResponse = part.text;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!imageData) {
|
|
158
|
+
const responseText = textResponse || 'No edited image was generated. The model may not support this type of edit.';
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: 'text', text: responseText }],
|
|
161
|
+
isError: true,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
console.error('[NanoBanana] Image edited successfully');
|
|
165
|
+
// Optionally save to disk with path traversal protection
|
|
166
|
+
let savedPath = null;
|
|
167
|
+
if (input.save_path) {
|
|
168
|
+
const resolveResult = resolveSavePath(input.save_path, imageMimeType);
|
|
169
|
+
if (!resolveResult.ok) {
|
|
170
|
+
return {
|
|
171
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, error: resolveResult.error }) }],
|
|
172
|
+
isError: true,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
fs.mkdirSync(path.dirname(resolveResult.path), { recursive: true });
|
|
177
|
+
fs.writeFileSync(resolveResult.path, Buffer.from(imageData, 'base64'));
|
|
178
|
+
savedPath = resolveResult.path;
|
|
179
|
+
console.error(`[NanoBanana] Saved edited image to: ${savedPath}`);
|
|
180
|
+
}
|
|
181
|
+
catch (saveError) {
|
|
182
|
+
const errMsg = saveError instanceof Error ? saveError.message : String(saveError);
|
|
183
|
+
console.error(`[NanoBanana] Failed to save: ${errMsg}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const textMessage = savedPath
|
|
187
|
+
? `Image edited and saved to: ${savedPath}`
|
|
188
|
+
: 'Image edited successfully!';
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{ type: 'text', text: textMessage },
|
|
192
|
+
{ type: 'image', data: imageData, mimeType: imageMimeType },
|
|
193
|
+
],
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=edit.js.map
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { getApiKey, hasApiKey } from '../auth.js';
|
|
5
|
+
import { geminiFetch } from '../client.js';
|
|
6
|
+
import { NanoBananaError, SUPPORTED_MODELS, DEFAULT_MODEL, SUPPORTED_ASPECT_RATIOS, } from '../types.js';
|
|
7
|
+
import { resolveSavePath } from './path-safety.js';
|
|
8
|
+
const MODEL_DESCRIPTION = 'Model to use: "gemini-3.1-flash-image-preview" (Nano Banana 2, default — pro-quality at flash speed, 4K), ' +
|
|
9
|
+
'"gemini-3-pro-image-preview" (Nano Banana Pro — highest quality), or ' +
|
|
10
|
+
'"gemini-2.5-flash-image" (original Nano Banana — fast, legacy)';
|
|
11
|
+
export function registerGenerateTools(server) {
|
|
12
|
+
server.registerTool('nano_banana_generate', {
|
|
13
|
+
title: 'Generate Image (Nano Banana)',
|
|
14
|
+
description: `Generate images from text descriptions using Google Gemini's image generation capabilities.\n\n` +
|
|
15
|
+
`Use this when the user asks you to create, generate, draw, or design an image, illustration, or visual content using Gemini/Google AI.\n\n` +
|
|
16
|
+
`The generated image will appear inline in the conversation. You can also save it to disk by providing save_path.\n\n` +
|
|
17
|
+
`Tips for good prompts:\n` +
|
|
18
|
+
`- Be specific about style, colors, composition, and mood\n` +
|
|
19
|
+
`- Mention artistic styles if relevant (e.g., "watercolor", "minimalist", "photorealistic")\n` +
|
|
20
|
+
`- Include details about lighting, perspective, and background`,
|
|
21
|
+
inputSchema: z.object({
|
|
22
|
+
prompt: z.string().min(1).describe('Text description of the image to generate'),
|
|
23
|
+
model: z.enum(SUPPORTED_MODELS).optional().describe(MODEL_DESCRIPTION),
|
|
24
|
+
aspect_ratio: z.enum(SUPPORTED_ASPECT_RATIOS).optional().describe('Aspect ratio for the generated image (default: 1:1)'),
|
|
25
|
+
save_path: z.string().optional().describe('Optional file path to save the image. IMPORTANT: Must be inside the workspace directory so the image can be displayed inline.'),
|
|
26
|
+
}),
|
|
27
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
28
|
+
}, async (input) => {
|
|
29
|
+
if (!hasApiKey()) {
|
|
30
|
+
return {
|
|
31
|
+
content: [{
|
|
32
|
+
type: 'text',
|
|
33
|
+
text: JSON.stringify({
|
|
34
|
+
ok: false,
|
|
35
|
+
error: 'Gemini API key not configured',
|
|
36
|
+
code: 'AUTH_REQUIRED',
|
|
37
|
+
resolution: 'Configure your Gemini API key. Get one at https://aistudio.google.com/api-keys',
|
|
38
|
+
next_step: {
|
|
39
|
+
action: 'Ask the user for their Gemini API key, then call configure_nano_banana_api_key',
|
|
40
|
+
tool_to_call: 'configure_nano_banana_api_key',
|
|
41
|
+
tool_parameters: { api_key: '<user_provided_key>' },
|
|
42
|
+
get_key_from: 'https://aistudio.google.com/api-keys',
|
|
43
|
+
},
|
|
44
|
+
}, null, 2),
|
|
45
|
+
}],
|
|
46
|
+
isError: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const model = input.model ?? DEFAULT_MODEL;
|
|
50
|
+
const generationConfig = { responseModalities: ['TEXT', 'IMAGE'] };
|
|
51
|
+
if (input.aspect_ratio) {
|
|
52
|
+
generationConfig.imageConfig = { aspectRatio: input.aspect_ratio };
|
|
53
|
+
}
|
|
54
|
+
const requestBody = {
|
|
55
|
+
contents: [{ parts: [{ text: input.prompt }] }],
|
|
56
|
+
generationConfig,
|
|
57
|
+
};
|
|
58
|
+
let data;
|
|
59
|
+
try {
|
|
60
|
+
data = await geminiFetch(getApiKey(), model, requestBody);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error instanceof NanoBananaError) {
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, error: error.message, code: error.code, resolution: error.resolution }) }],
|
|
66
|
+
isError: true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
70
|
+
return {
|
|
71
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, error: `Network error: ${errMsg}` }) }],
|
|
72
|
+
isError: true,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Check for prompt blocks
|
|
76
|
+
const candidates = data.candidates;
|
|
77
|
+
if (!candidates || !candidates.length) {
|
|
78
|
+
const blockReason = data.promptFeedback?.blockReason;
|
|
79
|
+
if (blockReason) {
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: 'text', text: `Prompt was blocked: ${blockReason}. Please try a different prompt.` }],
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: 'text', text: 'No image generated — API returned no results' }],
|
|
87
|
+
isError: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// Extract image and text from response
|
|
91
|
+
const parts = candidates[0]?.content?.parts || [];
|
|
92
|
+
let imageData = null;
|
|
93
|
+
let imageMimeType = 'image/png';
|
|
94
|
+
let textResponse = null;
|
|
95
|
+
for (const part of parts) {
|
|
96
|
+
if (part.inlineData?.data) {
|
|
97
|
+
imageData = part.inlineData.data;
|
|
98
|
+
imageMimeType = part.inlineData.mimeType || 'image/png';
|
|
99
|
+
}
|
|
100
|
+
else if (part.text) {
|
|
101
|
+
textResponse = part.text;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!imageData) {
|
|
105
|
+
const responseText = textResponse || 'No image was generated. The model may not support image generation for this prompt.';
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: 'text', text: responseText }],
|
|
108
|
+
isError: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
console.error('[NanoBanana] Image generated successfully');
|
|
112
|
+
// Optionally save to disk with path traversal protection
|
|
113
|
+
let savedPath = null;
|
|
114
|
+
if (input.save_path) {
|
|
115
|
+
const resolveResult = resolveSavePath(input.save_path, imageMimeType);
|
|
116
|
+
if (!resolveResult.ok) {
|
|
117
|
+
return {
|
|
118
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, error: resolveResult.error }) }],
|
|
119
|
+
isError: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
fs.mkdirSync(path.dirname(resolveResult.path), { recursive: true });
|
|
124
|
+
fs.writeFileSync(resolveResult.path, Buffer.from(imageData, 'base64'));
|
|
125
|
+
savedPath = resolveResult.path;
|
|
126
|
+
console.error(`[NanoBanana] Saved to: ${savedPath}`);
|
|
127
|
+
}
|
|
128
|
+
catch (saveError) {
|
|
129
|
+
const errMsg = saveError instanceof Error ? saveError.message : String(saveError);
|
|
130
|
+
console.error(`[NanoBanana] Failed to save: ${errMsg}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const textMessage = savedPath
|
|
134
|
+
? `Image generated and saved to: ${savedPath}`
|
|
135
|
+
: 'Image generated successfully!';
|
|
136
|
+
return {
|
|
137
|
+
content: [
|
|
138
|
+
{ type: 'text', text: textMessage },
|
|
139
|
+
{ type: 'image', data: imageData, mimeType: imageMimeType },
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=generate.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type ResolveResult = {
|
|
2
|
+
ok: true;
|
|
3
|
+
path: string;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
error: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Derive the canonical workspace root directory.
|
|
10
|
+
*
|
|
11
|
+
* Priority: REBEL_WORKSPACE_PATH env var > process.cwd()
|
|
12
|
+
* The result is always resolved to an absolute path.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getWorkspaceRoot(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Safely resolve a user-provided save_path.
|
|
17
|
+
*
|
|
18
|
+
* Security rules:
|
|
19
|
+
* - Rejects paths containing `..` segments (path traversal)
|
|
20
|
+
* - Rejects absolute paths outside the workspace root
|
|
21
|
+
* - Relative paths are resolved against the workspace root
|
|
22
|
+
* - Tilde expansion (~) is NOT allowed — it would escape the workspace boundary
|
|
23
|
+
* - Final path (after extension append) must still be within workspace root
|
|
24
|
+
*
|
|
25
|
+
* Adds appropriate file extension if missing.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveSavePath(savePath: string, mimeType: string): ResolveResult;
|
|
28
|
+
//# sourceMappingURL=path-safety.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
/**
|
|
3
|
+
* MIME type to file extension mapping.
|
|
4
|
+
*/
|
|
5
|
+
const MIME_TO_EXT = {
|
|
6
|
+
'image/png': '.png',
|
|
7
|
+
'image/jpeg': '.jpg',
|
|
8
|
+
'image/webp': '.webp',
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Derive the canonical workspace root directory.
|
|
12
|
+
*
|
|
13
|
+
* Priority: REBEL_WORKSPACE_PATH env var > process.cwd()
|
|
14
|
+
* The result is always resolved to an absolute path.
|
|
15
|
+
*/
|
|
16
|
+
export function getWorkspaceRoot() {
|
|
17
|
+
const envRoot = process.env.REBEL_WORKSPACE_PATH;
|
|
18
|
+
if (envRoot && envRoot.trim()) {
|
|
19
|
+
return path.resolve(envRoot.trim());
|
|
20
|
+
}
|
|
21
|
+
return path.resolve(process.cwd());
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Safely resolve a user-provided save_path.
|
|
25
|
+
*
|
|
26
|
+
* Security rules:
|
|
27
|
+
* - Rejects paths containing `..` segments (path traversal)
|
|
28
|
+
* - Rejects absolute paths outside the workspace root
|
|
29
|
+
* - Relative paths are resolved against the workspace root
|
|
30
|
+
* - Tilde expansion (~) is NOT allowed — it would escape the workspace boundary
|
|
31
|
+
* - Final path (after extension append) must still be within workspace root
|
|
32
|
+
*
|
|
33
|
+
* Adds appropriate file extension if missing.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveSavePath(savePath, mimeType) {
|
|
36
|
+
const workspaceRoot = getWorkspaceRoot();
|
|
37
|
+
// Reject explicit path traversal via '..' segments
|
|
38
|
+
// Normalise path separators so we catch both `/` and `\` on any OS
|
|
39
|
+
const normalized = savePath.replace(/\\/g, '/');
|
|
40
|
+
// Check for '..' segments in the raw input
|
|
41
|
+
const segments = normalized.split('/');
|
|
42
|
+
if (segments.some((s) => s === '..')) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: 'Path traversal is not allowed: save_path must not contain ".." segments.',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// Reject tilde paths — they resolve to the home directory, which is outside the workspace
|
|
49
|
+
if (savePath.startsWith('~')) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: `Tilde paths are not allowed: save_path must be within the workspace root (${workspaceRoot}). Use a relative path instead.`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
let expanded;
|
|
56
|
+
if (path.isAbsolute(savePath)) {
|
|
57
|
+
expanded = path.resolve(savePath);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// Relative — resolve against workspace root
|
|
61
|
+
expanded = path.resolve(workspaceRoot, savePath);
|
|
62
|
+
}
|
|
63
|
+
// Add file extension if missing
|
|
64
|
+
const defaultExt = MIME_TO_EXT[mimeType] || '.png';
|
|
65
|
+
const finalPath = expanded.match(/\.(png|jpg|jpeg|webp)$/i)
|
|
66
|
+
? expanded
|
|
67
|
+
: `${expanded}${defaultExt}`;
|
|
68
|
+
// Validate the FINAL path (after extension append) is within workspace root
|
|
69
|
+
const canonicalFinal = path.resolve(finalPath);
|
|
70
|
+
if (!canonicalFinal.startsWith(workspaceRoot + path.sep) && canonicalFinal !== workspaceRoot) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: `Path must be within the workspace root (${workspaceRoot}). Got: ${savePath}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, path: canonicalFinal };
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=path-safety.js.map
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export declare const REQUEST_TIMEOUT_MS = 30000;
|
|
2
|
+
export interface BridgeState {
|
|
3
|
+
port: number;
|
|
4
|
+
token: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class NanoBananaError extends Error {
|
|
7
|
+
readonly code: string;
|
|
8
|
+
readonly resolution: string;
|
|
9
|
+
constructor(message: string, code: string, resolution: string);
|
|
10
|
+
}
|
|
11
|
+
export declare const SUPPORTED_MODELS: readonly ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", "gemini-2.5-flash-image"];
|
|
12
|
+
export type SupportedModel = (typeof SUPPORTED_MODELS)[number];
|
|
13
|
+
export declare const DEFAULT_MODEL: SupportedModel;
|
|
14
|
+
export declare const SUPPORTED_ASPECT_RATIOS: readonly ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
|
|
15
|
+
export type SupportedAspectRatio = (typeof SUPPORTED_ASPECT_RATIOS)[number];
|
|
16
|
+
export interface GeminiApiErrorData {
|
|
17
|
+
error?: {
|
|
18
|
+
message?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface GeminiInlineData {
|
|
22
|
+
data: string;
|
|
23
|
+
mimeType?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface GeminiPart {
|
|
26
|
+
inlineData?: GeminiInlineData;
|
|
27
|
+
text?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface GeminiCandidate {
|
|
30
|
+
content?: {
|
|
31
|
+
parts?: GeminiPart[];
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export interface GeminiResponse {
|
|
35
|
+
candidates?: GeminiCandidate[];
|
|
36
|
+
promptFeedback?: {
|
|
37
|
+
blockReason?: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface ImageConfig {
|
|
41
|
+
aspectRatio: string;
|
|
42
|
+
}
|
|
43
|
+
export interface GenerationConfig {
|
|
44
|
+
responseModalities: string[];
|
|
45
|
+
imageConfig?: ImageConfig;
|
|
46
|
+
}
|
|
47
|
+
export declare const SUPPORTED_IMAGE_EXTENSIONS: Record<string, string>;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve an error status code to an actionable resolution string.
|
|
50
|
+
*/
|
|
51
|
+
export declare function getErrorResolution(status: number, detail?: string): string;
|
|
52
|
+
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
2
|
+
export class NanoBananaError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
resolution;
|
|
5
|
+
constructor(message, code, resolution) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.resolution = resolution;
|
|
9
|
+
this.name = 'NanoBananaError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Gemini API types
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
export const SUPPORTED_MODELS = [
|
|
16
|
+
'gemini-3.1-flash-image-preview',
|
|
17
|
+
'gemini-3-pro-image-preview',
|
|
18
|
+
'gemini-2.5-flash-image',
|
|
19
|
+
];
|
|
20
|
+
export const DEFAULT_MODEL = 'gemini-3.1-flash-image-preview';
|
|
21
|
+
export const SUPPORTED_ASPECT_RATIOS = [
|
|
22
|
+
'1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9',
|
|
23
|
+
];
|
|
24
|
+
export const SUPPORTED_IMAGE_EXTENSIONS = {
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.jpg': 'image/jpeg',
|
|
27
|
+
'.jpeg': 'image/jpeg',
|
|
28
|
+
'.webp': 'image/webp',
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Resolve an error status code to an actionable resolution string.
|
|
32
|
+
*/
|
|
33
|
+
export function getErrorResolution(status, detail) {
|
|
34
|
+
const msg = (detail || '').toLowerCase();
|
|
35
|
+
if (status === 401 || status === 403 || msg.includes('invalid') || msg.includes('unauthorized')) {
|
|
36
|
+
return 'Invalid Gemini API key. Check your key at https://aistudio.google.com/api-keys';
|
|
37
|
+
}
|
|
38
|
+
if (status === 429) {
|
|
39
|
+
return 'Rate limit exceeded. Wait a moment and try again.';
|
|
40
|
+
}
|
|
41
|
+
if (msg.includes('safety') || msg.includes('blocked')) {
|
|
42
|
+
return 'Content was blocked by safety filters. Try a different prompt.';
|
|
43
|
+
}
|
|
44
|
+
return 'Please try again. If the issue persists, check your API key at https://aistudio.google.com/api-keys';
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=types.js.map
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
type ToolHandler<T> = (args: T, extra: unknown) => Promise<CallToolResult>;
|
|
3
|
+
/**
|
|
4
|
+
* Wraps a tool handler with standard error handling.
|
|
5
|
+
*
|
|
6
|
+
* - On success: returns the string result as a text content block.
|
|
7
|
+
* - On NanoBananaError: returns a structured JSON error with code and resolution.
|
|
8
|
+
* - On unknown error: returns a generic error message.
|
|
9
|
+
*
|
|
10
|
+
* Secrets are never exposed in error messages.
|
|
11
|
+
*/
|
|
12
|
+
export declare function withErrorHandling<T>(fn: (args: T, extra: unknown) => Promise<string>): ToolHandler<T>;
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=utils.d.ts.map
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NanoBananaError } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Wraps a tool handler with standard error handling.
|
|
4
|
+
*
|
|
5
|
+
* - On success: returns the string result as a text content block.
|
|
6
|
+
* - On NanoBananaError: returns a structured JSON error with code and resolution.
|
|
7
|
+
* - On unknown error: returns a generic error message.
|
|
8
|
+
*
|
|
9
|
+
* Secrets are never exposed in error messages.
|
|
10
|
+
*/
|
|
11
|
+
export function withErrorHandling(fn) {
|
|
12
|
+
return async (args, extra) => {
|
|
13
|
+
try {
|
|
14
|
+
const result = await fn(args, extra);
|
|
15
|
+
return { content: [{ type: 'text', text: result }] };
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof NanoBananaError) {
|
|
19
|
+
return {
|
|
20
|
+
content: [
|
|
21
|
+
{
|
|
22
|
+
type: 'text',
|
|
23
|
+
text: JSON.stringify({
|
|
24
|
+
ok: false,
|
|
25
|
+
error: error.message,
|
|
26
|
+
code: error.code,
|
|
27
|
+
resolution: error.resolution,
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
isError: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: false, error: errorMessage }) }],
|
|
37
|
+
isError: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=utils.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mindstone-engineering/mcp-server-nano-banana",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Nano Banana MCP server — Google Gemini image generation and editing via Model Context Protocol",
|
|
5
|
+
"license": "FSL-1.1-MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-server-nano-banana": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"!dist/**/*.map"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/nspr-io/mcp-servers.git",
|
|
17
|
+
"directory": "connectors/nano-banana"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/nspr-io/mcp-servers/tree/main/connectors/nano-banana",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc && shx chmod +x dist/index.js",
|
|
25
|
+
"prepare": "npm run build",
|
|
26
|
+
"watch": "tsc --watch",
|
|
27
|
+
"start": "node dist/index.js",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"test:coverage": "vitest run --coverage"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
34
|
+
"zod": "^3.23.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@mindstone-engineering/mcp-test-harness": "file:../../test-harness",
|
|
38
|
+
"@types/node": "^22",
|
|
39
|
+
"@vitest/coverage-v8": "^4.1.3",
|
|
40
|
+
"msw": "^2.13.2",
|
|
41
|
+
"shx": "^0.3.4",
|
|
42
|
+
"typescript": "^5.8.2",
|
|
43
|
+
"vitest": "^4.1.3"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
}
|
|
48
|
+
}
|