corent-mcp 0.5.2 → 0.7.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/README.md +68 -3
- package/dist/server.js +446 -28
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Corent MCP Server
|
|
2
2
|
|
|
3
|
-
Give any AI agent the ability to generate images and
|
|
3
|
+
Give any AI agent the ability to generate images, videos, voice, and text through the [Corent](https://corent.tech) API — one key, automatic model routing, provider fallback built in.
|
|
4
4
|
|
|
5
5
|
## Tools
|
|
6
6
|
|
|
@@ -8,10 +8,19 @@ Give any AI agent the ability to generate images and videos through the [Corent]
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| `plan` | Describe a request in plain language; Corent returns the plan (image vs video, tier, settings) + cost estimate, without generating |
|
|
10
10
|
| `create` | Describe what you want + a spend ceiling; Corent decides everything and generates it (the "zero decisions" path) |
|
|
11
|
-
| `generate_image` | Text → image, synchronous, returns a permanent URL |
|
|
11
|
+
| `generate_image` | Text → image, synchronous, returns a permanent URL. Takes `reference_image_urls` for character and product consistency |
|
|
12
12
|
| `generate_video` | Text (or image) → video, async job |
|
|
13
|
+
| `generate_speech` | Text → spoken audio, synchronous |
|
|
14
|
+
| `generate_text` | Prompt or full conversation → text from a frontier language model. Supports tool calling and JSON mode |
|
|
15
|
+
| `generate_image_batch` | Up to 50 images in one call, async |
|
|
16
|
+
| `generate_video_batch` | Up to 50 clips in one call, async |
|
|
17
|
+
| `get_batch` | Progress of a submitted batch |
|
|
18
|
+
| `list_models` | The direct-access menu: every model that can be pinned by name (`corent-*`), with quality and live status |
|
|
19
|
+
| `list_tiers` | The tier menu: prices, shapes, video resolutions, and which tiers accept reference images or render sound |
|
|
20
|
+
| `list_voices` | The voices `generate_speech` accepts, with previews and descriptors |
|
|
21
|
+
| `cancel_job` | Stop a running job and release its money hold. Costs nothing |
|
|
13
22
|
| `get_job` | Poll a job until completed |
|
|
14
|
-
| `get_balance` |
|
|
23
|
+
| `get_balance` | Balance, holds, and what a new request can actually spend |
|
|
15
24
|
| `get_status` | Live tier health |
|
|
16
25
|
|
|
17
26
|
The **`plan`** and **`create`** tools are the agent-native path: an agent says
|
|
@@ -20,6 +29,62 @@ image-vs-video, the tier, aspect ratio, and duration — the agent never manages
|
|
|
20
29
|
models. `create` enforces a per-call spend ceiling so an autonomous agent can't
|
|
21
30
|
overspend.
|
|
22
31
|
|
|
32
|
+
### When the user names a specific model
|
|
33
|
+
|
|
34
|
+
All four `generate_*` tools also take an optional **`model`**: pin an exact
|
|
35
|
+
model from `list_models` and Corent runs that one, never a substitute (if it
|
|
36
|
+
can't deliver, the call fails and nothing is billed). Use it only when the user
|
|
37
|
+
asked for a particular model by name — otherwise omit it and let Corent route
|
|
38
|
+
to the best fit for the prompt, which is what the tiers are for.
|
|
39
|
+
|
|
40
|
+
Every name in the menu is spelled `corent-*` — `corent-flux-schnell`,
|
|
41
|
+
`corent-seedance-2.0`, `corent-eleven-multilingual-v2`,
|
|
42
|
+
`corent-claude-opus-5` — and that is the name the receipt echoes back. Pass one
|
|
43
|
+
back verbatim.
|
|
44
|
+
|
|
45
|
+
`generate_text` is the language-model lane: it puts every frontier lab on the
|
|
46
|
+
same key and the same bill, so an agent can get a named model's answer or a
|
|
47
|
+
second opinion from another lab without the user holding that lab's account.
|
|
48
|
+
Pass `messages` instead of `prompt` to continue a conversation or feed tool
|
|
49
|
+
results back, and `tools` / `response_format` for function calling and JSON.
|
|
50
|
+
|
|
51
|
+
### Keeping a character or product consistent
|
|
52
|
+
|
|
53
|
+
`generate_image` takes **`reference_image_urls`**: 1 to 4 public https images
|
|
54
|
+
that the prompt is applied as an *edit* of, so the same face, character, or
|
|
55
|
+
product survives into a new scene. Use it whenever the user wants "the same
|
|
56
|
+
person again", a product placed somewhere, or a matching series. Edit-capable
|
|
57
|
+
models sit at premium and up, so pass `tier` `premium` / `pro` / `max_pro`, or
|
|
58
|
+
no tier at all — `air` and `lite` are refused with a message saying so.
|
|
59
|
+
|
|
60
|
+
### What the generate tools can ask for
|
|
61
|
+
|
|
62
|
+
`generate_image` takes a `seed` (so a picture can be re-rendered and tweaked),
|
|
63
|
+
a `negative_prompt`, a `source_image_url` with `strength` to start from an
|
|
64
|
+
existing picture, `transparent` for logos and cutouts, an explicit `width` and
|
|
65
|
+
`height`, `output_format`, and `n` for several versions at once. `n` is n real
|
|
66
|
+
renders at full price, so confirm before spending on more than a couple.
|
|
67
|
+
|
|
68
|
+
`generate_video` takes `audio` for native sound (true routes only to models
|
|
69
|
+
that actually render it, so a silent model can never quietly serve the ask),
|
|
70
|
+
`end_image_url` for the frame to finish on, a `camera` move, `negative_prompt`,
|
|
71
|
+
`seed` and `fps`.
|
|
72
|
+
|
|
73
|
+
`generate_speech` takes `stability`, `similarity`, `style`, `speed` and
|
|
74
|
+
`language`. Call `list_voices` first whenever the user wants a particular
|
|
75
|
+
sounding narrator: `voice_id` cannot be guessed.
|
|
76
|
+
|
|
77
|
+
Anything the chosen model could not honour comes back in
|
|
78
|
+
`meta.unsupported_options`, so an ignored setting never reads as an applied
|
|
79
|
+
one. `enhance_prompt: false` sends the user's wording verbatim.
|
|
80
|
+
|
|
81
|
+
### Spending safety
|
|
82
|
+
|
|
83
|
+
Every money-spending call carries an `Idempotency-Key`, and a 429 or 5xx is
|
|
84
|
+
retried under that same key, so a retry replays the original job instead of
|
|
85
|
+
buying a second one. Batches bill per item: a 30-item image batch costs 30
|
|
86
|
+
generations, so check `get_balance` first.
|
|
87
|
+
|
|
23
88
|
## Setup
|
|
24
89
|
|
|
25
90
|
Get an API key at [corent.tech](https://corent.tech), then add to your MCP client config:
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared Corent MCP server definition — the
|
|
2
|
+
* Shared Corent MCP server definition — the 10 tools, used by both the
|
|
3
3
|
* stdio entrypoint (index.ts, for local `npx` use) and the hosted HTTP
|
|
4
4
|
* entrypoint (http.ts). Keeping the tools in one place means the two
|
|
5
5
|
* transports can never drift apart.
|
|
@@ -17,7 +17,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { MEDIA_WIDGET_HTML } from "./widget-html.js";
|
|
19
19
|
export const DEFAULT_API_URL = "https://api.corent.tech";
|
|
20
|
-
export const SERVER_VERSION = "0.
|
|
20
|
+
export const SERVER_VERSION = "0.7.0";
|
|
21
|
+
// Transport resilience, matching the two SDKs: a 429 or 5xx is retried with
|
|
22
|
+
// backoff, but ONLY on calls that carry an Idempotency-Key (or are GETs), so a
|
|
23
|
+
// retry can never buy a second generation.
|
|
24
|
+
const MAX_RETRIES = 3;
|
|
25
|
+
const RETRY_BASE_MS = 500;
|
|
26
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
27
|
// --- MCP Apps (SEP-1865): the media tools render an inline widget in hosts
|
|
22
28
|
// that support it (claude.ai web/desktop, others). Hosts without the extension
|
|
23
29
|
// ignore the _meta and fall back to plain text results, so this is additive.
|
|
@@ -51,6 +57,11 @@ function errorCode(err) {
|
|
|
51
57
|
return "invalid_api_key";
|
|
52
58
|
if (err.status === 402)
|
|
53
59
|
return "insufficient_balance";
|
|
60
|
+
// Scoped keys (image/video/voice/llm): the key is valid, this lane isn't
|
|
61
|
+
// enabled on it. Distinct from invalid_api_key -- retrying won't help, the
|
|
62
|
+
// user has to enable the capability on the key.
|
|
63
|
+
if (err.status === 403)
|
|
64
|
+
return "capability_not_enabled";
|
|
54
65
|
if (err.status === 404)
|
|
55
66
|
return "not_found";
|
|
56
67
|
if (err.status === 422 && detail.includes("content"))
|
|
@@ -75,6 +86,16 @@ function errorCode(err) {
|
|
|
75
86
|
export function createCorentServer(config = {}) {
|
|
76
87
|
const apiUrl = config.apiUrl ?? DEFAULT_API_URL;
|
|
77
88
|
const apiKey = config.apiKey;
|
|
89
|
+
/**
|
|
90
|
+
* A fresh Idempotency-Key for one money-spending call. The MCP used to send
|
|
91
|
+
* none at all, so any retry -- the agent's, the host's, or a user clicking
|
|
92
|
+
* again -- bought a second generation at full price (parity audit
|
|
93
|
+
* 2026-08-30). The API keys replays off (account, key), so the same key
|
|
94
|
+
* returns the ORIGINAL job instead of generating twice.
|
|
95
|
+
*/
|
|
96
|
+
function idempotencyKey() {
|
|
97
|
+
return globalThis.crypto?.randomUUID?.() ?? `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
98
|
+
}
|
|
78
99
|
async function corent(path, init) {
|
|
79
100
|
// Key is enforced here (at call time) rather than at startup, so the server
|
|
80
101
|
// can advertise its tools to catalogs/agents without a key configured.
|
|
@@ -83,19 +104,40 @@ export function createCorentServer(config = {}) {
|
|
|
83
104
|
message: "No Corent credentials. On claude.ai or other remote MCP clients, reconnect the Corent connector and approve access (OAuth). For local/stdio use, set CORENT_API_KEY in your MCP client config. Keys: https://corent.tech/dashboard/api-keys",
|
|
84
105
|
});
|
|
85
106
|
}
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
const headers = {
|
|
108
|
+
Authorization: `Bearer ${apiKey}`,
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
...(init?.headers ?? {}),
|
|
111
|
+
};
|
|
112
|
+
// Retrying is only safe when the call cannot be charged twice: a GET, or a
|
|
113
|
+
// POST carrying an Idempotency-Key (the API replays the original job for a
|
|
114
|
+
// repeated key). A POST without one is sent exactly once, forever.
|
|
115
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
116
|
+
const replaySafe = method === "GET" || Boolean(headers["Idempotency-Key"]);
|
|
117
|
+
const maxAttempts = replaySafe ? MAX_RETRIES : 1;
|
|
118
|
+
let lastErr;
|
|
119
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
120
|
+
if (attempt > 0)
|
|
121
|
+
await sleep(RETRY_BASE_MS * 2 ** (attempt - 1));
|
|
122
|
+
let res;
|
|
123
|
+
try {
|
|
124
|
+
res = await fetch(`${apiUrl}${path}`, { ...init, headers });
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
// Transport failure: on a replay-safe call the same key returns the
|
|
128
|
+
// original job, so retrying cannot double-charge.
|
|
129
|
+
lastErr = err;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const body = await res.json().catch(() => ({}));
|
|
133
|
+
if (res.ok)
|
|
134
|
+
return body;
|
|
135
|
+
const retriable = res.status === 429 || (res.status >= 500 && res.status !== 501);
|
|
136
|
+
lastErr = new CorentApiError(res.status, body.detail ?? body);
|
|
137
|
+
if (!retriable)
|
|
138
|
+
throw lastErr;
|
|
97
139
|
}
|
|
98
|
-
|
|
140
|
+
throw lastErr;
|
|
99
141
|
}
|
|
100
142
|
/** Success: typed structuredContent plus a text fallback for older clients. */
|
|
101
143
|
function ok(data) {
|
|
@@ -154,7 +196,13 @@ export function createCorentServer(config = {}) {
|
|
|
154
196
|
// yet) -- must be nullable, not just optional, or the SDK's output validator
|
|
155
197
|
// throws on the normal poll-loop response.
|
|
156
198
|
const mediaMeta = z
|
|
157
|
-
.object({
|
|
199
|
+
.object({
|
|
200
|
+
model: z.string().nullable().optional(),
|
|
201
|
+
cost_cents: z.number().nullable().optional(),
|
|
202
|
+
seed: z.number().nullable().optional(),
|
|
203
|
+
unsupported_options: z.array(z.string()).nullable().optional(),
|
|
204
|
+
has_audio: z.boolean().nullable().optional(),
|
|
205
|
+
})
|
|
158
206
|
.passthrough();
|
|
159
207
|
const imageOutput = {
|
|
160
208
|
id: z.string().optional(),
|
|
@@ -162,14 +210,34 @@ export function createCorentServer(config = {}) {
|
|
|
162
210
|
images: z.array(z.object({ url: z.string() }).passthrough()).optional(),
|
|
163
211
|
meta: mediaMeta.optional(),
|
|
164
212
|
};
|
|
213
|
+
// meta carries two honesty fields worth surfacing to an agent: the seed
|
|
214
|
+
// that was actually used (so a render can be repeated) and the settings the
|
|
215
|
+
// chosen model could NOT honour (so an ignored knob never reads as applied).
|
|
165
216
|
const jobOutput = {
|
|
166
217
|
id: z.string().optional(),
|
|
167
218
|
status: z.string().optional(),
|
|
168
219
|
images: z.array(z.object({ url: z.string() }).passthrough()).optional(),
|
|
169
220
|
videos: z.array(z.object({ url: z.string() }).passthrough()).optional(),
|
|
221
|
+
// Speech jobs answer with `audio` (routers/jobs.py). It was missing from
|
|
222
|
+
// this schema, so a polled speech job validated as a result with no
|
|
223
|
+
// deliverable in it (parity audit 2026-08-30).
|
|
224
|
+
audio: z.array(z.object({ url: z.string() }).passthrough()).optional(),
|
|
225
|
+
progress_percent: z.number().nullable().optional(),
|
|
170
226
|
meta: mediaMeta.optional(),
|
|
171
227
|
error: z.string().optional(),
|
|
172
228
|
};
|
|
229
|
+
const batchOutput = {
|
|
230
|
+
batch_id: z.string().optional(),
|
|
231
|
+
status: z.string().optional(),
|
|
232
|
+
jobs: z.array(z.object({ id: z.string().optional() }).passthrough()).optional(),
|
|
233
|
+
counts: z.object({}).passthrough().optional(),
|
|
234
|
+
error: z.string().optional(),
|
|
235
|
+
};
|
|
236
|
+
// Shared with generate_image and the image batch items, so the two can never
|
|
237
|
+
// drift apart on which shapes and styles are reachable.
|
|
238
|
+
const IMAGE_ASPECTS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"];
|
|
239
|
+
const IMAGE_STYLES = ["photorealistic", "artistic", "anime", "logo", "text_focused", "cinematic"];
|
|
240
|
+
const TIERS = ["air", "lite", "premium", "pro", "max_pro"];
|
|
173
241
|
server.registerTool("generate_image", {
|
|
174
242
|
title: "Generate image",
|
|
175
243
|
_meta: WIDGET_TOOL_META,
|
|
@@ -177,20 +245,78 @@ export function createCorentServer(config = {}) {
|
|
|
177
245
|
inputSchema: {
|
|
178
246
|
prompt: z.string().describe("What to generate, in plain language"),
|
|
179
247
|
tier: z
|
|
180
|
-
.enum(
|
|
248
|
+
.enum(TIERS)
|
|
181
249
|
.optional()
|
|
182
250
|
.describe("Quality tier: air=cheapest (best value, not the quickest), lite=everyday, premium=production, pro=advanced, max_pro=maximum fidelity (flagship models). Omit for a sensible default."),
|
|
251
|
+
model: z
|
|
252
|
+
.string()
|
|
253
|
+
.optional()
|
|
254
|
+
.describe('Direct model access: pin an exact model by name, e.g. "corent-flux-schnell" (list_models shows the menu; every name there is spelled corent-*). Bypasses Corent\'s routing -- never substituted, flat cost-plus price. Mutually exclusive with tier; prefer tier unless the user named a specific model.'),
|
|
183
255
|
style: z
|
|
184
|
-
.enum(
|
|
256
|
+
.enum(IMAGE_STYLES)
|
|
185
257
|
.optional()
|
|
186
258
|
.describe("Optional content style hint"),
|
|
187
|
-
aspect_ratio: z.enum(
|
|
259
|
+
aspect_ratio: z.enum(IMAGE_ASPECTS).default("1:1"),
|
|
260
|
+
n: z
|
|
261
|
+
.number()
|
|
262
|
+
.int()
|
|
263
|
+
.min(1)
|
|
264
|
+
.max(10)
|
|
265
|
+
.optional()
|
|
266
|
+
.describe("How many versions to render, 1 to 10. Each is a REAL render at the normal price, so n=4 " +
|
|
267
|
+
"costs four images; confirm with the user before spending on more than a couple. They come " +
|
|
268
|
+
"back together and meta.cost_cents is the total."),
|
|
269
|
+
seed: z
|
|
270
|
+
.number()
|
|
271
|
+
.int()
|
|
272
|
+
.min(0)
|
|
273
|
+
.optional()
|
|
274
|
+
.describe("Reproducibility. The same seed with the same prompt and model gives the same image, so use " +
|
|
275
|
+
"it to re-render a picture the user liked and change one thing. The seed that was used " +
|
|
276
|
+
"comes back in meta.seed even when you did not pass one."),
|
|
277
|
+
negative_prompt: z.string().optional().describe('What must NOT appear, e.g. "text, watermark".'),
|
|
278
|
+
source_image_url: z
|
|
279
|
+
.string()
|
|
280
|
+
.url()
|
|
281
|
+
.optional()
|
|
282
|
+
.describe("Image-to-image: start from this picture rather than from scratch. Pair with `strength`. " +
|
|
283
|
+
"Different from reference_image_urls, which pins an identity across scenes."),
|
|
284
|
+
strength: z
|
|
285
|
+
.number()
|
|
286
|
+
.min(0)
|
|
287
|
+
.max(1)
|
|
288
|
+
.optional()
|
|
289
|
+
.describe("How far to travel from source_image_url. 0 keeps it, 1 ignores it."),
|
|
290
|
+
output_format: z.enum(["png", "jpeg", "webp"]).optional().describe("Delivered file type."),
|
|
291
|
+
transparent: z
|
|
292
|
+
.boolean()
|
|
293
|
+
.optional()
|
|
294
|
+
.describe("Transparent background, for logos and product cutouts. Forces png."),
|
|
295
|
+
width: z.number().int().min(256).max(4096).optional().describe("Explicit pixel width; pass with height."),
|
|
296
|
+
height: z.number().int().min(256).max(4096).optional().describe("Explicit pixel height; pass with width."),
|
|
297
|
+
enhance_prompt: z
|
|
298
|
+
.boolean()
|
|
299
|
+
.optional()
|
|
300
|
+
.describe("Corent rewrites the prompt before dispatch to get a better render. Pass false when the user " +
|
|
301
|
+
"has carefully worded their own prompt and wants it sent verbatim."),
|
|
302
|
+
reference_image_urls: z
|
|
303
|
+
.array(z.string().url())
|
|
304
|
+
.min(1)
|
|
305
|
+
.max(4)
|
|
306
|
+
.optional()
|
|
307
|
+
.describe("1 to 4 public https image URLs that keep a CHARACTER, FACE, or PRODUCT consistent across " +
|
|
308
|
+
"generations. The prompt is applied as an EDIT of these references, so use it whenever the " +
|
|
309
|
+
"user wants the same person/object again in a new scene, a product placed somewhere, or a " +
|
|
310
|
+
"series that has to match. Served only by edit-capable models, which sit at premium and up: " +
|
|
311
|
+
"pass tier premium/pro/max_pro (or omit tier), never air or lite. Omit entirely for plain " +
|
|
312
|
+
"text-to-image."),
|
|
188
313
|
},
|
|
189
314
|
outputSchema: imageOutput,
|
|
190
315
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
191
|
-
}, wrap(async (
|
|
316
|
+
}, wrap(async (args) => corent("/v1/images/generate", {
|
|
192
317
|
method: "POST",
|
|
193
|
-
|
|
318
|
+
headers: { "Idempotency-Key": idempotencyKey() },
|
|
319
|
+
body: JSON.stringify(args),
|
|
194
320
|
})));
|
|
195
321
|
server.registerTool("generate_video", {
|
|
196
322
|
title: "Generate video",
|
|
@@ -199,22 +325,58 @@ export function createCorentServer(config = {}) {
|
|
|
199
325
|
inputSchema: {
|
|
200
326
|
prompt: z.string().describe("What to generate, in plain language"),
|
|
201
327
|
tier: z
|
|
202
|
-
.enum(
|
|
328
|
+
.enum(TIERS)
|
|
203
329
|
.optional()
|
|
204
330
|
.describe("Quality tier: air=cheapest (best value, not the quickest), lite=everyday, premium=production, pro=advanced, max_pro=maximum fidelity (flagship models). Omit for a sensible default."),
|
|
205
|
-
|
|
331
|
+
style: z
|
|
332
|
+
.enum(IMAGE_STYLES)
|
|
333
|
+
.optional()
|
|
334
|
+
.describe("Optional content style hint, same vocabulary as generate_image"),
|
|
335
|
+
aspect_ratio: z
|
|
336
|
+
.enum(["16:9", "9:16", "1:1"])
|
|
337
|
+
.optional()
|
|
338
|
+
.describe("Shape of the clip. OMIT IT when animating a source image: those models render the still's own shape and cannot be given a different one, so naming a shape is refused rather than silently ignored. Omitted for text-to-video means the model's own default (landscape)."),
|
|
206
339
|
duration_s: z.number().int().min(1).max(30).optional().describe("Requested duration in seconds"),
|
|
207
340
|
resolution: z
|
|
208
341
|
.enum(["720p", "1080p", "4k"])
|
|
209
342
|
.optional()
|
|
210
343
|
.describe("Pixel resolution (default 720p). Tier-capped: pro allows up to 1080p, max_pro up to 4k; above the tier's cap it is clamped down, never rejected. Higher resolutions cost more (1080p ~2.5x, 4k ~5.5x). GET /v1/tiers lists each tier's menu with prices."),
|
|
211
344
|
image_url: z.string().url().optional().describe("If set, animates this image instead of pure text-to-video"),
|
|
345
|
+
audio: z
|
|
346
|
+
.boolean()
|
|
347
|
+
.optional()
|
|
348
|
+
.describe("Ask for native SOUND. Some models render audio and some are silent. true routes only to " +
|
|
349
|
+
"models that actually deliver sound, so a silent model can never quietly serve the request; " +
|
|
350
|
+
"false prefers a silent one; omit to let Corent choose. list_tiers reports supports_audio " +
|
|
351
|
+
"per tier. Use true whenever the user asks for a clip with sound, music, or speech."),
|
|
352
|
+
end_image_url: z
|
|
353
|
+
.string()
|
|
354
|
+
.url()
|
|
355
|
+
.optional()
|
|
356
|
+
.describe("The frame the clip should END on. Together with image_url this is the go-from-A-to-B / morph " +
|
|
357
|
+
"effect; alone it is a target to move toward."),
|
|
358
|
+
camera: z
|
|
359
|
+
.enum([
|
|
360
|
+
"static", "pan_left", "pan_right", "zoom_in", "zoom_out",
|
|
361
|
+
"orbit_left", "orbit_right", "tilt_up", "tilt_down",
|
|
362
|
+
])
|
|
363
|
+
.optional()
|
|
364
|
+
.describe("Camera move."),
|
|
365
|
+
negative_prompt: z.string().optional().describe("What must NOT appear in the clip."),
|
|
366
|
+
seed: z.number().int().min(0).optional().describe("Reproducibility, same contract as generate_image."),
|
|
367
|
+
fps: z.number().int().min(8).max(60).optional().describe("Frames per second, where the model offers a choice."),
|
|
368
|
+
enhance_prompt: z.boolean().optional().describe("false sends the prompt exactly as written."),
|
|
369
|
+
model: z
|
|
370
|
+
.string()
|
|
371
|
+
.optional()
|
|
372
|
+
.describe('Direct model access: pin an exact model by name, e.g. "corent-seedance-2.0" (list_models shows the menu; every name there is spelled corent-*). Bypasses routing -- never substituted; duration and resolution snap to THAT model\'s own menu rather than a tier cap. Mutually exclusive with tier.'),
|
|
212
373
|
},
|
|
213
374
|
outputSchema: jobOutput,
|
|
214
375
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
215
|
-
}, wrap(async (
|
|
376
|
+
}, wrap(async (args) => corent("/v1/videos/generate", {
|
|
216
377
|
method: "POST",
|
|
217
|
-
|
|
378
|
+
headers: { "Idempotency-Key": idempotencyKey() },
|
|
379
|
+
body: JSON.stringify(args),
|
|
218
380
|
})));
|
|
219
381
|
server.registerTool("generate_speech", {
|
|
220
382
|
title: "Generate speech (voice)",
|
|
@@ -226,6 +388,21 @@ export function createCorentServer(config = {}) {
|
|
|
226
388
|
.regex(/^[A-Za-z0-9]{8,64}$/)
|
|
227
389
|
.optional()
|
|
228
390
|
.describe("Optional provider voice id; omit for the default narration voice"),
|
|
391
|
+
model: z
|
|
392
|
+
.string()
|
|
393
|
+
.optional()
|
|
394
|
+
.describe('Direct model access: pin an exact speech model by name, e.g. "corent-eleven-multilingual-v2" (list_models shows the menu with kind="speech"; every name there is spelled corent-*).'),
|
|
395
|
+
stability: z
|
|
396
|
+
.number()
|
|
397
|
+
.min(0)
|
|
398
|
+
.max(1)
|
|
399
|
+
.optional()
|
|
400
|
+
.describe("0 to 1. Low is more expressive and varies more take to take; high is steadier."),
|
|
401
|
+
similarity: z.number().min(0).max(1).optional().describe("0 to 1. How closely to hold the voice's character."),
|
|
402
|
+
style: z.number().min(0).max(1).optional().describe("0 to 1. Extra expressiveness."),
|
|
403
|
+
speed: z.number().min(0.5).max(2).optional().describe("Playback speed; 1.0 is natural pace."),
|
|
404
|
+
language: z.string().optional().describe('ISO code for the multilingual models, e.g. "en" or "pt-BR".'),
|
|
405
|
+
output_format: z.string().optional().describe('Audio format, e.g. "mp3" or "wav".'),
|
|
229
406
|
},
|
|
230
407
|
outputSchema: {
|
|
231
408
|
id: z.string().optional(),
|
|
@@ -235,10 +412,247 @@ export function createCorentServer(config = {}) {
|
|
|
235
412
|
error: z.string().optional(),
|
|
236
413
|
},
|
|
237
414
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
238
|
-
}, wrap(async (
|
|
415
|
+
}, wrap(async (args) => corent("/v1/audio/speech", {
|
|
239
416
|
method: "POST",
|
|
240
|
-
|
|
417
|
+
headers: { "Idempotency-Key": idempotencyKey() },
|
|
418
|
+
body: JSON.stringify(args),
|
|
241
419
|
})));
|
|
420
|
+
server.registerTool("generate_text", {
|
|
421
|
+
title: "Generate text (language model)",
|
|
422
|
+
description: "Run a prompt through a frontier language model on the user's Corent account. Synchronous: returns the finished text, the token usage and the exact charge. Billed per token (a short answer is a fraction of a cent). Use this when the user wants a SPECIFIC model's answer, a second opinion from another lab, or work billed to their Corent balance -- not for your own reasoning, which costs them nothing. Failed generations are never billed.",
|
|
423
|
+
inputSchema: {
|
|
424
|
+
prompt: z.string().min(1).optional().describe("What to ask, in plain language. Use this for a single question; use `messages` instead to continue a conversation."),
|
|
425
|
+
system: z.string().optional().describe("Optional system instruction that frames the request"),
|
|
426
|
+
messages: z
|
|
427
|
+
.array(z
|
|
428
|
+
.object({
|
|
429
|
+
role: z.enum(["system", "user", "assistant", "tool"]),
|
|
430
|
+
content: z.unknown().optional(),
|
|
431
|
+
name: z.string().optional(),
|
|
432
|
+
tool_call_id: z.string().optional(),
|
|
433
|
+
tool_calls: z.array(z.object({}).passthrough()).optional(),
|
|
434
|
+
})
|
|
435
|
+
.passthrough())
|
|
436
|
+
.min(1)
|
|
437
|
+
.max(200)
|
|
438
|
+
.optional()
|
|
439
|
+
.describe("Full OpenAI-shaped conversation, for multi-turn work: prior assistant turns, and the tool " +
|
|
440
|
+
"results you are feeding back after a tool call. Takes precedence over prompt/system. " +
|
|
441
|
+
"Without this the model sees only one question and has no memory of the exchange."),
|
|
442
|
+
tier: z
|
|
443
|
+
.enum(TIERS)
|
|
444
|
+
.optional()
|
|
445
|
+
.describe("Quality tier: air=cheapest, lite=everyday, premium=production, pro=advanced, max_pro=frontier flagships. Omit for a sensible default."),
|
|
446
|
+
model: z
|
|
447
|
+
.string()
|
|
448
|
+
.optional()
|
|
449
|
+
.describe('Direct model access: pin an exact text model by name, e.g. "corent-claude-opus-5" (list_models shows the menu with kind="text"; every name there is spelled corent-*). Bypasses routing -- never substituted, flat cost-plus price. Mutually exclusive with tier.'),
|
|
450
|
+
max_tokens: z
|
|
451
|
+
.number()
|
|
452
|
+
.int()
|
|
453
|
+
.min(1)
|
|
454
|
+
.max(8192)
|
|
455
|
+
.optional()
|
|
456
|
+
.describe("Cap on the reply length in tokens (default 1024). The balance gate reserves against this, so keep it realistic."),
|
|
457
|
+
temperature: z.number().min(0).max(2).optional().describe("Sampling temperature; omit for the model's default"),
|
|
458
|
+
tools: z
|
|
459
|
+
.array(z.object({}).passthrough())
|
|
460
|
+
.optional()
|
|
461
|
+
.describe("OpenAI-shaped function definitions the model may call. When the model answers with tool " +
|
|
462
|
+
"calls, they come back in `tool_calls` and the reply text is empty: run them, then call " +
|
|
463
|
+
"again with `messages` carrying the assistant turn and your tool results."),
|
|
464
|
+
tool_choice: z
|
|
465
|
+
.unknown()
|
|
466
|
+
.optional()
|
|
467
|
+
.describe('How the model may use tools: "auto", "none", "required", or {type:"function",function:{name}}.'),
|
|
468
|
+
response_format: z
|
|
469
|
+
.object({})
|
|
470
|
+
.passthrough()
|
|
471
|
+
.optional()
|
|
472
|
+
.describe('Structured output, e.g. {"type":"json_object"} for JSON mode, or a json_schema block.'),
|
|
473
|
+
},
|
|
474
|
+
outputSchema: {
|
|
475
|
+
text: z.string().optional(),
|
|
476
|
+
tool_calls: z.array(z.object({}).passthrough()).nullable().optional(),
|
|
477
|
+
model: z.string().optional(),
|
|
478
|
+
finish_reason: z.string().optional(),
|
|
479
|
+
usage: z
|
|
480
|
+
.object({
|
|
481
|
+
prompt_tokens: z.number().optional(),
|
|
482
|
+
completion_tokens: z.number().optional(),
|
|
483
|
+
total_tokens: z.number().optional(),
|
|
484
|
+
})
|
|
485
|
+
.passthrough()
|
|
486
|
+
.optional(),
|
|
487
|
+
cost_cents: z.number().nullable().optional(),
|
|
488
|
+
error: z.string().optional(),
|
|
489
|
+
},
|
|
490
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
491
|
+
}, wrap(async ({ prompt, system, messages: history, tier, model, max_tokens, temperature, tools, tool_choice, response_format }) => {
|
|
492
|
+
if (!history && !prompt) {
|
|
493
|
+
throw new CorentApiError(422, { message: "Pass either `prompt` (single question) or `messages` (conversation)." });
|
|
494
|
+
}
|
|
495
|
+
// An explicit conversation wins: it carries the turns and tool results a
|
|
496
|
+
// single prompt cannot express.
|
|
497
|
+
const messages = history ??
|
|
498
|
+
(system
|
|
499
|
+
? [{ role: "system", content: system }, { role: "user", content: prompt }]
|
|
500
|
+
: [{ role: "user", content: prompt }]);
|
|
501
|
+
// The API's `model` field is required and carries both modes: a pinned
|
|
502
|
+
// model name ("corent-claude-opus-5"), or "corent/text-<tier>" for the
|
|
503
|
+
// brain-routed lane -- note the slash, a separate namespace from the
|
|
504
|
+
// dash-prefixed model names. lite matches the router's own default for an
|
|
505
|
+
// unrecognised tier.
|
|
506
|
+
const r = await corent("/v1/chat/completions", {
|
|
507
|
+
method: "POST",
|
|
508
|
+
body: JSON.stringify({
|
|
509
|
+
model: model ?? `corent/text-${tier ?? "lite"}`,
|
|
510
|
+
messages,
|
|
511
|
+
max_tokens,
|
|
512
|
+
temperature,
|
|
513
|
+
tools,
|
|
514
|
+
tool_choice,
|
|
515
|
+
response_format,
|
|
516
|
+
}),
|
|
517
|
+
});
|
|
518
|
+
const choice = (r.choices ?? [{}])[0] ?? {};
|
|
519
|
+
return {
|
|
520
|
+
text: choice.message?.content ?? "",
|
|
521
|
+
// When the model answers with tool calls the content is empty and THIS
|
|
522
|
+
// is the answer. Returning only `text` made a tool-calling reply look
|
|
523
|
+
// like an empty response (parity audit 2026-08-30).
|
|
524
|
+
tool_calls: choice.message?.tool_calls ?? null,
|
|
525
|
+
model: r.model,
|
|
526
|
+
finish_reason: choice.finish_reason,
|
|
527
|
+
usage: r.usage,
|
|
528
|
+
cost_cents: r.corent?.cost_cents,
|
|
529
|
+
job_id: r.corent?.job_id,
|
|
530
|
+
};
|
|
531
|
+
}));
|
|
532
|
+
server.registerTool("list_models", {
|
|
533
|
+
title: "List available models",
|
|
534
|
+
description: 'The direct-access menu: every model that can be pinned by name on generate_image / generate_video / generate_speech / generate_text, with its kind (image, video, speech, text), quality score and live status. Names are spelled corent-* (e.g. "corent-flux-schnell", "corent-claude-opus-5") -- pass one back verbatim as `model`, and that is the name the receipt echoes. Carries NO price -- do not promise the user a per-model rate from this; billing is flat cost-plus and the exact charge is returned on each generation. Read-only and free. Use this only when the user wants a SPECIFIC model -- otherwise omit `model` and let Corent route to the best one for the prompt.',
|
|
535
|
+
inputSchema: {},
|
|
536
|
+
outputSchema: {
|
|
537
|
+
models: z
|
|
538
|
+
.array(z
|
|
539
|
+
.object({ model: z.string(), kind: z.string(), quality: z.number().optional() })
|
|
540
|
+
.passthrough())
|
|
541
|
+
.optional(),
|
|
542
|
+
count: z.number().optional(),
|
|
543
|
+
error: z.string().optional(),
|
|
544
|
+
},
|
|
545
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
546
|
+
}, wrap(async () => corent("/v1/models")));
|
|
547
|
+
server.registerTool("list_voices", {
|
|
548
|
+
title: "List voices",
|
|
549
|
+
description: "The voices generate_speech will accept as voice_id, with a display name, a preview clip, and neutral descriptors (gender, age, accent, use case). Read-only and free. CALL THIS BEFORE generate_speech whenever the user wants a particular sounding narrator: the voice_id is required to pick anything other than the default, and there is no other way to discover a legal value.",
|
|
550
|
+
inputSchema: {},
|
|
551
|
+
outputSchema: {
|
|
552
|
+
voices: z
|
|
553
|
+
.array(z.object({ voice_id: z.string() }).passthrough())
|
|
554
|
+
.optional(),
|
|
555
|
+
count: z.number().optional(),
|
|
556
|
+
default_voice_id: z.string().nullable().optional(),
|
|
557
|
+
error: z.string().optional(),
|
|
558
|
+
},
|
|
559
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
560
|
+
}, wrap(async () => corent("/v1/voices")));
|
|
561
|
+
server.registerTool("cancel_job", {
|
|
562
|
+
title: "Cancel a running job",
|
|
563
|
+
description: "Stop a job that has not finished yet and release the money held for it. Charges nothing. Use it as soon as the user says they did not mean to start something, especially for video, which runs for minutes against a reserved balance. If the job already finished, this reports that and changes nothing: a clip that landed a moment earlier stays completed and stays billed for what was actually delivered.",
|
|
564
|
+
inputSchema: { job_id: z.string().describe("The job id to cancel") },
|
|
565
|
+
outputSchema: {
|
|
566
|
+
id: z.string().optional(),
|
|
567
|
+
status: z.string().optional(),
|
|
568
|
+
cancelled: z.boolean().optional(),
|
|
569
|
+
detail: z.string().optional(),
|
|
570
|
+
error: z.string().optional(),
|
|
571
|
+
},
|
|
572
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
573
|
+
}, wrap(async ({ job_id }) => corent(`/v1/jobs/${job_id}/cancel`, { method: "POST" })));
|
|
574
|
+
server.registerTool("list_tiers", {
|
|
575
|
+
title: "List quality tiers and prices",
|
|
576
|
+
description: "The live tier menu for image and video: each tier's price estimate, the shapes it can render, video durations and per-resolution pricing, and whether it accepts reference images (image) or a source image (video). Read-only and free. Several other tools tell you to check this before quoting a price or offering a resolution -- this is that endpoint. Tier-level only: it never names a model.",
|
|
577
|
+
inputSchema: {},
|
|
578
|
+
outputSchema: {
|
|
579
|
+
tiers: z
|
|
580
|
+
.array(z
|
|
581
|
+
.object({
|
|
582
|
+
name: z.string().optional(),
|
|
583
|
+
estimated_cost_cents: z.number().nullable().optional(),
|
|
584
|
+
capabilities: z.object({}).passthrough().optional(),
|
|
585
|
+
})
|
|
586
|
+
.passthrough())
|
|
587
|
+
.optional(),
|
|
588
|
+
pricing_note: z.string().optional(),
|
|
589
|
+
error: z.string().optional(),
|
|
590
|
+
},
|
|
591
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
592
|
+
}, wrap(async () => corent("/v1/tiers")));
|
|
593
|
+
// --- Batch tools: one call, up to 50 renders. The single-item tools are the
|
|
594
|
+
// right default; these exist for the "make me 30 variations" ask, where 30
|
|
595
|
+
// separate tool calls would be slower and far more expensive in context. ---
|
|
596
|
+
const imageBatchItem = z
|
|
597
|
+
.object({
|
|
598
|
+
prompt: z.string(),
|
|
599
|
+
tier: z.enum(TIERS).optional(),
|
|
600
|
+
style: z.enum(IMAGE_STYLES).optional(),
|
|
601
|
+
aspect_ratio: z.enum(IMAGE_ASPECTS).optional(),
|
|
602
|
+
})
|
|
603
|
+
.describe("One image in the batch. Batch items are tier-routed: to pin an exact model, use generate_image.");
|
|
604
|
+
server.registerTool("generate_image_batch", {
|
|
605
|
+
title: "Generate many images (batch)",
|
|
606
|
+
description: "Submit up to 50 image generations in ONE call. Asynchronous: returns a batch_id and one job id per item immediately; poll get_batch for progress and get_job for each finished image. Every item bills at the normal rate, so a 30-item batch costs 30 generations -- check get_balance first. Items are tier-routed: pin an exact model with generate_image instead. Reference images are single-item only.",
|
|
607
|
+
inputSchema: {
|
|
608
|
+
items: z.array(imageBatchItem).min(1).max(50).describe("1 to 50 image requests"),
|
|
609
|
+
},
|
|
610
|
+
outputSchema: batchOutput,
|
|
611
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
612
|
+
}, wrap(async ({ items }) => corent("/v1/images/generate/batch", {
|
|
613
|
+
method: "POST",
|
|
614
|
+
headers: { "Idempotency-Key": idempotencyKey() },
|
|
615
|
+
body: JSON.stringify({ items }),
|
|
616
|
+
})));
|
|
617
|
+
server.registerTool("generate_video_batch", {
|
|
618
|
+
title: "Generate many videos (batch)",
|
|
619
|
+
description: "Submit up to 50 video generations in ONE call. Asynchronous: returns a batch_id and one job id per item; poll get_batch, then get_job for each clip. Video is the expensive lane -- a 10-item batch can run to several dollars, so confirm with the user and check get_balance first. Items are tier-routed: pin an exact model with generate_video instead.",
|
|
620
|
+
inputSchema: {
|
|
621
|
+
items: z
|
|
622
|
+
.array(z.object({
|
|
623
|
+
prompt: z.string(),
|
|
624
|
+
tier: z.enum(TIERS).optional(),
|
|
625
|
+
style: z.enum(IMAGE_STYLES).optional(),
|
|
626
|
+
aspect_ratio: z.enum(["16:9", "9:16", "1:1"]).optional(),
|
|
627
|
+
duration_s: z.number().int().min(1).max(30).optional(),
|
|
628
|
+
resolution: z.enum(["720p", "1080p", "4k"]).optional(),
|
|
629
|
+
}))
|
|
630
|
+
.min(1)
|
|
631
|
+
.max(50)
|
|
632
|
+
.describe("1 to 50 video requests"),
|
|
633
|
+
},
|
|
634
|
+
outputSchema: batchOutput,
|
|
635
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
636
|
+
}, wrap(async ({ items }) => corent("/v1/videos/generate/batch", {
|
|
637
|
+
method: "POST",
|
|
638
|
+
headers: { "Idempotency-Key": idempotencyKey() },
|
|
639
|
+
body: JSON.stringify({ items }),
|
|
640
|
+
})));
|
|
641
|
+
server.registerTool("get_batch", {
|
|
642
|
+
title: "Check batch progress",
|
|
643
|
+
description: "Progress of a batch submitted with generate_image_batch or generate_video_batch: how many items are completed, failed, and still pending, plus each item's job id. Read-only and free. Fetch a finished item's media with get_job.",
|
|
644
|
+
inputSchema: { batch_id: z.string().describe("The batch_id returned by a batch tool") },
|
|
645
|
+
outputSchema: {
|
|
646
|
+
batch_id: z.string().optional(),
|
|
647
|
+
total: z.number().optional(),
|
|
648
|
+
completed: z.number().optional(),
|
|
649
|
+
failed: z.number().optional(),
|
|
650
|
+
pending: z.number().optional(),
|
|
651
|
+
jobs: z.array(z.object({ id: z.string(), status: z.string() }).passthrough()).optional(),
|
|
652
|
+
error: z.string().optional(),
|
|
653
|
+
},
|
|
654
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
655
|
+
}, wrap(async ({ batch_id }) => corent(`/v1/batches/${batch_id}`)));
|
|
242
656
|
server.registerTool("get_job", {
|
|
243
657
|
title: "Check job status",
|
|
244
658
|
_meta: WIDGET_TOOL_META,
|
|
@@ -249,9 +663,13 @@ export function createCorentServer(config = {}) {
|
|
|
249
663
|
}, wrap(async ({ job_id }) => corent(`/v1/jobs/${job_id}`)));
|
|
250
664
|
server.registerTool("get_balance", {
|
|
251
665
|
title: "Check account balance",
|
|
252
|
-
description: "Get the Corent account's
|
|
666
|
+
description: "Get the Corent account's balance in cents. balance_cents is the total; held_cents is reserved by generations still running; available_cents is what a new request can actually spend -- check that one before an expensive video, since it is what a 402 is decided against. Read-only and free.",
|
|
253
667
|
inputSchema: {},
|
|
254
|
-
outputSchema: {
|
|
668
|
+
outputSchema: {
|
|
669
|
+
balance_cents: z.number().optional(),
|
|
670
|
+
held_cents: z.number().optional(),
|
|
671
|
+
available_cents: z.number().optional(),
|
|
672
|
+
},
|
|
255
673
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
256
674
|
}, wrap(async () => corent("/v1/account/balance")));
|
|
257
675
|
server.registerTool("get_status", {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "corent-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for the Corent
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "MCP server for the Corent API — give any AI agent the ability to generate images, videos, voice, and text.",
|
|
5
5
|
"mcpName": "io.github.gg13121/corent-mcp",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "node scripts/build-widget.mjs && tsc && chmod +x dist/index.js",
|
|
21
|
+
"test": "npm run build && node --test test/*.test.js",
|
|
21
22
|
"start": "node dist/index.js"
|
|
22
23
|
},
|
|
23
24
|
"keywords": [
|
|
@@ -25,6 +26,8 @@
|
|
|
25
26
|
"modelcontextprotocol",
|
|
26
27
|
"image-generation",
|
|
27
28
|
"video-generation",
|
|
29
|
+
"text-to-speech",
|
|
30
|
+
"llm",
|
|
28
31
|
"ai-agents",
|
|
29
32
|
"corent"
|
|
30
33
|
],
|