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.
Files changed (3) hide show
  1. package/README.md +68 -3
  2. package/dist/server.js +446 -28
  3. 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 videos through the [Corent](https://corent.tech) API — one key, automatic model routing, provider fallback built in.
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` | Remaining account 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 8 tools, used by both 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.5.2";
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 res = await fetch(`${apiUrl}${path}`, {
87
- ...init,
88
- headers: {
89
- Authorization: `Bearer ${apiKey}`,
90
- "Content-Type": "application/json",
91
- ...init?.headers,
92
- },
93
- });
94
- const body = await res.json().catch(() => ({}));
95
- if (!res.ok) {
96
- throw new CorentApiError(res.status, body.detail ?? body);
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
- return body;
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({ model: z.string().nullable().optional(), cost_cents: z.number().nullable().optional() })
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(["air", "lite", "premium", "pro", "max_pro"])
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(["photorealistic", "artistic", "anime", "logo", "text_focused"])
256
+ .enum(IMAGE_STYLES)
185
257
  .optional()
186
258
  .describe("Optional content style hint"),
187
- aspect_ratio: z.enum(["1:1", "16:9", "9:16", "4:3", "3:4"]).default("1:1"),
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 ({ prompt, tier, style, aspect_ratio }) => corent("/v1/images/generate", {
316
+ }, wrap(async (args) => corent("/v1/images/generate", {
192
317
  method: "POST",
193
- body: JSON.stringify({ prompt, tier, style, aspect_ratio }),
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(["air", "lite", "premium", "pro", "max_pro"])
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
- aspect_ratio: z.enum(["16:9", "9:16", "1:1"]).default("16:9"),
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 ({ prompt, tier, aspect_ratio, duration_s, resolution, image_url }) => corent("/v1/videos/generate", {
376
+ }, wrap(async (args) => corent("/v1/videos/generate", {
216
377
  method: "POST",
217
- body: JSON.stringify({ prompt, tier, aspect_ratio, duration_s, resolution, image_url }),
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 ({ text, voice_id }) => corent("/v1/audio/speech", {
415
+ }, wrap(async (args) => corent("/v1/audio/speech", {
239
416
  method: "POST",
240
- body: JSON.stringify({ text, voice_id }),
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 remaining balance in cents. Useful before starting expensive video generations. Read-only and free.",
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: { balance_cents: z.number().optional() },
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.5.2",
4
- "description": "MCP server for the Corent media generation API — give any AI agent the ability to generate images and videos.",
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
  ],