gpteam 0.1.16 → 0.1.18
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/README.md +10 -0
- package/lib/cli.js +2 -2
- package/lib/client-install.js +4 -2
- package/lib/config.js +8 -1
- package/lib/help.js +2 -2
- package/lib/image-mcp/errors.js +116 -0
- package/lib/image-mcp/files.js +244 -0
- package/lib/image-mcp/image.js +340 -78
- package/lib/image-mcp/server.js +119 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,16 @@ Client config writing follows the same safety pattern as cc-switch: keep Codex t
|
|
|
16
16
|
|
|
17
17
|
The Image MCP config uses cc-switch-style per-client env blocks. Codex writes `[mcp_servers.gpteam_image.env]`, OpenCode writes `mcp.gpteam_image.environment`, and Claude Code writes `mcpServers.gpteam_image.env` in `~/.claude.json`. The MCP receives `GPTEAM_API_KEY` and `GPTEAM_BASE_URL` from that MCP config, so it does not depend on Codex `auth.json` or inherited `OPENAI_API_KEY`.
|
|
18
18
|
|
|
19
|
+
The Image MCP exposes both a synchronous compatibility tool and a local async job flow:
|
|
20
|
+
|
|
21
|
+
- `create_image_job`: recommended for normal use. It starts a local background image job and returns `job_id` quickly, which avoids losing the whole generation when a VPN, proxy, or client has a 60-second idle timeout.
|
|
22
|
+
- `get_image_job_status`: checks whether the local job is queued, running, succeeded, failed, or cancelled.
|
|
23
|
+
- `cancel_image_job`: cancels a queued/running local job.
|
|
24
|
+
- `download_image_result`: returns the completed file metadata and image content.
|
|
25
|
+
- `generate_image`: waits for completion and writes the image file immediately. This is kept for synchronous compatibility.
|
|
26
|
+
|
|
27
|
+
Image MCP results are returned as stable JSON text and MCP `structuredContent`. Successful results include file path, model, size, format, quality, byte size, SHA-256, MIME type, image dimensions, duration, retry count, `job_id`, and `trace_id`. The image bytes are returned as MCP image content, not embedded in structured JSON. File writes create missing directories, avoid overwriting existing files by adding `-v2`, `-v3`, etc., and validate PNG/JPEG/WebP before returning success. `return_revised_prompt` controls whether the upstream revised prompt is included in the result.
|
|
28
|
+
|
|
19
29
|
Claude Code is written to `~/.claude/settings.json` under the `env` section, using the GPTeam `/anthropic` base URL. OpenClaw writes `models.providers.gpteam` and also selects `gpteam/<model>` under `agents.defaults.model`, so the chosen model is active without an extra manual step.
|
|
20
30
|
|
|
21
31
|
Supported clients:
|
package/lib/cli.js
CHANGED
|
@@ -71,8 +71,8 @@ export async function runCli(argv = []) {
|
|
|
71
71
|
for (const filePath of written) console.log(`- ${filePath}`);
|
|
72
72
|
console.log(`入口:${formatNodeLabel(selectedNode)}`);
|
|
73
73
|
console.log(`地址:${selectedNode.baseUrl}`);
|
|
74
|
-
if (
|
|
75
|
-
printHint(theme, '已写入 GPTeam Image MCP
|
|
74
|
+
if (['codex', 'opencode', 'claude-code'].includes(client.id)) {
|
|
75
|
+
printHint(theme, '已写入 GPTeam Image MCP。客户端对话里需要生图时优先调用 create_image_job,再用 get_image_job_status 和 download_image_result 取结果,MCP 使用专用环境变量读取 API key。');
|
|
76
76
|
}
|
|
77
77
|
} finally {
|
|
78
78
|
rl.close();
|
package/lib/client-install.js
CHANGED
|
@@ -89,7 +89,7 @@ export function resolveSpawnInvocation(command, args, platform = process.platfor
|
|
|
89
89
|
if (platform === 'win32' && command === 'npm') {
|
|
90
90
|
return {
|
|
91
91
|
command: 'cmd.exe',
|
|
92
|
-
args: ['/d', '/
|
|
92
|
+
args: ['/d', '/c', windowsCommandLine('npm.cmd', args)]
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
return { command, args };
|
|
@@ -123,7 +123,9 @@ function windowsCommandLine(command, args) {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
function windowsQuoteArg(value) {
|
|
126
|
-
|
|
126
|
+
const text = String(value);
|
|
127
|
+
if (!/[\s&()^|<>"]/.test(text)) return text;
|
|
128
|
+
return `"${text.replace(/"/g, '\\"')}"`;
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
function shellQuote(value) {
|
package/lib/config.js
CHANGED
|
@@ -8,6 +8,13 @@ const PROVIDER_ID = 'gpteam';
|
|
|
8
8
|
const IMAGE_MCP_ID = 'gpteam_image';
|
|
9
9
|
const IMAGE_MCP_PACKAGE = 'gpteam';
|
|
10
10
|
const IMAGE_MCP_BIN = 'gpteam-image-mcp';
|
|
11
|
+
const IMAGE_MCP_ENABLED_TOOLS = [
|
|
12
|
+
'create_image_job',
|
|
13
|
+
'get_image_job_status',
|
|
14
|
+
'download_image_result',
|
|
15
|
+
'cancel_image_job',
|
|
16
|
+
'generate_image'
|
|
17
|
+
];
|
|
11
18
|
|
|
12
19
|
export const CLIENTS = [
|
|
13
20
|
{ id: 'codex', label: 'Codex' },
|
|
@@ -56,7 +63,7 @@ export function writeCodexConfig(settings) {
|
|
|
56
63
|
`args = [${mcpCommand.args.map((arg) => tomlString(arg)).join(', ')}]`,
|
|
57
64
|
'startup_timeout_sec = 20',
|
|
58
65
|
'tool_timeout_sec = 300',
|
|
59
|
-
|
|
66
|
+
`enabled_tools = [${IMAGE_MCP_ENABLED_TOOLS.map((name) => tomlString(name)).join(', ')}]`,
|
|
60
67
|
'default_tools_approval_mode = "prompt"',
|
|
61
68
|
'',
|
|
62
69
|
`[mcp_servers.${IMAGE_MCP_ID}.env]`,
|
package/lib/help.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const PACKAGE_NAME = 'gpteam';
|
|
2
|
-
export const PACKAGE_VERSION = '0.1.
|
|
2
|
+
export const PACKAGE_VERSION = '0.1.18';
|
|
3
3
|
|
|
4
4
|
export function getHelpText() {
|
|
5
5
|
return [
|
|
@@ -22,6 +22,6 @@ export function getHelpText() {
|
|
|
22
22
|
' --help 显示帮助',
|
|
23
23
|
' --version 显示版本',
|
|
24
24
|
'',
|
|
25
|
-
'说明:输入 key 后会先请求 /v1/models 校验,通过后才继续。选好客户端后会检测本机命令是否可用,缺失时提示安装。Windows 暂不支持自动配置 OpenClaw。测速会请求 GET /api/health 和流式 POST /v1/responses。入口之间并行测速,写新配置前会先备份旧配置。Codex、OpenCode、Claude Code 会写入 GPTeam Image MCP,生图工具从 MCP env 读取 key
|
|
25
|
+
'说明:输入 key 后会先请求 /v1/models 校验,通过后才继续。选好客户端后会检测本机命令是否可用,缺失时提示安装。Windows 暂不支持自动配置 OpenClaw。测速会请求 GET /api/health 和流式 POST /v1/responses。入口之间并行测速,写新配置前会先备份旧配置。Codex、OpenCode、Claude Code 会写入 GPTeam Image MCP,生图工具从 MCP env 读取 key,长时间生图优先用 create_image_job 异步任务。'
|
|
26
26
|
].join('\n');
|
|
27
27
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { formatNetworkError } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
export class ImageMCPError extends Error {
|
|
4
|
+
constructor(message, options = {}) {
|
|
5
|
+
super(String(message || 'GPTeam image MCP error'));
|
|
6
|
+
this.name = 'ImageMCPError';
|
|
7
|
+
this.code = String(options.code || 'image_mcp_error');
|
|
8
|
+
this.category = String(options.category || 'unknown');
|
|
9
|
+
this.retryable = Boolean(options.retryable);
|
|
10
|
+
this.http_status = Number.isFinite(options.http_status) ? options.http_status : undefined;
|
|
11
|
+
this.details = options.details || undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function imageErrorFromHTTPResponse(response, apiKey) {
|
|
16
|
+
const status = Number(response && response.status) || 0;
|
|
17
|
+
const rawText = typeof response.text === 'function' ? await response.text() : '';
|
|
18
|
+
const detail = parseUpstreamError(rawText);
|
|
19
|
+
const upstreamCode = String(detail.code || detail.type || '').trim();
|
|
20
|
+
const bodyMessage = detail.message || rawText || '';
|
|
21
|
+
const message = redactSecret(bodyMessage ? `HTTP ${status}: ${bodyMessage}` : `HTTP ${status}`, apiKey);
|
|
22
|
+
if (status === 429) {
|
|
23
|
+
return new ImageMCPError(message, {
|
|
24
|
+
code: upstreamCode || 'rate_limit_exceeded',
|
|
25
|
+
category: 'upstream_rate_limit',
|
|
26
|
+
http_status: status,
|
|
27
|
+
retryable: true
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (status >= 500 || status === 408) {
|
|
31
|
+
return new ImageMCPError(message, {
|
|
32
|
+
code: upstreamCode || 'upstream_server_error',
|
|
33
|
+
category: status === 408 ? 'timeout' : 'upstream_server',
|
|
34
|
+
http_status: status,
|
|
35
|
+
retryable: true
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (isContentSafetyError(upstreamCode, message)) {
|
|
39
|
+
return new ImageMCPError(message, {
|
|
40
|
+
code: upstreamCode || 'content_safety_rejected',
|
|
41
|
+
category: 'content_safety',
|
|
42
|
+
http_status: status,
|
|
43
|
+
retryable: false
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return new ImageMCPError(message, {
|
|
47
|
+
code: upstreamCode || (status === 401 || status === 403 ? 'authentication_failed' : 'invalid_request_error'),
|
|
48
|
+
category: status === 401 || status === 403 ? 'authentication' : 'parameter',
|
|
49
|
+
http_status: status,
|
|
50
|
+
retryable: false
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function imageErrorFromFetch(error, options = {}) {
|
|
55
|
+
if (options.cancelled) {
|
|
56
|
+
return new ImageMCPError('图片生成任务已取消。', {
|
|
57
|
+
code: 'job_cancelled',
|
|
58
|
+
category: 'cancelled',
|
|
59
|
+
retryable: false
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const message = redactSecret(formatNetworkError(error), options.apiKey);
|
|
63
|
+
const timeoutLike = options.timedOut || /timeout|timedout|etimedout/i.test(message);
|
|
64
|
+
return new ImageMCPError(message, {
|
|
65
|
+
code: timeoutLike ? 'network_timeout' : 'network_error',
|
|
66
|
+
category: timeoutLike ? 'timeout' : 'network',
|
|
67
|
+
retryable: true
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function serializeImageError(error) {
|
|
72
|
+
if (error instanceof ImageMCPError) {
|
|
73
|
+
return {
|
|
74
|
+
code: error.code,
|
|
75
|
+
category: error.category,
|
|
76
|
+
retryable: error.retryable,
|
|
77
|
+
http_status: error.http_status,
|
|
78
|
+
message: error.message
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
code: 'image_mcp_error',
|
|
83
|
+
category: 'unknown',
|
|
84
|
+
retryable: false,
|
|
85
|
+
message: error && error.message ? String(error.message) : String(error || 'unknown error')
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function redactSecret(text, secret) {
|
|
90
|
+
let result = String(text || '');
|
|
91
|
+
if (secret) result = result.split(secret).join('[redacted]');
|
|
92
|
+
result = result.replace(/sk-[A-Za-z0-9_-]{6,}/g, 'sk-[redacted]');
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseUpstreamError(text) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(String(text || ''));
|
|
99
|
+
const error = parsed && typeof parsed === 'object' ? parsed.error : null;
|
|
100
|
+
if (error && typeof error === 'object') {
|
|
101
|
+
return {
|
|
102
|
+
code: error.code,
|
|
103
|
+
type: error.type,
|
|
104
|
+
message: error.message
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isContentSafetyError(code, message) {
|
|
114
|
+
const text = `${code || ''} ${message || ''}`.toLowerCase();
|
|
115
|
+
return /content|safety|policy|moderation/.test(text);
|
|
116
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { ImageMCPError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_IMAGE_FORMAT = 'png';
|
|
8
|
+
|
|
9
|
+
export function writeImageOutput(input = {}, options = {}) {
|
|
10
|
+
const bytes = decodeBase64Image(input.b64);
|
|
11
|
+
const image = inspectImage(bytes);
|
|
12
|
+
const format = image.format || normalizeImageFormat(input.format || input.output_format || DEFAULT_IMAGE_FORMAT);
|
|
13
|
+
const outputPath = writeImageFileNonOverwriting(input.output_path, format, bytes, options);
|
|
14
|
+
return {
|
|
15
|
+
file: outputPath,
|
|
16
|
+
path: outputPath,
|
|
17
|
+
bytes: bytes.length,
|
|
18
|
+
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
|
19
|
+
mime_type: image.mime_type,
|
|
20
|
+
mimeType: image.mime_type,
|
|
21
|
+
width: image.width,
|
|
22
|
+
height: image.height,
|
|
23
|
+
format,
|
|
24
|
+
output_format: format
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeImageFileNonOverwriting(rawOutputPath, format, bytes, options) {
|
|
29
|
+
let lastConflict;
|
|
30
|
+
for (let attempt = 0; attempt < 10000; attempt += 1) {
|
|
31
|
+
const outputPath = resolveAvailableOutputPath(rawOutputPath, format, options);
|
|
32
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
33
|
+
try {
|
|
34
|
+
atomicWriteFile(outputPath, bytes);
|
|
35
|
+
return outputPath;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!isOutputPathConflict(error)) throw error;
|
|
38
|
+
lastConflict = error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw lastConflict || new ImageMCPError('无法生成不覆盖现有文件的输出路径。', {
|
|
42
|
+
code: 'output_path_conflict',
|
|
43
|
+
category: 'file_system',
|
|
44
|
+
retryable: false
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalizeImageFormat(value) {
|
|
49
|
+
const normalized = String(value || DEFAULT_IMAGE_FORMAT).trim().toLowerCase();
|
|
50
|
+
if (normalized === 'jpg') return 'jpeg';
|
|
51
|
+
if (['png', 'jpeg', 'webp'].includes(normalized)) return normalized;
|
|
52
|
+
return DEFAULT_IMAGE_FORMAT;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function decodeBase64Image(value) {
|
|
56
|
+
const text = String(value || '').trim();
|
|
57
|
+
if (!text) {
|
|
58
|
+
throw new ImageMCPError('GPTeam 图片接口没有返回 b64_json 图片数据。', {
|
|
59
|
+
code: 'image_data_missing',
|
|
60
|
+
category: 'response_invalid',
|
|
61
|
+
retryable: false
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const bytes = Buffer.from(text, 'base64');
|
|
65
|
+
if (bytes.length === 0) {
|
|
66
|
+
throw new ImageMCPError('GPTeam 图片接口返回的图片数据为空。', {
|
|
67
|
+
code: 'image_data_empty',
|
|
68
|
+
category: 'response_invalid',
|
|
69
|
+
retryable: false
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return bytes;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function inspectImage(bytes) {
|
|
76
|
+
const png = inspectPNG(bytes);
|
|
77
|
+
if (png) return png;
|
|
78
|
+
const jpeg = inspectJPEG(bytes);
|
|
79
|
+
if (jpeg) return jpeg;
|
|
80
|
+
const webp = inspectWebP(bytes);
|
|
81
|
+
if (webp) return webp;
|
|
82
|
+
throw new ImageMCPError('写入前图片校验失败:无法识别 PNG/JPEG/WebP。', {
|
|
83
|
+
code: 'image_mime_invalid',
|
|
84
|
+
category: 'response_invalid',
|
|
85
|
+
retryable: false
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function inspectPNG(bytes) {
|
|
90
|
+
if (bytes.length < 24) return null;
|
|
91
|
+
const signature = '89504e470d0a1a0a';
|
|
92
|
+
if (bytes.subarray(0, 8).toString('hex') !== signature) return null;
|
|
93
|
+
return {
|
|
94
|
+
format: 'png',
|
|
95
|
+
mime_type: 'image/png',
|
|
96
|
+
width: bytes.readUInt32BE(16),
|
|
97
|
+
height: bytes.readUInt32BE(20)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function inspectJPEG(bytes) {
|
|
102
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
|
|
103
|
+
let offset = 2;
|
|
104
|
+
while (offset + 9 < bytes.length) {
|
|
105
|
+
if (bytes[offset] !== 0xff) {
|
|
106
|
+
offset += 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const marker = bytes[offset + 1];
|
|
110
|
+
const length = bytes.readUInt16BE(offset + 2);
|
|
111
|
+
if (length < 2) break;
|
|
112
|
+
if ([0xc0, 0xc1, 0xc2, 0xc3].includes(marker)) {
|
|
113
|
+
return {
|
|
114
|
+
format: 'jpeg',
|
|
115
|
+
mime_type: 'image/jpeg',
|
|
116
|
+
height: bytes.readUInt16BE(offset + 5),
|
|
117
|
+
width: bytes.readUInt16BE(offset + 7)
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
offset += 2 + length;
|
|
121
|
+
}
|
|
122
|
+
return { format: 'jpeg', mime_type: 'image/jpeg', width: undefined, height: undefined };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function inspectWebP(bytes) {
|
|
126
|
+
if (bytes.length < 16) return null;
|
|
127
|
+
if (bytes.subarray(0, 4).toString('ascii') !== 'RIFF') return null;
|
|
128
|
+
if (bytes.subarray(8, 12).toString('ascii') !== 'WEBP') return null;
|
|
129
|
+
if (bytes.subarray(12, 16).toString('ascii') === 'VP8X' && bytes.length >= 30) {
|
|
130
|
+
return {
|
|
131
|
+
format: 'webp',
|
|
132
|
+
mime_type: 'image/webp',
|
|
133
|
+
width: 1 + readUInt24LE(bytes, 24),
|
|
134
|
+
height: 1 + readUInt24LE(bytes, 27)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { format: 'webp', mime_type: 'image/webp', width: undefined, height: undefined };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readUInt24LE(bytes, offset) {
|
|
141
|
+
return bytes[offset] + (bytes[offset + 1] << 8) + (bytes[offset + 2] << 16);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveAvailableOutputPath(rawOutputPath, format, options) {
|
|
145
|
+
const resolved = resolveOutputPath(rawOutputPath, format, options);
|
|
146
|
+
if (!fs.existsSync(resolved)) return resolved;
|
|
147
|
+
const parsed = path.parse(resolved);
|
|
148
|
+
for (let version = 2; version < 10000; version += 1) {
|
|
149
|
+
const candidate = path.join(parsed.dir, `${parsed.name}-v${version}${parsed.ext}`);
|
|
150
|
+
if (!fs.existsSync(candidate)) return candidate;
|
|
151
|
+
}
|
|
152
|
+
throw new ImageMCPError('无法生成不覆盖现有文件的输出路径。', {
|
|
153
|
+
code: 'output_path_conflict',
|
|
154
|
+
category: 'file_system',
|
|
155
|
+
retryable: false
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolveOutputPath(rawOutputPath, format, options) {
|
|
160
|
+
const env = options.env || process.env;
|
|
161
|
+
const home = options.home || os.homedir();
|
|
162
|
+
const name = `gpteam-image-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-')}.${format}`;
|
|
163
|
+
if (rawOutputPath) {
|
|
164
|
+
const expanded = expandHome(String(rawOutputPath), home);
|
|
165
|
+
if (isDirectoryLike(expanded)) return path.join(expanded, name);
|
|
166
|
+
return withImageExtension(expanded, format);
|
|
167
|
+
}
|
|
168
|
+
const outputDir = expandHome(firstNonEmpty(env.GPTEAM_IMAGE_OUTPUT_DIR, defaultImageOutputDir(home)), home);
|
|
169
|
+
return path.join(outputDir, name);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function atomicWriteFile(outputPath, bytes) {
|
|
173
|
+
const tempPath = path.join(path.dirname(outputPath), `.${path.basename(outputPath)}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
174
|
+
let fd;
|
|
175
|
+
try {
|
|
176
|
+
fd = fs.openSync(tempPath, 'wx', 0o600);
|
|
177
|
+
fs.writeFileSync(fd, bytes);
|
|
178
|
+
fs.fsyncSync(fd);
|
|
179
|
+
fs.closeSync(fd);
|
|
180
|
+
fd = undefined;
|
|
181
|
+
fs.linkSync(tempPath, outputPath);
|
|
182
|
+
try {
|
|
183
|
+
fs.rmSync(tempPath, { force: true });
|
|
184
|
+
} catch {
|
|
185
|
+
// 目标文件已通过独占链接落盘,临时文件清理失败不影响返回结果。
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (fd !== undefined) {
|
|
189
|
+
try {
|
|
190
|
+
fs.closeSync(fd);
|
|
191
|
+
} catch {
|
|
192
|
+
// 忽略关闭临时文件失败,下面会清理临时路径。
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
fs.rmSync(tempPath, { force: true });
|
|
197
|
+
} catch {
|
|
198
|
+
// 忽略清理失败,返回主要写入错误。
|
|
199
|
+
}
|
|
200
|
+
const conflict = error && error.code === 'EEXIST';
|
|
201
|
+
throw new ImageMCPError(conflict ? '输出文件已存在,正在重新选择不覆盖路径。' : `图片文件写入失败:${error.message}`, {
|
|
202
|
+
code: conflict ? 'output_path_conflict' : 'file_write_failed',
|
|
203
|
+
category: 'file_system',
|
|
204
|
+
retryable: false
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isOutputPathConflict(error) {
|
|
210
|
+
return error instanceof ImageMCPError && error.code === 'output_path_conflict';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function defaultImageOutputDir(home) {
|
|
214
|
+
const desktop = path.join(home, 'Desktop');
|
|
215
|
+
return fs.existsSync(desktop) ? desktop : process.cwd();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isDirectoryLike(value) {
|
|
219
|
+
return /[\\/]$/.test(value) || (fs.existsSync(value) && fs.statSync(value).isDirectory());
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function firstNonEmpty(...values) {
|
|
223
|
+
for (const value of values) {
|
|
224
|
+
const text = String(value || '').trim();
|
|
225
|
+
if (text) return text;
|
|
226
|
+
}
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function expandHome(value, home) {
|
|
231
|
+
const text = String(value || '');
|
|
232
|
+
if (text === '~') return home;
|
|
233
|
+
if (text.startsWith(`~${path.sep}`)) return path.join(home, text.slice(2));
|
|
234
|
+
if (text.startsWith('~/')) return path.join(home, text.slice(2));
|
|
235
|
+
return text;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function withImageExtension(filePath, format) {
|
|
239
|
+
const parsed = path.parse(filePath);
|
|
240
|
+
const ext = parsed.ext.toLowerCase();
|
|
241
|
+
const normalizedExt = ext === '.jpg' ? '.jpeg' : ext;
|
|
242
|
+
if (normalizedExt === `.${format}`) return filePath;
|
|
243
|
+
return path.join(parsed.dir, `${parsed.name}.${format}`);
|
|
244
|
+
}
|
package/lib/image-mcp/image.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
ImageMCPError,
|
|
6
|
+
imageErrorFromFetch,
|
|
7
|
+
imageErrorFromHTTPResponse,
|
|
8
|
+
redactSecret,
|
|
9
|
+
serializeImageError
|
|
10
|
+
} from './errors.js';
|
|
11
|
+
import { DEFAULT_IMAGE_FORMAT, normalizeImageFormat, writeImageOutput } from './files.js';
|
|
4
12
|
|
|
5
13
|
export const DEFAULT_BASE_URL = 'https://api.gpteamservices.com/v1';
|
|
6
14
|
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2';
|
|
7
|
-
export
|
|
15
|
+
export { DEFAULT_IMAGE_FORMAT };
|
|
16
|
+
|
|
17
|
+
const defaultMaxAttempts = 3;
|
|
18
|
+
const defaultRetryDelayMs = 800;
|
|
19
|
+
const defaultRequestTimeoutMs = 5 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
const defaultJobStore = createImageJobStore();
|
|
8
22
|
|
|
9
23
|
export function buildImageGenerationPayload(input = {}) {
|
|
10
24
|
return {
|
|
@@ -25,7 +39,11 @@ export function loadGPTeamCredentials(options = {}) {
|
|
|
25
39
|
const configuredBaseUrl = parseGPTeamBaseUrl(configText);
|
|
26
40
|
const apiKey = firstNonEmpty(env.GPTEAM_API_KEY);
|
|
27
41
|
if (!apiKey) {
|
|
28
|
-
throw new
|
|
42
|
+
throw new ImageMCPError('没有找到 GPTeam API key。请在 MCP 配置 env 中设置 GPTEAM_API_KEY,或先运行 npx gpteam 完成本地配置。', {
|
|
43
|
+
code: 'api_key_missing',
|
|
44
|
+
category: 'configuration',
|
|
45
|
+
retryable: false
|
|
46
|
+
});
|
|
29
47
|
}
|
|
30
48
|
const baseUrl = normalizeBaseUrl(firstNonEmpty(env.GPTEAM_BASE_URL, configuredBaseUrl, DEFAULT_BASE_URL));
|
|
31
49
|
return { apiKey, baseUrl, codexHome };
|
|
@@ -55,107 +73,331 @@ export function normalizeBaseUrl(value) {
|
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
export async function generateImage(input = {}, options = {}) {
|
|
76
|
+
const startedAt = now(options);
|
|
58
77
|
const prompt = String(input.prompt || '').trim();
|
|
59
|
-
if (!prompt)
|
|
78
|
+
if (!prompt) {
|
|
79
|
+
throw new ImageMCPError('prompt 不能为空', {
|
|
80
|
+
code: 'prompt_required',
|
|
81
|
+
category: 'parameter',
|
|
82
|
+
retryable: false
|
|
83
|
+
});
|
|
84
|
+
}
|
|
60
85
|
const credentials = loadGPTeamCredentials(options);
|
|
61
86
|
const payload = buildImageGenerationPayload(input);
|
|
62
87
|
const fetchImpl = options.fetch || globalThis.fetch;
|
|
63
88
|
if (typeof fetchImpl !== 'function') {
|
|
64
|
-
throw new
|
|
89
|
+
throw new ImageMCPError('当前 Node.js 运行时不支持 fetch,请升级到 Node.js 18.18 或更高版本。', {
|
|
90
|
+
code: 'fetch_unavailable',
|
|
91
|
+
category: 'environment',
|
|
92
|
+
retryable: false
|
|
93
|
+
});
|
|
65
94
|
}
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
95
|
+
const jobID = String(input.job_id || options.jobID || makeID('img'));
|
|
96
|
+
const traceID = String(input.trace_id || options.traceID || makeID('tr'));
|
|
97
|
+
const result = await fetchImageWithRetry(fetchImpl, credentials, payload, options);
|
|
98
|
+
const format = normalizeImageFormat(payload.output_format);
|
|
99
|
+
const file = writeImageOutput({
|
|
100
|
+
b64: result.b64,
|
|
101
|
+
output_path: input.output_path,
|
|
102
|
+
format
|
|
103
|
+
}, options);
|
|
104
|
+
const includeRevisedPrompt = resolveRevisedPromptFlag(input);
|
|
105
|
+
const durationMs = now(options) - startedAt;
|
|
106
|
+
return buildSuccessResult({
|
|
107
|
+
jobID,
|
|
108
|
+
traceID,
|
|
109
|
+
payload,
|
|
110
|
+
file,
|
|
111
|
+
b64: result.b64,
|
|
112
|
+
revisedPrompt: includeRevisedPrompt ? result.revisedPrompt : '',
|
|
113
|
+
retryCount: result.retryCount,
|
|
114
|
+
durationMs
|
|
73
115
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createImageJobStore(options = {}) {
|
|
119
|
+
return {
|
|
120
|
+
jobs: new Map(),
|
|
121
|
+
now: typeof options.now === 'function' ? options.now : Date.now,
|
|
122
|
+
ttlMs: Number(options.ttlMs || 30 * 60 * 1000)
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createImageJob(input = {}, options = {}) {
|
|
127
|
+
const store = options.store || defaultJobStore;
|
|
128
|
+
cleanupImageJobs(store);
|
|
129
|
+
const jobID = makeID('img');
|
|
130
|
+
const traceID = makeID('tr');
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
const job = {
|
|
133
|
+
job_id: jobID,
|
|
134
|
+
trace_id: traceID,
|
|
135
|
+
status: 'queued',
|
|
136
|
+
ok: true,
|
|
137
|
+
created_at: new Date(store.now()).toISOString(),
|
|
138
|
+
updated_at: new Date(store.now()).toISOString(),
|
|
139
|
+
controller,
|
|
140
|
+
result: null,
|
|
141
|
+
error: null
|
|
142
|
+
};
|
|
143
|
+
store.jobs.set(jobID, job);
|
|
144
|
+
queueMicrotask(() => runImageJob(store, job, input, options));
|
|
145
|
+
return publicJobStatus(job);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function getImageJobStatus(input = {}, options = {}) {
|
|
149
|
+
const store = options.store || defaultJobStore;
|
|
150
|
+
cleanupImageJobs(store);
|
|
151
|
+
const job = store.jobs.get(String(input.job_id || ''));
|
|
152
|
+
if (!job) return missingJobResult(input.job_id);
|
|
153
|
+
return publicJobStatus(job);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function cancelImageJob(input = {}, options = {}) {
|
|
157
|
+
const store = options.store || defaultJobStore;
|
|
158
|
+
const job = store.jobs.get(String(input.job_id || ''));
|
|
159
|
+
if (!job) return missingJobResult(input.job_id);
|
|
160
|
+
if (job.status === 'succeeded' || job.status === 'failed') return publicJobStatus(job);
|
|
161
|
+
job.controller.abort();
|
|
162
|
+
job.status = 'cancelled';
|
|
163
|
+
job.ok = false;
|
|
164
|
+
job.updated_at = new Date(store.now()).toISOString();
|
|
165
|
+
job.error = {
|
|
166
|
+
code: 'job_cancelled',
|
|
167
|
+
category: 'cancelled',
|
|
168
|
+
retryable: false,
|
|
169
|
+
message: '图片生成任务已取消。'
|
|
170
|
+
};
|
|
171
|
+
return { ...publicJobStatus(job), ok: true };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function downloadImageResult(input = {}, options = {}) {
|
|
175
|
+
const store = options.store || defaultJobStore;
|
|
176
|
+
const job = store.jobs.get(String(input.job_id || ''));
|
|
177
|
+
if (!job) return missingJobResult(input.job_id);
|
|
178
|
+
if (job.status !== 'succeeded') return publicJobStatus(job);
|
|
179
|
+
return { ...job.result, status: 'succeeded' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function structuredToolResult(result) {
|
|
183
|
+
if (!result || typeof result !== 'object') {
|
|
184
|
+
return { ok: false, error: { code: 'empty_result', category: 'unknown', retryable: false, message: 'empty result' } };
|
|
77
185
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
186
|
+
const clone = { ...result };
|
|
187
|
+
delete clone.b64;
|
|
188
|
+
delete clone.mimeType;
|
|
189
|
+
delete clone.revisedPrompt;
|
|
190
|
+
delete clone.path;
|
|
191
|
+
return clone;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function toolResultContent(result) {
|
|
195
|
+
const structured = structuredToolResult(result);
|
|
196
|
+
const content = [{ type: 'text', text: JSON.stringify(structured, null, 2) }];
|
|
197
|
+
if (result && result.ok && result.b64 && result.mimeType) {
|
|
198
|
+
content.push({ type: 'image', data: result.b64, mimeType: result.mimeType });
|
|
83
199
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
200
|
+
return content;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function resultFromError(error, meta = {}) {
|
|
88
204
|
return {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
quality: payload.quality,
|
|
95
|
-
format,
|
|
96
|
-
revisedPrompt: first && typeof first.revised_prompt === 'string' ? first.revised_prompt : ''
|
|
205
|
+
ok: false,
|
|
206
|
+
job_id: meta.job_id || '',
|
|
207
|
+
trace_id: meta.trace_id || '',
|
|
208
|
+
status: 'failed',
|
|
209
|
+
error: serializeImageError(error)
|
|
97
210
|
};
|
|
98
211
|
}
|
|
99
212
|
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
213
|
+
async function fetchImageWithRetry(fetchImpl, credentials, payload, options) {
|
|
214
|
+
const maxAttempts = resolveBoundedInt(1, options.maxAttempts, options.env && options.env.GPTEAM_IMAGE_MAX_ATTEMPTS, defaultMaxAttempts);
|
|
215
|
+
const retryDelayMs = resolveBoundedInt(0, options.retryDelayMs, options.env && options.env.GPTEAM_IMAGE_RETRY_DELAY_MS, defaultRetryDelayMs);
|
|
216
|
+
let lastError;
|
|
217
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
218
|
+
try {
|
|
219
|
+
const response = await fetchImageOnce(fetchImpl, credentials, payload, options);
|
|
220
|
+
return { ...response, retryCount: attempt - 1 };
|
|
221
|
+
} catch (error) {
|
|
222
|
+
const classified = error instanceof ImageMCPError ? error : imageErrorFromFetch(error, { apiKey: credentials.apiKey });
|
|
223
|
+
lastError = classified;
|
|
224
|
+
if (!classified.retryable || attempt >= maxAttempts) throw classified;
|
|
225
|
+
await delay(retryDelayMs * Math.max(1, attempt), options);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
throw lastError;
|
|
112
229
|
}
|
|
113
230
|
|
|
114
|
-
function
|
|
115
|
-
|
|
231
|
+
async function fetchImageOnce(fetchImpl, credentials, payload, options) {
|
|
232
|
+
const requestSignal = createRequestSignal(options);
|
|
233
|
+
try {
|
|
234
|
+
const response = await fetchImpl(`${credentials.baseUrl}/images/generations`, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: {
|
|
237
|
+
Authorization: `Bearer ${credentials.apiKey}`,
|
|
238
|
+
'Content-Type': 'application/json'
|
|
239
|
+
},
|
|
240
|
+
body: JSON.stringify(payload),
|
|
241
|
+
signal: requestSignal.signal
|
|
242
|
+
});
|
|
243
|
+
if (!response.ok) throw await imageErrorFromHTTPResponse(response, credentials.apiKey);
|
|
244
|
+
const data = await response.json();
|
|
245
|
+
const first = Array.isArray(data && data.data) ? data.data[0] : null;
|
|
246
|
+
const b64 = first && typeof first.b64_json === 'string' ? first.b64_json : '';
|
|
247
|
+
if (!b64) {
|
|
248
|
+
throw new ImageMCPError('GPTeam 图片接口没有返回 b64_json 图片数据。', {
|
|
249
|
+
code: 'image_data_missing',
|
|
250
|
+
category: 'response_invalid',
|
|
251
|
+
retryable: false
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
b64,
|
|
256
|
+
revisedPrompt: first && typeof first.revised_prompt === 'string' ? first.revised_prompt : ''
|
|
257
|
+
};
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error instanceof ImageMCPError) throw error;
|
|
260
|
+
throw imageErrorFromFetch(error, {
|
|
261
|
+
apiKey: credentials.apiKey,
|
|
262
|
+
timedOut: requestSignal.timedOut(),
|
|
263
|
+
cancelled: Boolean(options.signal && options.signal.aborted)
|
|
264
|
+
});
|
|
265
|
+
} finally {
|
|
266
|
+
requestSignal.clear();
|
|
267
|
+
}
|
|
116
268
|
}
|
|
117
269
|
|
|
118
|
-
function
|
|
270
|
+
function buildSuccessResult(input) {
|
|
271
|
+
const result = {
|
|
272
|
+
ok: true,
|
|
273
|
+
status: 'succeeded',
|
|
274
|
+
file: input.file.file,
|
|
275
|
+
path: input.file.file,
|
|
276
|
+
model: input.payload.model,
|
|
277
|
+
size: input.payload.size,
|
|
278
|
+
format: input.file.format,
|
|
279
|
+
output_format: input.file.format,
|
|
280
|
+
quality: input.payload.quality,
|
|
281
|
+
mime_type: input.file.mime_type,
|
|
282
|
+
mimeType: input.file.mime_type,
|
|
283
|
+
bytes: input.file.bytes,
|
|
284
|
+
sha256: input.file.sha256,
|
|
285
|
+
width: input.file.width,
|
|
286
|
+
height: input.file.height,
|
|
287
|
+
duration_ms: input.durationMs,
|
|
288
|
+
retry_count: input.retryCount,
|
|
289
|
+
job_id: input.jobID,
|
|
290
|
+
trace_id: input.traceID,
|
|
291
|
+
b64: input.b64
|
|
292
|
+
};
|
|
293
|
+
if (input.revisedPrompt) {
|
|
294
|
+
result.revised_prompt = input.revisedPrompt;
|
|
295
|
+
result.revisedPrompt = input.revisedPrompt;
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function runImageJob(store, job, input, options) {
|
|
301
|
+
if (job.status === 'cancelled') return;
|
|
302
|
+
job.status = 'running';
|
|
303
|
+
job.updated_at = new Date(store.now()).toISOString();
|
|
119
304
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
305
|
+
const result = await generateImage({ ...input, job_id: job.job_id, trace_id: job.trace_id }, {
|
|
306
|
+
...options,
|
|
307
|
+
signal: job.controller.signal
|
|
308
|
+
});
|
|
309
|
+
if (job.status === 'cancelled') return;
|
|
310
|
+
job.status = 'succeeded';
|
|
311
|
+
job.ok = true;
|
|
312
|
+
job.result = result;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (job.status !== 'cancelled') {
|
|
315
|
+
job.status = 'failed';
|
|
316
|
+
job.ok = false;
|
|
317
|
+
job.error = serializeImageError(error);
|
|
318
|
+
}
|
|
319
|
+
} finally {
|
|
320
|
+
job.updated_at = new Date(store.now()).toISOString();
|
|
123
321
|
}
|
|
124
322
|
}
|
|
125
323
|
|
|
126
|
-
function
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
324
|
+
function publicJobStatus(job) {
|
|
325
|
+
const base = {
|
|
326
|
+
ok: job.status !== 'failed' && job.status !== 'cancelled',
|
|
327
|
+
job_id: job.job_id,
|
|
328
|
+
trace_id: job.trace_id,
|
|
329
|
+
status: job.status,
|
|
330
|
+
created_at: job.created_at,
|
|
331
|
+
updated_at: job.updated_at
|
|
332
|
+
};
|
|
333
|
+
if (job.result) return { ...base, ...structuredToolResult(job.result) };
|
|
334
|
+
if (job.error) return { ...base, error: job.error };
|
|
335
|
+
return base;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function missingJobResult(jobID) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
job_id: String(jobID || ''),
|
|
342
|
+
status: 'not_found',
|
|
343
|
+
error: {
|
|
344
|
+
code: 'job_not_found',
|
|
345
|
+
category: 'not_found',
|
|
346
|
+
retryable: false,
|
|
347
|
+
message: '没有找到这个图片任务。'
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function cleanupImageJobs(store) {
|
|
353
|
+
const threshold = store.now() - store.ttlMs;
|
|
354
|
+
for (const [jobID, job] of store.jobs.entries()) {
|
|
355
|
+
if (job.status === 'queued' || job.status === 'running') continue;
|
|
356
|
+
const updated = Date.parse(job.updated_at || job.created_at || '');
|
|
357
|
+
if (Number.isFinite(updated) && updated < threshold) store.jobs.delete(jobID);
|
|
134
358
|
}
|
|
135
|
-
const outputDir = expandHome(firstNonEmpty(env.GPTEAM_IMAGE_OUTPUT_DIR, defaultImageOutputDir(home)), home);
|
|
136
|
-
return path.join(outputDir, name);
|
|
137
359
|
}
|
|
138
360
|
|
|
139
|
-
function
|
|
140
|
-
const
|
|
141
|
-
|
|
361
|
+
function createRequestSignal(options = {}) {
|
|
362
|
+
const timeoutMs = resolveBoundedInt(1, options.requestTimeoutMs, options.env && options.env.GPTEAM_IMAGE_REQUEST_TIMEOUT_MS, defaultRequestTimeoutMs);
|
|
363
|
+
const controller = new AbortController();
|
|
364
|
+
let timedOut = false;
|
|
365
|
+
const timer = setTimeout(() => {
|
|
366
|
+
timedOut = true;
|
|
367
|
+
controller.abort();
|
|
368
|
+
}, timeoutMs);
|
|
369
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
370
|
+
const parent = options.signal;
|
|
371
|
+
if (parent) {
|
|
372
|
+
if (parent.aborted) controller.abort();
|
|
373
|
+
else parent.addEventListener('abort', () => {
|
|
374
|
+
clearTimeout(timer);
|
|
375
|
+
controller.abort();
|
|
376
|
+
}, { once: true });
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
signal: controller.signal,
|
|
380
|
+
timedOut: () => timedOut,
|
|
381
|
+
clear: () => clearTimeout(timer)
|
|
382
|
+
};
|
|
142
383
|
}
|
|
143
384
|
|
|
144
|
-
function
|
|
145
|
-
|
|
385
|
+
function resolveRevisedPromptFlag(input) {
|
|
386
|
+
if (Object.prototype.hasOwnProperty.call(input, 'include_revised_prompt')) return Boolean(input.include_revised_prompt);
|
|
387
|
+
if (Object.prototype.hasOwnProperty.call(input, 'return_revised_prompt')) return Boolean(input.return_revised_prompt);
|
|
388
|
+
return true;
|
|
146
389
|
}
|
|
147
390
|
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
if (normalized === 'jpg') return 'jpeg';
|
|
151
|
-
if (['png', 'jpeg', 'webp'].includes(normalized)) return normalized;
|
|
152
|
-
return DEFAULT_IMAGE_FORMAT;
|
|
391
|
+
function resolveCodexHome(env, home) {
|
|
392
|
+
return expandHome(firstNonEmpty(env.GPTEAM_CODEX_HOME, env.CODEX_HOME, path.join(home, '.codex')), home);
|
|
153
393
|
}
|
|
154
394
|
|
|
155
|
-
function
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
395
|
+
function safeRead(filePath, readFile) {
|
|
396
|
+
try {
|
|
397
|
+
return readFile(filePath);
|
|
398
|
+
} catch {
|
|
399
|
+
return '';
|
|
400
|
+
}
|
|
159
401
|
}
|
|
160
402
|
|
|
161
403
|
function firstNonEmpty(...values) {
|
|
@@ -178,9 +420,29 @@ function unescapeTomlString(value) {
|
|
|
178
420
|
return String(value || '').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
179
421
|
}
|
|
180
422
|
|
|
181
|
-
function
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
423
|
+
function makeID(prefix) {
|
|
424
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function now(options = {}) {
|
|
428
|
+
return typeof options.now === 'function' ? options.now() : Date.now();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function resolveBoundedInt(min, ...values) {
|
|
432
|
+
for (const value of values) {
|
|
433
|
+
if (value === undefined || value === null || value === '') continue;
|
|
434
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
435
|
+
if (Number.isFinite(parsed) && parsed >= min) return parsed;
|
|
436
|
+
}
|
|
437
|
+
return min;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function delay(ms, options = {}) {
|
|
441
|
+
if (ms <= 0) return;
|
|
442
|
+
if (typeof options.sleep === 'function') return options.sleep(ms);
|
|
443
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function redactImageSecret(text, secret) {
|
|
447
|
+
return redactSecret(text, secret);
|
|
186
448
|
}
|
package/lib/image-mcp/server.js
CHANGED
|
@@ -1,45 +1,88 @@
|
|
|
1
1
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
cancelImageJob,
|
|
7
|
+
createImageJob,
|
|
8
|
+
downloadImageResult,
|
|
9
|
+
generateImage,
|
|
10
|
+
getImageJobStatus,
|
|
11
|
+
resultFromError,
|
|
12
|
+
structuredToolResult,
|
|
13
|
+
toolResultContent
|
|
14
|
+
} from './image.js';
|
|
6
15
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
},
|
|
38
|
-
required: ['prompt'],
|
|
39
|
-
additionalProperties: false
|
|
16
|
+
const imageInputProperties = {
|
|
17
|
+
prompt: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
description: 'Image prompt.'
|
|
20
|
+
},
|
|
21
|
+
size: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Image size, for example 1024x1024.',
|
|
24
|
+
default: '1024x1024'
|
|
25
|
+
},
|
|
26
|
+
quality: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Image quality.',
|
|
29
|
+
default: 'high'
|
|
30
|
+
},
|
|
31
|
+
format: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'Output image format.',
|
|
34
|
+
enum: ['png', 'jpeg', 'webp'],
|
|
35
|
+
default: 'png'
|
|
36
|
+
},
|
|
37
|
+
output_path: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Optional output file path or directory.'
|
|
40
|
+
},
|
|
41
|
+
return_revised_prompt: {
|
|
42
|
+
type: 'boolean',
|
|
43
|
+
description: 'Return upstream revised prompt when available.',
|
|
44
|
+
default: true
|
|
40
45
|
}
|
|
41
46
|
};
|
|
42
47
|
|
|
48
|
+
const tools = [
|
|
49
|
+
{
|
|
50
|
+
name: 'create_image_job',
|
|
51
|
+
description: 'Recommended for normal use. Create a local background GPTeam Image 2 job and return immediately with a job_id.',
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: imageInputProperties,
|
|
55
|
+
required: ['prompt'],
|
|
56
|
+
additionalProperties: false
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'get_image_job_status',
|
|
61
|
+
description: 'Get the status of a local GPTeam Image 2 job.',
|
|
62
|
+
inputSchema: jobIDSchema()
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'cancel_image_job',
|
|
66
|
+
description: 'Cancel a local GPTeam Image 2 job when it is still queued or running.',
|
|
67
|
+
inputSchema: jobIDSchema()
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'download_image_result',
|
|
71
|
+
description: 'Return the saved local file metadata and image content for a completed image job.',
|
|
72
|
+
inputSchema: jobIDSchema()
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'generate_image',
|
|
76
|
+
description: 'Compatibility tool. Generate an image through GPTeam Image 2, wait for completion, and save it locally.',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: imageInputProperties,
|
|
80
|
+
required: ['prompt'],
|
|
81
|
+
additionalProperties: false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
];
|
|
85
|
+
|
|
43
86
|
export function createServer(deps = {}) {
|
|
44
87
|
const server = new Server({
|
|
45
88
|
name: 'gpteam-image-mcp',
|
|
@@ -50,18 +93,14 @@ export function createServer(deps = {}) {
|
|
|
50
93
|
}
|
|
51
94
|
});
|
|
52
95
|
|
|
53
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
54
|
-
tools: [GENERATE_IMAGE_TOOL]
|
|
55
|
-
}));
|
|
96
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
56
97
|
|
|
57
98
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
58
|
-
const
|
|
59
|
-
if (toolName !== GENERATE_IMAGE_TOOL.name) {
|
|
60
|
-
throw new Error(`未知工具:${toolName}`);
|
|
61
|
-
}
|
|
62
|
-
const result = await generateImage(request.params.arguments || {}, deps);
|
|
99
|
+
const result = await callImageTool(request.params && request.params.name, request.params && request.params.arguments, deps);
|
|
63
100
|
return {
|
|
64
|
-
content: toolResultContent(result)
|
|
101
|
+
content: toolResultContent(result),
|
|
102
|
+
structuredContent: structuredToolResult(result),
|
|
103
|
+
isError: result && result.ok === false
|
|
65
104
|
};
|
|
66
105
|
});
|
|
67
106
|
|
|
@@ -83,3 +122,39 @@ export function resolvePackageVersion(deps = {}) {
|
|
|
83
122
|
return '0.0.0';
|
|
84
123
|
}
|
|
85
124
|
}
|
|
125
|
+
|
|
126
|
+
export async function callImageTool(toolName, args = {}, deps = {}) {
|
|
127
|
+
try {
|
|
128
|
+
switch (toolName) {
|
|
129
|
+
case 'create_image_job':
|
|
130
|
+
return createImageJob(args || {}, deps);
|
|
131
|
+
case 'get_image_job_status':
|
|
132
|
+
return getImageJobStatus(args || {}, deps);
|
|
133
|
+
case 'cancel_image_job':
|
|
134
|
+
return cancelImageJob(args || {}, deps);
|
|
135
|
+
case 'download_image_result':
|
|
136
|
+
return downloadImageResult(args || {}, deps);
|
|
137
|
+
case 'generate_image':
|
|
138
|
+
return await generateImage(args || {}, deps);
|
|
139
|
+
default:
|
|
140
|
+
throw new McpError(ErrorCode.InvalidParams, `未知工具:${toolName}`);
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof McpError) throw error;
|
|
144
|
+
return resultFromError(error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function jobIDSchema() {
|
|
149
|
+
return {
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: {
|
|
152
|
+
job_id: {
|
|
153
|
+
type: 'string',
|
|
154
|
+
description: 'Image job id returned by create_image_job.'
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
required: ['job_id'],
|
|
158
|
+
additionalProperties: false
|
|
159
|
+
};
|
|
160
|
+
}
|