# Directory Structure
```
├── .cursorignore
├── .gitignore
├── LICENSE
├── package.json
├── README.md
├── src
│ └── index.ts
├── test_cat_image_edited.png
├── test_cat_image.png
├── test_cat_with_moustache.png
├── tsconfig.json
└── yarn.lock
```
# Files
--------------------------------------------------------------------------------
/.cursorignore:
--------------------------------------------------------------------------------
```
1 | node_modules/
2 | dist/
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | node_modules/
2 | .env
3 | /dist
4 | .DS_Store
5 | /dist
6 | /build
7 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # openai-gpt-image-mcp
2 |
3 | <p align="center">
4 | <a href="https://www.npmjs.com/package/@modelcontextprotocol/sdk"><img src="https://img.shields.io/npm/v/@modelcontextprotocol/sdk?label=MCP%20SDK&color=blue" alt="MCP SDK"></a>
5 | <a href="https://www.npmjs.com/package/openai"><img src="https://img.shields.io/npm/v/openai?label=OpenAI%20SDK&color=blueviolet" alt="OpenAI SDK"></a>
6 | <a href="https://github.com/SureScaleAI/openai-gpt-image-mcp/blob/main/LICENSE"><img src="https://img.shields.io/github/license/SureScaleAI/openai-gpt-image-mcp?color=brightgreen" alt="License"></a>
7 | <a href="https://github.com/SureScaleAI/openai-gpt-image-mcp/stargazers"><img src="https://img.shields.io/github/stars/SureScaleAI/openai-gpt-image-mcp?style=social" alt="GitHub stars"></a>
8 | <a href="https://github.com/SureScaleAI/openai-gpt-image-mcp/actions"><img src="https://img.shields.io/github/actions/workflow/status/SureScaleAI/openai-gpt-image-mcp/main.yml?label=build&logo=github" alt="Build Status"></a>
9 | </p>
10 |
11 | ---
12 |
13 | A Model Context Protocol (MCP) tool server for OpenAI's GPT-4o/gpt-image-1 image generation and editing APIs.
14 |
15 | - **Generate images** from text prompts using OpenAI's latest models.
16 | - **Edit images** (inpainting, outpainting, compositing) with advanced prompt control.
17 | - **Supports**: Claude Desktop, Cursor, VSCode, Windsurf, and any MCP-compatible client.
18 |
19 | ---
20 |
21 | ## ✨ Features
22 |
23 | - **create-image**: Generate images from a prompt, with advanced options (size, quality, background, etc).
24 | - **edit-image**: Edit or extend images using a prompt and optional mask, supporting both file paths and base64 input.
25 | - **File output**: Save generated images directly to disk, or receive as base64.
26 |
27 | ---
28 |
29 | ## 🚀 Installation
30 |
31 | ```sh
32 | git clone https://github.com/SureScaleAI/openai-gpt-image-mcp.git
33 | cd openai-gpt-image-mcp
34 | yarn install
35 | yarn build
36 | ```
37 |
38 | ---
39 |
40 | ## 🔑 Configuration
41 |
42 | Add to Claude Desktop or VSCode (including Cursor/Windsurf) config:
43 |
44 | ```json
45 | {
46 | "mcpServers": {
47 | "openai-gpt-image-mcp": {
48 | "command": "node",
49 | "args": ["/absolute/path/to/dist/index.js"],
50 | "env": { "OPENAI_API_KEY": "sk-..." }
51 | }
52 | }
53 | }
54 | ```
55 |
56 | Also supports Azure deployments:
57 |
58 | ```json
59 | {
60 | "mcpServers": {
61 | "openai-gpt-image-mcp": {
62 | "command": "node",
63 | "args": ["/absolute/path/to/dist/index.js"],
64 | "env": {
65 | "AZURE_OPENAI_API_KEY": "sk-...",
66 | "AZURE_OPENAI_ENDPOINT": "my.endpoint.com",
67 | "OPENAI_API_VERSION": "2024-12-01-preview"
68 | }
69 | }
70 | }
71 | }
72 | ```
73 |
74 | Also supports supplying an environment files:
75 |
76 | ```json
77 | {
78 | "mcpServers": {
79 | "openai-gpt-image-mcp": {
80 | "command": "node",
81 | "args": ["/absolute/path/to/dist/index.js", "--env-file", "./deployment/.env"]
82 | }
83 | }
84 | }
85 | ```
86 |
87 | ---
88 |
89 | ## ⚡ Advanced
90 |
91 | - For `create-image`, set `n` to generate up to 10 images at once.
92 | - For `edit-image`, provide a mask image (file path or base64) to control where edits are applied.
93 | - Provide an environment file with `--env-file path/to/file/.env`
94 | - See `src/index.ts` for all options.
95 |
96 | ---
97 |
98 | ## 🧑💻 Development
99 |
100 | - TypeScript source: `src/index.ts`
101 | - Build: `yarn build`
102 | - Run: `node dist/index.js`
103 |
104 | ---
105 |
106 | ## 📝 License
107 |
108 | MIT
109 |
110 | ---
111 |
112 | ## 🩺 Troubleshooting
113 |
114 | - Make sure your `OPENAI_API_KEY` is valid and has image API access.
115 | - You must have a [verified OpenAI organization](https://platform.openai.com/account/organization). After verifying, it can take 15–20 minutes for image API access to activate.
116 | - File paths must be absolute.
117 | - **Unix/macOS/Linux**: Starting with `/` (e.g., `/path/to/image.png`)
118 | - **Windows**: Drive letter followed by `:` (e.g., `C:/path/to/image.png` or `C:\path\to\image.png`)
119 | - For file output, ensure the directory is writable.
120 | - If you see errors about file types, check your image file extensions and formats.
121 |
122 | ---
123 |
124 | ## ⚠️ Limitations & Large File Handling
125 |
126 | - **1MB Payload Limit:** MCP clients (including Claude Desktop) have a hard 1MB limit for tool responses. Large images (especially high-res or multiple images) can easily exceed this limit if returned as base64.
127 | - **Auto-Switch to File Output:** If the total image size exceeds 1MB, the tool will automatically save images to disk and return the file path(s) instead of base64. This ensures compatibility and prevents errors like `result exceeds maximum length of 1048576`.
128 | - **Default File Location:** If you do not specify a `file_output` path, images will be saved to `/tmp` (or the directory set by the `MCP_HF_WORK_DIR` environment variable) with a unique filename.
129 | - **Environment Variable:**
130 | - `MCP_HF_WORK_DIR`: Set this to control where large images and file outputs are saved. Example: `export MCP_HF_WORK_DIR=/your/desired/dir`
131 | - **Best Practice:** For large or production images, always use file output and ensure your client is configured to handle file paths.
132 |
133 | ---
134 |
135 | ## 📚 References
136 |
137 | - [OpenAI Images API Documentation](https://platform.openai.com/docs/api-reference/images)
138 |
139 | ---
140 |
141 | ## 🙏 Credits
142 |
143 | - Built with [@modelcontextprotocol/sdk](https://www.npmjs.com/package/@modelcontextprotocol/sdk)
144 | - Uses [openai](https://www.npmjs.com/package/openai) Node.js SDK
145 | - Built by [SureScale.ai](https://surescale.ai)
146 | - Contributions from [Axle Research and Technology](https://axleinfo.com/)
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "openai-gpt-image-mcp",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "license": "MIT",
6 | "devDependencies": {
7 | "@types/node": "^22.15.2",
8 | "typescript": "^5.8.3"
9 | },
10 | "dependencies": {
11 | "@modelcontextprotocol/sdk": "^1.10.2",
12 | "openai": "^4.96.0"
13 | },
14 | "scripts": {
15 | "build": "tsc"
16 | }
17 | }
18 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
16 | // "jsx": "preserve", /* Specify what JSX code is generated. */
17 | // "libReplacement": true, /* Enable lib replacement. */
18 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
23 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
26 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
27 |
28 | /* Modules */
29 | "module": "commonjs", /* Specify what module code is generated. */
30 | "rootDir": "src", /* Specify the root folder within your source files. */
31 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
32 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
33 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
34 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
35 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
36 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
37 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
38 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
39 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
40 | // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
41 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
42 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
43 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
44 | // "noUncheckedSideEffectImports": true, /* Check side effect imports. */
45 | // "resolveJsonModule": true, /* Enable importing .json files. */
46 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
47 | // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
48 |
49 | /* JavaScript Support */
50 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
51 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
52 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
53 |
54 | /* Emit */
55 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
56 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */
57 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
58 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
59 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
60 | // "noEmit": true, /* Disable emitting files from a compilation. */
61 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
62 | "outDir": "dist", /* Specify an output folder for all emitted files. */
63 | // "removeComments": true, /* Disable emitting comments. */
64 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
65 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
66 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
67 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
68 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
69 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
70 | // "newLine": "crlf", /* Set the newline character for emitting files. */
71 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
72 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
73 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
74 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
75 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
76 |
77 | /* Interop Constraints */
78 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
79 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
80 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
81 | // "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
82 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
83 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
84 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
85 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
86 |
87 | /* Type Checking */
88 | "strict": false, /* Enable all strict type-checking options. */
89 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
90 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
91 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
92 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
93 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
94 | // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
95 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
96 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
97 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
98 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
99 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
100 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
101 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
102 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
103 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
104 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
105 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
106 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
107 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
108 |
109 | /* Completeness */
110 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
111 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
112 | }
113 | }
114 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Suppress all Node.js warnings (including deprecation)
2 | (process as any).emitWarning = () => { };
3 |
4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6 | import { z } from "zod";
7 | import { OpenAI, AzureOpenAI, toFile } from "openai";
8 | import fs from "fs";
9 | import path from "path";
10 |
11 | // Function to load environment variables from a file
12 | const loadEnvFile = (filePath: string) => {
13 | try {
14 | const envConfig = fs.readFileSync(filePath, "utf8");
15 | envConfig.split("\n").forEach((line) => {
16 | const trimmedLine = line.trim();
17 | if (trimmedLine && !trimmedLine.startsWith("#")) {
18 | const [key, ...valueParts] = trimmedLine.split("=");
19 | const value = valueParts.join("=").trim();
20 | if (key) {
21 | // Remove surrounding quotes if present
22 | process.env[key.trim()] = value.startsWith("'") && value.endsWith("'") || value.startsWith("\"") && value.endsWith("\"")
23 | ? value.slice(1, -1)
24 | : value;
25 | }
26 | }
27 | });
28 | console.log(`Loaded environment variables from ${filePath}`);
29 | } catch (error) {
30 | console.warn(`Warning: Could not read environment file at ${filePath}:`, error);
31 | }
32 | };
33 |
34 | // Parse command line arguments for --env-file
35 | const cmdArgs = process.argv.slice(2);
36 | const envFileArgIndex = cmdArgs.findIndex(arg => arg === "--env-file");
37 | if (envFileArgIndex !== -1 && cmdArgs[envFileArgIndex + 1]) {
38 | console.log("Loading environment variables from file:", cmdArgs[envFileArgIndex + 1]);
39 | const envFilePath = cmdArgs[envFileArgIndex + 1];
40 | loadEnvFile(envFilePath);
41 | } else {
42 | console.log("No environment file provided");
43 | }
44 |
45 | (async () => {
46 | const server = new McpServer({
47 | name: "openai-gpt-image-mcp",
48 | version: "1.0.0"
49 | }, {
50 | capabilities: {
51 | tools: { listChanged: false }
52 | }
53 | });
54 |
55 | // Zod schema for create-image tool input
56 | const createImageSchema = z.object({
57 | prompt: z.string().max(32000),
58 | background: z.enum(["transparent", "opaque", "auto"]).optional(),
59 | model: z.literal("gpt-image-1").default("gpt-image-1"),
60 | moderation: z.enum(["auto", "low"]).optional(),
61 | n: z.number().int().min(1).max(10).optional(),
62 | output_compression: z.number().int().min(0).max(100).optional(),
63 | output_format: z.enum(["png", "jpeg", "webp"]).optional(),
64 | quality: z.enum(["auto", "high", "medium", "low"]).optional(),
65 | size: z.enum(["1024x1024", "1536x1024", "1024x1536", "auto"]).optional(),
66 | user: z.string().optional(),
67 | output: z.enum(["base64", "file_output"]).default("base64"),
68 | file_output: z.string().optional().refine(
69 | (val) => {
70 | if (!val) return true;
71 | // Check for Unix/Linux/macOS absolute paths
72 | if (val.startsWith("/")) return true;
73 | // Check for Windows absolute paths (C:/, D:\, etc.)
74 | if (/^[a-zA-Z]:[/\\]/.test(val)) return true;
75 | return false;
76 | },
77 | { message: "file_output must be an absolute path" }
78 | ).describe("Absolute path to save the image file, including the desired file extension (e.g., /path/to/image.png). If multiple images are generated (n > 1), an index will be appended (e.g., /path/to/image_1.png)."),
79 | }).refine(
80 | (data) => {
81 | if (data.output !== "file_output") return true;
82 | if (typeof data.file_output !== "string") return false;
83 | // Check for Unix/Linux/macOS absolute paths
84 | if (data.file_output.startsWith("/")) return true;
85 | // Check for Windows absolute paths (C:/, D:\, etc.)
86 | if (/^[a-zA-Z]:[/\\]/.test(data.file_output)) return true;
87 | return false;
88 | },
89 | { message: "file_output must be an absolute path when output is 'file_output'", path: ["file_output"] }
90 | );
91 |
92 | // Use ._def.schema.shape to get the raw shape for server.tool due to Zod refinements
93 | server.tool(
94 | "create-image",
95 | (createImageSchema as any)._def.schema.shape,
96 | async (args, _extra) => {
97 | // If AZURE_OPENAI_API_KEY is defined, use the AzureOpenAI class
98 | const openai = process.env.AZURE_OPENAI_API_KEY ? new AzureOpenAI() : new OpenAI();
99 |
100 | // Only allow gpt-image-1
101 | const {
102 | prompt,
103 | background,
104 | model = "gpt-image-1",
105 | moderation,
106 | n,
107 | output_compression,
108 | output_format,
109 | quality,
110 | size,
111 | user,
112 | output = "base64",
113 | file_output: file_outputRaw,
114 | } = args;
115 | const file_output: string | undefined = file_outputRaw;
116 |
117 | // Enforce: if background is 'transparent', output_format must be 'png' or 'webp'
118 | if (background === "transparent" && output_format && !["png", "webp"].includes(output_format)) {
119 | throw new Error("If background is 'transparent', output_format must be 'png' or 'webp'");
120 | }
121 |
122 | // Only include output_compression if output_format is webp or jpeg
123 | const imageParams: any = {
124 | prompt,
125 | model,
126 | ...(background ? { background } : {}),
127 | ...(moderation ? { moderation } : {}),
128 | ...(n ? { n } : {}),
129 | ...(output_format ? { output_format } : {}),
130 | ...(quality ? { quality } : {}),
131 | ...(size ? { size } : {}),
132 | ...(user ? { user } : {}),
133 | };
134 | if (
135 | typeof output_compression !== "undefined" &&
136 | output_format &&
137 | ["webp", "jpeg"].includes(output_format)
138 | ) {
139 | imageParams.output_compression = output_compression;
140 | }
141 |
142 | const result = await openai.images.generate(imageParams);
143 |
144 | // gpt-image-1 always returns base64 images in data[].b64_json
145 | const images = (result.data ?? []).map((img: any) => ({
146 | b64: img.b64_json,
147 | mimeType: output_format === "jpeg" ? "image/jpeg" : output_format === "webp" ? "image/webp" : "image/png",
148 | ext: output_format === "jpeg" ? "jpg" : output_format === "webp" ? "webp" : "png",
149 | }));
150 |
151 | // Auto-switch to file_output if total base64 size exceeds 1MB
152 | const MAX_RESPONSE_SIZE = 1048576; // 1MB
153 | const totalBase64Size = images.reduce((sum, img) => sum + Buffer.byteLength(img.b64, "base64"), 0);
154 | let effectiveOutput = output;
155 | let effectiveFileOutput = file_output;
156 | if (output === "base64" && totalBase64Size > MAX_RESPONSE_SIZE) {
157 | effectiveOutput = "file_output";
158 | if (!file_output) {
159 | // Use /tmp or MCP_HF_WORK_DIR if set
160 | const tmpDir = process.env.MCP_HF_WORK_DIR || "/tmp";
161 | const unique = Date.now();
162 | effectiveFileOutput = path.join(tmpDir, `openai_image_${unique}.${images[0]?.ext ?? "png"}`);
163 | }
164 | }
165 |
166 | if (effectiveOutput === "file_output") {
167 | const fs = await import("fs/promises");
168 | const path = await import("path");
169 | // If multiple images, append index to filename
170 | const basePath = effectiveFileOutput!;
171 | const responses = [];
172 | for (let i = 0; i < images.length; i++) {
173 | const img = images[i];
174 | let filePath = basePath;
175 | if (images.length > 1) {
176 | const parsed = path.parse(basePath);
177 | filePath = path.join(parsed.dir, `${parsed.name}_${i + 1}.${img.ext ?? "png"}`);
178 | } else {
179 | // Ensure correct extension
180 | const parsed = path.parse(basePath);
181 | filePath = path.join(parsed.dir, `${parsed.name}.${img.ext ?? "png"}`);
182 | }
183 | await fs.writeFile(filePath, Buffer.from(img.b64, "base64"));
184 | responses.push({ type: "text", text: `Image saved to: file://${filePath}` });
185 | }
186 | return { content: responses };
187 | } else {
188 | // Default: base64
189 | return {
190 | content: images.map((img) => ({
191 | type: "image",
192 | data: img.b64,
193 | mimeType: img.mimeType,
194 | })),
195 | };
196 | }
197 | }
198 | );
199 |
200 | // Zod schema for edit-image tool input (gpt-image-1 only)
201 | const absolutePathCheck = (val: string | undefined) => {
202 | if (!val) return true;
203 | // Check for Unix/Linux/macOS absolute paths
204 | if (val.startsWith("/")) return true;
205 | // Check for Windows absolute paths (C:/, D:\, etc.)
206 | if (/^[a-zA-Z]:[/\\]/.test(val)) return true;
207 | return false;
208 | };
209 | const base64Check = (val: string | undefined) => !!val && (/^([A-Za-z0-9+/=\r\n]+)$/.test(val) || val.startsWith("data:image/"));
210 | const imageInputSchema = z.string().refine(
211 | (val) => absolutePathCheck(val) || base64Check(val),
212 | { message: "Must be an absolute path or a base64-encoded string (optionally as a data URL)" }
213 | ).describe("Absolute path to an image file (png, jpg, webp < 25MB) or a base64-encoded image string.");
214 |
215 | // Base schema without refinement for server.tool signature
216 | const editImageBaseSchema = z.object({
217 | image: z.string().describe("Absolute image path or base64 string to edit."),
218 | prompt: z.string().max(32000).describe("A text description of the desired edit. Max 32000 chars."),
219 | mask: z.string().optional().describe("Optional absolute path or base64 string for a mask image (png < 4MB, same dimensions as the first image). Fully transparent areas indicate where to edit."),
220 | model: z.literal("gpt-image-1").default("gpt-image-1"),
221 | n: z.number().int().min(1).max(10).optional().describe("Number of images to generate (1-10)."),
222 | quality: z.enum(["auto", "high", "medium", "low"]).optional().describe("Quality (high, medium, low) - only for gpt-image-1."),
223 | size: z.enum(["1024x1024", "1536x1024", "1024x1536", "auto"]).optional().describe("Size of the generated images."),
224 | user: z.string().optional().describe("Optional user identifier for OpenAI monitoring."),
225 | output: z.enum(["base64", "file_output"]).default("base64").describe("Output format: base64 or file path."),
226 | file_output: z.string().refine(absolutePathCheck, { message: "Path must be absolute" }).optional()
227 | .describe("Absolute path to save the output image file, including the desired file extension (e.g., /path/to/image.png). If n > 1, an index is appended."),
228 | });
229 |
230 | // Full schema with refinement for validation inside the handler
231 | const editImageSchema = editImageBaseSchema.refine(
232 | (data) => {
233 | if (data.output !== "file_output") return true;
234 | if (typeof data.file_output !== "string") return false;
235 | return absolutePathCheck(data.file_output);
236 | },
237 | { message: "file_output must be an absolute path when output is 'file_output'", path: ["file_output"] }
238 | );
239 |
240 | // Edit Image Tool (gpt-image-1 only)
241 | server.tool(
242 | "edit-image",
243 | editImageBaseSchema.shape, // <-- Use the base schema shape here
244 | async (args, _extra) => {
245 | // Validate arguments using the full schema with refinements
246 | const validatedArgs = editImageSchema.parse(args);
247 |
248 | // Explicitly validate image and mask inputs here
249 | if (!absolutePathCheck(validatedArgs.image) && !base64Check(validatedArgs.image)) {
250 | throw new Error("Invalid 'image' input: Must be an absolute path or a base64-encoded string.");
251 | }
252 | if (validatedArgs.mask && !absolutePathCheck(validatedArgs.mask) && !base64Check(validatedArgs.mask)) {
253 | throw new Error("Invalid 'mask' input: Must be an absolute path or a base64-encoded string.");
254 | }
255 |
256 | const openai = process.env.AZURE_OPENAI_API_KEY ? new AzureOpenAI() : new OpenAI();
257 | const {
258 | image: imageInput,
259 | prompt,
260 | mask: maskInput,
261 | model = "gpt-image-1",
262 | n,
263 | quality,
264 | size,
265 | user,
266 | output = "base64",
267 | file_output: file_outputRaw,
268 | } = validatedArgs; // <-- Use validatedArgs here
269 | const file_output: string | undefined = file_outputRaw;
270 |
271 | // Helper to convert input (path or base64) to toFile
272 | async function inputToFile(input: string, idx = 0) {
273 | if (absolutePathCheck(input)) {
274 | // File path: infer mime type from extension
275 | const ext = input.split('.').pop()?.toLowerCase();
276 | let mime = "image/png";
277 | if (ext === "jpg" || ext === "jpeg") mime = "image/jpeg";
278 | else if (ext === "webp") mime = "image/webp";
279 | else if (ext === "png") mime = "image/png";
280 | // else default to png
281 | return await toFile(fs.createReadStream(input), undefined, { type: mime });
282 | } else {
283 | // Base64 or data URL
284 | let base64 = input;
285 | let mime = "image/png";
286 | if (input.startsWith("data:image/")) {
287 | // data URL
288 | const match = input.match(/^data:(image\/\w+);base64,(.*)$/);
289 | if (match) {
290 | mime = match[1];
291 | base64 = match[2];
292 | }
293 | }
294 | const buffer = Buffer.from(base64, "base64");
295 | return await toFile(buffer, `input_${idx}.${mime.split("/")[1] || "png"}`, { type: mime });
296 | }
297 | }
298 |
299 | // Prepare image input
300 | const imageFile = await inputToFile(imageInput, 0);
301 |
302 | // Prepare mask input
303 | const maskFile = maskInput ? await inputToFile(maskInput, 1) : undefined;
304 |
305 | // Construct parameters for OpenAI API
306 | const editParams: any = {
307 | image: imageFile,
308 | prompt,
309 | model, // Always gpt-image-1
310 | ...(maskFile ? { mask: maskFile } : {}),
311 | ...(n ? { n } : {}),
312 | ...(quality ? { quality } : {}),
313 | ...(size ? { size } : {}),
314 | ...(user ? { user } : {}),
315 | // response_format is not applicable for gpt-image-1 (always b64_json)
316 | };
317 |
318 | const result = await openai.images.edit(editParams);
319 |
320 | // gpt-image-1 always returns base64 images in data[].b64_json
321 | // We need to determine the output mime type and extension based on input/defaults
322 | // Since OpenAI doesn't return this for edits, we'll default to png
323 | const images = (result.data ?? []).map((img: any) => ({
324 | b64: img.b64_json,
325 | mimeType: "image/png",
326 | ext: "png",
327 | }));
328 |
329 | // Auto-switch to file_output if total base64 size exceeds 1MB
330 | const MAX_RESPONSE_SIZE = 1048576; // 1MB
331 | const totalBase64Size = images.reduce((sum, img) => sum + Buffer.byteLength(img.b64, "base64"), 0);
332 | let effectiveOutput = output;
333 | let effectiveFileOutput = file_output;
334 | if (output === "base64" && totalBase64Size > MAX_RESPONSE_SIZE) {
335 | effectiveOutput = "file_output";
336 | if (!file_output) {
337 | // Use /tmp or MCP_HF_WORK_DIR if set
338 | const tmpDir = process.env.MCP_HF_WORK_DIR || "/tmp";
339 | const unique = Date.now();
340 | effectiveFileOutput = path.join(tmpDir, `openai_image_edit_${unique}.png`);
341 | }
342 | }
343 |
344 | if (effectiveOutput === "file_output") {
345 | if (!effectiveFileOutput) {
346 | throw new Error("file_output path is required when output is 'file_output'");
347 | }
348 | // Use fs/promises and path (already imported)
349 | const basePath = effectiveFileOutput!;
350 | const responses = [];
351 | for (let i = 0; i < images.length; i++) {
352 | const img = images[i];
353 | let filePath = basePath;
354 | if (images.length > 1) {
355 | const parsed = path.parse(basePath);
356 | // Append index before the original extension if it exists, otherwise just append index and .png
357 | const ext = parsed.ext || `.${img.ext}`;
358 | filePath = path.join(parsed.dir, `${parsed.name}_${i + 1}${ext}`);
359 | } else {
360 | // Ensure the extension from the path is used, or default to .png
361 | const parsed = path.parse(basePath);
362 | const ext = parsed.ext || `.${img.ext}`;
363 | filePath = path.join(parsed.dir, `${parsed.name}${ext}`);
364 | }
365 | await fs.promises.writeFile(filePath, Buffer.from(img.b64, "base64"));
366 | // Workaround: Return file path as text
367 | responses.push({ type: "text", text: `Image saved to: file://${filePath}` });
368 | }
369 | return { content: responses };
370 | } else {
371 | // Default: base64
372 | return {
373 | content: images.map((img) => ({
374 | type: "image",
375 | data: img.b64,
376 | mimeType: img.mimeType, // Should be image/png
377 | })),
378 | };
379 | }
380 | }
381 | );
382 |
383 | const transport = new StdioServerTransport();
384 | await server.connect(transport);
385 | })();
```