corent-mcp 0.6.0 → 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 +54 -4
  2. package/dist/server.js +357 -39
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,13 +8,19 @@ Give any AI agent the ability to generate images, videos, voice, and text throug
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
13
  | `generate_speech` | Text → spoken audio, synchronous |
14
- | `generate_text` | Prompt → text from a frontier language model, synchronous, billed per token |
15
- | `list_models` | The direct-access menu: every model that can be pinned by name, with quality and live status |
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 |
16
22
  | `get_job` | Poll a job until completed |
17
- | `get_balance` | Remaining account balance |
23
+ | `get_balance` | Balance, holds, and what a new request can actually spend |
18
24
  | `get_status` | Live tier health |
19
25
 
20
26
  The **`plan`** and **`create`** tools are the agent-native path: an agent says
@@ -31,9 +37,53 @@ can't deliver, the call fails and nothing is billed). Use it only when the user
31
37
  asked for a particular model by name — otherwise omit it and let Corent route
32
38
  to the best fit for the prompt, which is what the tiers are for.
33
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
+
34
45
  `generate_text` is the language-model lane: it puts every frontier lab on the
35
46
  same key and the same bill, so an agent can get a named model's answer or a
36
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.
37
87
 
38
88
  ## Setup
39
89
 
package/dist/server.js CHANGED
@@ -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.6.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.
@@ -80,6 +86,16 @@ function errorCode(err) {
80
86
  export function createCorentServer(config = {}) {
81
87
  const apiUrl = config.apiUrl ?? DEFAULT_API_URL;
82
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
+ }
83
99
  async function corent(path, init) {
84
100
  // Key is enforced here (at call time) rather than at startup, so the server
85
101
  // can advertise its tools to catalogs/agents without a key configured.
@@ -88,19 +104,40 @@ export function createCorentServer(config = {}) {
88
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",
89
105
  });
90
106
  }
91
- const res = await fetch(`${apiUrl}${path}`, {
92
- ...init,
93
- headers: {
94
- Authorization: `Bearer ${apiKey}`,
95
- "Content-Type": "application/json",
96
- ...init?.headers,
97
- },
98
- });
99
- const body = await res.json().catch(() => ({}));
100
- if (!res.ok) {
101
- 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;
102
139
  }
103
- return body;
140
+ throw lastErr;
104
141
  }
105
142
  /** Success: typed structuredContent plus a text fallback for older clients. */
106
143
  function ok(data) {
@@ -159,7 +196,13 @@ export function createCorentServer(config = {}) {
159
196
  // yet) -- must be nullable, not just optional, or the SDK's output validator
160
197
  // throws on the normal poll-loop response.
161
198
  const mediaMeta = z
162
- .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
+ })
163
206
  .passthrough();
