@cheapbuddy/media-mcp 0.0.0-stage → 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 +120 -2
- package/RELEASE-CHECKLIST.md +50 -0
- package/examples/claude-code.mcp.json +11 -0
- package/examples/codex.toml +6 -0
- package/examples/opencode.json +13 -0
- package/package.json +35 -4
- package/src/backend.mjs +172 -0
- package/src/catalog.mjs +113 -0
- package/src/cli.mjs +18 -0
- package/src/errors.mjs +98 -0
- package/src/http.mjs +168 -0
- package/src/job-id.mjs +34 -0
- package/src/media-input.mjs +181 -0
- package/src/normalize.mjs +214 -0
- package/src/server.mjs +28 -0
- package/src/tools.mjs +404 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { encodeJobId } from "./job-id.mjs";
|
|
2
|
+
import { validationError } from "./errors.mjs";
|
|
3
|
+
|
|
4
|
+
const STATUS_MAP = new Map([
|
|
5
|
+
["queued", "queued"], ["queue", "queued"], ["pending", "queued"], ["not_start", "queued"], ["submitted", "queued"],
|
|
6
|
+
["running", "in_progress"], ["in_progress", "in_progress"], ["processing", "in_progress"], ["started", "in_progress"],
|
|
7
|
+
["success", "completed"], ["succeeded", "completed"], ["completed", "completed"], ["done", "completed"], ["finish", "completed"],
|
|
8
|
+
["failure", "failed"], ["failed", "failed"], ["error", "failed"], ["cancelled", "failed"], ["canceled", "failed"], ["expired", "failed"], ["released", "failed"],
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export async function normalizeImageResponse(payload, context) {
|
|
12
|
+
const taskId = extractImageTaskId(payload);
|
|
13
|
+
const rawStatus = extractStatus(payload);
|
|
14
|
+
const status = rawStatus || (Array.isArray(payload?.data) ? "completed" : taskId ? "in_progress" : "unknown");
|
|
15
|
+
const media = [];
|
|
16
|
+
const inlineImages = [];
|
|
17
|
+
const rows = imageRows(payload);
|
|
18
|
+
for (const row of rows) {
|
|
19
|
+
const imageUrl = row.url || row.image_url;
|
|
20
|
+
if (typeof imageUrl === "string" && isHttpsUrl(imageUrl)) {
|
|
21
|
+
media.push({ url: imageUrl, mime_type: imageMime(row.mime_type || row.content_type || imageUrl), ...(row.expires_at ? { expires_at: row.expires_at } : {}) });
|
|
22
|
+
}
|
|
23
|
+
if (typeof row.b64_json === "string") {
|
|
24
|
+
const decoded = decodeImageBase64(row.b64_json);
|
|
25
|
+
const uploaded = await context.backend.uploadResult(decoded.bytes, decoded.mimeType);
|
|
26
|
+
const url = uploaded?.url || uploaded?.data?.url;
|
|
27
|
+
if (!isHttpsUrl(url)) throw validationError("Relay did not return a safe temporary image URL.");
|
|
28
|
+
media.push({ url, mime_type: decoded.mimeType, ...(uploaded.expires_at ? { expires_at: uploaded.expires_at } : {}) });
|
|
29
|
+
inlineImages.push({ type: "image", data: decoded.bytes.toString("base64"), mimeType: decoded.mimeType });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const id = taskId ? encodeJobId("image", taskId) : null;
|
|
33
|
+
return buildJob({
|
|
34
|
+
kind: "image", model: context.model, id, status: media.length ? "completed" : status, request: context.request,
|
|
35
|
+
media, inlineImages, payload, pollAfterMs: media.length || ["completed", "failed"].includes(status) ? undefined : pollAfter(payload),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeVideoResponse(payload, context) {
|
|
40
|
+
const taskId = extractTaskId(payload);
|
|
41
|
+
const status = extractStatus(payload) || (taskId ? "queued" : "unknown");
|
|
42
|
+
const media = [];
|
|
43
|
+
if (status === "completed") {
|
|
44
|
+
const artifact = extractArtifactUrl(payload);
|
|
45
|
+
if (taskId && context.backend) {
|
|
46
|
+
// Always prefer the Relay capability URL. A provider-signed URL in a
|
|
47
|
+
// NewAPI task payload must not become an MCP-visible provider boundary.
|
|
48
|
+
media.push({ url: context.backend.videoContentUrl(taskId), mime_type: "video/mp4", requires_auth: true, ...(extractExpiry(payload) ? { expires_at: extractExpiry(payload) } : {}) });
|
|
49
|
+
} else if (artifact && context.backend?.isRelayUrl?.(artifact)) {
|
|
50
|
+
media.push({ url: artifact, mime_type: "video/mp4", ...(extractExpiry(payload) ? { expires_at: extractExpiry(payload) } : {}) });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return buildJob({
|
|
54
|
+
kind: "video", model: context.model, id: taskId ? encodeJobId("video", taskId) : null, status,
|
|
55
|
+
request: context.request, media, payload, pollAfterMs: status === "completed" ? undefined : pollAfter(payload),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeQueriedJob(kind, payload, context) {
|
|
60
|
+
return kind === "image" ? normalizeImageResponse(payload, context) : normalizeVideoResponse(payload, context);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildJob({ kind, model, id, status, request, media = [], inlineImages = [], payload, pollAfterMs }) {
|
|
64
|
+
const job = {
|
|
65
|
+
object: "media_job",
|
|
66
|
+
id,
|
|
67
|
+
media_type: kind,
|
|
68
|
+
model,
|
|
69
|
+
status: normalizeStatus(status),
|
|
70
|
+
request: request ? structuredClone(request) : undefined,
|
|
71
|
+
media,
|
|
72
|
+
usage: extractUsage(payload),
|
|
73
|
+
billing: extractBilling(payload),
|
|
74
|
+
...(pollAfterMs === undefined ? {} : { poll_after_ms: pollAfterMs }),
|
|
75
|
+
};
|
|
76
|
+
if (job.status === "failed") job.error = extractError(payload);
|
|
77
|
+
if (payload?.accepted_unknown === true || payload?.error?.type === "accepted_unknown") job.accepted_unknown = true;
|
|
78
|
+
if (job.request === undefined) delete job.request;
|
|
79
|
+
return { job, inlineImages };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function extractTaskId(value) {
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
const visit = (node, depth = 0) => {
|
|
85
|
+
if (!node || depth > 4 || typeof node !== "object" || seen.has(node)) return "";
|
|
86
|
+
seen.add(node);
|
|
87
|
+
for (const key of ["task_id", "id", "response_id"]) {
|
|
88
|
+
if (typeof node[key] === "string" && node[key].trim() && !node[key].startsWith("http")) return node[key].trim();
|
|
89
|
+
}
|
|
90
|
+
for (const key of ["data", "task", "result", "response", "output"]) {
|
|
91
|
+
const found = visit(node[key], depth + 1);
|
|
92
|
+
if (found) return found;
|
|
93
|
+
}
|
|
94
|
+
return "";
|
|
95
|
+
};
|
|
96
|
+
return visit(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function extractImageTaskId(payload) {
|
|
100
|
+
if (!payload || typeof payload !== "object") return "";
|
|
101
|
+
for (const key of ["task_id", "taskId"]) {
|
|
102
|
+
if (typeof payload[key] === "string" && payload[key].trim()) return payload[key].trim();
|
|
103
|
+
}
|
|
104
|
+
if (typeof payload.id === "string" && ["queued", "in_progress", "failed", "unknown"].includes(extractStatus(payload))) return payload.id.trim();
|
|
105
|
+
if (payload.task && typeof payload.task === "object") return extractTaskId(payload.task);
|
|
106
|
+
if (payload.data && !Array.isArray(payload.data) && typeof payload.data === "object") {
|
|
107
|
+
if (payload.data.task_id || payload.data.taskId) return extractTaskId(payload.data);
|
|
108
|
+
if (payload.data.status && payload.data.id) return String(payload.data.id);
|
|
109
|
+
}
|
|
110
|
+
// An image response row's `id` identifies an output object, not a Relay
|
|
111
|
+
// processing task. Do not turn it into a resumable task ID.
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function extractStatus(value) {
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const visit = (node, depth = 0) => {
|
|
118
|
+
if (!node || depth > 4 || typeof node !== "object" || seen.has(node)) return "";
|
|
119
|
+
seen.add(node);
|
|
120
|
+
for (const key of ["status", "task_status", "state"]) {
|
|
121
|
+
const raw = String(node[key] || "").trim().toLowerCase();
|
|
122
|
+
if (raw && STATUS_MAP.has(raw)) return STATUS_MAP.get(raw);
|
|
123
|
+
}
|
|
124
|
+
for (const key of ["data", "task", "result", "response", "output"]) {
|
|
125
|
+
const found = visit(node[key], depth + 1);
|
|
126
|
+
if (found) return found;
|
|
127
|
+
}
|
|
128
|
+
return "";
|
|
129
|
+
};
|
|
130
|
+
return visit(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function pollAfter(payload) {
|
|
134
|
+
const raw = payload?.poll_after_ms ?? payload?.data?.poll_after_ms ?? payload?.task?.poll_after_ms;
|
|
135
|
+
const number = Number(raw);
|
|
136
|
+
return Number.isFinite(number) && number >= 0 ? Math.min(Math.max(Math.round(number), 500), 10000) : 2000;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function imageRows(payload) {
|
|
140
|
+
if (Array.isArray(payload?.data)) return payload.data.filter((row) => row && typeof row === "object");
|
|
141
|
+
if (payload?.data && typeof payload.data === "object") return [payload.data];
|
|
142
|
+
if (payload && typeof payload === "object" && (payload.url || payload.image_url || payload.b64_json)) return [payload];
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function decodeImageBase64(value) {
|
|
147
|
+
const encoded = value.replace(/^data:([^;,]+);base64,/iu, "").replace(/\s+/gu, "");
|
|
148
|
+
if (!encoded || !/^[A-Za-z0-9+/]*={0,2}$/u.test(encoded) || encoded.length % 4 === 1) throw validationError("Relay returned invalid base64 image data.");
|
|
149
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
150
|
+
if (bytes.length === 0 || bytes.length > 30 * 1024 * 1024) throw validationError("Relay returned an oversized image.");
|
|
151
|
+
const mimeType = detectImageMime(bytes);
|
|
152
|
+
if (!mimeType) throw validationError("Relay returned unsupported image bytes.");
|
|
153
|
+
return { bytes, mimeType };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function detectImageMime(bytes) {
|
|
157
|
+
if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
|
|
158
|
+
if (bytes.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) return "image/jpeg";
|
|
159
|
+
if (bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
|
|
160
|
+
if (["GIF87a", "GIF89a"].includes(bytes.subarray(0, 6).toString("ascii"))) return "image/gif";
|
|
161
|
+
return "";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function imageMime(value) {
|
|
165
|
+
const text = String(value || "").toLowerCase();
|
|
166
|
+
if (text.includes("png")) return "image/png";
|
|
167
|
+
if (text.includes("webp")) return "image/webp";
|
|
168
|
+
if (text.includes("gif")) return "image/gif";
|
|
169
|
+
return "image/jpeg";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function extractArtifactUrl(payload) {
|
|
173
|
+
const candidates = [
|
|
174
|
+
payload?.url, payload?.video_url, payload?.content_url,
|
|
175
|
+
payload?.content?.url, payload?.video?.url, payload?.artifact?.url,
|
|
176
|
+
payload?.data?.url, payload?.data?.video_url, payload?.data?.content_url,
|
|
177
|
+
payload?.data?.content?.url, payload?.data?.video?.url, payload?.task?.content?.url,
|
|
178
|
+
];
|
|
179
|
+
return candidates.find(isHttpsUrl) || "";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function extractExpiry(payload) {
|
|
183
|
+
return payload?.expires_at || payload?.data?.expires_at || payload?.content?.expires_at || payload?.artifact?.expires_at || null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractUsage(payload) {
|
|
187
|
+
const usage = payload?.usage || payload?.data?.usage || payload?.task?.usage;
|
|
188
|
+
return usage && typeof usage === "object" ? structuredClone(usage) : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function extractBilling(payload) {
|
|
192
|
+
const source = payload?.billing || payload?.data?.billing || {};
|
|
193
|
+
const fields = {};
|
|
194
|
+
for (const key of ["status", "amount", "currency", "reservation", "reservation_id", "ledger_id", "estimated_amount_usd", "final_amount_usd", "message"]) {
|
|
195
|
+
if (source[key] !== undefined && source[key] !== null) fields[key] = source[key];
|
|
196
|
+
}
|
|
197
|
+
if (payload?.cheapbuddy) fields.cheapbuddy = structuredClone(payload.cheapbuddy);
|
|
198
|
+
return Object.keys(fields).length ? fields : { status: "pending_final_charge" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function extractError(payload) {
|
|
202
|
+
const source = payload?.error || payload?.data?.error || payload?.task?.error || {};
|
|
203
|
+
const message = typeof source === "string" ? source : source.message || source.code || "Media task failed.";
|
|
204
|
+
return { code: String(source.code || "media_task_failed"), message: String(message).slice(0, 500) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizeStatus(value) {
|
|
208
|
+
return STATUS_MAP.get(String(value || "").toLowerCase()) || "unknown";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isHttpsUrl(value) {
|
|
212
|
+
try { return new URL(value).protocol === "https:"; } catch { return false; }
|
|
213
|
+
}
|
|
214
|
+
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CheapBuddyRelayBackend } from "./backend.mjs";
|
|
5
|
+
import { createToolHandlers, toolDefinitions } from "./tools.mjs";
|
|
6
|
+
|
|
7
|
+
export function createMediaMcpServer(options = {}) {
|
|
8
|
+
const backend = options.backend || new CheapBuddyRelayBackend(options);
|
|
9
|
+
const handlers = options.handlers || createToolHandlers({ backend, sleep: options.sleep, now: options.now });
|
|
10
|
+
const server = new Server(
|
|
11
|
+
{ name: "cheapbuddy-media-mcp", version: options.version || "0.1.0" },
|
|
12
|
+
{
|
|
13
|
+
capabilities: { tools: { listChanged: false } },
|
|
14
|
+
instructions: "CheapBuddy media tools use account balance directly. Quotes are optional and no budget or confirmation step is implemented.",
|
|
15
|
+
},
|
|
16
|
+
);
|
|
17
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolDefinitions() }));
|
|
18
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => handlers.call(request.params.name, request.params.arguments || {}));
|
|
19
|
+
return { server, backend, handlers };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function startStdioServer(options = {}) {
|
|
23
|
+
const { server } = createMediaMcpServer(options);
|
|
24
|
+
const transport = options.transport || new StdioServerTransport();
|
|
25
|
+
await server.connect(transport);
|
|
26
|
+
return { server, transport };
|
|
27
|
+
}
|
|
28
|
+
|
package/src/tools.mjs
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { assertNoUnknownKeys, assertPlainObject, publicError, validationError } from "./errors.mjs";
|
|
3
|
+
import { findMediaModel, modelAllowsOperation, allowedParameterNames } from "./catalog.mjs";
|
|
4
|
+
import { decodeJobId } from "./job-id.mjs";
|
|
5
|
+
import { normalizeImageResponse, normalizeQueriedJob, normalizeVideoResponse } from "./normalize.mjs";
|
|
6
|
+
import { validateMediaInput } from "./media-input.mjs";
|
|
7
|
+
|
|
8
|
+
export const TOOL_NAMES = Object.freeze([
|
|
9
|
+
"cheapbuddy_list_media_models",
|
|
10
|
+
"cheapbuddy_quote_image",
|
|
11
|
+
"cheapbuddy_generate_image",
|
|
12
|
+
"cheapbuddy_quote_video",
|
|
13
|
+
"cheapbuddy_submit_video",
|
|
14
|
+
"cheapbuddy_get_media_job",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const WRITE_NOTICE = "This operation consumes CheapBuddy account balance; no budget or confirmation step is used.";
|
|
18
|
+
|
|
19
|
+
export function toolDefinitions() {
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
name: "cheapbuddy_list_media_models",
|
|
23
|
+
description: "List image and video models visible to the current CheapBuddy API key, including capabilities and conservative parameter schemas.",
|
|
24
|
+
inputSchema: { type: "object", additionalProperties: false, properties: { refresh: { type: "boolean", default: false } } },
|
|
25
|
+
annotations: { title: "List CheapBuddy media models", readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "cheapbuddy_quote_image",
|
|
29
|
+
description: "Read an optional image rate card or non-exact estimate. This never locks balance and is not required before generation.",
|
|
30
|
+
inputSchema: imageQuoteSchema(),
|
|
31
|
+
annotations: { title: "Quote image pricing", readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "cheapbuddy_generate_image",
|
|
35
|
+
description: `Generate, edit, or vary an image through CheapBuddy Relay. ${WRITE_NOTICE}`,
|
|
36
|
+
inputSchema: imageGenerateSchema(),
|
|
37
|
+
annotations: { title: "Generate or edit image", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "cheapbuddy_quote_video",
|
|
41
|
+
description: "Read an optional MiniMax-H3 rate card or duration estimate. This never locks balance and is not required before submission.",
|
|
42
|
+
inputSchema: videoQuoteSchema(),
|
|
43
|
+
annotations: { title: "Quote video pricing", readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "cheapbuddy_submit_video",
|
|
47
|
+
description: `Submit a non-blocking MiniMax-H3 video task with text, frame, image, video, or audio references. ${WRITE_NOTICE}`,
|
|
48
|
+
inputSchema: videoSubmitSchema(),
|
|
49
|
+
annotations: { title: "Submit H3 video", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "cheapbuddy_get_media_job",
|
|
53
|
+
description: "Query one MCP-generated image or video job ID. Video queries are bounded and never block until completion.",
|
|
54
|
+
inputSchema: { type: "object", additionalProperties: false, required: ["job_id"], properties: { job_id: { type: "string", minLength: 12 } } },
|
|
55
|
+
annotations: { title: "Get media job", readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createToolHandlers({ backend, sleep, now } = {}) {
|
|
61
|
+
if (!backend) throw new Error("backend is required");
|
|
62
|
+
const wait = sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
63
|
+
const clock = now || (() => Date.now());
|
|
64
|
+
const handlers = {
|
|
65
|
+
cheapbuddy_list_media_models: async (args) => {
|
|
66
|
+
assertPlainObject(args, "arguments");
|
|
67
|
+
assertNoUnknownKeys(args, new Set(["refresh"]));
|
|
68
|
+
const rows = await backend.listMediaModels({ refresh: args.refresh === true });
|
|
69
|
+
return successResult({ object: "media_model_list", data: rows });
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
cheapbuddy_quote_image: async (args) => {
|
|
73
|
+
assertPlainObject(args, "arguments");
|
|
74
|
+
assertNoUnknownKeys(args, new Set(["model", "operation"]));
|
|
75
|
+
const model = await resolveModel(args.model, "image");
|
|
76
|
+
const operation = args.operation || "generate";
|
|
77
|
+
if (!modelAllowsOperation(model, operation)) throw validationError(`Model does not advertise image operation: ${operation}.`);
|
|
78
|
+
const payload = await backend.quote(model.id, {});
|
|
79
|
+
return successResult(imageQuote(payload, model, operation));
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
cheapbuddy_generate_image: async (args) => {
|
|
83
|
+
assertPlainObject(args, "arguments");
|
|
84
|
+
assertNoUnknownKeys(args, new Set(["model", "operation", "prompt", "images", "mask", "parameters", "wait_seconds", "idempotency_key"]));
|
|
85
|
+
let model = await resolveModel(args.model, "image");
|
|
86
|
+
const operation = requireEnum(args.operation, ["generate", "edit", "variation"], "operation");
|
|
87
|
+
if (!modelAllowsOperation(model, operation)) {
|
|
88
|
+
backend.invalidateCatalog();
|
|
89
|
+
model = await resolveModel(args.model, "image");
|
|
90
|
+
if (!modelAllowsOperation(model, operation)) throw validationError(`Model does not advertise image operation: ${operation}.`);
|
|
91
|
+
}
|
|
92
|
+
const prompt = optionalText(args.prompt, "prompt");
|
|
93
|
+
const images = arrayOrEmpty(args.images, "images");
|
|
94
|
+
const mask = args.mask === undefined ? undefined : validateMediaInput(args.mask, "image");
|
|
95
|
+
const waitSeconds = normalizeWaitSeconds(args.wait_seconds);
|
|
96
|
+
if (operation !== "variation" && !prompt) throw validationError("prompt is required for image generation and editing.");
|
|
97
|
+
if (operation !== "generate" && images.length === 0) throw validationError(`${operation} requires at least one image input.`);
|
|
98
|
+
if (operation === "generate" && images.length) throw validationError("generate does not accept image inputs; use edit or variation.");
|
|
99
|
+
if (operation !== "edit" && mask) throw validationError("mask is supported only for operation edit.");
|
|
100
|
+
const maxImages = model.max_images || 1;
|
|
101
|
+
if (images.length > maxImages) throw validationError(`This model accepts at most ${maxImages} image input(s).`);
|
|
102
|
+
const parameters = validateParameters(args.parameters, model);
|
|
103
|
+
const request = { operation, prompt: prompt || undefined, image_count: images.length, ...(mask ? { mask: true } : {}), parameters };
|
|
104
|
+
let normalized;
|
|
105
|
+
try {
|
|
106
|
+
const response = await backend.createImage({ model: model.id, operation, prompt, images, mask, parameters, idempotencyKey: normalizeIdempotencyKey(args.idempotency_key) });
|
|
107
|
+
normalized = await normalizeImageResponse(response, { backend, model: model.id, request });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (!error.ambiguous) throw error;
|
|
110
|
+
return successResult(acceptedUnknown("image", model.id, request, error));
|
|
111
|
+
}
|
|
112
|
+
if (normalized.job.status === "in_progress" || normalized.job.status === "queued") {
|
|
113
|
+
const task = decodeOptionalTask(normalized.job.id);
|
|
114
|
+
if (task) normalized = await pollImage(task.taskId, normalized, model.id, request, waitSeconds);
|
|
115
|
+
}
|
|
116
|
+
return successResult(normalized.job, normalized.inlineImages);
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
cheapbuddy_quote_video: async (args) => {
|
|
120
|
+
assertPlainObject(args, "arguments");
|
|
121
|
+
assertNoUnknownKeys(args, new Set(["model", "seconds", "ratio", "resolution", "has_reference"]));
|
|
122
|
+
const model = await resolveModel(args.model, "video");
|
|
123
|
+
const hasReference = args.has_reference === undefined ? false : optionalBoolean(args.has_reference, "has_reference");
|
|
124
|
+
const normalized = normalizeH3Fields({ seconds: args.seconds, ratio: args.ratio, resolution: args.resolution, hasReference });
|
|
125
|
+
const payload = await backend.quote(model.id, { seconds: normalized.seconds });
|
|
126
|
+
return successResult(videoQuote(payload, model, normalized));
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
cheapbuddy_submit_video: async (args) => {
|
|
130
|
+
assertPlainObject(args, "arguments");
|
|
131
|
+
assertNoUnknownKeys(args, new Set(["model", "prompt", "seconds", "ratio", "resolution", "first_frame", "last_frame", "reference_image", "reference_video", "reference_audio", "images", "context_ir_enabled", "aigc_watermark", "idempotency_key"]));
|
|
132
|
+
const model = await resolveModel(args.model, "video");
|
|
133
|
+
const refs = normalizeVideoReferences(args);
|
|
134
|
+
const fields = normalizeH3Fields({ seconds: args.seconds, ratio: args.ratio, resolution: args.resolution, hasReference: refs.hasReference });
|
|
135
|
+
const prompt = requiredText(args.prompt, "prompt");
|
|
136
|
+
const request = {
|
|
137
|
+
model: model.id,
|
|
138
|
+
prompt,
|
|
139
|
+
seconds: fields.seconds,
|
|
140
|
+
ratio: fields.ratio,
|
|
141
|
+
resolution: fields.resolution,
|
|
142
|
+
...(refs.serialized ? { references: refs.serialized } : {}),
|
|
143
|
+
...(args.context_ir_enabled === undefined ? {} : { context_ir_enabled: optionalBoolean(args.context_ir_enabled, "context_ir_enabled") }),
|
|
144
|
+
...(args.aigc_watermark === undefined ? {} : { aigc_watermark: optionalBoolean(args.aigc_watermark, "aigc_watermark") }),
|
|
145
|
+
};
|
|
146
|
+
try {
|
|
147
|
+
const response = await backend.createVideo({
|
|
148
|
+
model: model.id, prompt, ...fields, ...refs.inputs,
|
|
149
|
+
context_ir_enabled: optionalBoolean(args.context_ir_enabled, "context_ir_enabled"),
|
|
150
|
+
aigc_watermark: optionalBoolean(args.aigc_watermark, "aigc_watermark"),
|
|
151
|
+
idempotencyKey: normalizeIdempotencyKey(args.idempotency_key) || `cheapbuddy-media-${randomUUID()}`,
|
|
152
|
+
});
|
|
153
|
+
const normalized = normalizeVideoResponse(response, { backend, model: model.id, request });
|
|
154
|
+
return successResult(normalized.job, normalized.inlineImages);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (!error.ambiguous && error.code !== "accepted_unknown") throw error;
|
|
157
|
+
return successResult(acceptedUnknown("video", model.id, request, error));
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
cheapbuddy_get_media_job: async (args) => {
|
|
162
|
+
assertPlainObject(args, "arguments");
|
|
163
|
+
assertNoUnknownKeys(args, new Set(["job_id"]));
|
|
164
|
+
const decoded = decodeJobId(args.job_id);
|
|
165
|
+
const payload = decoded.kind === "image" ? await backend.getImageTask(decoded.taskId) : await backend.getVideoTask(decoded.taskId);
|
|
166
|
+
const model = payload?.model || payload?.data?.model || payload?.task?.model || "unknown";
|
|
167
|
+
const normalized = await normalizeQueriedJob(decoded.kind, payload, { backend, model, request: undefined });
|
|
168
|
+
return successResult(normalized.job, normalized.inlineImages);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
async call(name, rawArgs = {}) {
|
|
174
|
+
if (!TOOL_NAMES.includes(name)) throw validationError(`Unknown CheapBuddy Media MCP tool: ${name}.`);
|
|
175
|
+
try {
|
|
176
|
+
return await handlers[name](rawArgs);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return errorResult(error);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
async function resolveModel(modelId, expectedType) {
|
|
184
|
+
if (typeof modelId !== "string" || !modelId.trim() || modelId.length > 200) throw validationError("model is required and must be at most 200 characters.");
|
|
185
|
+
let rows = await backend.listMediaModels();
|
|
186
|
+
let model;
|
|
187
|
+
try {
|
|
188
|
+
model = findMediaModel(rows, modelId);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
backend.invalidateCatalog();
|
|
191
|
+
rows = await backend.listMediaModels({ refresh: true });
|
|
192
|
+
model = findMediaModel(rows, modelId);
|
|
193
|
+
}
|
|
194
|
+
if (model.media_type !== expectedType) throw validationError(`Model ${modelId} is not a ${expectedType} model.`);
|
|
195
|
+
return model;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function pollImage(taskId, initial, model, request, requestedWaitSeconds) {
|
|
199
|
+
const waitSeconds = requestedWaitSeconds === undefined ? 300 : Number(requestedWaitSeconds);
|
|
200
|
+
if (!Number.isFinite(waitSeconds) || waitSeconds < 0 || waitSeconds > 300) throw validationError("wait_seconds must be between 0 and 300.");
|
|
201
|
+
const deadline = clock() + waitSeconds * 1000;
|
|
202
|
+
let current = initial;
|
|
203
|
+
const delays = [2000, 3000, 5000, 8000];
|
|
204
|
+
let index = 0;
|
|
205
|
+
while (clock() < deadline) {
|
|
206
|
+
const serverDelay = Number(current.job.poll_after_ms);
|
|
207
|
+
const fallbackDelay = delays[Math.min(index, delays.length - 1)];
|
|
208
|
+
const delay = Number.isFinite(serverDelay) && serverDelay >= 0 ? Math.min(Math.max(serverDelay, 500), 10000) : Math.min(fallbackDelay, 10000);
|
|
209
|
+
await wait(delay);
|
|
210
|
+
const payload = await backend.getImageTask(taskId);
|
|
211
|
+
current = await normalizeImageResponse(payload, { backend, model, request });
|
|
212
|
+
if (["completed", "failed", "unknown"].includes(current.job.status)) return current;
|
|
213
|
+
index += 1;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
job: { ...current.job, status: current.job.status === "unknown" ? "unknown" : current.job.status, poll_after_ms: 2000 },
|
|
217
|
+
inlineImages: current.inlineImages,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function imageQuote(payload, model, operation) {
|
|
223
|
+
const metadata = payload?.cheapbuddy || {};
|
|
224
|
+
const estimate = numberOrNull(metadata.estimated_amount_usd ?? metadata.estimated_amount ?? payload?.estimated_amount_usd);
|
|
225
|
+
return {
|
|
226
|
+
object: "media_quote", media_type: "image", model: model.id, operation,
|
|
227
|
+
pricing_type: estimate === null ? "rate_card" : "estimate", estimated_amount: estimate, currency: "USD",
|
|
228
|
+
rate_card: payload?.data || null,
|
|
229
|
+
billing_note: "Final charge follows successful upstream usage; this quote does not reserve balance.",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function videoQuote(payload, model, fields) {
|
|
234
|
+
const estimate = numberOrNull(payload?.cheapbuddy?.reservation?.estimated_amount_usd);
|
|
235
|
+
return {
|
|
236
|
+
object: "media_quote", media_type: "video", model: model.id, seconds: fields.seconds, ratio: fields.ratio, resolution: fields.resolution,
|
|
237
|
+
pricing_type: estimate === null ? "rate_card" : "estimate", estimated_amount: estimate, currency: "USD",
|
|
238
|
+
rate_card: payload?.data || null, reservation: payload?.cheapbuddy?.reservation || null,
|
|
239
|
+
billing_note: "Final charge follows successful upstream usage; this quote does not reserve balance.",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizeVideoReferences(args) {
|
|
244
|
+
const single = ["first_frame", "last_frame", "reference_image", "reference_video", "reference_audio"];
|
|
245
|
+
const inputs = {};
|
|
246
|
+
const serialized = {};
|
|
247
|
+
for (const field of single) {
|
|
248
|
+
if (args[field] === undefined) continue;
|
|
249
|
+
const kind = field.includes("video") ? "video" : field.includes("audio") ? "audio" : "image";
|
|
250
|
+
inputs[field] = validateMediaInput(args[field], kind);
|
|
251
|
+
serialized[field] = args[field];
|
|
252
|
+
}
|
|
253
|
+
const images = arrayOrEmpty(args.images, "images");
|
|
254
|
+
if (images.length && single.some((name) => args[name] !== undefined && ["first_frame", "last_frame", "reference_image"].includes(name))) {
|
|
255
|
+
throw validationError("Use images or named H3 image roles, not both.");
|
|
256
|
+
}
|
|
257
|
+
if (images.length > 3) throw validationError("images accepts at most three H3 image references.");
|
|
258
|
+
if (images.length) {
|
|
259
|
+
inputs.images = images.map((value) => validateMediaInput(value, "image"));
|
|
260
|
+
serialized.images = images;
|
|
261
|
+
}
|
|
262
|
+
return { inputs, serialized: Object.keys(serialized).length ? serialized : null, hasReference: Object.keys(inputs).length > 0 };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function normalizeH3Fields({ seconds, ratio, resolution, hasReference }) {
|
|
266
|
+
if (seconds !== undefined && typeof seconds !== "number") throw validationError("seconds must be a number.");
|
|
267
|
+
if (ratio !== undefined && ratio !== "" && typeof ratio !== "string") throw validationError("ratio must be a string.");
|
|
268
|
+
if (resolution !== undefined && typeof resolution !== "string") throw validationError("resolution must be a string.");
|
|
269
|
+
const normalizedSeconds = seconds === undefined ? 6 : seconds;
|
|
270
|
+
if (!Number.isInteger(normalizedSeconds) || normalizedSeconds < 4 || normalizedSeconds > 15) throw validationError("seconds must be an integer between 4 and 15.");
|
|
271
|
+
const normalizedResolution = (resolution || "768P").toUpperCase();
|
|
272
|
+
if (normalizedResolution !== "768P") throw validationError("resolution must be 768P.");
|
|
273
|
+
const validRatios = new Set(["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"]);
|
|
274
|
+
const normalizedRatio = ratio === undefined || ratio === "" ? (hasReference ? "adaptive" : undefined) : ratio;
|
|
275
|
+
if (!normalizedRatio || (!validRatios.has(normalizedRatio) && !(hasReference && normalizedRatio === "adaptive"))) {
|
|
276
|
+
throw validationError(hasReference ? "ratio must be one of the supported ratios or adaptive." : "ratio is required for text-to-video.");
|
|
277
|
+
}
|
|
278
|
+
return { seconds: normalizedSeconds, ratio: normalizedRatio, resolution: normalizedResolution };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function validateParameters(parameters, model) {
|
|
282
|
+
if (parameters === undefined) return {};
|
|
283
|
+
assertPlainObject(parameters, "parameters");
|
|
284
|
+
const allowed = allowedParameterNames(model);
|
|
285
|
+
for (const key of Object.keys(parameters)) if (!allowed.has(key)) throw validationError(`Unsupported parameter for ${model.id}: ${key}.`);
|
|
286
|
+
return structuredClone(parameters);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function arrayOrEmpty(value, name) {
|
|
290
|
+
if (value === undefined) return [];
|
|
291
|
+
if (!Array.isArray(value)) throw validationError(`${name} must be an array.`);
|
|
292
|
+
return value;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function requiredText(value, name) {
|
|
296
|
+
const text = optionalText(value, name);
|
|
297
|
+
if (!text) throw validationError(`${name} is required.`);
|
|
298
|
+
return text;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function optionalText(value, name) {
|
|
302
|
+
if (value === undefined || value === null) return "";
|
|
303
|
+
if (typeof value !== "string" || value.length > 100000) throw validationError(`${name} must be a string.`);
|
|
304
|
+
return value.trim();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function optionalBoolean(value, name) {
|
|
308
|
+
if (value === undefined) return undefined;
|
|
309
|
+
if (typeof value !== "boolean") throw validationError(`${name} must be a boolean.`);
|
|
310
|
+
return value;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function normalizeWaitSeconds(value) {
|
|
314
|
+
if (value === undefined) return 300;
|
|
315
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 300) {
|
|
316
|
+
throw validationError("wait_seconds must be an integer between 0 and 300.");
|
|
317
|
+
}
|
|
318
|
+
return value;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function requireEnum(value, allowed, name) {
|
|
322
|
+
if (!allowed.includes(value)) throw validationError(`${name} must be one of: ${allowed.join(", ")}.`);
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function acceptedUnknown(kind, model, request, error) {
|
|
327
|
+
return {
|
|
328
|
+
object: "media_job", id: null, media_type: kind, model, status: "unknown", accepted_unknown: true,
|
|
329
|
+
request, media: [], usage: null, billing: { status: "pending_reconciliation" },
|
|
330
|
+
error: { code: "accepted_unknown", message: "Relay may have accepted this task; query the provider-side task identity when available." },
|
|
331
|
+
...(error?.retryAfterMs === undefined ? {} : { poll_after_ms: error.retryAfterMs }),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function decodeOptionalTask(id) {
|
|
336
|
+
if (!id) return null;
|
|
337
|
+
try { return decodeJobId(id); } catch { return null; }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function numberOrNull(value) {
|
|
341
|
+
const number = Number(value);
|
|
342
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function normalizeIdempotencyKey(value) {
|
|
346
|
+
if (value === undefined) return undefined;
|
|
347
|
+
if (typeof value !== "string" || !value.trim() || value.length > 200 || /[\r\n\u0000]/u.test(value)) {
|
|
348
|
+
throw validationError("idempotency_key must be a non-empty safe string of at most 200 characters.");
|
|
349
|
+
}
|
|
350
|
+
return value.trim();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function successResult(value, inlineImages = []) {
|
|
354
|
+
return {
|
|
355
|
+
content: [
|
|
356
|
+
{ type: "text", text: JSON.stringify(value) },
|
|
357
|
+
...inlineImages,
|
|
358
|
+
],
|
|
359
|
+
structuredContent: value,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function errorResult(error) {
|
|
364
|
+
const safe = publicError(error);
|
|
365
|
+
return { isError: true, content: [{ type: "text", text: JSON.stringify({ error: safe }) }], structuredContent: { error: safe } };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function imageQuoteSchema() {
|
|
369
|
+
return { type: "object", additionalProperties: false, required: ["model"], properties: { model: { type: "string" }, operation: { type: "string", enum: ["generate", "edit", "variation"], default: "generate" } } };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function imageGenerateSchema() {
|
|
373
|
+
return {
|
|
374
|
+
type: "object", additionalProperties: false, required: ["model", "operation"],
|
|
375
|
+
properties: {
|
|
376
|
+
model: { type: "string" }, operation: { type: "string", enum: ["generate", "edit", "variation"] }, prompt: { type: "string" },
|
|
377
|
+
images: { type: "array", items: mediaInputSchema() }, mask: mediaInputSchema(), parameters: { type: "object", additionalProperties: true },
|
|
378
|
+
wait_seconds: { type: "integer", minimum: 0, maximum: 300, default: 300 }, idempotency_key: { type: "string", maxLength: 200 },
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function videoQuoteSchema() {
|
|
384
|
+
return { type: "object", additionalProperties: false, required: ["model"], properties: { model: { type: "string" }, seconds: { type: "integer", minimum: 4, maximum: 15, default: 6 }, ratio: { type: "string" }, resolution: { type: "string", enum: ["768P"], default: "768P" }, has_reference: { type: "boolean", default: false } } };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function videoSubmitSchema() {
|
|
388
|
+
return {
|
|
389
|
+
type: "object", additionalProperties: false, required: ["model", "prompt"],
|
|
390
|
+
properties: {
|
|
391
|
+
model: { type: "string" }, prompt: { type: "string" }, seconds: { type: "integer", minimum: 4, maximum: 15, default: 6 },
|
|
392
|
+
ratio: { type: "string", enum: ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16", "adaptive"] }, resolution: { type: "string", enum: ["768P"], default: "768P" },
|
|
393
|
+
first_frame: mediaInputSchema(), last_frame: mediaInputSchema(), reference_image: mediaInputSchema(), reference_video: mediaInputSchema(), reference_audio: mediaInputSchema(),
|
|
394
|
+
images: { type: "array", maxItems: 3, items: mediaInputSchema() }, context_ir_enabled: { type: "boolean" }, aigc_watermark: { type: "boolean" }, idempotency_key: { type: "string", maxLength: 200 },
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function mediaInputSchema() {
|
|
400
|
+
return { type: "object", additionalProperties: false, minProperties: 1, maxProperties: 1, properties: { path: { type: "string" }, url: { type: "string", format: "uri", pattern: "^https://" } } };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export { normalizeH3Fields, normalizeVideoReferences, validateParameters, successResult, errorResult };
|
|
404
|
+
|