@alonetech/gpt-image-mcp 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alonetech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # @alonetech/gpt-image-mcp
2
+
3
+ MCP stdio server and CLI for OpenAI-compatible image generation APIs.
4
+
5
+ It supports:
6
+
7
+ - Text-to-image: prompt only
8
+ - Image-to-image: prompt plus one or more reference images
9
+ - MCP stdio mode for MCP clients
10
+ - Direct CLI mode for local testing
11
+
12
+ ## Requirements
13
+
14
+ - Node.js 18.17 or newer
15
+ - An OpenAI-compatible image API `base_url`
16
+ - A bearer token for that API
17
+
18
+ ## CLI
19
+
20
+ Show help:
21
+
22
+ ```bash
23
+ npx @alonetech/gpt-image-mcp --help
24
+ ```
25
+
26
+ Text-to-image:
27
+
28
+ ```bash
29
+ npx @alonetech/gpt-image-mcp generate \
30
+ --base-url "https://api.openai.com/v1" \
31
+ --token "YOUR_TOKEN" \
32
+ --prompt "An orange cat wearing a space suit, movie poster style" \
33
+ --output "./cat.png" \
34
+ --size "1024x1024" \
35
+ --quality "high" \
36
+ --format "png"
37
+ ```
38
+
39
+ Image-to-image:
40
+
41
+ ```bash
42
+ npx @alonetech/gpt-image-mcp generate \
43
+ --base-url "https://api.openai.com/v1" \
44
+ --token "YOUR_TOKEN" \
45
+ --prompt "Turn the references into a clean product image" \
46
+ --image "./front.png" \
47
+ --image "./side.jpg" \
48
+ --output "./product.png"
49
+ ```
50
+
51
+ On PowerShell, use backticks instead of backslashes for line continuation.
52
+
53
+ ## MCP Usage
54
+
55
+ Example MCP server config:
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "gpt-image": {
61
+ "command": "npx",
62
+ "args": [
63
+ "-y",
64
+ "@alonetech/gpt-image-mcp",
65
+ "--base-url",
66
+ "https://api.openai.com/v1",
67
+ "--token",
68
+ "YOUR_TOKEN"
69
+ ]
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ For debugging client startup, add:
76
+
77
+ ```json
78
+ "--debug-log",
79
+ "D:\\tmp\\gpt-image-mcp-debug.log"
80
+ ```
81
+
82
+ ## MCP Tool
83
+
84
+ Tool name:
85
+
86
+ ```text
87
+ generate_image
88
+ ```
89
+
90
+ Required arguments:
91
+
92
+ - `prompt`: prompt describing the output image
93
+
94
+ Optional arguments:
95
+
96
+ - `images`: reference images as base64 strings or data URLs. Omit or pass an empty array for text-to-image mode.
97
+ - `model`: defaults to `gpt-image-2`
98
+ - `size`: defaults to `auto`
99
+ - `quality`: `auto`, `low`, `medium`, or `high`
100
+ - `background`: `auto`, `transparent`, or `opaque`
101
+
102
+ `base_url` and `token` are server startup configuration, not per-tool-call arguments.
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ npm test
108
+ npm run pack:dry-run
109
+ ```
110
+
111
+ Create a local package tarball:
112
+
113
+ ```bash
114
+ npm pack
115
+ ```
116
+
117
+ Test the tarball:
118
+
119
+ ```bash
120
+ npx ./alonetech-gpt-image-mcp-0.1.0.tgz --help
121
+ ```
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../src/cli.js";
3
+ import { runStdioServer } from "../src/mcp-server.js";
4
+
5
+ if (process.argv[2] === "generate" || process.argv[2] === "--help" || process.argv[2] === "-h") {
6
+ const exitCode = await runCli(process.argv.slice(2));
7
+ process.exitCode = exitCode;
8
+ } else {
9
+ runStdioServer().catch((error) => {
10
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
11
+ process.exitCode = 1;
12
+ });
13
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@alonetech/gpt-image-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP stdio server and CLI for text-to-image and image-to-image generation.",
5
+ "type": "module",
6
+ "bin": {
7
+ "gpt-image-mcp": "./bin/gpt-image-mcp.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test",
11
+ "pack:dry-run": "npm pack --dry-run"
12
+ },
13
+ "engines": {
14
+ "node": ">=18.17"
15
+ },
16
+ "files": [
17
+ "bin",
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "keywords": [
23
+ "mcp",
24
+ "gpt-image",
25
+ "image-generation",
26
+ "text-to-image",
27
+ "image-to-image"
28
+ ],
29
+ "author": "alonetech",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/alonetech/gpt-image-mcp.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/alonetech/gpt-image-mcp/issues"
37
+ },
38
+ "homepage": "https://github.com/alonetech/gpt-image-mcp#readme",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
package/src/cli.js ADDED
@@ -0,0 +1,188 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+
4
+ import { generateImage } from "./image-api.js";
5
+
6
+ const DEFAULT_OUTPUT = "output.png";
7
+
8
+ function usage() {
9
+ return `Usage:
10
+ gpt-image-mcp generate --base-url <url> --token <token> --prompt <text> [options]
11
+
12
+ Options:
13
+ --image <path> Optional input image. Repeat for image-to-image mode.
14
+ --output <path> Output image path. Default: ${DEFAULT_OUTPUT}
15
+ --model <model> Image model. Default: gpt-image-2
16
+ --size <size> Image size. Default: auto
17
+ --quality <quality> auto, low, medium, or high. Default: auto
18
+ --format <format> png, jpeg, or webp. Default: png
19
+ `;
20
+ }
21
+
22
+ function readOption(args, index) {
23
+ const value = args[index + 1];
24
+ if (value === undefined || value.startsWith("--")) {
25
+ throw new Error(`Missing value for ${args[index]}`);
26
+ }
27
+
28
+ return value;
29
+ }
30
+
31
+ function parseGenerateArgs(args) {
32
+ const options = {
33
+ images: [],
34
+ output: DEFAULT_OUTPUT,
35
+ output_format: "png",
36
+ };
37
+
38
+ for (let index = 0; index < args.length; index += 1) {
39
+ const arg = args[index];
40
+
41
+ switch (arg) {
42
+ case "--base-url":
43
+ options.baseUrl = readOption(args, index);
44
+ index += 1;
45
+ break;
46
+ case "--token":
47
+ options.token = readOption(args, index);
48
+ index += 1;
49
+ break;
50
+ case "--prompt":
51
+ options.prompt = readOption(args, index);
52
+ index += 1;
53
+ break;
54
+ case "--image":
55
+ options.images.push(readOption(args, index));
56
+ index += 1;
57
+ break;
58
+ case "--output":
59
+ options.output = readOption(args, index);
60
+ index += 1;
61
+ break;
62
+ case "--model":
63
+ options.model = readOption(args, index);
64
+ index += 1;
65
+ break;
66
+ case "--size":
67
+ options.size = readOption(args, index);
68
+ index += 1;
69
+ break;
70
+ case "--quality":
71
+ options.quality = readOption(args, index);
72
+ index += 1;
73
+ break;
74
+ case "--format":
75
+ options.output_format = readOption(args, index);
76
+ index += 1;
77
+ break;
78
+ case "--help":
79
+ case "-h":
80
+ options.help = true;
81
+ break;
82
+ default:
83
+ throw new Error(`Unknown option: ${arg}`);
84
+ }
85
+ }
86
+
87
+ return options;
88
+ }
89
+
90
+ function requireOption(options, key, flagName) {
91
+ if (!options[key]) {
92
+ throw new Error(`Missing required option: ${flagName}`);
93
+ }
94
+ }
95
+
96
+ function mimeTypeForPath(path) {
97
+ const lower = path.toLowerCase();
98
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
99
+ return "image/jpeg";
100
+ }
101
+ if (lower.endsWith(".webp")) {
102
+ return "image/webp";
103
+ }
104
+ if (lower.endsWith(".gif")) {
105
+ return "image/gif";
106
+ }
107
+ return "image/png";
108
+ }
109
+
110
+ async function imagesFromPaths(paths, readFileImpl) {
111
+ const images = [];
112
+
113
+ for (const path of paths) {
114
+ const bytes = await readFileImpl(path);
115
+ images.push({
116
+ data: Buffer.from(bytes).toString("base64"),
117
+ mime_type: mimeTypeForPath(path),
118
+ name: basename(path),
119
+ });
120
+ }
121
+
122
+ return images;
123
+ }
124
+
125
+ async function runGenerate(args, dependencies) {
126
+ const options = parseGenerateArgs(args);
127
+ const stdout = dependencies.stdout || process.stdout;
128
+
129
+ if (options.help) {
130
+ stdout.write(usage());
131
+ return 0;
132
+ }
133
+
134
+ requireOption(options, "baseUrl", "--base-url");
135
+ requireOption(options, "token", "--token");
136
+ requireOption(options, "prompt", "--prompt");
137
+
138
+ const input = {
139
+ prompt: options.prompt,
140
+ model: options.model,
141
+ size: options.size,
142
+ quality: options.quality,
143
+ output_format: options.output_format,
144
+ };
145
+
146
+ if (options.images.length > 0) {
147
+ input.images = await imagesFromPaths(options.images, dependencies.readFile || readFile);
148
+ }
149
+
150
+ for (const key of Object.keys(input)) {
151
+ if (input[key] === undefined) {
152
+ delete input[key];
153
+ }
154
+ }
155
+
156
+ const result = await (dependencies.generateImage || generateImage)(input, {
157
+ baseUrl: options.baseUrl,
158
+ token: options.token,
159
+ });
160
+
161
+ const bytes = Buffer.from(result.data, "base64");
162
+ await (dependencies.writeFile || writeFile)(options.output, bytes);
163
+ stdout.write(`Saved ${result.mode} image to ${options.output}\n`);
164
+ return 0;
165
+ }
166
+
167
+ export async function runCli(args = process.argv.slice(2), dependencies = {}) {
168
+ const stderr = dependencies.stderr || process.stderr;
169
+ const stdout = dependencies.stdout || process.stdout;
170
+ const [command, ...rest] = args;
171
+
172
+ try {
173
+ if (!command || command === "--help" || command === "-h") {
174
+ stdout.write(usage());
175
+ return 0;
176
+ }
177
+
178
+ if (command !== "generate") {
179
+ stderr.write(`Unknown command: ${command}\n${usage()}`);
180
+ return 2;
181
+ }
182
+
183
+ return await runGenerate(rest, dependencies);
184
+ } catch (error) {
185
+ stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
186
+ return 2;
187
+ }
188
+ }
@@ -0,0 +1,182 @@
1
+ const DEFAULT_MODEL = "gpt-image-2";
2
+ const DEFAULT_OUTPUT_FORMAT = "png";
3
+
4
+ function requireString(value, fieldName) {
5
+ if (typeof value !== "string" || value.trim() === "") {
6
+ throw new Error(`${fieldName} is required`);
7
+ }
8
+
9
+ return value.trim();
10
+ }
11
+
12
+ function normalizeBase64Image(data) {
13
+ const match = data.match(/^data:(?<mime>image\/[a-z0-9.+-]+);base64,(?<body>.+)$/i);
14
+
15
+ if (match?.groups) {
16
+ return {
17
+ data: match.groups.body,
18
+ mimeType: match.groups.mime.toLowerCase(),
19
+ };
20
+ }
21
+
22
+ return {
23
+ data,
24
+ mimeType: undefined,
25
+ };
26
+ }
27
+
28
+ function extensionForMimeType(mimeType) {
29
+ if (mimeType === "image/jpeg") {
30
+ return "jpg";
31
+ }
32
+
33
+ return mimeType.split("/")[1] || "png";
34
+ }
35
+
36
+ function imageBlobFromInput(image, index) {
37
+ if (!image || typeof image !== "object") {
38
+ throw new Error(`images[${index}] must be an object`);
39
+ }
40
+
41
+ const rawData = requireString(image.data, `images[${index}].data`);
42
+ const normalized = normalizeBase64Image(rawData);
43
+ const mimeType = image.mime_type || image.mimeType || normalized.mimeType || "image/png";
44
+
45
+ if (!String(mimeType).startsWith("image/")) {
46
+ throw new Error(`images[${index}].mime_type must be an image MIME type`);
47
+ }
48
+
49
+ const buffer = Buffer.from(normalized.data, "base64");
50
+ if (buffer.length === 0) {
51
+ throw new Error(`images[${index}].data must contain base64 image bytes`);
52
+ }
53
+
54
+ const extension = extensionForMimeType(mimeType);
55
+ const filename = image.name || image.filename || `image-${index + 1}.${extension}`;
56
+
57
+ return {
58
+ blob: new Blob([buffer], { type: mimeType }),
59
+ filename,
60
+ };
61
+ }
62
+
63
+ function buildEndpoint(baseUrl, path) {
64
+ return new URL(path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
65
+ }
66
+
67
+ function appendIfPresent(form, key, value) {
68
+ if (value !== undefined && value !== null && value !== "") {
69
+ form.set(key, String(value));
70
+ }
71
+ }
72
+
73
+ async function parseErrorResponse(response) {
74
+ const contentType = response.headers?.get?.("content-type") || "";
75
+
76
+ if (contentType.includes("application/json")) {
77
+ const body = await response.json().catch(() => undefined);
78
+ return body?.error?.message || body?.message || JSON.stringify(body);
79
+ }
80
+
81
+ return response.text ? response.text() : response.statusText;
82
+ }
83
+
84
+ function mimeTypeForOutputFormat(outputFormat) {
85
+ return `image/${outputFormat === "jpg" ? "jpeg" : outputFormat}`;
86
+ }
87
+
88
+ function commonOptions(input, outputFormat) {
89
+ return {
90
+ model: input.model || DEFAULT_MODEL,
91
+ prompt: requireString(input?.prompt, "prompt"),
92
+ n: 1,
93
+ size: input.size || "auto",
94
+ quality: input.quality || "auto",
95
+ output_format: outputFormat,
96
+ background: input.background,
97
+ };
98
+ }
99
+
100
+ function resultFromResponseBody(body, outputFormat, model, mode) {
101
+ const data = body?.data?.[0]?.b64_json;
102
+
103
+ if (!data) {
104
+ throw new Error("Image API response did not include base64 image data");
105
+ }
106
+
107
+ return {
108
+ data,
109
+ mimeType: mimeTypeForOutputFormat(outputFormat),
110
+ model,
111
+ mode,
112
+ };
113
+ }
114
+
115
+ export async function generateImage(input, config = {}) {
116
+ const fetchImpl = config.fetchImpl || globalThis.fetch;
117
+
118
+ if (typeof fetchImpl !== "function") {
119
+ throw new Error("fetch is not available in this Node runtime");
120
+ }
121
+
122
+ const baseUrl = requireString(config.baseUrl || config.base_url, "base_url");
123
+ const token = requireString(config.token, "token");
124
+ const images = input?.images;
125
+ const outputFormat = input.output_format || input.outputFormat || DEFAULT_OUTPUT_FORMAT;
126
+ const options = commonOptions(input, outputFormat);
127
+
128
+ if (!Array.isArray(images) || images.length === 0) {
129
+ const { background, ...body } = options;
130
+ if (background) {
131
+ body.background = background;
132
+ }
133
+
134
+ const response = await fetchImpl(buildEndpoint(baseUrl, "images/generations"), {
135
+ method: "POST",
136
+ headers: {
137
+ Authorization: `Bearer ${token}`,
138
+ "Content-Type": "application/json",
139
+ },
140
+ body: JSON.stringify(body),
141
+ });
142
+
143
+ if (!response.ok) {
144
+ const message = await parseErrorResponse(response);
145
+ throw new Error(`Image API request failed (${response.status} ${response.statusText}): ${message}`);
146
+ }
147
+
148
+ return resultFromResponseBody(await response.json(), outputFormat, options.model, "text");
149
+ }
150
+
151
+ const form = new FormData();
152
+
153
+ form.set("model", options.model);
154
+ form.set("prompt", options.prompt);
155
+ form.set("n", String(options.n));
156
+ appendIfPresent(form, "size", options.size);
157
+ appendIfPresent(form, "quality", options.quality);
158
+ appendIfPresent(form, "output_format", options.output_format);
159
+ appendIfPresent(form, "background", options.background);
160
+
161
+ images.forEach((image, index) => {
162
+ const { blob, filename } = imageBlobFromInput(image, index);
163
+ form.append("image", blob, filename);
164
+ });
165
+
166
+ const response = await fetchImpl(buildEndpoint(baseUrl, "images/edits"), {
167
+ method: "POST",
168
+ headers: {
169
+ Authorization: `Bearer ${token}`,
170
+ },
171
+ body: form,
172
+ });
173
+
174
+ if (!response.ok) {
175
+ const message = await parseErrorResponse(response);
176
+ throw new Error(`Image API request failed (${response.status} ${response.statusText}): ${message}`);
177
+ }
178
+
179
+ return resultFromResponseBody(await response.json(), outputFormat, options.model, "edit");
180
+ }
181
+
182
+ export const generateEditedImage = generateImage;
@@ -0,0 +1,416 @@
1
+ import { appendFileSync } from "node:fs";
2
+
3
+ import { generateImage } from "./image-api.js";
4
+
5
+ export const TOOL_NAME = "generate_image";
6
+
7
+ const JSONRPC_VERSION = "2.0";
8
+
9
+ const TOOL_DEFINITION = {
10
+ name: TOOL_NAME,
11
+ description:
12
+ "Generate one image in text-to-image mode from a prompt only, or in image-to-image mode from a prompt plus optional reference images.",
13
+ inputSchema: {
14
+ type: "object",
15
+ required: ["prompt"],
16
+ properties: {
17
+ prompt: {
18
+ type: "string",
19
+ description: "Prompt describing the single output image to generate.",
20
+ },
21
+ images: {
22
+ type: "array",
23
+ minItems: 0,
24
+ description:
25
+ "Optional reference images as base64 strings or data URLs. Omit this field or pass an empty array for text-to-image mode; provide one or more images for image-to-image mode.",
26
+ items: {
27
+ type: "object",
28
+ required: ["data"],
29
+ properties: {
30
+ data: {
31
+ type: "string",
32
+ description: "Base64 image data, optionally as a data:image/...;base64,... URL.",
33
+ },
34
+ mime_type: {
35
+ type: "string",
36
+ description: "Image MIME type. Defaults to image/png when omitted.",
37
+ },
38
+ name: {
39
+ type: "string",
40
+ description: "Optional filename sent in the multipart request.",
41
+ },
42
+ },
43
+ },
44
+ },
45
+ model: {
46
+ type: "string",
47
+ default: "gpt-image-2",
48
+ },
49
+ size: {
50
+ type: "string",
51
+ default: "auto",
52
+ },
53
+ quality: {
54
+ type: "string",
55
+ enum: ["auto", "low", "medium", "high"],
56
+ default: "auto",
57
+ },
58
+ background: {
59
+ type: "string",
60
+ enum: ["auto", "transparent", "opaque"],
61
+ },
62
+ },
63
+ },
64
+ };
65
+
66
+ function success(id, result) {
67
+ return {
68
+ jsonrpc: JSONRPC_VERSION,
69
+ id,
70
+ result,
71
+ };
72
+ }
73
+
74
+ function failure(id, code, message) {
75
+ return {
76
+ jsonrpc: JSONRPC_VERSION,
77
+ id,
78
+ error: {
79
+ code,
80
+ message,
81
+ },
82
+ };
83
+ }
84
+
85
+ function valueAfterFlag(argv, flagName) {
86
+ const equalsPrefix = `${flagName}=`;
87
+ const inline = argv.find((value) => value.startsWith(equalsPrefix));
88
+ if (inline) {
89
+ return inline.slice(equalsPrefix.length);
90
+ }
91
+
92
+ const flagIndex = argv.indexOf(flagName);
93
+ if (flagIndex >= 0) {
94
+ return argv[flagIndex + 1];
95
+ }
96
+
97
+ return undefined;
98
+ }
99
+
100
+ export function createServerConfig({ argv = process.argv, env = process.env } = {}) {
101
+ const config = {
102
+ baseUrl:
103
+ valueAfterFlag(argv, "--base-url") ||
104
+ env.GPT_IMAGE_BASE_URL ||
105
+ env.GPT_IMAGE_BASEURL,
106
+ token: valueAfterFlag(argv, "--token") || env.GPT_IMAGE_TOKEN,
107
+ };
108
+ const debugLog = valueAfterFlag(argv, "--debug-log") || env.GPT_IMAGE_DEBUG_LOG;
109
+
110
+ if (debugLog) {
111
+ config.debugLog = debugLog;
112
+ }
113
+
114
+ return config;
115
+ }
116
+
117
+ function createDebugLogger(debugLog) {
118
+ if (!debugLog) {
119
+ return () => {};
120
+ }
121
+
122
+ return (event, data = {}) => {
123
+ const entry = {
124
+ time: new Date().toISOString(),
125
+ event,
126
+ ...data,
127
+ };
128
+ appendFileSync(debugLog, `${JSON.stringify(entry)}\n`, "utf8");
129
+ };
130
+ }
131
+
132
+ function framedMessage(message) {
133
+ const body = JSON.stringify(message);
134
+ return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
135
+ }
136
+
137
+ function rawMessage(message) {
138
+ return `${JSON.stringify(message)}\n`;
139
+ }
140
+
141
+ function findHeaderSeparator(buffer) {
142
+ const crlfIndex = buffer.indexOf("\r\n\r\n");
143
+ if (crlfIndex >= 0) {
144
+ return {
145
+ index: crlfIndex,
146
+ length: 4,
147
+ };
148
+ }
149
+
150
+ const lfIndex = buffer.indexOf("\n\n");
151
+ if (lfIndex >= 0) {
152
+ return {
153
+ index: lfIndex,
154
+ length: 2,
155
+ };
156
+ }
157
+
158
+ return undefined;
159
+ }
160
+
161
+ function parseFirstRawJsonMessage(buffer) {
162
+ const text = buffer.toString("utf8");
163
+ const start = text.search(/\S/);
164
+
165
+ if (start < 0 || text[start] !== "{") {
166
+ return undefined;
167
+ }
168
+
169
+ let depth = 0;
170
+ let inString = false;
171
+ let escaped = false;
172
+
173
+ for (let index = start; index < text.length; index += 1) {
174
+ const char = text[index];
175
+
176
+ if (escaped) {
177
+ escaped = false;
178
+ continue;
179
+ }
180
+
181
+ if (char === "\\") {
182
+ escaped = true;
183
+ continue;
184
+ }
185
+
186
+ if (char === '"') {
187
+ inString = !inString;
188
+ continue;
189
+ }
190
+
191
+ if (inString) {
192
+ continue;
193
+ }
194
+
195
+ if (char === "{") {
196
+ depth += 1;
197
+ } else if (char === "}") {
198
+ depth -= 1;
199
+ if (depth === 0) {
200
+ const body = text.slice(start, index + 1);
201
+ return {
202
+ message: JSON.parse(body),
203
+ remaining: Buffer.from(text.slice(index + 1), "utf8"),
204
+ };
205
+ }
206
+ }
207
+ }
208
+
209
+ return undefined;
210
+ }
211
+
212
+ async function* readFramedMessages(input, debug = () => {}) {
213
+ let buffer = Buffer.alloc(0);
214
+
215
+ for await (const chunk of input) {
216
+ const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
217
+ debug("stdin_chunk", {
218
+ bytes: chunkBuffer.length,
219
+ });
220
+ buffer = Buffer.concat([buffer, chunkBuffer]);
221
+
222
+ while (true) {
223
+ const separator = findHeaderSeparator(buffer);
224
+ if (!separator) {
225
+ const raw = parseFirstRawJsonMessage(buffer);
226
+ if (raw) {
227
+ debug("raw_json_complete", {
228
+ bufferedBytes: buffer.length,
229
+ remainingBytes: raw.remaining.length,
230
+ });
231
+ buffer = raw.remaining;
232
+ yield {
233
+ message: raw.message,
234
+ transport: "raw",
235
+ };
236
+ continue;
237
+ }
238
+
239
+ debug("frame_waiting_for_headers", {
240
+ bufferedBytes: buffer.length,
241
+ });
242
+ break;
243
+ }
244
+
245
+ const headers = buffer.slice(0, separator.index).toString("utf8");
246
+ debug("frame_headers", {
247
+ bytes: separator.index,
248
+ headers: headers.replaceAll("\r", "\\r").replaceAll("\n", "\\n"),
249
+ });
250
+ const lengthMatch = headers.match(/^Content-Length:\s*(\d+)$/im);
251
+
252
+ if (!lengthMatch) {
253
+ throw new Error("Missing Content-Length header");
254
+ }
255
+
256
+ const contentLength = Number.parseInt(lengthMatch[1], 10);
257
+ const bodyStart = separator.index + separator.length;
258
+ const bodyEnd = bodyStart + contentLength;
259
+
260
+ if (buffer.length < bodyEnd) {
261
+ debug("frame_waiting_for_body", {
262
+ contentLength,
263
+ bufferedBodyBytes: buffer.length - bodyStart,
264
+ });
265
+ break;
266
+ }
267
+
268
+ const body = buffer.slice(bodyStart, bodyEnd).toString("utf8");
269
+ buffer = buffer.slice(bodyEnd);
270
+ debug("frame_body_complete", {
271
+ contentLength,
272
+ remainingBytes: buffer.length,
273
+ });
274
+ yield {
275
+ message: JSON.parse(body),
276
+ transport: "framed",
277
+ };
278
+ }
279
+ }
280
+
281
+ const trailing = buffer.toString("utf8").trim();
282
+ if (trailing) {
283
+ debug("raw_json_fallback", {
284
+ bufferedBytes: buffer.length,
285
+ });
286
+
287
+ for (const line of trailing.split(/\r?\n/)) {
288
+ const trimmed = line.trim();
289
+ if (trimmed) {
290
+ yield {
291
+ message: JSON.parse(trimmed),
292
+ transport: "raw",
293
+ };
294
+ }
295
+ }
296
+ }
297
+ }
298
+
299
+ async function handleToolCall(message, dependencies) {
300
+ const name = message.params?.name;
301
+ if (name !== TOOL_NAME) {
302
+ return failure(message.id, -32602, `Unknown tool: ${name || ""}`);
303
+ }
304
+
305
+ try {
306
+ const image = await generateImage(
307
+ message.params?.arguments || {},
308
+ {
309
+ ...dependencies.config,
310
+ fetchImpl: dependencies.fetchImpl || globalThis.fetch,
311
+ },
312
+ );
313
+
314
+ return success(message.id, {
315
+ content: [
316
+ {
317
+ type: "image",
318
+ data: image.data,
319
+ mimeType: image.mimeType,
320
+ },
321
+ {
322
+ type: "text",
323
+ text: `Generated one image with ${image.model} using ${image.mode} mode.`,
324
+ },
325
+ ],
326
+ isError: false,
327
+ });
328
+ } catch (error) {
329
+ return success(message.id, {
330
+ content: [
331
+ {
332
+ type: "text",
333
+ text: error instanceof Error ? error.message : String(error),
334
+ },
335
+ ],
336
+ isError: true,
337
+ });
338
+ }
339
+ }
340
+
341
+ export async function handleJsonRpcMessage(message, dependencies = {}) {
342
+ if (!message || message.jsonrpc !== JSONRPC_VERSION || typeof message.method !== "string") {
343
+ return failure(message?.id ?? null, -32600, "Invalid JSON-RPC request");
344
+ }
345
+
346
+ const isNotification = message.id === undefined;
347
+
348
+ switch (message.method) {
349
+ case "initialize":
350
+ return success(message.id, {
351
+ protocolVersion: message.params?.protocolVersion || "2024-11-05",
352
+ capabilities: {
353
+ tools: {
354
+ listChanged: false,
355
+ },
356
+ },
357
+ serverInfo: {
358
+ name: "gpt-image-mcp",
359
+ version: "0.1.0",
360
+ },
361
+ });
362
+ case "notifications/initialized":
363
+ return undefined;
364
+ case "ping":
365
+ return success(message.id, {});
366
+ case "tools/list":
367
+ return success(message.id, {
368
+ tools: [TOOL_DEFINITION],
369
+ });
370
+ case "tools/call":
371
+ return handleToolCall(message, dependencies);
372
+ default:
373
+ if (isNotification) {
374
+ return undefined;
375
+ }
376
+ return failure(message.id, -32601, `Method not found: ${message.method}`);
377
+ }
378
+ }
379
+
380
+ export async function runStdioServer({
381
+ input = process.stdin,
382
+ output = process.stdout,
383
+ config = createServerConfig(),
384
+ } = {}) {
385
+ const debug = createDebugLogger(config.debugLog);
386
+ debug("server_started", {
387
+ hasBaseUrl: Boolean(config.baseUrl),
388
+ hasToken: Boolean(config.token),
389
+ });
390
+
391
+ try {
392
+ for await (const { message, transport } of readFramedMessages(input, debug)) {
393
+ debug("message_received", {
394
+ id: message?.id,
395
+ method: message?.method,
396
+ transport,
397
+ });
398
+ const response = await handleJsonRpcMessage(message, { config });
399
+ if (response) {
400
+ debug("message_sent", {
401
+ id: response.id,
402
+ hasError: Boolean(response.error),
403
+ hasResult: Boolean(response.result),
404
+ transport,
405
+ });
406
+ output.write(transport === "raw" ? rawMessage(response) : framedMessage(response));
407
+ }
408
+ }
409
+ debug("input_closed");
410
+ } catch (error) {
411
+ debug("server_error", {
412
+ message: error instanceof Error ? error.message : String(error),
413
+ });
414
+ output.write(framedMessage(failure(null, -32700, "Parse error")));
415
+ }
416
+ }