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/.env.example +11 -0
- package/LICENSE +21 -0
- package/README.md +270 -0
- package/dist/client.js +13 -0
- package/dist/config.js +60 -0
- package/dist/diagnostics.js +57 -0
- package/dist/images.js +142 -0
- package/dist/index.js +31 -0
- package/dist/input.js +39 -0
- package/dist/server.js +72 -0
- package/dist/storage.js +57 -0
- package/package.json +49 -0
package/.env.example
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# 通过 MCP 客户端的 env 注入;服务不会自动读取此文件。
|
|
2
|
+
OPENAI_API_KEY=替换为你的密钥
|
|
3
|
+
# 留空使用官方端点;只填域名会补 /v1,已有路径保留,不要填写 /images/generations。
|
|
4
|
+
OPENAI_BASE_URL=https://api.openai.com/v1
|
|
5
|
+
IMAGE_GEN_MODEL=gpt-image-2.5-sunburst
|
|
6
|
+
# 留空则使用用户主目录下的 gpt-image-mcp/images,图片按 yyyy/MM/dd 分目录保存。
|
|
7
|
+
IMAGE_GEN_OUTPUT_DIR=
|
|
8
|
+
IMAGE_GEN_TIMEOUT_MS=300000
|
|
9
|
+
# API 返回图片的方式:b64_json(默认,返回 Base64 数据)或 url(返回下载地址)。
|
|
10
|
+
# 部分中转站默认返回 url,此时需设置为 url 以正常接收图片。
|
|
11
|
+
IMAGE_GEN_RESPONSE_FORMAT=b64_json
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WainZeng
|
|
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,270 @@
|
|
|
1
|
+
# gpt-image-mcp
|
|
2
|
+
|
|
3
|
+
基于 TypeScript 的本地 `stdio` MCP 服务,调用 GPT Image 完成文生图、图片编辑和参考风格创作,图片保存在本机,返回绝对路径及文件 URI。
|
|
4
|
+
|
|
5
|
+
## 安装与运行
|
|
6
|
+
|
|
7
|
+
需要 Node.js 22 或更高版本,以及具有所选模型调用权限和可用额度的 OpenAI 或兼容服务商 API 密钥。支持 Linux、macOS 和 Windows;图片处理依赖 sharp 的平台二进制包,请在目标机器运行安装命令,不要跨系统复制 `node_modules`。
|
|
8
|
+
|
|
9
|
+
### 通过安装包使用
|
|
10
|
+
|
|
11
|
+
当前可分发 `gpt-image-mcp-0.1.0.tgz`,尚未发布到公共 npm 注册表。收到安装包后,在文件所在目录执行:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install --global ./gpt-image-mcp-0.1.0.tgz
|
|
15
|
+
gpt-image-mcp --help
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
安装时 npm 会获取运行依赖,用户无需安装 TypeScript 或自行构建。每位用户在自己的 MCP 客户端中配置 Key、端点和输出目录。
|
|
19
|
+
|
|
20
|
+
macOS / Linux 的 MCP 配置示例:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"image-gen": {
|
|
26
|
+
"command": "gpt-image-mcp",
|
|
27
|
+
"env": {
|
|
28
|
+
"OPENAI_API_KEY": "你的 API 密钥",
|
|
29
|
+
"OPENAI_BASE_URL": "https://你的服务商/v1",
|
|
30
|
+
"IMAGE_GEN_MODEL": "服务商提供的图片模型名称",
|
|
31
|
+
"IMAGE_GEN_OUTPUT_DIR": "~/pictures"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Windows 上 npm 会生成 `.cmd` 启动器,客户端如果无法直接启动它,可将上述命令配置改为:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"command": "cmd",
|
|
43
|
+
"args": ["/d", "/c", "gpt-image-mcp"]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
如果 GUI 客户端的 PATH 中没有 npm 全局目录,可运行 `npm root --global` 找到安装位置,再配置 `command: "node"`,将该目录下 `gpt-image-mcp/dist/index.js` 的绝对路径填入 `args`。这个方式适用于三个系统。
|
|
48
|
+
|
|
49
|
+
也可以通过本地压缩包临时运行,无需全局安装(替换为安装包实际路径):
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npx --yes --package="/安装包绝对路径/gpt-image-mcp-0.1.0.tgz" gpt-image-mcp --help
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 从源码运行
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npm ci
|
|
59
|
+
npm run build
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
在 MCP 客户端配置中注册服务。通过 `node` 直接运行构建结果,启动位置不影响默认输出目录。
|
|
63
|
+
|
|
64
|
+
macOS / Linux 示例(把项目路径改成实际绝对路径):
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"mcpServers": {
|
|
69
|
+
"image-gen": {
|
|
70
|
+
"command": "node",
|
|
71
|
+
"args": ["/你的项目路径/gpt-image-mcp/dist/index.js"],
|
|
72
|
+
"env": {
|
|
73
|
+
"OPENAI_API_KEY": "你的 API 密钥"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Windows 示例,JSON 中使用正斜杠可避免反斜杠转义:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"mcpServers": {
|
|
85
|
+
"image-gen": {
|
|
86
|
+
"command": "node",
|
|
87
|
+
"args": ["C:/你的项目路径/gpt-image-mcp/dist/index.js"],
|
|
88
|
+
"env": {
|
|
89
|
+
"OPENAI_API_KEY": "你的 API 密钥",
|
|
90
|
+
"IMAGE_GEN_OUTPUT_DIR": "D:/pictures"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
如果客户端找不到 `node`,将 `command` 替换为本机 Node.js 可执行文件的绝对路径。工具执行超时建议设为至少 360 秒,具体配置字段由客户端决定;服务自身的 API 超时默认为 300 秒。
|
|
98
|
+
|
|
99
|
+
服务启动后会等待 MCP 输入,直接在终端运行时没有欢迎输出属于正常行为。日志只写入 stderr,stdout 保留给 MCP 协议。
|
|
100
|
+
|
|
101
|
+
## 配置
|
|
102
|
+
|
|
103
|
+
| 环境变量 | 默认值 | 说明 |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `OPENAI_API_KEY` | 必填 | OpenAI API 密钥,通过客户端环境变量注入 |
|
|
106
|
+
| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | 未填写或留空时使用官方端点;仅填写域名时自动补 `/v1`,已有路径保留 |
|
|
107
|
+
| `IMAGE_GEN_MODEL` | `gpt-image-2.5-sunburst` | 文生图和编辑共用的模型,允许兼容服务商的模型别名,需账号有权限 |
|
|
108
|
+
| `IMAGE_GEN_OUTPUT_DIR` | 用户主目录下的 `gpt-image-mcp/images` | 输出根目录,支持本机绝对路径或 `~/`;自动按本地日期创建 `yyyy/MM/dd` 子目录 |
|
|
109
|
+
| `IMAGE_GEN_TIMEOUT_MS` | `300000` | API 请求超时,单位毫秒,必须为不小于 1000 的整数 |
|
|
110
|
+
| `IMAGE_GEN_RESPONSE_FORMAT` | `b64_json` | API 返回图片的方式:`b64_json`(返回 Base64 数据)或 `url`(返回下载地址,服务自动下载保存)。部分中转站默认返回 `url`,需对应配置 |
|
|
111
|
+
|
|
112
|
+
`.env.example` 仅作为变量示例,服务不会自动读取 `.env`。本地调试可运行 `node --env-file=.env dist/index.js`,或通过 MCP 客户端的 `env` 传入变量。
|
|
113
|
+
|
|
114
|
+
`OPENAI_BASE_URL` 填写 API 根地址;未配置、空字符串或纯空格均使用官方端点。只有地址不包含路径时自动补 `/v1`,已有路径则按用户配置保留,避免破坏代理前缀或其他版本。尾部斜杠会去除,不会重复追加 `/v1`。
|
|
115
|
+
|
|
116
|
+
| 用户填写 | 实际使用的 API 根地址 |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| 不填 | `https://api.openai.com/v1` |
|
|
119
|
+
| `https://gateway.example` | `https://gateway.example/v1` |
|
|
120
|
+
| `https://gateway.example/` | `https://gateway.example/v1` |
|
|
121
|
+
| `https://gateway.example/v1/` | `https://gateway.example/v1` |
|
|
122
|
+
| `https://gateway.example/proxy/v1/` | `https://gateway.example/proxy/v1` |
|
|
123
|
+
| `https://gateway.example/proxy/` | `https://gateway.example/proxy` |
|
|
124
|
+
|
|
125
|
+
服务在根地址后追加 `/images/generations`、`/images/edits` 或 `/models`,不要填写完整生图接口地址。自定义路径如果需要 `/v1`,请明确填成 `/proxy/v1`。支持 HTTP 和 HTTPS,本地代理可用 `http://localhost:8080`;远程端点建议使用 HTTPS。地址不能包含账号密码、查询参数或 URL 片段。
|
|
126
|
+
|
|
127
|
+
Key、提示词和输入图片会发送到你配置的服务商。服务不会跟随 HTTP 重定向,请直接填写最终 API 根地址。
|
|
128
|
+
|
|
129
|
+
兼容服务商必须支持 OpenAI Image API 的 JSON 文生图、multipart 图片编辑,以及 `data[].b64_json` 或 `data[].url` 返回结构(通过 `IMAGE_GEN_RESPONSE_FORMAT` 选择)。仅兼容聊天接口或异步任务 ID 的服务不在当前兼容范围内。
|
|
130
|
+
|
|
131
|
+
## 端点检查与实际能力验证
|
|
132
|
+
|
|
133
|
+
服务启动时只校验本地配置,不自动联网探测或生成测试图片。需要检查时,在 MCP 客户端调用 `check_endpoint`,参数为 `{}`;也可以在配置好环境变量后运行:
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
gpt-image-mcp --check
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
从源码使用 `.env` 时:
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
node --env-file=.env dist/index.js --check
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
诊断最多等待 30 秒,不自动重试,只请求 `GET /models`,不会调用图片生成或编辑接口。结果说明:
|
|
146
|
+
|
|
147
|
+
| 字段 | 含义 |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| `configuration` | `valid` 表示本地配置格式合法,不代表密钥已获授权 |
|
|
150
|
+
| `modelsEndpoint` | `available` 表示模型列表响应结构正确;`unavailable` 表示请求失败;`unexpected_response` 表示返回内容不符合列表结构 |
|
|
151
|
+
| `modelListed` | 当前模型是否出现在本次返回的列表中,无法确定时为 `null` |
|
|
152
|
+
| `generation` / `editing` | 本项检查始终返回 `unverified`,不会将模型列表成功当成图片能力证明 |
|
|
153
|
+
| `httpStatus` / `message` | HTTP 状态码和说明,不回显密钥或上游原始错误消息 |
|
|
154
|
+
|
|
155
|
+
有些图片服务不提供 `/models`,或不会列出别名模型,所以检查失败和模型未列出都不会禁止生图。CLI 在模型列表正常时退出码为 0,其余情况为 1;退出码不代表图片能力通过或失败。
|
|
156
|
+
|
|
157
|
+
真正的兼容性验证需要分别执行一次 `generate_image` 和 `edit_image`,检查图片返回和落盘是否成功。这些调用可能计费,建议先使用 `quality: "low"`。文生图通过不能替代编辑接口测试,也不能保证所有参数组合均被服务商支持。
|
|
158
|
+
|
|
159
|
+
## 文件命名与路径
|
|
160
|
+
|
|
161
|
+
图片按 `输出根目录/yyyy/MM/dd/文件名` 保存,年月日取自实际保存时用户电脑的本地时间,月份和日期补齐两位,不采用 UTC 日期。跨天保存自动进入新目录。
|
|
162
|
+
|
|
163
|
+
文件名使用自建俏皮词表,组合“形容词 + 小动物 + 动作 + 奇妙事物 + 随机短码”,不额外调用语言模型取名:
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
cozy-otter-paints-moonlight-7d3a9b2c.png
|
|
167
|
+
sassy-capybara-juggles-marshmallows-c8e2a104.webp
|
|
168
|
+
dreamy-axolotl-brews-stardust-f0914abc.jpg
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
默认路径示例:
|
|
172
|
+
|
|
173
|
+
```text
|
|
174
|
+
Linux: /home/用户名/gpt-image-mcp/images/2026/09/10/cozy-otter-paints-moonlight-7d3a9b2c.png
|
|
175
|
+
macOS: /Users/用户名/gpt-image-mcp/images/2026/09/10/cozy-otter-paints-moonlight-7d3a9b2c.png
|
|
176
|
+
Windows: C:\Users\用户名\gpt-image-mcp\images\2026\09\10\cozy-otter-paints-moonlight-7d3a9b2c.png
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
输出根目录保持固定,按日期归档;图片文件名使用 ASCII 小写字母、连字符和随机短码,避免系统保留字符与大小写差异。每次使用独占方式创建文件,名称碰撞会重新取名,绝不覆盖已有图片。支持中文和空格目录;`uri` 使用标准 URL 编码。旧版本生成的图片保留原位,不会自动迁移。
|
|
180
|
+
|
|
181
|
+
结果中的 `path` 是本机路径,`uri` 是 `file://` URI,均不是公网下载地址。调用客户端必须能访问服务所在机器的文件系统;是否内联显示图片取决于客户端。图生图可以直接使用上一次返回的 `path`。
|
|
182
|
+
|
|
183
|
+
## 工具
|
|
184
|
+
|
|
185
|
+
### `generate_image`
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"prompt": "一只水獭在月光下画画,暖色手绘插画,柔和笔触",
|
|
190
|
+
"size": "1024x1024",
|
|
191
|
+
"quality": "medium",
|
|
192
|
+
"format": "png"
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### `edit_image`
|
|
197
|
+
|
|
198
|
+
```json
|
|
199
|
+
{
|
|
200
|
+
"prompt": "保留图一主体和构图,参考图二的配色与笔触,将背景改为雨夜街道",
|
|
201
|
+
"images": ["/绝对路径/原图.png", "/绝对路径/风格参考.webp"],
|
|
202
|
+
"quality": "high"
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
局部编辑可增加 `mask`,填写本机遮罩路径。遮罩必须是含透明通道的 PNG,尺寸与第一张原图一致;完全透明区域表示希望编辑的部分。遮罩是模型的编辑指引,不保证像素级边界精确。
|
|
207
|
+
|
|
208
|
+
| 参数 | 默认值与范围 |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| `prompt` | 必填,去除首尾空白后 1~32000 字符 |
|
|
211
|
+
| `size` | 默认 `auto`,也可选 `WIDTHxHEIGHT`(如 `1024x1024`、`1536x1024`、`3840x2160`)。宽高需为 16 的倍数,比例不超过 3:1,总像素 655360~8294400 |
|
|
212
|
+
| `quality` | 默认 `auto`,也可选 `low`、`medium`、`high`、`xhigh`、`max` |
|
|
213
|
+
| `format` | 默认 `png`,也可选 `jpeg`、`webp` |
|
|
214
|
+
| `background` | 默认 `auto`,也可选 `transparent`、`opaque`;透明背景需配合 `png` 或 `webp` 格式 |
|
|
215
|
+
| `moderation` | 仅文生图,默认 `auto`,也可选 `low`;内容安全审核级别 |
|
|
216
|
+
| `output_compression` | 可选 0~100 整数,仅 `jpeg` 和 `webp` 格式生效 |
|
|
217
|
+
| `input_fidelity` | 仅编辑,可选 `high` 或 `low`;控制对原图细节的保留程度 |
|
|
218
|
+
| `images` | 编辑必填,1~16 张本机 PNG、JPEG 或 WebP;单图小于 50 MiB,参考图合计不超过 100 MiB |
|
|
219
|
+
| `mask` | 编辑可选,本机 PNG 遮罩路径;单文件小于 50 MiB |
|
|
220
|
+
|
|
221
|
+
输入图片路径必须为本机绝对路径或以 `~/` 开头。每次生成一张新图片,不修改输入文件。
|
|
222
|
+
|
|
223
|
+
成功时同时返回 MCP 文本和 `structuredContent`,内容一致:
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{
|
|
227
|
+
"images": [{
|
|
228
|
+
"path": "/Users/用户名/gpt-image-mcp/images/2026/09/10/cozy-otter-paints-moonlight-7d3a9b2c.png",
|
|
229
|
+
"uri": "file:///Users/%E7%94%A8%E6%88%B7%E5%90%8D/gpt-image-mcp/images/2026/09/10/cozy-otter-paints-moonlight-7d3a9b2c.png",
|
|
230
|
+
"mimeType": "image/png",
|
|
231
|
+
"bytes": 123456,
|
|
232
|
+
"width": 1024,
|
|
233
|
+
"height": 1024
|
|
234
|
+
}],
|
|
235
|
+
"model": "gpt-image-2.5-sunburst"
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
失败返回 `isError: true` 和错误说明。服务关闭自动重试;超时或断线不代表上游没有执行,重新调用可能再次计费。图片生成成功但本地保存失败时会明确提示,不会重新调用生成接口。客户端取消会传递给 API 请求,但无法保证取消上游已开始的计费。
|
|
240
|
+
|
|
241
|
+
## 开发与验证
|
|
242
|
+
|
|
243
|
+
```sh
|
|
244
|
+
npm run check
|
|
245
|
+
npm run test:package
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
执行类型检查、测试及构建。测试通过真实 OpenAI SDK 的模拟 HTTP 响应,覆盖 MCP 工具发现与调用、文生图、多图编辑、自定义端点路由、诊断结果边界、遮罩校验、本地保存、中文及空格路径、并发命名、失败不重试。测试不需要真实密钥,也不消耗图片 API 额度。
|
|
249
|
+
|
|
250
|
+
`test:package` 会在临时目录打包和安装,验证发布文件白名单、npm 命令入口和安装后的 MCP 握手;安装依赖时需要访问 npm 或具有完整本地缓存。
|
|
251
|
+
|
|
252
|
+
GitHub Actions 配置了 Linux、macOS、Windows 和 Node.js 22/24 的检查矩阵。配置存在不代表已在全部系统实际跑过;本地测试不能代替目标系统 CI 或真实 API 验收。
|
|
253
|
+
|
|
254
|
+
真实验收建议用 `quality: "low"` 各执行一次文生图和图生图,确认账号模型权限、网络、图片效果与客户端展示行为。
|
|
255
|
+
|
|
256
|
+
## 制作分发包
|
|
257
|
+
|
|
258
|
+
在源码目录执行:
|
|
259
|
+
|
|
260
|
+
```sh
|
|
261
|
+
npm ci
|
|
262
|
+
npm run check
|
|
263
|
+
npm pack
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`npm pack` 会自动构建,生成 `gpt-image-mcp-0.1.0.tgz`。分发包仅包含编译结果、`package.json`、README 和 `.env.example`,不包含真实 `.env`、测试文件、源码或 `node_modules`。同一压缩包可发给三种系统的用户安装,安装时会选择对应平台依赖。
|
|
267
|
+
|
|
268
|
+
如以后发布到公共 npm,需要先确定自己有权使用的包名或作用域、发布账号和许可证。当前未执行 `npm publish`,不能假定注册表中的同名包属于本项目;现阶段请使用此项目生成的 `.tgz` 文件。
|
|
269
|
+
|
|
270
|
+
接口依据:[OpenAI 图片生成文档](https://developers.openai.com/api/docs/guides/image-generation)、[MCP 服务开发文档](https://modelcontextprotocol.io/docs/develop/build-server)。
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
/**
|
|
3
|
+
* 为一份用户配置创建独立的 API 客户端,不修改进程环境变量或共享凭证。
|
|
4
|
+
* @param config 已校验的端点、密钥和毫秒超时配置。
|
|
5
|
+
* @returns 禁用自动重试、重定向和 SDK 日志的客户端。
|
|
6
|
+
*/
|
|
7
|
+
export function createApiClient(config) {
|
|
8
|
+
return new OpenAI({
|
|
9
|
+
apiKey: config.apiKey, baseURL: config.baseURL, timeout: config.timeout,
|
|
10
|
+
// 生成可能计费,不自动重试;不跟随重定向将凭证发送到其他地址。
|
|
11
|
+
maxRetries: 0, fetchOptions: { redirect: "error" }, logLevel: "off",
|
|
12
|
+
});
|
|
13
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* 展开用户主目录并规范化本机路径,避免 MCP 客户端的启动目录影响文件位置。
|
|
5
|
+
* @param value 本机绝对路径,或以 ~/、~\ 开头的主目录路径。
|
|
6
|
+
* @returns 使用当前操作系统路径分隔符的绝对路径。
|
|
7
|
+
* @throws 输入为相对路径时抛出错误。
|
|
8
|
+
*/
|
|
9
|
+
export function localPath(value) {
|
|
10
|
+
const expanded = value === "~" ? homedir()
|
|
11
|
+
: /^~[/\\]/.test(value) ? path.join(homedir(), value.slice(2)) : value;
|
|
12
|
+
if (!path.isAbsolute(expanded)) {
|
|
13
|
+
throw new Error("请使用本机绝对路径,或以 ~/ 开头的路径。");
|
|
14
|
+
}
|
|
15
|
+
return path.normalize(expanded);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 从环境变量读取并校验配置,不访问网络,也不创建输出目录。
|
|
19
|
+
* @param env 配置来源;默认使用进程环境变量,密钥不会写入日志。
|
|
20
|
+
* @returns 规范化的端点、模型、请求超时(毫秒)和本地输出根目录。
|
|
21
|
+
* @throws 缺少密钥、端点格式非法、超时不合法或输出目录为相对路径时抛出错误。
|
|
22
|
+
*/
|
|
23
|
+
export function readConfig(env = process.env) {
|
|
24
|
+
const apiKey = env.OPENAI_API_KEY?.trim();
|
|
25
|
+
if (!apiKey)
|
|
26
|
+
throw new Error("请通过 OPENAI_API_KEY 配置 OpenAI API 密钥。");
|
|
27
|
+
const model = env.IMAGE_GEN_MODEL?.trim() || "gpt-image-2.5-sunburst";
|
|
28
|
+
// 兼容服务商对 GPT Image 的模型别名,不根据名称猜测图片能力。
|
|
29
|
+
const baseURL = normalizeBaseURL(env.OPENAI_BASE_URL?.trim() || "https://api.openai.com/v1");
|
|
30
|
+
const timeout = Number(env.IMAGE_GEN_TIMEOUT_MS || 300_000);
|
|
31
|
+
if (!Number.isSafeInteger(timeout) || timeout < 1000) {
|
|
32
|
+
throw new Error("IMAGE_GEN_TIMEOUT_MS 必须是大于或等于 1000 的整数,单位为毫秒。");
|
|
33
|
+
}
|
|
34
|
+
const outputDir = env.IMAGE_GEN_OUTPUT_DIR?.trim();
|
|
35
|
+
const responseFormat = (env.IMAGE_GEN_RESPONSE_FORMAT?.trim() || "b64_json");
|
|
36
|
+
if (responseFormat !== "b64_json" && responseFormat !== "url") {
|
|
37
|
+
throw new Error("IMAGE_GEN_RESPONSE_FORMAT 仅支持 b64_json 或 url。");
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
apiKey, baseURL, model, timeout, responseFormat,
|
|
41
|
+
outputDir: outputDir ? localPath(outputDir) : path.join(homedir(), "gpt-image-mcp", "images"),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** 仅为无路径地址补 /v1;显式路径代表用户选择,不能猜测并改写。 */
|
|
45
|
+
function normalizeBaseURL(value) {
|
|
46
|
+
let url;
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(value);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error("OPENAI_BASE_URL 必须是完整的 HTTP 或 HTTPS API 根地址。");
|
|
52
|
+
}
|
|
53
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
54
|
+
throw new Error("OPENAI_BASE_URL 仅支持 HTTP/HTTPS,且不能包含账号密码、查询参数或片段;密钥请通过 OPENAI_API_KEY 配置。");
|
|
55
|
+
}
|
|
56
|
+
// 只填域名时补齐常见的 /v1;已有路径按显式配置保留,不改写代理前缀或其他版本。
|
|
57
|
+
if (!url.pathname.replace(/\/+$/, ""))
|
|
58
|
+
url.pathname = "/v1";
|
|
59
|
+
return url.href.replace(/\/+$/, "");
|
|
60
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/** 诊断结果契约:模型列表的连通状态与尚未验证的图片能力分开返回。 */
|
|
4
|
+
export const diagnosticSchema = z.object({
|
|
5
|
+
configuration: z.literal("valid"),
|
|
6
|
+
modelsEndpoint: z.enum(["available", "unavailable", "unexpected_response"]),
|
|
7
|
+
model: z.string(),
|
|
8
|
+
modelListed: z.boolean().nullable(),
|
|
9
|
+
generation: z.literal("unverified"),
|
|
10
|
+
editing: z.literal("unverified"),
|
|
11
|
+
httpStatus: z.number().nullable(),
|
|
12
|
+
message: z.string(),
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* 仅检查模型列表接口,不能据此推断文生图或编辑接口的可用性。
|
|
16
|
+
* @param client 当前端点的 API 客户端。
|
|
17
|
+
* @param config 用于匹配模型名称及限制超时;诊断最多等待 30 秒。
|
|
18
|
+
* @param signal 可选取消信号。
|
|
19
|
+
* @returns 模型列表检查报告;请求失败也转换为报告,不回显上游原始错误。
|
|
20
|
+
*/
|
|
21
|
+
export async function checkEndpoint(client, config, signal) {
|
|
22
|
+
const baseline = {
|
|
23
|
+
configuration: "valid", model: config.model, modelListed: null,
|
|
24
|
+
generation: "unverified", editing: "unverified", httpStatus: null,
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
const response = await client.models.list({
|
|
28
|
+
timeout: Math.min(config.timeout, 30_000), signal,
|
|
29
|
+
}).asResponse();
|
|
30
|
+
// 校验原始响应,避免 SDK 把缺失的 data 自动转成空列表而误报兼容。
|
|
31
|
+
const models = z.object({ data: z.array(z.object({ id: z.string() })) })
|
|
32
|
+
.safeParse(await response.json().catch(() => null));
|
|
33
|
+
if (!models.success) {
|
|
34
|
+
return { ...baseline, modelsEndpoint: "unexpected_response", httpStatus: response.status,
|
|
35
|
+
message: "端点已响应,但返回内容不是兼容的模型列表。请确认 API 根地址;图片能力尚未验证。" };
|
|
36
|
+
}
|
|
37
|
+
const listed = models.data.data.some((model) => model.id === config.model);
|
|
38
|
+
return { ...baseline, modelsEndpoint: "available", modelListed: listed, httpStatus: response.status,
|
|
39
|
+
message: `${listed ? "当前模型出现在返回的列表中。" : "当前模型未出现在返回的列表中;可能是别名或服务商未列出。"}模型列表响应正常不代表支持图片生成或编辑。请分别调用 generate_image 和 edit_image 实测,这些调用可能计费。` };
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
// 列表接口可能独立鉴权或根本不存在,这些失败不能作为禁止生图的依据。
|
|
43
|
+
const status = error instanceof OpenAI.APIError ? error.status ?? null : null;
|
|
44
|
+
let message = "模型列表请求失败,请检查网络和端点配置;未发送任何图片生成请求。";
|
|
45
|
+
if (error instanceof OpenAI.APIConnectionTimeoutError)
|
|
46
|
+
message = "模型列表请求超时;图片能力仍未验证。";
|
|
47
|
+
else if (signal?.aborted)
|
|
48
|
+
message = "端点检查已取消。";
|
|
49
|
+
else if (status === 401 || status === 403)
|
|
50
|
+
message = "模型列表接口拒绝访问,请检查密钥及权限;不能据此确定图片接口是否可用。";
|
|
51
|
+
else if (status === 404 || status === 405)
|
|
52
|
+
message = "此端点没有可用的模型列表接口;部分图片服务不提供该接口,不代表不支持生图。";
|
|
53
|
+
else if (status === 429)
|
|
54
|
+
message = "模型列表接口返回限流或额度错误,请检查服务商配置后重试;未自动重试。";
|
|
55
|
+
return { ...baseline, modelsEndpoint: "unavailable", httpStatus: status, message };
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/images.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readImage } from "./input.js";
|
|
4
|
+
import { saveImage } from "./storage.js";
|
|
5
|
+
/** 校验 WIDTHxHEIGHT 格式:宽高均为 16 的倍数,比例不超过 3:1,总像素 655360~8294400。 */
|
|
6
|
+
const sizeSchema = z.string().default("auto").describe("输出尺寸:auto 或 WIDTHxHEIGHT(如 1024x1024、1536x1024、3840x2160)。宽高需为 16 的倍数,比例不超过 3:1。").refine((v) => {
|
|
7
|
+
if (v === "auto")
|
|
8
|
+
return true;
|
|
9
|
+
const match = /^(\d+)x(\d+)$/.exec(v);
|
|
10
|
+
if (!match)
|
|
11
|
+
return false;
|
|
12
|
+
const w = Number(match[1]), h = Number(match[2]);
|
|
13
|
+
return w % 16 === 0 && h % 16 === 0 && w > 0 && h > 0
|
|
14
|
+
&& Math.max(w, h) / Math.min(w, h) <= 3
|
|
15
|
+
&& w * h >= 655_360 && w * h <= 8_294_400;
|
|
16
|
+
}, { message: "尺寸需为 auto 或 WIDTHxHEIGHT,宽高为 16 的倍数,比例不超过 3:1,总像素 655360~8294400。" });
|
|
17
|
+
/** MCP 文生图输入契约;默认值由工具层校验时补齐,未知参数直接拒绝。 */
|
|
18
|
+
export const generateSchema = z.object({
|
|
19
|
+
prompt: z.string().trim().min(1).max(32000).describe("图片内容、风格、构图等要求。"),
|
|
20
|
+
size: sizeSchema,
|
|
21
|
+
quality: z.enum(["auto", "low", "medium", "high", "xhigh", "max"]).default("auto").describe("生成质量,越高通常越慢且费用越高。"),
|
|
22
|
+
format: z.enum(["png", "jpeg", "webp"]).default("png").describe("保存的图片格式。"),
|
|
23
|
+
background: z.enum(["auto", "transparent", "opaque"]).default("auto").describe("背景模式;transparent 需配合 png 或 webp 格式。"),
|
|
24
|
+
moderation: z.enum(["auto", "low"]).default("auto").describe("内容安全审核级别。"),
|
|
25
|
+
output_compression: z.number().int().min(0).max(100).optional().describe("输出压缩率(0-100),仅 jpeg 和 webp 格式生效。"),
|
|
26
|
+
}).strict();
|
|
27
|
+
/** 文生图独有字段(moderation),编辑接口不支持。 */
|
|
28
|
+
const baseEditSchema = generateSchema.omit({ moderation: true });
|
|
29
|
+
/** 编辑沿用生成参数(不含 moderation);图片顺序与提示词中的图一、图二保持一致。 */
|
|
30
|
+
export const editSchema = baseEditSchema.extend({
|
|
31
|
+
images: z.array(z.string().min(1)).min(1).max(16).describe("本机原图或参考图的绝对路径,可使用 ~/;按提示词引用的顺序排列。"),
|
|
32
|
+
mask: z.string().min(1).optional().describe("可选 PNG 遮罩的本机绝对路径,需含透明通道且尺寸与第一张原图一致;透明区域用于引导编辑。"),
|
|
33
|
+
input_fidelity: z.enum(["high", "low"]).optional().describe("编辑时对原图细节的保留程度,high 尽量保留原图主体。"),
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* 协调图片 API 调用、本地输入校验和结果保存。
|
|
37
|
+
* 每次请求生成一张新图片;不重写原图、不自动重试,也不保存用户密钥。
|
|
38
|
+
* 输入参数由 MCP 层先按对应 schema 校验,调用成功后才返回实际落盘位置。
|
|
39
|
+
*/
|
|
40
|
+
export class ImageService {
|
|
41
|
+
client;
|
|
42
|
+
config;
|
|
43
|
+
/**
|
|
44
|
+
* @param client 属于当前用户配置的 API 客户端,允许测试注入模拟客户端。
|
|
45
|
+
* @param config 已校验的模型和输出目录配置。
|
|
46
|
+
*/
|
|
47
|
+
constructor(client, config) {
|
|
48
|
+
this.client = client;
|
|
49
|
+
this.config = config;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 根据提示词生成一张图片并保存,调用上游可能计费。
|
|
53
|
+
* @param input 已通过 generateSchema 校验并补齐默认值的参数。
|
|
54
|
+
* @param signal 可选取消信号;不能保证撤销上游已开始的生成或计费。
|
|
55
|
+
* @returns 已保存图片的位置、实际尺寸、大小和所用模型。
|
|
56
|
+
* @throws 上游请求失败、结果不可解析或本地保存失败时抛出错误。
|
|
57
|
+
*/
|
|
58
|
+
async generate(input, signal) {
|
|
59
|
+
const response = await this.client.images.generate({
|
|
60
|
+
model: this.config.model, prompt: input.prompt, size: input.size,
|
|
61
|
+
quality: input.quality, output_format: input.format, n: 1,
|
|
62
|
+
response_format: this.config.responseFormat,
|
|
63
|
+
background: input.background,
|
|
64
|
+
moderation: input.moderation,
|
|
65
|
+
...(input.output_compression != null && { output_compression: input.output_compression }),
|
|
66
|
+
}, { signal });
|
|
67
|
+
return this.persist(response, input.format, signal);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 校验参考图和遮罩后执行编辑,结果另存为新图片。
|
|
71
|
+
* @param input 已通过 editSchema 校验的参数,至少包含一张本地图片。
|
|
72
|
+
* @param signal 可选取消信号,在读取参考图之间及 API 请求期间生效。
|
|
73
|
+
* @returns 编辑后新文件的位置和元数据,原图内容保持不变。
|
|
74
|
+
* @throws 输入文件、遮罩不合法、参考图合计超过 100 MiB 或调用、保存失败时抛出错误。
|
|
75
|
+
*/
|
|
76
|
+
async edit(input, signal) {
|
|
77
|
+
const images = [];
|
|
78
|
+
let totalBytes = 0;
|
|
79
|
+
for (const imagePath of input.images) {
|
|
80
|
+
signal?.throwIfAborted();
|
|
81
|
+
const image = await readImage(imagePath);
|
|
82
|
+
totalBytes += image.bytes.length;
|
|
83
|
+
// 限制单次请求的内存占用,多张参考图按输入顺序传给模型。
|
|
84
|
+
if (totalBytes > 100 * 1024 * 1024)
|
|
85
|
+
throw new Error("参考图片总大小不能超过 100 MiB。");
|
|
86
|
+
images.push(image);
|
|
87
|
+
}
|
|
88
|
+
const mask = input.mask ? await readImage(input.mask) : undefined;
|
|
89
|
+
if (mask) {
|
|
90
|
+
// API 将遮罩应用于第一张参考图,尺寸检查必须使用同一张图。
|
|
91
|
+
const first = images[0].metadata;
|
|
92
|
+
if (mask.metadata.format !== "png" || !mask.metadata.hasAlpha
|
|
93
|
+
|| mask.metadata.width !== first.width || mask.metadata.height !== first.height) {
|
|
94
|
+
throw new Error("遮罩必须是带透明通道的 PNG,且尺寸与第一张原图一致。");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const response = await this.client.images.edit({
|
|
98
|
+
model: this.config.model, prompt: input.prompt, size: input.size,
|
|
99
|
+
quality: input.quality, output_format: input.format, n: 1,
|
|
100
|
+
response_format: this.config.responseFormat,
|
|
101
|
+
background: input.background,
|
|
102
|
+
image: images.map((image) => image.upload), mask: mask?.upload,
|
|
103
|
+
...(input.input_fidelity != null && { input_fidelity: input.input_fidelity }),
|
|
104
|
+
...(input.output_compression != null && { output_compression: input.output_compression }),
|
|
105
|
+
}, { signal });
|
|
106
|
+
return this.persist(response, input.format, signal);
|
|
107
|
+
}
|
|
108
|
+
/** 从 API 响应中提取图片字节:优先 b64_json,其次下载 url。 */
|
|
109
|
+
async extractBytes(response, signal) {
|
|
110
|
+
const item = response.data?.[0];
|
|
111
|
+
if (item?.b64_json)
|
|
112
|
+
return Buffer.from(item.b64_json, "base64");
|
|
113
|
+
if (item?.url) {
|
|
114
|
+
const res = await fetch(item.url, { signal, redirect: "follow" });
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw new Error(`下载图片失败(状态码:${res.status})。`);
|
|
117
|
+
return Buffer.from(await res.arrayBuffer());
|
|
118
|
+
}
|
|
119
|
+
throw new Error("图片 API 未返回图片数据,请检查所配置模型是否支持图片生成。");
|
|
120
|
+
}
|
|
121
|
+
/** 验证返回图片的实际格式后落盘,支持 b64_json 和 url 两种返回方式。 */
|
|
122
|
+
async persist(response, format, signal) {
|
|
123
|
+
const bytes = await this.extractBytes(response, signal);
|
|
124
|
+
const metadata = await sharp(bytes).metadata().catch(() => {
|
|
125
|
+
throw new Error("API 返回的图片数据无法解析,未保存结果。");
|
|
126
|
+
});
|
|
127
|
+
if (metadata.format !== format)
|
|
128
|
+
throw new Error("API 返回的图片格式与请求不一致,未保存结果。");
|
|
129
|
+
let saved;
|
|
130
|
+
try {
|
|
131
|
+
saved = await saveImage(this.config.outputDir, bytes, format);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// 保存失败时不重新调用生成接口,避免再次计费。
|
|
135
|
+
throw new Error("图片已生成,但保存失败。请检查输出目录权限和磁盘空间;重新调用会再次生成并可能计费。");
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
images: [{ ...saved, width: metadata.width, height: metadata.height }],
|
|
139
|
+
model: this.config.model,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import { createApiClient } from "./client.js";
|
|
4
|
+
import { readConfig } from "./config.js";
|
|
5
|
+
import { checkEndpoint } from "./diagnostics.js";
|
|
6
|
+
import { createServer } from "./server.js";
|
|
7
|
+
// 无参数才进入 MCP 模式;帮助和诊断是独立 CLI 模式,可以向 stdout 输出文本。
|
|
8
|
+
try {
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
if (args.length === 1 && args[0] === "--help") {
|
|
11
|
+
process.stdout.write("gpt-image-mcp\n\n用法:\n gpt-image-mcp 启动本地 stdio MCP 服务\n gpt-image-mcp --check 检查模型列表接口,不发送生图请求\n gpt-image-mcp --help 显示帮助\n\n配置:OPENAI_API_KEY、OPENAI_BASE_URL、IMAGE_GEN_MODEL、IMAGE_GEN_OUTPUT_DIR、IMAGE_GEN_TIMEOUT_MS、IMAGE_GEN_RESPONSE_FORMAT\n");
|
|
12
|
+
}
|
|
13
|
+
else if (args.length === 1 && args[0] === "--check") {
|
|
14
|
+
const config = readConfig();
|
|
15
|
+
const result = await checkEndpoint(createApiClient(config), config);
|
|
16
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
17
|
+
process.exitCode = result.modelsEndpoint === "available" ? 0 : 1;
|
|
18
|
+
}
|
|
19
|
+
else if (args.length === 0) {
|
|
20
|
+
const server = createServer(readConfig());
|
|
21
|
+
await server.connect(new StdioServerTransport());
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
throw new Error("不支持的命令参数,请使用 gpt-image-mcp --help 查看用法。");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
// stdout 专用于 MCP 协议消息,启动错误只能写入 stderr。
|
|
29
|
+
console.error(error instanceof Error ? error.message : "MCP 服务启动失败。");
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
package/dist/input.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
import { toFile } from "openai";
|
|
5
|
+
import { localPath } from "./config.js";
|
|
6
|
+
/** 单个输入文件的大小上限,单位为字节;达到上限也会被拒绝。 */
|
|
7
|
+
export const MAX_IMAGE_BYTES = 50 * 1024 * 1024;
|
|
8
|
+
/**
|
|
9
|
+
* 读取本机参考图或遮罩,并按文件内容识别格式,不信任文件扩展名。
|
|
10
|
+
* @param value 本机绝对路径或主目录路径,不接受远程 URL。
|
|
11
|
+
* @returns 原始字节、图片元数据和供 SDK 上传的文件对象;不会修改源文件。
|
|
12
|
+
* @throws 文件不可读、为空、过大,或元数据无法解析、格式不受支持时抛出错误。
|
|
13
|
+
*/
|
|
14
|
+
export async function readImage(value) {
|
|
15
|
+
const filePath = localPath(value);
|
|
16
|
+
const file = await open(filePath, "r");
|
|
17
|
+
let bytes;
|
|
18
|
+
try {
|
|
19
|
+
const stat = await file.stat();
|
|
20
|
+
if (!stat.isFile() || stat.size === 0 || stat.size >= MAX_IMAGE_BYTES) {
|
|
21
|
+
throw new Error("输入必须是非空图片文件,且小于 50 MiB。");
|
|
22
|
+
}
|
|
23
|
+
bytes = await file.readFile();
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await file.close();
|
|
27
|
+
}
|
|
28
|
+
// 文件可能在 stat 后被其他程序写入,读取后再次检查实际字节数。
|
|
29
|
+
if (bytes.length >= MAX_IMAGE_BYTES)
|
|
30
|
+
throw new Error("输入图片必须小于 50 MiB。");
|
|
31
|
+
const metadata = await sharp(bytes).metadata().catch(() => {
|
|
32
|
+
throw new Error("无法解析输入图片,请检查文件是否损坏以及实际格式是否受支持。");
|
|
33
|
+
});
|
|
34
|
+
if (!["png", "jpeg", "webp"].includes(metadata.format)) {
|
|
35
|
+
throw new Error("仅支持 PNG、JPEG 和 WebP 图片,请检查文件实际格式。");
|
|
36
|
+
}
|
|
37
|
+
const mimeType = `image/${metadata.format}`;
|
|
38
|
+
return { bytes, metadata, upload: await toFile(bytes, path.basename(filePath), { type: mimeType }) };
|
|
39
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import OpenAI from "openai";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { createApiClient } from "./client.js";
|
|
5
|
+
import { checkEndpoint, diagnosticSchema } from "./diagnostics.js";
|
|
6
|
+
import { editSchema, generateSchema, ImageService } from "./images.js";
|
|
7
|
+
/** 返回已落盘文件的元数据,避免将大段 Base64 放入模型上下文。 */
|
|
8
|
+
const outputSchema = z.object({
|
|
9
|
+
images: z.array(z.object({
|
|
10
|
+
path: z.string(), uri: z.string(), mimeType: z.string(),
|
|
11
|
+
bytes: z.number(), width: z.number(), height: z.number(),
|
|
12
|
+
})),
|
|
13
|
+
model: z.string(),
|
|
14
|
+
});
|
|
15
|
+
/** 统一 MCP 的结构化及文本结果,并将已知业务错误转为可供客户端展示的说明。 */
|
|
16
|
+
async function toolResult(operation) {
|
|
17
|
+
try {
|
|
18
|
+
const result = await operation();
|
|
19
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result };
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
let message = "图片处理失败,请检查输入文件及服务配置。";
|
|
23
|
+
if (error instanceof OpenAI.APIConnectionTimeoutError) {
|
|
24
|
+
message = "图片 API 调用超时;请求可能仍在上游处理中,重新调用可能再次计费。";
|
|
25
|
+
}
|
|
26
|
+
else if (error instanceof OpenAI.APIConnectionError) {
|
|
27
|
+
message = "无法连接图片 API,请检查网络连接。";
|
|
28
|
+
}
|
|
29
|
+
else if (error instanceof OpenAI.APIUserAbortError || (error instanceof Error && error.name === "AbortError")) {
|
|
30
|
+
message = "图片请求已取消;已提交到上游的生成任务可能仍会计费。";
|
|
31
|
+
}
|
|
32
|
+
else if (error instanceof OpenAI.APIError) {
|
|
33
|
+
// 仅回传状态和错误码,避免服务商的原始错误包含凭证或请求内容。
|
|
34
|
+
message = `图片 API 调用失败(状态码:${error.status ?? "未知"},错误码:${error.code ?? "未知"})。请检查密钥、模型权限、额度或输入内容。`;
|
|
35
|
+
}
|
|
36
|
+
else if (error instanceof Error && !error.message.includes("ENOENT") && !error.message.includes("EACCES")) {
|
|
37
|
+
message = error.message;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
message = "无法读取本地图片,请检查文件是否存在以及服务是否有读取权限。";
|
|
41
|
+
}
|
|
42
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 注册端点检查、文生图和编辑工具,创建过程不访问网络。
|
|
47
|
+
* @param config 已校验的用户配置。
|
|
48
|
+
* @param client 可选注入的 API 客户端;默认根据 config 独立创建。
|
|
49
|
+
* @returns 尚未连接传输层的 MCP 服务,由入口负责连接 stdio。
|
|
50
|
+
*/
|
|
51
|
+
export function createServer(config, client = createApiClient(config)) {
|
|
52
|
+
const service = new ImageService(client, config);
|
|
53
|
+
const server = new McpServer({ name: "gpt-image-mcp", version: "0.1.0" });
|
|
54
|
+
const annotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
55
|
+
server.registerTool("check_endpoint", {
|
|
56
|
+
title: "端点检查",
|
|
57
|
+
description: "请求当前端点的模型列表,检查响应格式和模型是否在列表中。不会发送生图或编辑请求;即使检查通过,图片能力仍需实际调用验证。",
|
|
58
|
+
inputSchema: z.object({}).strict(), outputSchema: diagnosticSchema,
|
|
59
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
60
|
+
}, (_, context) => toolResult(() => checkEndpoint(client, config, context.mcpReq.signal)));
|
|
61
|
+
server.registerTool("generate_image", {
|
|
62
|
+
title: "文生图",
|
|
63
|
+
description: "根据提示词调用 GPT Image 生成一张图片,保存到本机并返回绝对路径和文件 URI。调用会产生 API 费用。",
|
|
64
|
+
inputSchema: generateSchema, outputSchema, annotations,
|
|
65
|
+
}, (input, context) => toolResult(() => service.generate(input, context.mcpReq.signal)));
|
|
66
|
+
server.registerTool("edit_image", {
|
|
67
|
+
title: "编辑与参考创作",
|
|
68
|
+
description: "读取本机图片,按提示词编辑、替换背景,或参考风格和构图生成新图。请明确各参考图的作用和需要保留的内容;可传遮罩引导局部编辑。保存新文件并返回绝对路径,不覆盖原图。调用会产生 API 费用。",
|
|
69
|
+
inputSchema: editSchema, outputSchema, annotations,
|
|
70
|
+
}, (input, context) => toolResult(() => service.edit(input, context.mcpReq.signal)));
|
|
71
|
+
return server;
|
|
72
|
+
}
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { randomBytes, randomInt } from "node:crypto";
|
|
2
|
+
import { mkdir, open, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
const adjectives = ["cozy", "dreamy", "jolly", "wobbly", "sparkly", "sassy", "fuzzy", "bouncy"];
|
|
6
|
+
const creatures = ["otter", "panda", "fox", "penguin", "capybara", "badger", "owl", "axolotl"];
|
|
7
|
+
const actions = ["paints", "conjures", "juggles", "sprinkles", "whisks", "doodles", "chases", "brews"];
|
|
8
|
+
const wonders = ["moonlight", "stardust", "rainbows", "marshmallows", "daydreams", "confetti", "clouds", "sunbeams"];
|
|
9
|
+
/**
|
|
10
|
+
* 使用本地词表和随机短码生成文件名主体,不调用模型、不携带提示词。
|
|
11
|
+
* @returns 不含目录和扩展名的跨平台名称;重名由保存时的独占创建处理。
|
|
12
|
+
*/
|
|
13
|
+
export function playfulName() {
|
|
14
|
+
const words = [adjectives, creatures, actions, wonders].map((items) => items[randomInt(items.length)]);
|
|
15
|
+
// 只使用小写 ASCII 和连字符,避开 Windows 保留名、大小写冲突和路径转义。
|
|
16
|
+
return `${words.join("-")}-${randomBytes(4).toString("hex")}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 将一张图片保存到本地日期目录,只创建新文件,不覆盖已有图片。
|
|
20
|
+
* @param directory 已规范化的输出根目录绝对路径。
|
|
21
|
+
* @param bytes 已由调用方校验格式的图片字节。
|
|
22
|
+
* @param format 实际编码格式;jpeg 对应 .jpg 扩展名。
|
|
23
|
+
* @returns 写入并关闭文件后的绝对路径、文件 URI、媒体类型和字节数。
|
|
24
|
+
* @throws 创建目录、写入文件失败或连续分配重名文件时抛出错误。
|
|
25
|
+
*/
|
|
26
|
+
export async function saveImage(directory, bytes, format) {
|
|
27
|
+
// 使用实际保存时的本地日期,避免 UTC 日期在午夜附近偏移一天。
|
|
28
|
+
const now = new Date();
|
|
29
|
+
const datedDirectory = path.join(directory, String(now.getFullYear()).padStart(4, "0"), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0"));
|
|
30
|
+
await mkdir(datedDirectory, { recursive: true });
|
|
31
|
+
const extension = format === "jpeg" ? "jpg" : format;
|
|
32
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
33
|
+
const filePath = path.join(datedDirectory, `${playfulName()}.${extension}`);
|
|
34
|
+
let file;
|
|
35
|
+
try {
|
|
36
|
+
// 独占创建,随机名即使碰撞也不会覆盖旧图片。
|
|
37
|
+
file = await open(filePath, "wx", 0o600);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST")
|
|
41
|
+
continue;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
await file.writeFile(bytes);
|
|
46
|
+
await file.close();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
// 仅清理本次已独占创建的文件,避免写入失败后留下半成品。
|
|
50
|
+
await file.close().catch(() => { });
|
|
51
|
+
await unlink(filePath).catch(() => { });
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
return { path: filePath, uri: pathToFileURL(filePath).href, mimeType: `image/${format}`, bytes: bytes.length };
|
|
55
|
+
}
|
|
56
|
+
throw new Error("无法分配不重名的图片文件,请重新尝试。");
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gpt-image-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "支持文生图、图片编辑和参考风格生成的本地 MCP 服务",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "WainZeng",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/zK0G0w/gpt-image-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"mcp",
|
|
13
|
+
"openai",
|
|
14
|
+
"gpt-image",
|
|
15
|
+
"image-generation",
|
|
16
|
+
"image-editing",
|
|
17
|
+
"ai",
|
|
18
|
+
"claude",
|
|
19
|
+
"model-context-protocol"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": { "gpt-image-mcp": "dist/index.js" },
|
|
23
|
+
"files": ["dist", "README.md", "LICENSE", ".env.example"],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc -p tsconfig.build.json",
|
|
29
|
+
"start": "node dist/index.js",
|
|
30
|
+
"dev": "node --import tsx src/index.ts",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
33
|
+
"test:package": "npm run build && node scripts/verify-package.mjs",
|
|
34
|
+
"check": "npm run typecheck && npm test && npm run build",
|
|
35
|
+
"prepack": "npm run build"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
39
|
+
"openai": "^7.13.0",
|
|
40
|
+
"sharp": "^0.35.4",
|
|
41
|
+
"zod": "^4.6.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@modelcontextprotocol/client": "^2.0.0",
|
|
45
|
+
"@types/node": "^22.20.2",
|
|
46
|
+
"tsx": "^4.23.13",
|
|
47
|
+
"typescript": "^7.0.2"
|
|
48
|
+
}
|
|
49
|
+
}
|