164
207
  const imageOutput = {
165
208
  id: z.string().optional(),
@@ -167,14 +210,34 @@ export function createCorentServer(config = {}) {
167
210
  images: z.array(z.object({ url: z.string() }).passthrough()).optional(),
168
211
  meta: mediaMeta.optional(),
169
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).
170
216
  const jobOutput = {
171
217
  id: z.string().optional(),
172
218
  status: z.string().optional(),
173
219
  images: z.array(z.object({ url: z.string() }).passthrough()).optional(),
174
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(),
175
226
  meta: mediaMeta.optional(),
176
227
  error: z.string().optional(),
177
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"];
178
241
  server.registerTool("generate_image", {
179
242
  title: "Generate image",
180
243
  _meta: WIDGET_TOOL_META,
@@ -182,24 +245,78 @@ export function createCorentServer(config = {}) {
182
245
  inputSchema: {
183
246
  prompt: z.string().describe("What to generate, in plain language"),
184
247
  tier: z
185
- .enum(["air", "lite", "premium", "pro", "max_pro"])
248
+ .enum(TIERS)
186
249
  .optional()
187
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."),
188
251
  model: z
189
252
  .string()
190
253
  .optional()
191
- .describe("Direct model access: pin an exact model by name (list_models shows the menu). Bypasses Corent's routing -- never substituted, flat cost-plus price. Mutually exclusive with tier; prefer tier unless the user named a specific model."),
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.'),
192
255
  style: z
193
- .enum(["photorealistic", "artistic", "anime", "logo", "text_focused"])
256
+ .enum(IMAGE_STYLES)
194
257
  .optional()
195
258
  .describe("Optional content style hint"),
196
- 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."),
197
313
  },
198
314
  outputSchema: imageOutput,
199
315
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
200
- }, wrap(async ({ prompt, tier, model, style, aspect_ratio }) => corent("/v1/images/generate", {
316
+ }, wrap(async (args) => corent("/v1/images/generate", {
201
317
  method: "POST",
202
- body: JSON.stringify({ prompt, tier, model, style, aspect_ratio }),
318
+ headers: { "Idempotency-Key": idempotencyKey() },
319
+ body: JSON.stringify(args),
203
320
  })));
204
321
  server.registerTool("generate_video", {
205
322
  title: "Generate video",
@@ -208,9 +325,13 @@ export function createCorentServer(config = {}) {
208
325
  inputSchema: {
209
326
  prompt: z.string().describe("What to generate, in plain language"),
210
327
  tier: z
211
- .enum(["air", "lite", "premium", "pro", "max_pro"])
328
+ .enum(TIERS)
212
329
  .optional()
213
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."),
331
+ style: z
332
+ .enum(IMAGE_STYLES)
333
+ .optional()
334
+ .describe("Optional content style hint, same vocabulary as generate_image"),
214
335
  aspect_ratio: z
215
336
  .enum(["16:9", "9:16", "1:1"])
216
337
  .optional()
@@ -221,16 +342,41 @@ export function createCorentServer(config = {}) {
221
342
  .optional()
222
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."),
223
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."),
224
369
  model: z
225
370
  .string()
226
371
  .optional()
227
- .describe("Direct model access: pin an exact model by name (list_models shows the menu). Bypasses routing -- never substituted; duration and resolution snap to THAT model's own menu rather than a tier cap. Mutually exclusive with tier."),
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.'),
228
373
  },
229
374
  outputSchema: jobOutput,
230
375
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
231
- }, wrap(async ({ prompt, tier, model, aspect_ratio, duration_s, resolution, image_url }) => corent("/v1/videos/generate", {
376
+ }, wrap(async (args) => corent("/v1/videos/generate", {
232
377
  method: "POST",
233
- body: JSON.stringify({ prompt, tier, model, aspect_ratio, duration_s, resolution, image_url }),
378
+ headers: { "Idempotency-Key": idempotencyKey() },
379
+ body: JSON.stringify(args),
234
380
  })));
235
381
  server.registerTool("generate_speech", {
236
382
  title: "Generate speech (voice)",
@@ -245,7 +391,18 @@ export function createCorentServer(config = {}) {
245
391
  model: z
246
392
  .string()
247
393
  .optional()
248
- .describe("Direct model access: pin an exact speech model by name (list_models shows the menu)."),
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".'),
249
406
  },
250
407
  outputSchema: {
251
408
  id: z.string().optional(),
@@ -255,24 +412,41 @@ export function createCorentServer(config = {}) {
255
412
  error: z.string().optional(),
256
413
  },
257
414
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
258
- }, wrap(async ({ text, voice_id, model }) => corent("/v1/audio/speech", {
415
+ }, wrap(async (args) => corent("/v1/audio/speech", {
259
416
  method: "POST",
260
- body: JSON.stringify({ text, voice_id, model }),
417
+ headers: { "Idempotency-Key": idempotencyKey() },
418
+ body: JSON.stringify(args),
261
419
  })));
262
420
  server.registerTool("generate_text", {
263
421
  title: "Generate text (language model)",
264
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.",
265
423
  inputSchema: {
266
- prompt: z.string().min(1).describe("What to ask, in plain language"),
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."),
267
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."),
268
442
  tier: z
269
- .enum(["air", "lite", "premium", "pro", "max_pro"])
443
+ .enum(TIERS)
270
444
  .optional()
271
445
  .describe("Quality tier: air=cheapest, lite=everyday, premium=production, pro=advanced, max_pro=frontier flagships. Omit for a sensible default."),
272
446
  model: z
273
447
  .string()
274
448
  .optional()
275
- .describe("Direct model access: pin an exact text model by name (list_models shows the menu, kind='text'). Bypasses routing -- never substituted, flat cost-plus price. Mutually exclusive with tier."),
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.'),
276
450
  max_tokens: z
277
451
  .number()
278
452
  .int()
@@ -281,9 +455,25 @@ export function createCorentServer(config = {}) {
281
455
  .optional()
282
456
  .describe("Cap on the reply length in tokens (default 1024). The balance gate reserves against this, so keep it realistic."),
283
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.'),
284
473
  },
285
474
  outputSchema: {
286
475
  text: z.string().optional(),
476
+ tool_calls: z.array(z.object({}).passthrough()).nullable().optional(),
287
477
  model: z.string().optional(),
288
478
  finish_reason: z.string().optional(),
289
479
  usage: z
@@ -298,13 +488,21 @@ export function createCorentServer(config = {}) {
298
488
  error: z.string().optional(),
299
489
  },
300
490
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
301
- }, wrap(async ({ prompt, system, tier, model, max_tokens, temperature }) => {
302
- const messages = system
303
- ? [{ role: "system", content: system }, { role: "user", content: prompt }]
304
- : [{ role: "user", content: prompt }];
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 }]);
305
501
  // The API's `model` field is required and carries both modes: a pinned
306
- // catalog name, or "corent/text-<tier>" for the brain-routed lane. lite
307
- // matches the router's own default for an unrecognised tier.
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.
308
506
  const r = await corent("/v1/chat/completions", {
309
507
  method: "POST",
310
508
  body: JSON.stringify({
@@ -312,11 +510,18 @@ export function createCorentServer(config = {}) {
312
510
  messages,
313
511
  max_tokens,
314
512
  temperature,
513
+ tools,
514
+ tool_choice,
515
+ response_format,
315
516
  }),
316
517
  });
317
518
  const choice = (r.choices ?? [{}])[0] ?? {};
318
519
  return {
319
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,
320
525
  model: r.model,
321
526
  finish_reason: choice.finish_reason,
322
527
  usage: r.usage,
@@ -326,7 +531,7 @@ export function createCorentServer(config = {}) {
326
531
  }));
327
532
  server.registerTool("list_models", {
328
533
  title: "List available models",
329
- 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. 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.",
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.',
330
535
  inputSchema: {},
331
536
  outputSchema: {
332
537
  models: z
@@ -339,6 +544,115 @@ export function createCorentServer(config = {}) {
339
544
  },
340
545
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
341
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}`)));
342
656
  server.registerTool("get_job", {
343
657
  title: "Check job status",
344
658
  _meta: WIDGET_TOOL_META,
@@ -349,9 +663,13 @@ export function createCorentServer(config = {}) {
349
663
  }, wrap(async ({ job_id }) => corent(`/v1/jobs/${job_id}`)));
350
664
  server.registerTool("get_balance", {
351
665
  title: "Check account balance",
352
- 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.",
353
667
  inputSchema: {},
354
- 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
+ },
355
673
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
356
674
  }, wrap(async () => corent("/v1/account/balance")));
357
675
  server.registerTool("get_status", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "corent-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
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",