imagen-switch-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 +21 -0
- package/README.md +331 -0
- package/dist/index.js +723 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 imagen-switch-mcp contributors
|
|
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,331 @@
|
|
|
1
|
+
# imagen-switch-mcp
|
|
2
|
+
|
|
3
|
+
`imagen-switch-mcp` 是一个 TypeScript 实现的 MCP server,用环境变量把任意 MCP Agent 连接到图像生成/编辑端点。
|
|
4
|
+
|
|
5
|
+
它内置 OpenAI Images、OpenAI 兼容网关、Gemini 图像接口,并提供 `custom` 数据驱动适配器,用于接入长尾 Provider。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- MCP 工具:`generate_image` 与 `edit_image`
|
|
10
|
+
- Provider:`openai`、`gemini`、`custom`
|
|
11
|
+
- 输入图:本地路径、data URL、裸 base64、HTTP(S) URL
|
|
12
|
+
- 输出:默认保存到本地并返回绝对路径,可选内联返回 MCP image 内容块
|
|
13
|
+
- HTTP:统一认证注入、超时、429/5xx/网络错误重试、友好错误信息与 API key 脱敏
|
|
14
|
+
- 分发:发布到 npm 后可通过 `npx -y imagen-switch-mcp` 直接启动
|
|
15
|
+
|
|
16
|
+
## 快速开始
|
|
17
|
+
|
|
18
|
+
### 作为 npm 包使用
|
|
19
|
+
|
|
20
|
+
发布后,在 Agent 的 MCP 配置里使用:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"imagen": {
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "imagen-switch-mcp"],
|
|
28
|
+
"env": {
|
|
29
|
+
"IMAGEN_FORMAT": "openai",
|
|
30
|
+
"IMAGEN_BASE_URL": "https://api.openai.com/v1",
|
|
31
|
+
"IMAGEN_API_KEY": "sk-...",
|
|
32
|
+
"IMAGEN_MODEL": "gpt-image-2",
|
|
33
|
+
"IMAGEN_OUTPUT_DIR": "D:/generated/imagen"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 本地开发版本使用
|
|
41
|
+
|
|
42
|
+
还没发布到 npm 时,先构建:
|
|
43
|
+
|
|
44
|
+
```powershell
|
|
45
|
+
cd D:\Repo\projects\imagen-switch
|
|
46
|
+
npm install
|
|
47
|
+
npm run build
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
然后让 Agent 指向本地构建产物:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"imagen": {
|
|
56
|
+
"command": "node",
|
|
57
|
+
"args": ["D:/Repo/projects/imagen-switch/dist/index.js"],
|
|
58
|
+
"env": {
|
|
59
|
+
"IMAGEN_FORMAT": "openai",
|
|
60
|
+
"IMAGEN_BASE_URL": "https://api.openai.com/v1",
|
|
61
|
+
"IMAGEN_API_KEY": "sk-...",
|
|
62
|
+
"IMAGEN_MODEL": "gpt-image-2",
|
|
63
|
+
"IMAGEN_OUTPUT_DIR": "D:/Repo/projects/imagen-switch/.imagen"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
配置后重启 Agent。工具列表中应出现:
|
|
71
|
+
|
|
72
|
+
- `generate_image`
|
|
73
|
+
- `edit_image`
|
|
74
|
+
|
|
75
|
+
## 工具
|
|
76
|
+
|
|
77
|
+
### `generate_image`
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
generate_image(prompt, model?, size?, n?, output_path?, ...provider 参数)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
OpenAI 格式常用参数:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
generate_image(
|
|
87
|
+
prompt="一只透明背景的极简柴犬贴纸",
|
|
88
|
+
background="transparent",
|
|
89
|
+
output_format="png"
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### `edit_image`
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
edit_image(prompt, images[], mask?, model?, size?, n?, output_path?, ...provider 参数)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`images` 支持:
|
|
100
|
+
|
|
101
|
+
- 本地文件路径
|
|
102
|
+
- `data:image/png;base64,...`
|
|
103
|
+
- 裸 base64
|
|
104
|
+
- `https://...`
|
|
105
|
+
|
|
106
|
+
## 环境变量
|
|
107
|
+
|
|
108
|
+
| 变量 | 说明 | 默认 |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| `IMAGEN_FORMAT` | `openai`、`gemini`、`custom` | `openai` |
|
|
111
|
+
| `IMAGEN_BASE_URL` | Provider API 基址 | openai/gemini 有内置默认 |
|
|
112
|
+
| `IMAGEN_API_KEY` | Provider API key | 无 |
|
|
113
|
+
| `IMAGEN_MODEL` | 默认模型 | 无 |
|
|
114
|
+
| `IMAGEN_OUTPUT_DIR` | 图像保存目录 | 系统临时目录下 `imagen-switch/` |
|
|
115
|
+
| `IMAGEN_RETURN_INLINE` | 是否同时返回 MCP image 内容块 | `false` |
|
|
116
|
+
| `IMAGEN_TIMEOUT_MS` | 单次请求超时 | `120000` |
|
|
117
|
+
| `IMAGEN_MAX_RETRIES` | 429/5xx/网络错误重试次数 | `2` |
|
|
118
|
+
| `IMAGEN_EXTRA_BODY` | 额外 JSON body,JSON 对象字符串 | `{}` |
|
|
119
|
+
| `IMAGEN_EXTRA_HEADERS` | 额外 headers,JSON 对象字符串 | `{}` |
|
|
120
|
+
| `IMAGEN_EXTRA_QUERY` | 额外 query,JSON 对象字符串 | `{}` |
|
|
121
|
+
|
|
122
|
+
`custom` 专用变量:
|
|
123
|
+
|
|
124
|
+
| 变量 | 说明 |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| `IMAGEN_CUSTOM_GENERATE_PATH` | 生成接口路径,如 `/v1/text2image` |
|
|
127
|
+
| `IMAGEN_CUSTOM_EDIT_PATH` | 编辑接口路径;配置后才注册 `edit_image` |
|
|
128
|
+
| `IMAGEN_CUSTOM_ENCODING` | `json` 或 `multipart` |
|
|
129
|
+
| `IMAGEN_CUSTOM_BODY_TEMPLATE` | 请求体模板,支持 `{{prompt}}`、`{{model}}`、`{{size}}`、`{{n}}`、`{{image}}` |
|
|
130
|
+
| `IMAGEN_CUSTOM_RESPONSE_IMAGES_PATH` | 响应图像路径,如 `output.images[*]` |
|
|
131
|
+
| `IMAGEN_CUSTOM_RESPONSE_IMAGE_KIND` | `b64`、`url`、`dataurl` |
|
|
132
|
+
|
|
133
|
+
完整设计背景见 [设计文档](docs/superpowers/specs/2026-06-23-imagen-switch-mcp-design.md)。
|
|
134
|
+
|
|
135
|
+
## Provider 配方
|
|
136
|
+
|
|
137
|
+
### OpenAI
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"IMAGEN_FORMAT": "openai",
|
|
142
|
+
"IMAGEN_BASE_URL": "https://api.openai.com/v1",
|
|
143
|
+
"IMAGEN_API_KEY": "sk-...",
|
|
144
|
+
"IMAGEN_MODEL": "gpt-image-2"
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### OpenAI 兼容网关
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
{
|
|
152
|
+
"IMAGEN_FORMAT": "openai",
|
|
153
|
+
"IMAGEN_BASE_URL": "https://your-gateway.example/v1",
|
|
154
|
+
"IMAGEN_API_KEY": "...",
|
|
155
|
+
"IMAGEN_MODEL": "flux.1-schnell"
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Gemini
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"IMAGEN_FORMAT": "gemini",
|
|
164
|
+
"IMAGEN_BASE_URL": "https://generativelanguage.googleapis.com/v1beta",
|
|
165
|
+
"IMAGEN_API_KEY": "...",
|
|
166
|
+
"IMAGEN_MODEL": "gemini-2.5-flash-image"
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### custom
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
{
|
|
174
|
+
"IMAGEN_FORMAT": "custom",
|
|
175
|
+
"IMAGEN_BASE_URL": "https://api.example.com",
|
|
176
|
+
"IMAGEN_API_KEY": "...",
|
|
177
|
+
"IMAGEN_CUSTOM_GENERATE_PATH": "/v1/text2image",
|
|
178
|
+
"IMAGEN_CUSTOM_ENCODING": "json",
|
|
179
|
+
"IMAGEN_CUSTOM_BODY_TEMPLATE": "{\"model\":\"{{model}}\",\"prompt\":\"{{prompt}}\",\"num\":{{n}}}",
|
|
180
|
+
"IMAGEN_CUSTOM_RESPONSE_IMAGES_PATH": "output.images[*]",
|
|
181
|
+
"IMAGEN_CUSTOM_RESPONSE_IMAGE_KIND": "url"
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## 真实端点人工测试
|
|
186
|
+
|
|
187
|
+
真实端点测试会消耗 Provider 额度。不要把 API key 写进仓库文件。
|
|
188
|
+
|
|
189
|
+
先构建:
|
|
190
|
+
|
|
191
|
+
```powershell
|
|
192
|
+
npm install
|
|
193
|
+
npm run build
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
设置环境变量,以 OpenAI 为例:
|
|
197
|
+
|
|
198
|
+
```powershell
|
|
199
|
+
$env:IMAGEN_FORMAT="openai"
|
|
200
|
+
$env:IMAGEN_BASE_URL="https://api.openai.com/v1"
|
|
201
|
+
$env:IMAGEN_API_KEY="你的真实 API Key"
|
|
202
|
+
$env:IMAGEN_MODEL="gpt-image-2"
|
|
203
|
+
$env:IMAGEN_OUTPUT_DIR="D:\Repo\projects\imagen-switch\.imagen-real"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
可以使用仓库根目录的 `tmp-real-test.mjs`,也可以临时创建同名文件。通用测试脚本如下:
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
210
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
211
|
+
|
|
212
|
+
const transport = new StdioClientTransport({
|
|
213
|
+
command: "node",
|
|
214
|
+
args: ["dist/index.js"],
|
|
215
|
+
env: process.env,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const client = new Client({ name: "real-test", version: "0.0.0" });
|
|
219
|
+
await client.connect(transport);
|
|
220
|
+
|
|
221
|
+
const tools = await client.listTools();
|
|
222
|
+
console.log("tools:", tools.tools.map((tool) => tool.name));
|
|
223
|
+
|
|
224
|
+
const result = await client.callTool(
|
|
225
|
+
{
|
|
226
|
+
name: "generate_image",
|
|
227
|
+
arguments: {
|
|
228
|
+
prompt: "一只透明背景的极简柴犬贴纸",
|
|
229
|
+
background: "transparent",
|
|
230
|
+
output_format: "png",
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
undefined,
|
|
234
|
+
{ timeout: 300_000 },
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
console.dir(result, { depth: null });
|
|
238
|
+
await client.close();
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
运行:
|
|
242
|
+
|
|
243
|
+
```powershell
|
|
244
|
+
node .\tmp-real-test.mjs
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
成功时返回文本中会包含保存路径,例如:
|
|
248
|
+
|
|
249
|
+
```text
|
|
250
|
+
已生成 1 张图像(provider=openai, model=gpt-image-2):
|
|
251
|
+
- D:\Repo\projects\imagen-switch\.imagen-real\imagen-0000000000000-0.png
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
测试结束后清理环境变量:
|
|
255
|
+
|
|
256
|
+
```powershell
|
|
257
|
+
Remove-Item Env:\IMAGEN_FORMAT,Env:\IMAGEN_BASE_URL,Env:\IMAGEN_API_KEY,Env:\IMAGEN_MODEL,Env:\IMAGEN_OUTPUT_DIR -ErrorAction SilentlyContinue
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## 开发
|
|
261
|
+
|
|
262
|
+
```powershell
|
|
263
|
+
npm install
|
|
264
|
+
npm test
|
|
265
|
+
npm run typecheck
|
|
266
|
+
npm run build
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
常用脚本:
|
|
270
|
+
|
|
271
|
+
| 命令 | 说明 |
|
|
272
|
+
| --- | --- |
|
|
273
|
+
| `npm test` | 运行 Vitest |
|
|
274
|
+
| `npm run typecheck` | TypeScript 类型检查 |
|
|
275
|
+
| `npm run build` | 通过 tsup 构建 `dist/index.js` |
|
|
276
|
+
| `npm run ci` | 测试、类型检查、构建 |
|
|
277
|
+
| `npm run publish:dry-run` | 本地检查 npm 发布包内容 |
|
|
278
|
+
|
|
279
|
+
目录结构:
|
|
280
|
+
|
|
281
|
+
```text
|
|
282
|
+
src/
|
|
283
|
+
adapters/ Provider 适配器与 registry
|
|
284
|
+
config.ts 环境变量解析与校验
|
|
285
|
+
errors.ts 友好错误与 API key 脱敏
|
|
286
|
+
http.ts fetch、认证、超时、重试、下载
|
|
287
|
+
input.ts 输入图解析
|
|
288
|
+
output.ts 图像 materialize、落盘、工具返回
|
|
289
|
+
server.ts MCP server 工具注册与流水线
|
|
290
|
+
index.ts stdio 入口
|
|
291
|
+
test/
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## 发布到 npm
|
|
295
|
+
|
|
296
|
+
发布前确认:
|
|
297
|
+
|
|
298
|
+
```powershell
|
|
299
|
+
npm run ci
|
|
300
|
+
npm run publish:dry-run
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
手动发布:
|
|
304
|
+
|
|
305
|
+
```powershell
|
|
306
|
+
npm publish --provenance --access public
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
自动发布使用 `.github/workflows/npm-publish.yml`,只监听 `main` 分支。
|
|
310
|
+
|
|
311
|
+
当前 workflow 使用 npm automation token,适合包的首次发布,也能避开 publish 2FA 在 CI 中要求一次性验证码的问题:
|
|
312
|
+
|
|
313
|
+
1. 在 npm 创建 `Automation` 类型的 access token。
|
|
314
|
+
2. 在 GitHub 仓库设置 `Settings -> Secrets and variables -> Actions` 中新增 secret:`NPM_AUTOMATION_TOKEN`。
|
|
315
|
+
3. 不要使用普通 classic/granular publish token;如果账号开启了 publish 2FA,这类 token 会在 CI 中触发 `EOTP`,因为 Action 无法输入一次性验证码。
|
|
316
|
+
|
|
317
|
+
发布成功后,如果想改成 npm Trusted Publishing,可以在 npm package 的发布设置里添加 GitHub Actions trusted publisher,并相应调整 workflow 移除 token 校验。
|
|
318
|
+
|
|
319
|
+
合并到 `main` 后,Action 会运行 `npm run ci`。如果 `package.json` 中的版本还没有发布过,Action 会执行 `npm publish --provenance --access public`;如果版本已存在于 npm,Action 会跳过发布,避免主分支文档更新导致失败。
|
|
320
|
+
|
|
321
|
+
发布新版本时,请先更新 `package.json` 里的 `version`,再合并到主分支。
|
|
322
|
+
|
|
323
|
+
## 安全
|
|
324
|
+
|
|
325
|
+
- 不要把 `IMAGEN_API_KEY` 写入仓库。
|
|
326
|
+
- `.env`、`.env.*`、`.imagen/`、`.imagen-real/` 已被忽略。
|
|
327
|
+
- 错误信息会对已配置的 API key 做脱敏处理。
|
|
328
|
+
|
|
329
|
+
## License
|
|
330
|
+
|
|
331
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// src/errors.ts
|
|
8
|
+
var ConfigError = class extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "ConfigError";
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
var ProviderError = class extends Error {
|
|
15
|
+
status;
|
|
16
|
+
constructor(message, status) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "ProviderError";
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function redactKey(text, apiKey) {
|
|
23
|
+
if (!apiKey) return text;
|
|
24
|
+
return text.split(apiKey).join("***");
|
|
25
|
+
}
|
|
26
|
+
function friendlyHttpError(status, providerBody) {
|
|
27
|
+
const body = providerBody.slice(0, 800);
|
|
28
|
+
if (status === 401 || status === 403) {
|
|
29
|
+
return `\u8BA4\u8BC1\u5931\u8D25 (HTTP ${status})\uFF1A\u8BF7\u68C0\u67E5 IMAGEN_API_KEY / IMAGEN_AUTH_*\u3002Provider \u8FD4\u56DE\uFF1A${body}`;
|
|
30
|
+
}
|
|
31
|
+
if (status === 429) {
|
|
32
|
+
return `\u89E6\u53D1\u9650\u6D41 (HTTP 429)\uFF1A\u8BF7\u7A0D\u540E\u91CD\u8BD5\u6216\u964D\u4F4E\u9891\u7387\u3002Provider \u8FD4\u56DE\uFF1A${body}`;
|
|
33
|
+
}
|
|
34
|
+
if (status === 400) {
|
|
35
|
+
return `\u8BF7\u6C42\u53C2\u6570\u88AB\u62D2 (HTTP 400)\uFF1A${body}`;
|
|
36
|
+
}
|
|
37
|
+
return `Provider \u8FD4\u56DE\u9519\u8BEF (HTTP ${status})\uFF1A${body}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/config.ts
|
|
41
|
+
function parseJsonEnv(name, value) {
|
|
42
|
+
if (!value) return {};
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(value);
|
|
45
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
46
|
+
throw new Error("not an object");
|
|
47
|
+
}
|
|
48
|
+
return parsed;
|
|
49
|
+
} catch (e) {
|
|
50
|
+
throw new ConfigError(`\u73AF\u5883\u53D8\u91CF ${name} \u4E0D\u662F\u5408\u6CD5\u7684 JSON \u5BF9\u8C61\uFF1A${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function parseIntEnv(name, value, fallback) {
|
|
54
|
+
if (value === void 0 || value === "") return fallback;
|
|
55
|
+
const n = Number(value);
|
|
56
|
+
if (!Number.isFinite(n)) {
|
|
57
|
+
throw new ConfigError(`\u73AF\u5883\u53D8\u91CF ${name} \u5FC5\u987B\u662F\u6570\u5B57\uFF0C\u6536\u5230\uFF1A${value}`);
|
|
58
|
+
}
|
|
59
|
+
return n;
|
|
60
|
+
}
|
|
61
|
+
function optionalCustomImageKind(value) {
|
|
62
|
+
if (value === void 0 || value === "") return void 0;
|
|
63
|
+
if (value === "b64" || value === "url" || value === "dataurl") return value;
|
|
64
|
+
throw new ConfigError(`IMAGEN_CUSTOM_RESPONSE_IMAGE_KIND \u5FC5\u987B\u662F b64|url|dataurl\uFF0C\u6536\u5230\uFF1A${value}`);
|
|
65
|
+
}
|
|
66
|
+
function loadRawConfig(env) {
|
|
67
|
+
const style = env.IMAGEN_AUTH_STYLE;
|
|
68
|
+
if (style && !["bearer", "header", "query", "none"].includes(style)) {
|
|
69
|
+
throw new ConfigError(`IMAGEN_AUTH_STYLE \u5FC5\u987B\u662F bearer|header|query|none\uFF0C\u6536\u5230\uFF1A${style}`);
|
|
70
|
+
}
|
|
71
|
+
const encoding = env.IMAGEN_CUSTOM_ENCODING ?? "json";
|
|
72
|
+
if (!["json", "multipart"].includes(encoding)) {
|
|
73
|
+
throw new ConfigError(`IMAGEN_CUSTOM_ENCODING \u5FC5\u987B\u662F json|multipart\uFF0C\u6536\u5230\uFF1A${encoding}`);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
format: env.IMAGEN_FORMAT ?? "openai",
|
|
77
|
+
baseUrl: env.IMAGEN_BASE_URL,
|
|
78
|
+
apiKey: env.IMAGEN_API_KEY,
|
|
79
|
+
model: env.IMAGEN_MODEL,
|
|
80
|
+
authStyle: style,
|
|
81
|
+
authHeaderName: env.IMAGEN_AUTH_HEADER_NAME,
|
|
82
|
+
authQueryName: env.IMAGEN_AUTH_QUERY_NAME,
|
|
83
|
+
outputDir: env.IMAGEN_OUTPUT_DIR,
|
|
84
|
+
returnInline: env.IMAGEN_RETURN_INLINE === "true",
|
|
85
|
+
timeoutMs: parseIntEnv("IMAGEN_TIMEOUT_MS", env.IMAGEN_TIMEOUT_MS, 12e4),
|
|
86
|
+
maxRetries: parseIntEnv("IMAGEN_MAX_RETRIES", env.IMAGEN_MAX_RETRIES, 2),
|
|
87
|
+
extraBody: parseJsonEnv("IMAGEN_EXTRA_BODY", env.IMAGEN_EXTRA_BODY),
|
|
88
|
+
extraHeaders: parseJsonEnv("IMAGEN_EXTRA_HEADERS", env.IMAGEN_EXTRA_HEADERS),
|
|
89
|
+
extraQuery: parseJsonEnv("IMAGEN_EXTRA_QUERY", env.IMAGEN_EXTRA_QUERY),
|
|
90
|
+
custom: {
|
|
91
|
+
generatePath: env.IMAGEN_CUSTOM_GENERATE_PATH,
|
|
92
|
+
editPath: env.IMAGEN_CUSTOM_EDIT_PATH,
|
|
93
|
+
encoding,
|
|
94
|
+
bodyTemplate: env.IMAGEN_CUSTOM_BODY_TEMPLATE,
|
|
95
|
+
responseImagesPath: env.IMAGEN_CUSTOM_RESPONSE_IMAGES_PATH,
|
|
96
|
+
responseImageKind: optionalCustomImageKind(env.IMAGEN_CUSTOM_RESPONSE_IMAGE_KIND)
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function resolveBaseUrl(raw, defaultBaseUrl) {
|
|
101
|
+
const url = raw.baseUrl ?? defaultBaseUrl;
|
|
102
|
+
if (!url) {
|
|
103
|
+
throw new ConfigError(`\u7F3A\u5C11 IMAGEN_BASE_URL\uFF0C\u4E14 format "${raw.format}" \u65E0\u5185\u7F6E\u9ED8\u8BA4\u503C`);
|
|
104
|
+
}
|
|
105
|
+
return url.replace(/\/+$/, "");
|
|
106
|
+
}
|
|
107
|
+
function resolveAuth(raw, defaults) {
|
|
108
|
+
return {
|
|
109
|
+
style: raw.authStyle ?? defaults.style,
|
|
110
|
+
headerName: raw.authHeaderName ?? defaults.headerName,
|
|
111
|
+
queryName: raw.authQueryName ?? defaults.queryName,
|
|
112
|
+
apiKey: raw.apiKey
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/server.ts
|
|
117
|
+
import { tmpdir } from "os";
|
|
118
|
+
import { join as join2 } from "path";
|
|
119
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
120
|
+
import { z as z2 } from "zod";
|
|
121
|
+
|
|
122
|
+
// src/adapters/openai.ts
|
|
123
|
+
import { z } from "zod";
|
|
124
|
+
var OPENAI_META = {
|
|
125
|
+
format: "openai",
|
|
126
|
+
defaultBaseUrl: "https://api.openai.com/v1",
|
|
127
|
+
defaultAuth: { style: "bearer" },
|
|
128
|
+
supportsEdit: true,
|
|
129
|
+
extraParams: {
|
|
130
|
+
quality: z.string().optional().describe("standard/high (gpt-image-2); low/medium/high/auto (gpt-image-1); standard/hd (dall-e-3)"),
|
|
131
|
+
background: z.string().optional().describe("auto | transparent | opaque"),
|
|
132
|
+
thinking: z.string().optional().describe("off | low | medium | high"),
|
|
133
|
+
seed: z.number().int().optional().describe("int32"),
|
|
134
|
+
output_format: z.string().optional().describe("png | jpeg | webp"),
|
|
135
|
+
output_compression: z.number().int().optional().describe("0-100"),
|
|
136
|
+
moderation: z.string().optional().describe("auto | low"),
|
|
137
|
+
user: z.string().optional().describe("abuse detection identifier")
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
function parseResponse(raw) {
|
|
141
|
+
const data = raw?.data;
|
|
142
|
+
if (!Array.isArray(data)) return [];
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const item of data) {
|
|
145
|
+
const image = item;
|
|
146
|
+
if (typeof image.b64_json === "string") {
|
|
147
|
+
out.push({ kind: "b64", data: image.b64_json });
|
|
148
|
+
} else if (typeof image.url === "string") {
|
|
149
|
+
out.push({ kind: "url", data: image.url });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
function appendFormValue(fd, key, value) {
|
|
155
|
+
if (value === void 0 || value === null) return;
|
|
156
|
+
fd.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
157
|
+
}
|
|
158
|
+
function imageBlob(image) {
|
|
159
|
+
const copy = new Uint8Array(image.bytes.length);
|
|
160
|
+
copy.set(image.bytes);
|
|
161
|
+
return new Blob([copy.buffer], { type: image.mime });
|
|
162
|
+
}
|
|
163
|
+
function createOpenAiAdapter(baseUrl) {
|
|
164
|
+
return {
|
|
165
|
+
...OPENAI_META,
|
|
166
|
+
buildGenerate(req) {
|
|
167
|
+
const body = {
|
|
168
|
+
...req.params,
|
|
169
|
+
model: req.model,
|
|
170
|
+
prompt: req.prompt,
|
|
171
|
+
n: req.n
|
|
172
|
+
};
|
|
173
|
+
if (req.size) body.size = req.size;
|
|
174
|
+
return { method: "POST", url: `${baseUrl}/images/generations`, headers: {}, body };
|
|
175
|
+
},
|
|
176
|
+
buildEdit(req, images, mask) {
|
|
177
|
+
const fd = new FormData();
|
|
178
|
+
fd.set("prompt", req.prompt);
|
|
179
|
+
fd.set("model", req.model);
|
|
180
|
+
fd.set("n", String(req.n));
|
|
181
|
+
if (req.size) fd.set("size", req.size);
|
|
182
|
+
for (const [key, value] of Object.entries(req.params)) {
|
|
183
|
+
appendFormValue(fd, key, value);
|
|
184
|
+
}
|
|
185
|
+
const field = images.length > 1 ? "image[]" : "image";
|
|
186
|
+
for (const img of images) {
|
|
187
|
+
fd.append(field, imageBlob(img), "image.png");
|
|
188
|
+
}
|
|
189
|
+
if (mask) fd.set("mask", imageBlob(mask), "mask.png");
|
|
190
|
+
return { method: "POST", url: `${baseUrl}/images/edits`, headers: {}, body: fd };
|
|
191
|
+
},
|
|
192
|
+
parseResponse
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/adapters/gemini.ts
|
|
197
|
+
var GEMINI_META = {
|
|
198
|
+
format: "gemini",
|
|
199
|
+
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
200
|
+
defaultAuth: { style: "header", headerName: "x-goog-api-key" },
|
|
201
|
+
supportsEdit: true,
|
|
202
|
+
extraParams: {}
|
|
203
|
+
};
|
|
204
|
+
function bytesToB64(bytes) {
|
|
205
|
+
return Buffer.from(bytes).toString("base64");
|
|
206
|
+
}
|
|
207
|
+
function baseGenerationConfig(req) {
|
|
208
|
+
const existing = req.params.generationConfig ?? {};
|
|
209
|
+
const generationConfig = {
|
|
210
|
+
responseModalities: ["TEXT", "IMAGE"],
|
|
211
|
+
...existing
|
|
212
|
+
};
|
|
213
|
+
if (req.size) {
|
|
214
|
+
generationConfig.imageConfig = {
|
|
215
|
+
...generationConfig.imageConfig ?? {},
|
|
216
|
+
aspectRatio: req.size
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return generationConfig;
|
|
220
|
+
}
|
|
221
|
+
function parseResponse2(raw) {
|
|
222
|
+
const parts = raw?.candidates?.[0]?.content?.parts ?? [];
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const part of parts) {
|
|
225
|
+
const inline = part.inline_data ?? part.inlineData;
|
|
226
|
+
if (inline?.data) {
|
|
227
|
+
out.push({
|
|
228
|
+
kind: "b64",
|
|
229
|
+
data: inline.data,
|
|
230
|
+
mime: inline.mime_type ?? inline.mimeType ?? "image/png"
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
function createGeminiAdapter(baseUrl) {
|
|
237
|
+
return {
|
|
238
|
+
...GEMINI_META,
|
|
239
|
+
buildGenerate(req) {
|
|
240
|
+
const { generationConfig: _generationConfig, ...params } = req.params;
|
|
241
|
+
const body = {
|
|
242
|
+
contents: [{ parts: [{ text: req.prompt }] }],
|
|
243
|
+
...params,
|
|
244
|
+
generationConfig: baseGenerationConfig(req)
|
|
245
|
+
};
|
|
246
|
+
return { method: "POST", url: `${baseUrl}/models/${req.model}:generateContent`, headers: {}, body };
|
|
247
|
+
},
|
|
248
|
+
buildEdit(req, images, _mask) {
|
|
249
|
+
const { generationConfig: _generationConfig, ...params } = req.params;
|
|
250
|
+
const parts = [{ text: req.prompt }];
|
|
251
|
+
for (const img of images) {
|
|
252
|
+
parts.push({ inline_data: { mime_type: img.mime, data: bytesToB64(img.bytes) } });
|
|
253
|
+
}
|
|
254
|
+
const body = {
|
|
255
|
+
contents: [{ parts }],
|
|
256
|
+
...params,
|
|
257
|
+
generationConfig: baseGenerationConfig(req)
|
|
258
|
+
};
|
|
259
|
+
return { method: "POST", url: `${baseUrl}/models/${req.model}:generateContent`, headers: {}, body };
|
|
260
|
+
},
|
|
261
|
+
parseResponse: parseResponse2
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/adapters/jsonpath.ts
|
|
266
|
+
function extractPath(obj, path) {
|
|
267
|
+
const tokens = path.replace(/\[(\d+|\*)\]/g, ".$1").split(".").filter(Boolean);
|
|
268
|
+
let current = [obj];
|
|
269
|
+
for (const token of tokens) {
|
|
270
|
+
const next = [];
|
|
271
|
+
for (const node of current) {
|
|
272
|
+
if (node == null) continue;
|
|
273
|
+
if (token === "*") {
|
|
274
|
+
if (Array.isArray(node)) next.push(...node);
|
|
275
|
+
} else if (/^\d+$/.test(token)) {
|
|
276
|
+
if (Array.isArray(node)) {
|
|
277
|
+
const value = node[Number(token)];
|
|
278
|
+
if (value !== void 0) next.push(value);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
const value = node[token];
|
|
282
|
+
if (value !== void 0) next.push(value);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
current = next;
|
|
286
|
+
}
|
|
287
|
+
return current;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/adapters/custom.ts
|
|
291
|
+
var CUSTOM_META = {
|
|
292
|
+
format: "custom",
|
|
293
|
+
defaultAuth: { style: "bearer" },
|
|
294
|
+
extraParams: {}
|
|
295
|
+
};
|
|
296
|
+
function escapeTemplateString(value) {
|
|
297
|
+
return JSON.stringify(value).slice(1, -1);
|
|
298
|
+
}
|
|
299
|
+
function render(template, req) {
|
|
300
|
+
const filled = template.replace(/\{\{prompt\}\}/g, escapeTemplateString(req.prompt)).replace(/\{\{model\}\}/g, escapeTemplateString(req.model)).replace(/\{\{size\}\}/g, escapeTemplateString(req.size ?? "")).replace(/\{\{n\}\}/g, String(req.n));
|
|
301
|
+
try {
|
|
302
|
+
return JSON.parse(filled);
|
|
303
|
+
} catch (e) {
|
|
304
|
+
throw new ConfigError(`\u6E32\u67D3 IMAGEN_CUSTOM_BODY_TEMPLATE \u540E\u4E0D\u662F\u5408\u6CD5 JSON\uFF1A${e.message}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function appendFormValue2(fd, key, value) {
|
|
308
|
+
if (value === void 0 || value === null) return;
|
|
309
|
+
if (value instanceof Blob) {
|
|
310
|
+
fd.set(key, value);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
fd.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
314
|
+
}
|
|
315
|
+
function encodeBody(encoding, fields, params) {
|
|
316
|
+
const merged = { ...fields, ...params };
|
|
317
|
+
if (encoding === "json") return merged;
|
|
318
|
+
const fd = new FormData();
|
|
319
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
320
|
+
appendFormValue2(fd, key, value);
|
|
321
|
+
}
|
|
322
|
+
return fd;
|
|
323
|
+
}
|
|
324
|
+
function createCustomAdapter(baseUrl, custom) {
|
|
325
|
+
const kind = custom.responseImageKind ?? "url";
|
|
326
|
+
return {
|
|
327
|
+
...CUSTOM_META,
|
|
328
|
+
supportsEdit: Boolean(custom.editPath),
|
|
329
|
+
defaultBaseUrl: void 0,
|
|
330
|
+
buildGenerate(req) {
|
|
331
|
+
if (!custom.generatePath) throw new ConfigError("custom \u683C\u5F0F\u7F3A\u5C11 IMAGEN_CUSTOM_GENERATE_PATH");
|
|
332
|
+
if (!custom.bodyTemplate) throw new ConfigError("custom \u683C\u5F0F\u7F3A\u5C11 IMAGEN_CUSTOM_BODY_TEMPLATE");
|
|
333
|
+
const body = encodeBody(custom.encoding, render(custom.bodyTemplate, req), req.params);
|
|
334
|
+
return { method: "POST", url: `${baseUrl}${custom.generatePath}`, headers: {}, body };
|
|
335
|
+
},
|
|
336
|
+
buildEdit(req, images, _mask) {
|
|
337
|
+
if (!custom.editPath) throw new ConfigError("custom \u683C\u5F0F\u672A\u914D\u7F6E IMAGEN_CUSTOM_EDIT_PATH\uFF0C\u4E0D\u652F\u6301 edit");
|
|
338
|
+
if (!custom.bodyTemplate) throw new ConfigError("custom \u683C\u5F0F\u7F3A\u5C11 IMAGEN_CUSTOM_BODY_TEMPLATE");
|
|
339
|
+
const b64 = Buffer.from(images[0]?.bytes ?? new Uint8Array()).toString("base64");
|
|
340
|
+
const template = custom.bodyTemplate.replace(/\{\{image\}\}/g, escapeTemplateString(b64));
|
|
341
|
+
const body = encodeBody(custom.encoding, render(template, req), req.params);
|
|
342
|
+
return { method: "POST", url: `${baseUrl}${custom.editPath}`, headers: {}, body };
|
|
343
|
+
},
|
|
344
|
+
parseResponse(raw) {
|
|
345
|
+
if (!custom.responseImagesPath) throw new ConfigError("custom \u683C\u5F0F\u7F3A\u5C11 IMAGEN_CUSTOM_RESPONSE_IMAGES_PATH");
|
|
346
|
+
const values = extractPath(raw, custom.responseImagesPath);
|
|
347
|
+
return values.filter((value) => typeof value === "string").map((value) => {
|
|
348
|
+
if (kind === "url") return { kind: "url", data: value };
|
|
349
|
+
if (kind === "dataurl") {
|
|
350
|
+
const match = value.match(/^data:([^;]+);base64,(.+)$/s);
|
|
351
|
+
return { kind: "b64", data: match ? match[2] : value, mime: match?.[1] };
|
|
352
|
+
}
|
|
353
|
+
return { kind: "b64", data: value };
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/adapters/registry.ts
|
|
360
|
+
function createAdapter(raw) {
|
|
361
|
+
if (raw.format === "openai") {
|
|
362
|
+
const baseUrl = resolveBaseUrl(raw, OPENAI_META.defaultBaseUrl);
|
|
363
|
+
return { adapter: createOpenAiAdapter(baseUrl), baseUrl, auth: resolveAuth(raw, OPENAI_META.defaultAuth) };
|
|
364
|
+
}
|
|
365
|
+
if (raw.format === "gemini") {
|
|
366
|
+
const baseUrl = resolveBaseUrl(raw, GEMINI_META.defaultBaseUrl);
|
|
367
|
+
return { adapter: createGeminiAdapter(baseUrl), baseUrl, auth: resolveAuth(raw, GEMINI_META.defaultAuth) };
|
|
368
|
+
}
|
|
369
|
+
if (raw.format === "custom") {
|
|
370
|
+
const baseUrl = resolveBaseUrl(raw, void 0);
|
|
371
|
+
return { adapter: createCustomAdapter(baseUrl, raw.custom), baseUrl, auth: resolveAuth(raw, CUSTOM_META.defaultAuth) };
|
|
372
|
+
}
|
|
373
|
+
throw new ConfigError(`\u672A\u77E5 format / IMAGEN_FORMAT "${raw.format}"\uFF0C\u53D7\u652F\u6301\uFF1Aopenai | gemini | custom`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/adapters/overrides.ts
|
|
377
|
+
function applyOverrides(spec, extra) {
|
|
378
|
+
return {
|
|
379
|
+
...spec,
|
|
380
|
+
headers: { ...spec.headers, ...extra.headers },
|
|
381
|
+
query: { ...spec.query ?? {}, ...extra.query }
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// src/http.ts
|
|
386
|
+
function sleep(ms) {
|
|
387
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
388
|
+
}
|
|
389
|
+
function shouldRetryStatus(status) {
|
|
390
|
+
return status === 429 || status >= 500;
|
|
391
|
+
}
|
|
392
|
+
function appendQuery(url, query) {
|
|
393
|
+
const out = new URL(url);
|
|
394
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
395
|
+
out.searchParams.set(key, value);
|
|
396
|
+
}
|
|
397
|
+
return out.toString();
|
|
398
|
+
}
|
|
399
|
+
function applyAuth(url, headers, auth) {
|
|
400
|
+
if (auth.style === "none" || !auth.apiKey) return url;
|
|
401
|
+
if (auth.style === "bearer") {
|
|
402
|
+
headers.Authorization = `Bearer ${auth.apiKey}`;
|
|
403
|
+
return url;
|
|
404
|
+
}
|
|
405
|
+
if (auth.style === "header") {
|
|
406
|
+
headers[auth.headerName ?? "Authorization"] = auth.apiKey;
|
|
407
|
+
return url;
|
|
408
|
+
}
|
|
409
|
+
const queryName = auth.queryName ?? "key";
|
|
410
|
+
return appendQuery(url, { [queryName]: auth.apiKey });
|
|
411
|
+
}
|
|
412
|
+
function bodyAndHeaders(body, headers) {
|
|
413
|
+
if (body instanceof FormData) return body;
|
|
414
|
+
if (!Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) {
|
|
415
|
+
headers["Content-Type"] = "application/json";
|
|
416
|
+
}
|
|
417
|
+
return JSON.stringify(body);
|
|
418
|
+
}
|
|
419
|
+
async function withTimeoutFetch(url, init, timeoutMs) {
|
|
420
|
+
const controller = new AbortController();
|
|
421
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
422
|
+
try {
|
|
423
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
424
|
+
} finally {
|
|
425
|
+
clearTimeout(timer);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async function retrying(maxRetries, operation) {
|
|
429
|
+
let lastError;
|
|
430
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
431
|
+
try {
|
|
432
|
+
return await operation(attempt);
|
|
433
|
+
} catch (e) {
|
|
434
|
+
lastError = e;
|
|
435
|
+
if (e instanceof ProviderError && !shouldRetryStatus(e.status ?? 0)) break;
|
|
436
|
+
if (attempt === maxRetries) break;
|
|
437
|
+
await sleep(Math.min(50 * 2 ** attempt, 1e3));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
throw lastError;
|
|
441
|
+
}
|
|
442
|
+
async function sendRequest(spec, auth, opts) {
|
|
443
|
+
return retrying(opts.maxRetries, async () => {
|
|
444
|
+
const headers = { ...spec.headers };
|
|
445
|
+
let url = appendQuery(spec.url, spec.query);
|
|
446
|
+
url = applyAuth(url, headers, auth);
|
|
447
|
+
const response = await withTimeoutFetch(
|
|
448
|
+
url,
|
|
449
|
+
{
|
|
450
|
+
method: spec.method,
|
|
451
|
+
headers,
|
|
452
|
+
body: bodyAndHeaders(spec.body, headers)
|
|
453
|
+
},
|
|
454
|
+
opts.timeoutMs
|
|
455
|
+
);
|
|
456
|
+
if (!response.ok) {
|
|
457
|
+
const providerBody = redactKey(await response.text(), auth.apiKey);
|
|
458
|
+
throw new ProviderError(friendlyHttpError(response.status, providerBody), response.status);
|
|
459
|
+
}
|
|
460
|
+
return response.json();
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
async function downloadToBytes(url, opts) {
|
|
464
|
+
return retrying(opts.maxRetries, async () => {
|
|
465
|
+
const response = await withTimeoutFetch(url, { method: "GET" }, opts.timeoutMs);
|
|
466
|
+
if (!response.ok) {
|
|
467
|
+
throw new ProviderError(`\u56FE\u50CF\u4E0B\u8F7D\u5931\u8D25 (HTTP ${response.status})\uFF1A${await response.text()}`, response.status);
|
|
468
|
+
}
|
|
469
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
470
|
+
const mime = response.headers.get("content-type")?.split(";")[0] || "application/octet-stream";
|
|
471
|
+
return { bytes, mime };
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/input.ts
|
|
476
|
+
import { readFile } from "fs/promises";
|
|
477
|
+
function sniffMime(bytes) {
|
|
478
|
+
if (bytes[0] === 137 && bytes[1] === 80) return "image/png";
|
|
479
|
+
if (bytes[0] === 255 && bytes[1] === 216) return "image/jpeg";
|
|
480
|
+
if (bytes[0] === 71 && bytes[1] === 73) return "image/gif";
|
|
481
|
+
if (bytes[0] === 82 && bytes[1] === 73 && bytes[8] === 87) return "image/webp";
|
|
482
|
+
return "application/octet-stream";
|
|
483
|
+
}
|
|
484
|
+
var DATA_URL_RE = /^data:([^;]+);base64,(.+)$/s;
|
|
485
|
+
async function resolveImage(ref, timeoutMs) {
|
|
486
|
+
const dataMatch = ref.match(DATA_URL_RE);
|
|
487
|
+
if (dataMatch) {
|
|
488
|
+
const bytes = new Uint8Array(Buffer.from(dataMatch[2], "base64"));
|
|
489
|
+
return { bytes, mime: dataMatch[1] };
|
|
490
|
+
}
|
|
491
|
+
if (/^https?:\/\//.test(ref)) {
|
|
492
|
+
return downloadToBytes(ref, { timeoutMs, maxRetries: 0 });
|
|
493
|
+
}
|
|
494
|
+
const compact = ref.replace(/\s/g, "");
|
|
495
|
+
if (/^[A-Za-z0-9+/=]+$/.test(compact) && compact.length % 4 === 0 && compact.length > 16) {
|
|
496
|
+
const bytes = new Uint8Array(Buffer.from(compact, "base64"));
|
|
497
|
+
if (bytes.length > 0) return { bytes, mime: sniffMime(bytes) };
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
const buf = await readFile(ref);
|
|
501
|
+
const bytes = new Uint8Array(buf);
|
|
502
|
+
return { bytes, mime: sniffMime(bytes) };
|
|
503
|
+
} catch {
|
|
504
|
+
throw new ConfigError(`\u65E0\u6CD5\u89E3\u6790\u8F93\u5165\u56FE\uFF1A${ref}\uFF08\u65E2\u975E data URL / http URL / base64\uFF0C\u4E5F\u975E\u53EF\u8BFB\u6587\u4EF6\uFF09`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/output.ts
|
|
509
|
+
import { mkdir, stat, writeFile } from "fs/promises";
|
|
510
|
+
import { basename, dirname, extname, join, resolve } from "path";
|
|
511
|
+
function extFromMime(mime) {
|
|
512
|
+
if (mime.includes("jpeg") || mime.includes("jpg")) return "jpg";
|
|
513
|
+
if (mime.includes("webp")) return "webp";
|
|
514
|
+
if (mime.includes("gif")) return "gif";
|
|
515
|
+
if (mime.includes("png")) return "png";
|
|
516
|
+
return "png";
|
|
517
|
+
}
|
|
518
|
+
function mimeFromOutputFormat(format) {
|
|
519
|
+
if (format !== "png" && format !== "jpeg" && format !== "jpg" && format !== "webp") return void 0;
|
|
520
|
+
return format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`;
|
|
521
|
+
}
|
|
522
|
+
async function materialize(images, opts) {
|
|
523
|
+
const out = [];
|
|
524
|
+
const preferredMime = mimeFromOutputFormat(opts.preferredOutputFormat);
|
|
525
|
+
for (const image of images) {
|
|
526
|
+
if (image.kind === "b64") {
|
|
527
|
+
out.push({
|
|
528
|
+
bytes: new Uint8Array(Buffer.from(image.data, "base64")),
|
|
529
|
+
mime: image.mime ?? preferredMime ?? "image/png"
|
|
530
|
+
});
|
|
531
|
+
} else {
|
|
532
|
+
out.push(await downloadToBytes(image.data, opts));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
async function isDir(path) {
|
|
538
|
+
try {
|
|
539
|
+
return (await stat(path)).isDirectory();
|
|
540
|
+
} catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
async function saveImages(mats, opts) {
|
|
545
|
+
const paths = [];
|
|
546
|
+
const multi = mats.length > 1;
|
|
547
|
+
let targetDir = opts.outputDir;
|
|
548
|
+
let baseName;
|
|
549
|
+
if (opts.outputPath) {
|
|
550
|
+
const outputPath = resolve(opts.outputPath);
|
|
551
|
+
if (opts.outputPath.endsWith("/") || opts.outputPath.endsWith("\\") || await isDir(outputPath)) {
|
|
552
|
+
targetDir = outputPath;
|
|
553
|
+
} else {
|
|
554
|
+
targetDir = dirname(outputPath);
|
|
555
|
+
baseName = basename(outputPath);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
await mkdir(targetDir, { recursive: true });
|
|
559
|
+
const stamp = Date.now();
|
|
560
|
+
for (let i = 0; i < mats.length; i += 1) {
|
|
561
|
+
const ext = extFromMime(mats[i].mime);
|
|
562
|
+
let name;
|
|
563
|
+
if (baseName) {
|
|
564
|
+
if (multi) {
|
|
565
|
+
const currentExt = extname(baseName);
|
|
566
|
+
const base = baseName.slice(0, baseName.length - currentExt.length);
|
|
567
|
+
name = `${base}-${i}${currentExt || `.${ext}`}`;
|
|
568
|
+
} else {
|
|
569
|
+
name = baseName;
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
name = `imagen-${stamp}-${i}.${ext}`;
|
|
573
|
+
}
|
|
574
|
+
const full = join(targetDir, name);
|
|
575
|
+
await writeFile(full, mats[i].bytes);
|
|
576
|
+
paths.push(resolve(full));
|
|
577
|
+
}
|
|
578
|
+
return paths;
|
|
579
|
+
}
|
|
580
|
+
function buildToolResult(paths, mats, meta, returnInline) {
|
|
581
|
+
const lines = [
|
|
582
|
+
`\u5DF2\u751F\u6210 ${paths.length} \u5F20\u56FE\u50CF\uFF08provider=${meta.provider}, model=${meta.model}${meta.size ? `, size=${meta.size}` : ""}\uFF09\uFF1A`,
|
|
583
|
+
...paths.map((path) => `- ${path}`)
|
|
584
|
+
];
|
|
585
|
+
const content = [{ type: "text", text: lines.join("\n") }];
|
|
586
|
+
if (returnInline) {
|
|
587
|
+
for (const mat of mats) {
|
|
588
|
+
content.push({ type: "image", data: Buffer.from(mat.bytes).toString("base64"), mimeType: mat.mime });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return { content, isError: false };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/server.ts
|
|
595
|
+
var coreShape = {
|
|
596
|
+
prompt: z2.string().describe("\u56FE\u50CF\u63CF\u8FF0/\u7F16\u8F91\u6307\u4EE4"),
|
|
597
|
+
model: z2.string().optional().describe("\u8986\u76D6\u9ED8\u8BA4\u6A21\u578B IMAGEN_MODEL"),
|
|
598
|
+
size: z2.string().optional().describe("\u5982 1024x1024\uFF1B\u9002\u914D\u5668\u81EA\u52A8\u6620\u5C04"),
|
|
599
|
+
n: z2.number().int().optional().describe("\u751F\u6210\u5F20\u6570\uFF0C\u9ED8\u8BA4 1"),
|
|
600
|
+
output_path: z2.string().optional().describe("\u4FDD\u5B58\u6587\u4EF6\u540D\u6216\u76EE\u5F55\uFF0C\u8986\u76D6\u9ED8\u8BA4\u8F93\u51FA\u76EE\u5F55")
|
|
601
|
+
};
|
|
602
|
+
var CORE_KEYS = /* @__PURE__ */ new Set(["prompt", "model", "size", "n", "output_path", "images", "mask"]);
|
|
603
|
+
function defaultDir() {
|
|
604
|
+
return join2(tmpdir(), "imagen-switch");
|
|
605
|
+
}
|
|
606
|
+
function toNormReq(args, raw) {
|
|
607
|
+
const model = args.model ?? raw.model;
|
|
608
|
+
if (!model) throw new ConfigError("\u672A\u63D0\u4F9B model\uFF0C\u4E14\u672A\u8BBE\u7F6E IMAGEN_MODEL");
|
|
609
|
+
const extras = {};
|
|
610
|
+
for (const [key, value] of Object.entries(args)) {
|
|
611
|
+
if (!CORE_KEYS.has(key) && value !== void 0) extras[key] = value;
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
prompt: args.prompt,
|
|
615
|
+
model,
|
|
616
|
+
size: args.size,
|
|
617
|
+
n: args.n ?? 1,
|
|
618
|
+
params: { ...raw.extraBody, ...extras }
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
function errorResult(error, apiKey) {
|
|
622
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
623
|
+
return { content: [{ type: "text", text: redactKey(message, apiKey) }], isError: true };
|
|
624
|
+
}
|
|
625
|
+
function buildServer(raw) {
|
|
626
|
+
const { adapter, auth } = createAdapter(raw);
|
|
627
|
+
const server = new McpServer({ name: "imagen-switch", version: "0.1.0" });
|
|
628
|
+
const opts = { timeoutMs: raw.timeoutMs, maxRetries: raw.maxRetries };
|
|
629
|
+
const overrides = { headers: raw.extraHeaders, query: raw.extraQuery };
|
|
630
|
+
server.registerTool(
|
|
631
|
+
"generate_image",
|
|
632
|
+
{
|
|
633
|
+
title: "Generate Image",
|
|
634
|
+
description: "\u7528\u914D\u7F6E\u7684 Provider \u751F\u6210\u56FE\u50CF\uFF0C\u4FDD\u5B58\u5230\u672C\u5730\u5E76\u8FD4\u56DE\u8DEF\u5F84\u3002",
|
|
635
|
+
inputSchema: { ...coreShape, ...adapter.extraParams }
|
|
636
|
+
},
|
|
637
|
+
async (args) => {
|
|
638
|
+
try {
|
|
639
|
+
const req = toNormReq(args, raw);
|
|
640
|
+
const spec = applyOverrides(adapter.buildGenerate(req), overrides);
|
|
641
|
+
const rawResponse = await sendRequest(spec, auth, opts);
|
|
642
|
+
const images = adapter.parseResponse(rawResponse);
|
|
643
|
+
if (images.length === 0) throw new ProviderError("Provider \u672A\u8FD4\u56DE\u4EFB\u4F55\u56FE\u50CF");
|
|
644
|
+
const mats = await materialize(images, { ...opts, preferredOutputFormat: req.params.output_format });
|
|
645
|
+
const paths = await saveImages(mats, {
|
|
646
|
+
outputDir: raw.outputDir ?? defaultDir(),
|
|
647
|
+
outputPath: args.output_path
|
|
648
|
+
});
|
|
649
|
+
return buildToolResult(paths, mats, { model: req.model, size: req.size, provider: adapter.format }, raw.returnInline);
|
|
650
|
+
} catch (e) {
|
|
651
|
+
return errorResult(e, auth.apiKey);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
);
|
|
655
|
+
if (adapter.supportsEdit) {
|
|
656
|
+
server.registerTool(
|
|
657
|
+
"edit_image",
|
|
658
|
+
{
|
|
659
|
+
title: "Edit Image",
|
|
660
|
+
description: "\u57FA\u4E8E\u4E00\u5F20\u6216\u591A\u5F20\u8F93\u5165\u56FE\u6309\u6307\u4EE4\u7F16\u8F91/\u91CD\u7ED8\uFF0C\u4FDD\u5B58\u5230\u672C\u5730\u5E76\u8FD4\u56DE\u8DEF\u5F84\u3002",
|
|
661
|
+
inputSchema: {
|
|
662
|
+
...coreShape,
|
|
663
|
+
images: z2.array(z2.string()).describe("\u8F93\u5165\u56FE\uFF1A\u672C\u5730\u8DEF\u5F84 / data URL / \u88F8 base64 / http(s) URL"),
|
|
664
|
+
mask: z2.string().optional().describe("inpaint \u8499\u7248\uFF08\u652F\u6301\u7684 Provider \u624D\u7528\uFF09"),
|
|
665
|
+
...adapter.extraParams
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
async (args) => {
|
|
669
|
+
try {
|
|
670
|
+
const req = toNormReq(args, raw);
|
|
671
|
+
const refs = args.images ?? [];
|
|
672
|
+
if (refs.length === 0) throw new ConfigError("edit_image \u9700\u8981\u81F3\u5C11\u4E00\u5F20 images");
|
|
673
|
+
const images = await Promise.all(refs.map((ref) => resolveImage(ref, opts.timeoutMs)));
|
|
674
|
+
const mask = args.mask ? await resolveImage(args.mask, opts.timeoutMs) : void 0;
|
|
675
|
+
const spec = applyOverrides(adapter.buildEdit(req, images, mask), overrides);
|
|
676
|
+
const rawResponse = await sendRequest(spec, auth, opts);
|
|
677
|
+
const out = adapter.parseResponse(rawResponse);
|
|
678
|
+
if (out.length === 0) throw new ProviderError("Provider \u672A\u8FD4\u56DE\u4EFB\u4F55\u56FE\u50CF");
|
|
679
|
+
const mats = await materialize(out, { ...opts, preferredOutputFormat: req.params.output_format });
|
|
680
|
+
const paths = await saveImages(mats, {
|
|
681
|
+
outputDir: raw.outputDir ?? defaultDir(),
|
|
682
|
+
outputPath: args.output_path
|
|
683
|
+
});
|
|
684
|
+
return buildToolResult(paths, mats, { model: req.model, size: req.size, provider: adapter.format }, raw.returnInline);
|
|
685
|
+
} catch (e) {
|
|
686
|
+
return errorResult(e, auth.apiKey);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
return server;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// src/index.ts
|
|
695
|
+
async function main() {
|
|
696
|
+
let raw;
|
|
697
|
+
try {
|
|
698
|
+
raw = loadRawConfig(process.env);
|
|
699
|
+
} catch (e) {
|
|
700
|
+
process.stderr.write(`[imagen-switch] \u914D\u7F6E\u9519\u8BEF\uFF1A${e instanceof Error ? e.message : String(e)}
|
|
701
|
+
`);
|
|
702
|
+
process.exit(1);
|
|
703
|
+
}
|
|
704
|
+
const server = buildServer(raw);
|
|
705
|
+
const transport = new StdioServerTransport();
|
|
706
|
+
await server.connect(transport);
|
|
707
|
+
process.stderr.write(`[imagen-switch] started (format=${raw.format})
|
|
708
|
+
`);
|
|
709
|
+
process.on("SIGINT", async () => {
|
|
710
|
+
await server.close();
|
|
711
|
+
process.exit(0);
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
715
|
+
main().catch((e) => {
|
|
716
|
+
process.stderr.write(`[imagen-switch] fatal: ${e instanceof Error ? e.message : String(e)}
|
|
717
|
+
`);
|
|
718
|
+
process.exit(1);
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
export {
|
|
722
|
+
main
|
|
723
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "imagen-switch-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for multi-provider image generation via env config",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"imagen-switch-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"ci": "npm test && npm run typecheck && npm run build",
|
|
18
|
+
"prepublishOnly": "npm run ci",
|
|
19
|
+
"publish:dry-run": "npm publish --dry-run",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mcp",
|
|
27
|
+
"model-context-protocol",
|
|
28
|
+
"image-generation",
|
|
29
|
+
"openai",
|
|
30
|
+
"gemini",
|
|
31
|
+
"imagen",
|
|
32
|
+
"agent"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
39
|
+
"zod": "^3.25.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^20.0.0",
|
|
43
|
+
"tsup": "^8.0.0",
|
|
44
|
+
"typescript": "^5.4.0",
|
|
45
|
+
"vitest": "^2.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|