corent-mcp 0.7.0 → 0.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Corent MCP Server
2
2
 
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.
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
 
@@ -22,10 +22,40 @@ Give any AI agent the ability to generate images, videos, voice, and text throug
22
22
  | `get_job` | Poll a job until completed |
23
23
  | `get_balance` | Balance, holds, and what a new request can actually spend |
24
24
  | `get_status` | Live tier health |
25
+ | `upscale_image` | Make an existing image bigger and sharper. Flat price, no tier |
26
+ | `remove_background` | Cut the subject out, returns a PNG with a real transparent background |
27
+ | `inpaint_image` | Regenerate only the white region of a mask; every pixel outside it stays byte-for-byte unchanged |
28
+ | `list_media` | What the account already made: the last 100 jobs, filtered by type and prompt keyword, so a result can be reused instead of paid for again |
29
+ | `upload_image` | Base64 in, public https URL out. Use it as `image_url`, `mask_url`, or a reference in the other tools (max 25 MB) |
30
+
31
+ Every tool description is under 400 characters and every tool carries
32
+ `readOnlyHint` / `destructiveHint` / `openWorldHint` annotations plus the
33
+ ChatGPT `openai/toolInvocation` status strings, so the server can be listed
34
+ as a ChatGPT app as well as a Claude connector.
35
+
36
+ ## Prompts (recipes)
37
+
38
+ The server also registers five MCP prompts. Each is a step list over the
39
+ tools above: plan, confirm the budget with the user, stills with references,
40
+ clips, narration, poll. Hosts that support prompts show them as slash
41
+ commands; the same five ship as markdown in [`skills/`](skills/) for
42
+ publication as a skills repo.
43
+
44
+ | Prompt | Arguments | What it makes |
45
+ |---|---|---|
46
+ | `product-ugc-reel` | `product_url`, `brief`, `budget_usd` | Vertical creator-style reel for one product |
47
+ | `character-series` | `character`, `scenes`, `budget_usd` | One consistent character across many scenes |
48
+ | `storyboard-to-clip` | `storyboard`, `style`, `budget_usd` | Numbered storyboard to stills, clips and narration |
49
+ | `ad-variations` | `concept`, `count`, `product_url` | Many versions of one ad visual, in a single batch |
50
+ | `faceless-explainer` | `topic`, `duration_s`, `budget_usd` | Narrated explainer with no presenter |
51
+
52
+ Prices the recipes quote before spending: image premium ~7c, video premium
53
+ ~52c per clip, pro ~84c per clip, speech ~25-30c per started block of 1,000
54
+ characters.
25
55
 
26
56
  The **`plan`** and **`create`** tools are the agent-native path: an agent says
27
57
  what it wants ("a 10s vertical clip of a sunrise for TikTok") and Corent picks
28
- image-vs-video, the tier, aspect ratio, and duration — the agent never manages
58
+ image-vs-video, the tier, aspect ratio, and duration, the agent never manages
29
59
  models. `create` enforces a per-call spend ceiling so an autonomous agent can't
30
60
  overspend.
31
61
 
@@ -34,12 +64,12 @@ overspend.
34
64
  All four `generate_*` tools also take an optional **`model`**: pin an exact
35
65
  model from `list_models` and Corent runs that one, never a substitute (if it
36
66
  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
67
+ asked for a particular model by name, otherwise omit it and let Corent route
38
68
  to the best fit for the prompt, which is what the tiers are for.
39
69
 
40
- Every name in the menu is spelled `corent-*` — `corent-flux-schnell`,
70
+ Every name in the menu is spelled `corent-*`: `corent-flux-schnell`,
41
71
  `corent-seedance-2.0`, `corent-eleven-multilingual-v2`,
42
- `corent-claude-opus-5` — and that is the name the receipt echoes back. Pass one
72
+ `corent-claude-opus-5`, and that is the name the receipt echoes back. Pass one
43
73
  back verbatim.
44
74
 
45
75
  `generate_text` is the language-model lane: it puts every frontier lab on the
@@ -55,7 +85,7 @@ that the prompt is applied as an *edit* of, so the same face, character, or
55
85
  product survives into a new scene. Use it whenever the user wants "the same
56
86
  person again", a product placed somewhere, or a matching series. Edit-capable
57
87
  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.
88
+ no tier at all: `air` and `lite` are refused with a message saying so.
59
89
 
60
90
  ### What the generate tools can ask for
61
91
 
@@ -78,6 +108,16 @@ Anything the chosen model could not honour comes back in
78
108
  `meta.unsupported_options`, so an ignored setting never reads as an applied
79
109
  one. `enhance_prompt: false` sends the user's wording verbatim.
80
110
 
111
+ ### Editing what already exists
112
+
113
+ `upscale_image` and `remove_background` take a public https URL and return a
114
+ new one. `inpaint_image` takes the image, a same-size black-and-white mask
115
+ (white = regenerate, black = keep) and a prompt for the masked region; unlike
116
+ `reference_image_urls`, which re-creates a likeness, the pixels outside the
117
+ mask are your own file, unchanged. `upload_image` turns a local file into a
118
+ URL any of these accept, and `list_media` finds an earlier result by prompt
119
+ keyword ("the beach one") so it can be reused rather than re-generated.
120
+
81
121
  ### Spending safety
82
122
 
83
123
  Every money-spending call carries an `Idempotency-Key`, and a 429 or 5xx is
@@ -107,7 +147,7 @@ For Claude Code: `claude mcp add corent -e CORENT_API_KEY=co_live_... -- npx -y
107
147
 
108
148
  The hosted server at `https://mcp.corent.tech/mcp` uses the Streamable HTTP
109
149
  transport and supports **OAuth**: in claude.ai (Settings → Connectors → Add
110
- custom connector) just enter the URL with no key — you'll be sent to a Corent
150
+ custom connector) just enter the URL with no key, you'll be sent to a Corent
111
151
  page to approve access. Your API key is verified once, sealed into an encrypted
112
152
  connection token, and never shown to the client. Revoking the key in your
113
153
  dashboard disconnects the client instantly.
@@ -129,7 +169,7 @@ Non-OAuth clients can instead pass an API key in the **Authorization header**:
129
169
 
130
170
  > **Smithery note:** for compatibility with Smithery, the hosted server also
131
171
  > accepts the key via query parameters (`?corent_api_key=...` or Smithery's
132
- > base64 `?config=`). Avoid this form anywhere else — URLs can be logged by
172
+ > base64 `?config=`). Avoid this form anywhere else, URLs can be logged by
133
173
  > proxies, gateways, and access logs, which would expose your key. Prefer the
134
174
  > Authorization header.
135
175
 
@@ -142,5 +182,7 @@ CORENT_API_KEY=... node dist/index.js
142
182
  ```
143
183
 
144
184
  The hosted entrypoint (`dist/http.js`) additionally needs `MCP_TOKEN_SECRET`
145
- (random string; encrypts OAuth tokens — rotating it logs every connector out)
185
+ (random string; encrypts OAuth tokens, rotating it logs every connector out)
146
186
  and optionally `MCP_PUBLIC_URL` (defaults to `https://mcp.corent.tech`).
187
+ Set `OPENAI_APPS_CHALLENGE_TOKEN` to serve the ChatGPT Apps domain
188
+ verification at `/.well-known/openai-apps-challenge` (404 while unset).
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The hosted server's request handler, split out of http.ts so tests can mount
3
+ * it on an ephemeral port. Everything about auth, OAuth and the /mcp endpoint
4
+ * is unchanged from the original entrypoint; see http.ts for the overview.
5
+ */
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { createCorentServer, SERVER_VERSION } from "./server.js";
8
+ import { handleOauth, isOauthToken, apiKeyFromAccessToken } from "./oauth.js";
9
+ // upload_image carries a whole file as base64 inside one JSON-RPC call: a
10
+ // 25 MB file is ~34 MB of base64. Below this cap the hosted transport would
11
+ // refuse an upload the API itself accepts (the previous 4 MB cap predates
12
+ // uploads). Anything larger is still cut off before it is buffered whole.
13
+ const MAX_BODY_BYTES = 36_000_000;
14
+ function extractApiKey(req, url) {
15
+ const auth = req.headers["authorization"];
16
+ if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
17
+ const v = auth.slice(7).trim();
18
+ // OAuth access tokens (coa_...) wrap the real API key; anything else is
19
+ // treated as a raw Corent key. Expired/forged tokens resolve to nothing,
20
+ // which falls through to the 401 + WWW-Authenticate below.
21
+ if (v && isOauthToken(v))
22
+ return apiKeyFromAccessToken(v) ?? undefined;
23
+ if (v)
24
+ return v;
25
+ }
26
+ const header = req.headers["x-corent-api-key"];
27
+ if (typeof header === "string" && header.trim())
28
+ return header.trim();
29
+ for (const k of ["corent_api_key", "corentApiKey", "api_key", "apiKey"]) {
30
+ const v = url.searchParams.get(k);
31
+ if (v)
32
+ return v;
33
+ }
34
+ // Smithery encodes the configSchema object as base64 JSON in ?config=
35
+ const cfg = url.searchParams.get("config");
36
+ if (cfg) {
37
+ try {
38
+ const decoded = JSON.parse(Buffer.from(cfg, "base64").toString("utf8"));
39
+ if (decoded && typeof decoded.corentApiKey === "string")
40
+ return decoded.corentApiKey;
41
+ if (decoded && typeof decoded.CORENT_API_KEY === "string")
42
+ return decoded.CORENT_API_KEY;
43
+ }
44
+ catch {
45
+ /* ignore malformed config */
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+ // Query params may carry the caller's API key (Smithery compatibility), so a
51
+ // raw req.url must NEVER be logged. Any request-level logging — now or added
52
+ // later — MUST go through this helper, which strips every param that can
53
+ // carry a key.
54
+ const SENSITIVE_PARAMS = ["corent_api_key", "corentApiKey", "api_key", "apiKey", "config"];
55
+ function redactedPath(url) {
56
+ const clean = new URL(url.toString());
57
+ for (const k of SENSITIVE_PARAMS) {
58
+ if (clean.searchParams.has(k))
59
+ clean.searchParams.set(k, "[redacted]");
60
+ }
61
+ return clean.pathname + clean.search;
62
+ }
63
+ function setCors(res) {
64
+ res.setHeader("Access-Control-Allow-Origin", "*");
65
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
66
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Corent-Api-Key, Mcp-Session-Id, Mcp-Protocol-Version");
67
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
68
+ }
69
+ function readBody(req) {
70
+ return new Promise((resolve, reject) => {
71
+ let data = "";
72
+ req.on("data", (chunk) => {
73
+ data += chunk;
74
+ if (data.length > MAX_BODY_BYTES)
75
+ reject(new Error("body too large"));
76
+ });
77
+ req.on("end", () => {
78
+ if (!data)
79
+ return resolve(undefined);
80
+ try {
81
+ resolve(JSON.parse(data));
82
+ }
83
+ catch (e) {
84
+ reject(e);
85
+ }
86
+ });
87
+ req.on("error", reject);
88
+ });
89
+ }
90
+ // Method-level request logging for diagnosing client behavior (which methods a
91
+ // host calls, whether it advertises the MCP Apps ui extension). Never logs
92
+ // prompts, arguments, keys, or results.
93
+ function logMcpRequest(body) {
94
+ for (const msg of Array.isArray(body) ? body : [body]) {
95
+ const m = msg;
96
+ if (!m || typeof m.method !== "string")
97
+ continue;
98
+ if (m.method === "initialize") {
99
+ const client = m.params?.clientInfo?.name ?? "unknown";
100
+ const uiCap = m.params?.capabilities?.extensions?.["io.modelcontextprotocol/ui"];
101
+ console.error(`[mcp] initialize from ${client} v${m.params?.clientInfo?.version ?? "?"} proto=${m.params?.protocolVersion} ui-extension=${uiCap ? JSON.stringify(uiCap) : "NOT advertised"}`);
102
+ }
103
+ else if (m.method === "resources/read") {
104
+ console.error(`[mcp] resources/read ${m.params?.uri ?? "?"}`);
105
+ }
106
+ else if (m.method === "tools/call") {
107
+ console.error(`[mcp] tools/call ${m.params?.name ?? "?"}`);
108
+ }
109
+ else if (m.method === "resources/list" || m.method === "tools/list") {
110
+ console.error(`[mcp] ${m.method}`);
111
+ }
112
+ }
113
+ }
114
+ export function createHttpApp(config) {
115
+ const API_URL = config.apiUrl;
116
+ const PUBLIC_URL = config.publicUrl;
117
+ return async (req, res) => {
118
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
119
+ setCors(res);
120
+ // ChatGPT Apps domain verification: OpenAI fetches this path and expects the
121
+ // plain-text token from the developer console. Unset token = 404, so nothing
122
+ // is ever served by accident. Auth-free by design; the token is not a secret.
123
+ if (req.method === "GET" && url.pathname === "/.well-known/openai-apps-challenge") {
124
+ const token = process.env.OPENAI_APPS_CHALLENGE_TOKEN?.trim();
125
+ if (!token) {
126
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
127
+ res.end("not found");
128
+ return;
129
+ }
130
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
131
+ res.end(token);
132
+ return;
133
+ }
134
+ if (req.method === "OPTIONS") {
135
+ res.writeHead(204);
136
+ res.end();
137
+ return;
138
+ }
139
+ // Favicon: some clients derive a connector icon from the server origin.
140
+ if (req.method === "GET" && url.pathname === "/favicon.ico") {
141
+ res.writeHead(302, {
142
+ Location: "https://corent.tech/brand/intro-mark.png",
143
+ "Cache-Control": "public, max-age=86400",
144
+ });
145
+ res.end();
146
+ return;
147
+ }
148
+ // Health check — used by Fly and easy to eyeball.
149
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) {
150
+ res.writeHead(200, { "Content-Type": "application/json" });
151
+ res.end(JSON.stringify({
152
+ status: "ok",
153
+ service: "corent-mcp",
154
+ transport: "streamable-http",
155
+ version: SERVER_VERSION,
156
+ endpoint: "/mcp",
157
+ }));
158
+ return;
159
+ }
160
+ // OAuth endpoints (discovery, registration, consent page, token exchange).
161
+ try {
162
+ if (await handleOauth(req, res, url, {
163
+ publicUrl: PUBLIC_URL,
164
+ apiUrl: API_URL,
165
+ supabaseUrl: config.supabaseUrl,
166
+ supabaseAnonKey: config.supabaseAnonKey,
167
+ }))
168
+ return;
169
+ }
170
+ catch (err) {
171
+ console.error(`oauth request failed: ${req.method} ${url.pathname}:`, err instanceof Error ? err.message : err);
172
+ if (!res.headersSent) {
173
+ res.writeHead(500, { "Content-Type": "application/json" });
174
+ res.end(JSON.stringify({ error: "server_error" }));
175
+ }
176
+ return;
177
+ }
178
+ if (url.pathname !== "/mcp") {
179
+ res.writeHead(404, { "Content-Type": "application/json" });
180
+ res.end(JSON.stringify({ error: "not found; MCP endpoint is /mcp" }));
181
+ return;
182
+ }
183
+ const apiKey = extractApiKey(req, url);
184
+ // No credentials -> standard OAuth challenge (RFC 9728). This is what makes
185
+ // claude.ai (and other remote MCP clients) open the Connect flow instead of
186
+ // silently failing tool calls later.
187
+ if (!apiKey) {
188
+ // Distinguish "no credentials at all" from "OAuth token present but bad".
189
+ const auth = req.headers["authorization"];
190
+ const hadOauthToken = typeof auth === "string" && isOauthToken(auth.replace(/^[Bb]earer\s+/, ""));
191
+ console.error(`[mcp] 401: ${hadOauthToken ? "oauth token invalid/expired" : "no credentials"}`);
192
+ res.writeHead(401, {
193
+ "Content-Type": "application/json",
194
+ "WWW-Authenticate": `Bearer resource_metadata="${PUBLIC_URL}/.well-known/oauth-protected-resource"`,
195
+ });
196
+ res.end(JSON.stringify({
197
+ error: "unauthorized",
198
+ error_description: "Authenticate via OAuth (connect this server in your MCP client), or pass a Corent API key as 'Authorization: Bearer co_live_...'. Keys: https://corent.tech/dashboard/api-keys",
199
+ }));
200
+ return;
201
+ }
202
+ const mcp = createCorentServer({ apiKey, apiUrl: API_URL });
203
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
204
+ res.on("close", () => {
205
+ transport.close().catch(() => { });
206
+ mcp.close().catch(() => { });
207
+ });
208
+ try {
209
+ await mcp.connect(transport);
210
+ const body = req.method === "POST" ? await readBody(req) : undefined;
211
+ logMcpRequest(body);
212
+ await transport.handleRequest(req, res, body);
213
+ }
214
+ catch (err) {
215
+ // Redacted path only — never req.url, which may carry an API key.
216
+ console.error(`request failed: ${req.method} ${redactedPath(url)}:`, err instanceof Error ? err.message : err);
217
+ if (!res.headersSent) {
218
+ res.writeHead(500, { "Content-Type": "application/json" });
219
+ res.end(JSON.stringify({
220
+ jsonrpc: "2.0",
221
+ error: { code: -32603, message: err instanceof Error ? err.message : "Internal server error" },
222
+ id: null,
223
+ }));
224
+ }
225
+ }
226
+ };
227
+ }
package/dist/http.js CHANGED
@@ -18,207 +18,21 @@
18
18
  *
19
19
  * Stateless mode: a fresh server + transport is created for each request, so
20
20
  * there is no cross-user session state to leak.
21
+ *
22
+ * The request handler itself lives in http-app.ts (exported, so tests can
23
+ * drive it on an ephemeral port); this file only reads the environment and
24
+ * listens. `dist/http.js` stays the deployed entrypoint.
21
25
  */
22
26
  import http from "node:http";
23
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
24
- import { createCorentServer, SERVER_VERSION } from "./server.js";
25
- import { handleOauth, isOauthToken, apiKeyFromAccessToken } from "./oauth.js";
27
+ import { createHttpApp } from "./http-app.js";
28
+ import { SERVER_VERSION } from "./server.js";
26
29
  const PORT = Number(process.env.PORT ?? 8080);
27
- const API_URL = process.env.CORENT_API_URL ?? "https://api.corent.tech";
28
- const PUBLIC_URL = process.env.MCP_PUBLIC_URL ?? "https://mcp.corent.tech";
29
- function extractApiKey(req, url) {
30
- const auth = req.headers["authorization"];
31
- if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
32
- const v = auth.slice(7).trim();
33
- // OAuth access tokens (coa_...) wrap the real API key; anything else is
34
- // treated as a raw Corent key. Expired/forged tokens resolve to nothing,
35
- // which falls through to the 401 + WWW-Authenticate below.
36
- if (v && isOauthToken(v))
37
- return apiKeyFromAccessToken(v) ?? undefined;
38
- if (v)
39
- return v;
40
- }
41
- const header = req.headers["x-corent-api-key"];
42
- if (typeof header === "string" && header.trim())
43
- return header.trim();
44
- for (const k of ["corent_api_key", "corentApiKey", "api_key", "apiKey"]) {
45
- const v = url.searchParams.get(k);
46
- if (v)
47
- return v;
48
- }
49
- // Smithery encodes the configSchema object as base64 JSON in ?config=
50
- const cfg = url.searchParams.get("config");
51
- if (cfg) {
52
- try {
53
- const decoded = JSON.parse(Buffer.from(cfg, "base64").toString("utf8"));
54
- if (decoded && typeof decoded.corentApiKey === "string")
55
- return decoded.corentApiKey;
56
- if (decoded && typeof decoded.CORENT_API_KEY === "string")
57
- return decoded.CORENT_API_KEY;
58
- }
59
- catch {
60
- /* ignore malformed config */
61
- }
62
- }
63
- return undefined;
64
- }
65
- // Query params may carry the caller's API key (Smithery compatibility), so a
66
- // raw req.url must NEVER be logged. Any request-level logging — now or added
67
- // later — MUST go through this helper, which strips every param that can
68
- // carry a key.
69
- const SENSITIVE_PARAMS = ["corent_api_key", "corentApiKey", "api_key", "apiKey", "config"];
70
- function redactedPath(url) {
71
- const clean = new URL(url.toString());
72
- for (const k of SENSITIVE_PARAMS) {
73
- if (clean.searchParams.has(k))
74
- clean.searchParams.set(k, "[redacted]");
75
- }
76
- return clean.pathname + clean.search;
77
- }
78
- function setCors(res) {
79
- res.setHeader("Access-Control-Allow-Origin", "*");
80
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
81
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Corent-Api-Key, Mcp-Session-Id, Mcp-Protocol-Version");
82
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
83
- }
84
- function readBody(req) {
85
- return new Promise((resolve, reject) => {
86
- let data = "";
87
- req.on("data", (chunk) => {
88
- data += chunk;
89
- if (data.length > 4_000_000)
90
- reject(new Error("body too large"));
91
- });
92
- req.on("end", () => {
93
- if (!data)
94
- return resolve(undefined);
95
- try {
96
- resolve(JSON.parse(data));
97
- }
98
- catch (e) {
99
- reject(e);
100
- }
101
- });
102
- req.on("error", reject);
103
- });
104
- }
105
- // Method-level request logging for diagnosing client behavior (which methods a
106
- // host calls, whether it advertises the MCP Apps ui extension). Never logs
107
- // prompts, arguments, keys, or results.
108
- function logMcpRequest(body) {
109
- for (const msg of Array.isArray(body) ? body : [body]) {
110
- const m = msg;
111
- if (!m || typeof m.method !== "string")
112
- continue;
113
- if (m.method === "initialize") {
114
- const client = m.params?.clientInfo?.name ?? "unknown";
115
- const uiCap = m.params?.capabilities?.extensions?.["io.modelcontextprotocol/ui"];
116
- console.error(`[mcp] initialize from ${client} v${m.params?.clientInfo?.version ?? "?"} proto=${m.params?.protocolVersion} ui-extension=${uiCap ? JSON.stringify(uiCap) : "NOT advertised"}`);
117
- }
118
- else if (m.method === "resources/read") {
119
- console.error(`[mcp] resources/read ${m.params?.uri ?? "?"}`);
120
- }
121
- else if (m.method === "tools/call") {
122
- console.error(`[mcp] tools/call ${m.params?.name ?? "?"}`);
123
- }
124
- else if (m.method === "resources/list" || m.method === "tools/list") {
125
- console.error(`[mcp] ${m.method}`);
126
- }
127
- }
128
- }
129
- const server = http.createServer(async (req, res) => {
130
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
131
- setCors(res);
132
- if (req.method === "OPTIONS") {
133
- res.writeHead(204);
134
- res.end();
135
- return;
136
- }
137
- // Favicon: some clients derive a connector icon from the server origin.
138
- if (req.method === "GET" && url.pathname === "/favicon.ico") {
139
- res.writeHead(302, { Location: "https://corent.tech/brand/intro-mark.png", "Cache-Control": "public, max-age=86400" });
140
- res.end();
141
- return;
142
- }
143
- // Health check — used by Fly and easy to eyeball.
144
- if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) {
145
- res.writeHead(200, { "Content-Type": "application/json" });
146
- res.end(JSON.stringify({
147
- status: "ok",
148
- service: "corent-mcp",
149
- transport: "streamable-http",
150
- version: SERVER_VERSION,
151
- endpoint: "/mcp",
152
- }));
153
- return;
154
- }
155
- // OAuth endpoints (discovery, registration, consent page, token exchange).
156
- try {
157
- if (await handleOauth(req, res, url, {
158
- publicUrl: PUBLIC_URL,
159
- apiUrl: API_URL,
160
- supabaseUrl: process.env.SUPABASE_URL,
161
- supabaseAnonKey: process.env.SUPABASE_ANON_KEY,
162
- }))
163
- return;
164
- }
165
- catch (err) {
166
- console.error(`oauth request failed: ${req.method} ${url.pathname}:`, err instanceof Error ? err.message : err);
167
- if (!res.headersSent) {
168
- res.writeHead(500, { "Content-Type": "application/json" });
169
- res.end(JSON.stringify({ error: "server_error" }));
170
- }
171
- return;
172
- }
173
- if (url.pathname !== "/mcp") {
174
- res.writeHead(404, { "Content-Type": "application/json" });
175
- res.end(JSON.stringify({ error: "not found; MCP endpoint is /mcp" }));
176
- return;
177
- }
178
- const apiKey = extractApiKey(req, url);
179
- // No credentials -> standard OAuth challenge (RFC 9728). This is what makes
180
- // claude.ai (and other remote MCP clients) open the Connect flow instead of
181
- // silently failing tool calls later.
182
- if (!apiKey) {
183
- // Distinguish "no credentials at all" from "OAuth token present but bad".
184
- const auth = req.headers["authorization"];
185
- const hadOauthToken = typeof auth === "string" && isOauthToken(auth.replace(/^[Bb]earer\s+/, ""));
186
- console.error(`[mcp] 401: ${hadOauthToken ? "oauth token invalid/expired" : "no credentials"}`);
187
- res.writeHead(401, {
188
- "Content-Type": "application/json",
189
- "WWW-Authenticate": `Bearer resource_metadata="${PUBLIC_URL}/.well-known/oauth-protected-resource"`,
190
- });
191
- res.end(JSON.stringify({
192
- error: "unauthorized",
193
- error_description: "Authenticate via OAuth (connect this server in your MCP client), or pass a Corent API key as 'Authorization: Bearer co_live_...'. Keys: https://corent.tech/dashboard/api-keys",
194
- }));
195
- return;
196
- }
197
- const mcp = createCorentServer({ apiKey, apiUrl: API_URL });
198
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
199
- res.on("close", () => {
200
- transport.close().catch(() => { });
201
- mcp.close().catch(() => { });
202
- });
203
- try {
204
- await mcp.connect(transport);
205
- const body = req.method === "POST" ? await readBody(req) : undefined;
206
- logMcpRequest(body);
207
- await transport.handleRequest(req, res, body);
208
- }
209
- catch (err) {
210
- // Redacted path only — never req.url, which may carry an API key.
211
- console.error(`request failed: ${req.method} ${redactedPath(url)}:`, err instanceof Error ? err.message : err);
212
- if (!res.headersSent) {
213
- res.writeHead(500, { "Content-Type": "application/json" });
214
- res.end(JSON.stringify({
215
- jsonrpc: "2.0",
216
- error: { code: -32603, message: err instanceof Error ? err.message : "Internal server error" },
217
- id: null,
218
- }));
219
- }
220
- }
221
- });
30
+ const server = http.createServer(createHttpApp({
31
+ apiUrl: process.env.CORENT_API_URL ?? "https://api.corent.tech",
32
+ publicUrl: process.env.MCP_PUBLIC_URL ?? "https://mcp.corent.tech",
33
+ supabaseUrl: process.env.SUPABASE_URL,
34
+ supabaseAnonKey: process.env.SUPABASE_ANON_KEY,
35
+ }));
222
36
  server.listen(PORT, () => {
223
37
  console.error(`corent-mcp (streamable-http) v${SERVER_VERSION} listening on :${PORT}${" -> /mcp"}`);
224
38
  });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * MCP prompts: five production recipes built ONLY from the tools this server
3
+ * already exposes. Each is a step list an assistant follows with the user:
4
+ * plan, confirm the budget, stills with references, clips, narration, poll.
5
+ *
6
+ * The long "how to" guidance used to live inside tool descriptions; it lives
7
+ * here now so every tool description stays under 400 characters (ChatGPT and
8
+ * other hosts truncate or reject long ones). The same five recipes ship as
9
+ * markdown in mcp/skills/<name>/SKILL.md for publication as a skills repo.
10
+ */
11
+ import { z } from "zod";
12
+ // Honest per-unit prices, in the same words the tool descriptions use. Quote
13
+ // these to the user before spending; the exact charge comes back per call.
14
+ export const PRICE_NOTE = "Prices to quote before spending: image premium ~7c each; video premium ~52c per clip, pro ~84c per clip; " +
15
+ "speech ~25-30c per started block of 1,000 characters (send the whole script in ONE generate_speech call). " +
16
+ "Failed generations are never billed. The exact charge is in meta.cost_cents on every result.";
17
+ const BUDGET_RULE = "Add the numbers up, show the user the total against their budget, and do NOT call a paid tool until they say yes. " +
18
+ "Call get_balance first: available_cents is what can actually be spent.";
19
+ const POLLING = "Video is asynchronous: each generate_video returns a job id. Poll get_job every 10-15 seconds until status is " +
20
+ "completed (a clip usually takes 1-4 minutes); the URL is in videos[0].url. If the user changes their mind, cancel_job releases the hold.";
21
+ const DELIVERY = "Corent does not stitch clips together. Deliver every still, clip and narration URL in order with its cost, " +
22
+ "so the user can assemble them in any editor. list_media can find these again later by prompt keyword.";
23
+ function user(text) {
24
+ return { messages: [{ role: "user", content: { type: "text", text } }] };
25
+ }
26
+ export function registerPrompts(server) {
27
+ server.registerPrompt("product-ugc-reel", {
28
+ title: "Product UGC reel",
29
+ description: "A vertical creator-style reel for one product: 3-5 stills that keep the product consistent, one clip per still, and a single narration track.",
30
+ argsSchema: {
31
+ product_url: z.string().describe("Public https URL of a product photo (use upload_image for a local file)"),
32
+ brief: z.string().describe("Who it is for and the one thing the reel must say"),
33
+ budget_usd: z.string().optional().describe("Spend ceiling in US dollars, e.g. 5"),
34
+ },
35
+ }, ({ product_url, brief, budget_usd }) => user([
36
+ `Make a product UGC reel. Product image: ${product_url}. Brief: ${brief}. Budget: $${budget_usd ?? "ask the user"}.`,
37
+ "",
38
+ "1. PLAN. Write a 4-shot vertical (9:16) shot list: hook, product in use, the benefit, call to action. One line of narration per shot; keep the whole script under 1,000 characters.",
39
+ `2. COST. ${PRICE_NOTE} ${BUDGET_RULE}`,
40
+ "3. STILLS. For each shot call generate_image with reference_image_urls=[product image], tier premium (never air or lite: references need an edit-capable model), aspect_ratio 9:16, style photorealistic. If the label or logo must be pixel-exact, generate the scene, then inpaint_image with a mask that is BLACK over the product and WHITE elsewhere: pixels outside the mask stay byte-for-byte.",
41
+ "4. CLIPS. For each approved still call generate_video with image_url = that still, tier premium (pro if the user wants the best), duration_s 5. Omit aspect_ratio when animating a still. Pass audio false; the narration is a separate track.",
42
+ "5. NARRATION. Call list_voices, let the user pick a voice_id, then ONE generate_speech call with the full script.",
43
+ `6. POLL. ${POLLING}`,
44
+ `7. DELIVER. ${DELIVERY}`,
45
+ ].join("\n")));
46
+ server.registerPrompt("character-series", {
47
+ title: "Character series",
48
+ description: "One consistent character across many scenes: an anchor portrait, then every scene generated as an edit of that anchor.",
49
+ argsSchema: {
50
+ character: z.string().describe("Who the character is: age, look, clothing, mood"),
51
+ scenes: z.string().describe("The scenes, comma separated, e.g. 'at a cafe, on a train, at the beach'"),
52
+ budget_usd: z.string().optional().describe("Spend ceiling in US dollars"),
53
+ },
54
+ }, ({ character, scenes, budget_usd }) => user([
55
+ `Make a consistent character series. Character: ${character}. Scenes: ${scenes}. Budget: $${budget_usd ?? "ask the user"}.`,
56
+ "",
57
+ "1. ANCHOR. One generate_image, tier premium, aspect_ratio 3:4: the character alone, neutral pose, plain background, face clearly visible. Show it to the user and iterate (same seed, one change) until they approve. This is the only image the series is built from.",
58
+ `2. COST. One image per scene at ~7c (premium). ${BUDGET_RULE}`,
59
+ "3. SCENES. For each scene call generate_image with reference_image_urls=[anchor URL], tier premium or pro, the scene in the prompt, same style every time. Keep the anchor as the FIRST reference if you add a second (e.g. a product).",
60
+ "4. OPTIONAL CLIPS. generate_video with image_url = the scene still, duration_s 5, omit aspect_ratio. Quote ~52c (premium) or ~84c (pro) per clip first.",
61
+ `5. POLL. ${POLLING}`,
62
+ `6. DELIVER. ${DELIVERY} Tell the user the anchor URL: pass it as a reference any time they want this character again.`,
63
+ ].join("\n")));
64
+ server.registerPrompt("storyboard-to-clip", {
65
+ title: "Storyboard to clips",
66
+ description: "Turn a numbered storyboard into stills, then one clip per still with a camera move, plus optional narration.",
67
+ argsSchema: {
68
+ storyboard: z.string().describe("Numbered shots, one line each: what is in frame and what moves"),
69
+ style: z.string().optional().describe("Look, e.g. cinematic, anime, documentary"),
70
+ budget_usd: z.string().optional().describe("Spend ceiling in US dollars"),
71
+ },
72
+ }, ({ storyboard, style, budget_usd }) => user([
73
+ `Turn this storyboard into clips. Style: ${style ?? "cinematic"}. Budget: $${budget_usd ?? "ask the user"}.`,
74
+ storyboard,
75
+ "",
76
+ "1. PLAN. Restate the shots as a table: number, frame, movement, seconds (5 each unless told otherwise), narration line if any.",
77
+ `2. COST. One still (~7c) plus one clip (~52c premium, ~84c pro) per shot, plus ~30c if there is narration. ${BUDGET_RULE}`,
78
+ "3. STILLS. generate_image per shot, aspect_ratio 16:9, style cinematic (or the requested style), tier premium. Use reference_image_urls when a character or object recurs across shots, with the first approved still as the reference.",
79
+ "4. CLIPS. generate_video per approved still: image_url = the still, duration_s 5, a camera value that matches the movement (pan_left, zoom_in, orbit_right, static ...). For an A-to-B shot pass end_image_url = the next shot's still. Omit aspect_ratio when animating a still.",
80
+ "5. NARRATION. If the storyboard has lines, list_voices then ONE generate_speech with the whole script.",
81
+ `6. POLL. ${POLLING}`,
82
+ `7. DELIVER. ${DELIVERY}`,
83
+ ].join("\n")));
84
+ server.registerPrompt("ad-variations", {
85
+ title: "Ad variations",
86
+ description: "Many versions of one ad visual for testing, in a single batch, with the product kept consistent when a product photo is given.",
87
+ argsSchema: {
88
+ concept: z.string().describe("The ad idea in one or two sentences"),
89
+ count: z.string().optional().describe("How many variations, 2-50 (default 6)"),
90
+ product_url: z.string().optional().describe("Public https URL of the product photo, if the product must stay consistent"),
91
+ },
92
+ }, ({ concept, count, product_url }) => user([
93
+ `Make ${count ?? "6"} variations of this ad: ${concept}.${product_url ? ` Product image: ${product_url}.` : ""}`,
94
+ "",
95
+ "1. PLAN. Choose the axes to vary (setting, angle, time of day, audience, mood) and write one prompt per variation. Same aspect_ratio for all (1:1 for feed, 9:16 for stories). No readable text in the images; headlines go on top in the editor.",
96
+ `2. COST. ~7c per premium image, so count x 7c. ${BUDGET_RULE}`,
97
+ product_url
98
+ ? "3. RENDER. With a product photo, batch items cannot carry references: call generate_image once per variation with reference_image_urls=[product image], tier premium. Use inpaint_image (mask BLACK over the product) when the label must be pixel-exact."
99
+ : "3. RENDER. One generate_image_batch call with every prompt as an item, tier premium. Poll get_batch, then get_job for each finished item.",
100
+ "4. REVIEW. Show the user a numbered list with each URL and prompt. Re-render a favourite with the same seed and one change.",
101
+ "5. OPTIONAL MOTION. For the winners, generate_video with image_url = the still, duration_s 5, quoted at ~52c (premium) per clip first.",
102
+ `6. DELIVER. ${DELIVERY}`,
103
+ ].join("\n")));
104
+ server.registerPrompt("faceless-explainer", {
105
+ title: "Faceless explainer",
106
+ description: "A narrated explainer with no presenter: a script, one still and one clip per scene, and a single voice track.",
107
+ argsSchema: {
108
+ topic: z.string().describe("What the video explains, and to whom"),
109
+ duration_s: z.string().optional().describe("Target length in seconds (default 30)"),
110
+ budget_usd: z.string().optional().describe("Spend ceiling in US dollars"),
111
+ },
112
+ }, ({ topic, duration_s, budget_usd }) => user([
113
+ `Make a faceless explainer about: ${topic}. Target length ${duration_s ?? "30"} seconds. Budget: $${budget_usd ?? "ask the user"}.`,
114
+ "",
115
+ "1. SCRIPT. Write it yourself (free): about 2.5 words per second, one scene per 5 seconds, each scene = one sentence of narration + one visual. Keep the whole script under 1,000 characters when you can; it bills per started block.",
116
+ `2. COST. Per scene one still (~7c) and one clip (~52c premium), plus ~30c narration. ${BUDGET_RULE}`,
117
+ "3. STILLS. generate_image per scene, aspect_ratio 16:9 (9:16 for shorts), tier premium, one consistent style across scenes. Diagrams and objects rather than faces; no readable text in the image.",
118
+ "4. CLIPS. generate_video per approved still, image_url = the still, duration_s 5, a gentle camera move (zoom_in or pan_right), audio false. Omit aspect_ratio when animating a still.",
119
+ "5. NARRATION. list_voices, pick a clear narration voice, then ONE generate_speech call with the whole script.",
120
+ `6. POLL. ${POLLING}`,
121
+ `7. DELIVER. ${DELIVERY}`,
122
+ ].join("\n")));
123
+ }
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared Corent MCP server definition — the 10 tools, used by both the
2
+ * Shared Corent MCP server definition — the tools and prompts, 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.
@@ -16,8 +16,9 @@
16
16
  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
+ import { registerPrompts } from "./prompts.js";
19
20
  export const DEFAULT_API_URL = "https://api.corent.tech";
20
- export const SERVER_VERSION = "0.7.0";
21
+ export const SERVER_VERSION = "0.8.0";
21
22
  // Transport resilience, matching the two SDKs: a 429 or 5xx is retried with
22
23
  // backoff, but ONLY on calls that carry an Idempotency-Key (or are GETs), so a
23
24
  // retry can never buy a second generation.
@@ -35,6 +36,19 @@ const WIDGET_TOOL_META = {
35
36
  ui: { resourceUri: WIDGET_URI },
36
37
  "ui/resourceUri": WIDGET_URI,
37
38
  };
39
+ /**
40
+ * Per-tool _meta. ChatGPT's Apps SDK reads two plain status strings from a
41
+ * tool's _meta and shows them while the call runs and once it lands
42
+ * ("openai/toolInvocation/invoking" / "invoked"); every other host ignores
43
+ * unknown _meta keys. Media tools also carry the MCP Apps widget pointer.
44
+ */
45
+ function toolMeta(invoking, invoked, widget = false) {
46
+ return {
47
+ "openai/toolInvocation/invoking": invoking,
48
+ "openai/toolInvocation/invoked": invoked,
49
+ ...(widget ? WIDGET_TOOL_META : {}),
50
+ };
51
+ }
38
52
  // Generated media lives on Supabase storage; the logo and any brand assets on
39
53
  // corent.tech. Everything else stays blocked by the host's CSP.
40
54
  const WIDGET_CSP = {
@@ -240,8 +254,8 @@ export function createCorentServer(config = {}) {
240
254
  const TIERS = ["air", "lite", "premium", "pro", "max_pro"];
241
255
  server.registerTool("generate_image", {
242
256
  title: "Generate image",
243
- _meta: WIDGET_TOOL_META,
244
- description: "Generate an image from a text prompt. Returns a permanent public URL. Synchronous: typically completes in 2-20 seconds. Costs a few cents, billed to the Corent account. Failed generations are never billed.",
257
+ _meta: toolMeta("Generating image", "Image ready", true),
258
+ description: "Generate an image from a text prompt. Returns a permanent public URL. Synchronous, usually 2-20 seconds. Costs a few cents (premium ~7c); failed generations are never billed. Takes reference_image_urls to keep a face, character or product consistent (tier premium or up).",
245
259
  inputSchema: {
246
260
  prompt: z.string().describe("What to generate, in plain language"),
247
261
  tier: z
@@ -320,8 +334,8 @@ export function createCorentServer(config = {}) {
320
334
  })));
321
335
  server.registerTool("generate_video", {
322
336
  title: "Generate video",
323
- _meta: WIDGET_TOOL_META,
324
- description: "Start generating a video from a text prompt (optionally animating a source image). Asynchronous: returns a job id immediately; poll get_job until status is 'completed'. Video costs more than images (tens of cents to a few dollars depending on tier). Failed generations are never billed.",
337
+ _meta: toolMeta("Starting video", "Video job started", true),
338
+ description: "Start a video from a text prompt, or animate a source image. Asynchronous: returns a job id at once; poll get_job until status is completed. Costs tens of cents to a few dollars per clip (premium ~52c, pro ~84c). Failed generations are never billed.",
325
339
  inputSchema: {
326
340
  prompt: z.string().describe("What to generate, in plain language"),
327
341
  tier: z
@@ -380,7 +394,8 @@ export function createCorentServer(config = {}) {
380
394
  })));
381
395
  server.registerTool("generate_speech", {
382
396
  title: "Generate speech (voice)",
383
- description: "Generate spoken audio (text to speech) from text. Synchronous: returns the finished audio URL and the exact charge. Billed in started blocks of 1,000 input characters, so even a one-sentence request bills the full first block (roughly 25-30 cents); longer scripts amortize better. Failed generations are never billed.",
397
+ _meta: toolMeta("Generating speech", "Speech ready"),
398
+ description: "Text to speech. Synchronous: returns the finished audio URL and the exact charge. Billed in started blocks of 1,000 characters (roughly 25-30c for the first block), so a short line costs the same as a paragraph. Call list_voices first to pick a voice_id. Failed generations are never billed.",
384
399
  inputSchema: {
385
400
  text: z.string().min(1).max(5000).describe("The text to speak, max 5000 characters"),
386
401
  voice_id: z
@@ -419,7 +434,8 @@ export function createCorentServer(config = {}) {
419
434
  })));
420
435
  server.registerTool("generate_text", {
421
436
  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.",
437
+ _meta: toolMeta("Asking the model", "Answer ready"),
438
+ description: "Run a prompt or conversation through a frontier language model on the user's Corent account. Synchronous: returns the text, token usage and exact charge (fractions of a cent). Use it when the user wants a specific model's answer or work billed to Corent, not for your own reasoning. Supports tool calling and JSON mode.",
423
439
  inputSchema: {
424
440
  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
441
  system: z.string().optional().describe("Optional system instruction that frames the request"),
@@ -531,7 +547,8 @@ export function createCorentServer(config = {}) {
531
547
  }));
532
548
  server.registerTool("list_models", {
533
549
  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.',
550
+ _meta: toolMeta("Listing models", "Models listed"),
551
+ description: "The direct-access menu: every model that can be pinned by name (spelled corent-*) on the generate tools, with kind, quality score and live status. Carries no price; billing is flat cost-plus and each generation returns its exact charge. Read-only and free. Only needed when the user names a specific model.",
535
552
  inputSchema: {},
536
553
  outputSchema: {
537
554
  models: z
@@ -546,7 +563,8 @@ export function createCorentServer(config = {}) {
546
563
  }, wrap(async () => corent("/v1/models")));
547
564
  server.registerTool("list_voices", {
548
565
  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.",
566
+ _meta: toolMeta("Listing voices", "Voices listed"),
567
+ description: "The voices generate_speech accepts as voice_id, with display name, preview clip and descriptors (gender, age, accent, use case). Read-only and free. Call it before generate_speech whenever the user wants a particular narrator; a voice_id cannot be guessed.",
550
568
  inputSchema: {},
551
569
  outputSchema: {
552
570
  voices: z
@@ -560,7 +578,8 @@ export function createCorentServer(config = {}) {
560
578
  }, wrap(async () => corent("/v1/voices")));
561
579
  server.registerTool("cancel_job", {
562
580
  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.",
581
+ _meta: toolMeta("Cancelling job", "Job cancelled"),
582
+ description: "Stop a job that has not finished 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 a video. A job that already finished stays completed and billed for what was delivered.",
564
583
  inputSchema: { job_id: z.string().describe("The job id to cancel") },
565
584
  outputSchema: {
566
585
  id: z.string().optional(),
@@ -573,7 +592,8 @@ export function createCorentServer(config = {}) {
573
592
  }, wrap(async ({ job_id }) => corent(`/v1/jobs/${job_id}/cancel`, { method: "POST" })));
574
593
  server.registerTool("list_tiers", {
575
594
  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.",
595
+ _meta: toolMeta("Listing tiers", "Tiers listed"),
596
+ description: "The live tier menu for image and video: each tier's price estimate, shapes, video durations, per-resolution pricing, and whether it accepts reference images (image) or a source image (video). Read-only and free. Check it before quoting a price. Tier-level only; never names a model.",
577
597
  inputSchema: {},
578
598
  outputSchema: {
579
599
  tiers: z
@@ -603,7 +623,8 @@ export function createCorentServer(config = {}) {
603
623
  .describe("One image in the batch. Batch items are tier-routed: to pin an exact model, use generate_image.");
604
624
  server.registerTool("generate_image_batch", {
605
625
  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.",
626
+ _meta: toolMeta("Submitting image batch", "Image batch submitted"),
627
+ description: "Submit up to 50 image generations in one call. Asynchronous: returns a batch_id and a job id per item; poll get_batch, then get_job per image. Every item bills at the normal rate, so check get_balance first. Items are tier-routed; pin a model with generate_image instead. No reference images here.",
607
628
  inputSchema: {
608
629
  items: z.array(imageBatchItem).min(1).max(50).describe("1 to 50 image requests"),
609
630
  },
@@ -616,7 +637,8 @@ export function createCorentServer(config = {}) {
616
637
  })));
617
638
  server.registerTool("generate_video_batch", {
618
639
  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.",
640
+ _meta: toolMeta("Submitting video batch", "Video batch submitted"),
641
+ description: "Submit up to 50 video generations in one call. Asynchronous: returns a batch_id and a job id per item; poll get_batch, then get_job per clip. Video is the expensive lane: a 10-item batch can cost several dollars, so confirm with the user and check get_balance first. Items are tier-routed.",
620
642
  inputSchema: {
621
643
  items: z
622
644
  .array(z.object({
@@ -640,7 +662,8 @@ export function createCorentServer(config = {}) {
640
662
  })));
641
663
  server.registerTool("get_batch", {
642
664
  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.",
665
+ _meta: toolMeta("Checking batch", "Batch checked"),
666
+ description: "Progress of a batch from generate_image_batch or generate_video_batch: completed, failed and pending counts plus each item's job id. Read-only and free. Fetch a finished item's media with get_job.",
644
667
  inputSchema: { batch_id: z.string().describe("The batch_id returned by a batch tool") },
645
668
  outputSchema: {
646
669
  batch_id: z.string().optional(),
@@ -655,15 +678,16 @@ export function createCorentServer(config = {}) {
655
678
  }, wrap(async ({ batch_id }) => corent(`/v1/batches/${batch_id}`)));
656
679
  server.registerTool("get_job", {
657
680
  title: "Check job status",
658
- _meta: WIDGET_TOOL_META,
659
- description: "Check the status of a generation job (mainly videos). When completed, the response includes the permanent media URL and the cost. Read-only and free.",
681
+ _meta: toolMeta("Checking job", "Job checked", true),
682
+ description: "Check a generation job (mainly video). When completed, the response carries the permanent media URL and the cost. Read-only and free.",
660
683
  inputSchema: { job_id: z.string().describe("The job id returned by generate_video or generate_image") },
661
684
  outputSchema: jobOutput,
662
685
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
663
686
  }, wrap(async ({ job_id }) => corent(`/v1/jobs/${job_id}`)));
664
687
  server.registerTool("get_balance", {
665
688
  title: "Check account balance",
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.",
689
+ _meta: toolMeta("Checking balance", "Balance checked"),
690
+ description: "The account balance in cents. balance_cents is the total, held_cents is reserved by running generations, available_cents is what a new request can actually spend; check that one before an expensive video. Read-only and free.",
667
691
  inputSchema: {},
668
692
  outputSchema: {
669
693
  balance_cents: z.number().optional(),
@@ -674,7 +698,8 @@ export function createCorentServer(config = {}) {
674
698
  }, wrap(async () => corent("/v1/account/balance")));
675
699
  server.registerTool("get_status", {
676
700
  title: "Check service status",
677
- description: "Get live operational status of Corent's generation tiers (operational/degraded). Read-only and free; useful to pick a healthy tier.",
701
+ _meta: toolMeta("Checking status", "Status checked"),
702
+ description: "Live operational status of Corent's generation tiers (operational or degraded). Read-only and free; useful to pick a healthy tier.",
678
703
  inputSchema: {},
679
704
  // /v1/status returns tiers as an ARRAY of {name, status} — declaring it
680
705
  // as a record made every call fail output validation (audit 2026-07-20).
@@ -689,7 +714,8 @@ export function createCorentServer(config = {}) {
689
714
  // prompt. The agent never picks a model or tier. ---
690
715
  server.registerTool("plan", {
691
716
  title: "Plan (cost preview, no generation)",
692
- description: "Preview how Corent would handle a plain-language request WITHOUT generating anything (costs a fraction of a cent). Corent decides whether it's an image or video, which tier, aspect ratio, and duration, and returns the plan plus an estimated cost. Use this to decide or confirm cost before spending. The planner covers image and video; for spoken audio call generate_speech directly. If the request is something Corent can't generate (text documents, 3D, editing, real-world actions), can_fulfill is false with a reason.",
717
+ _meta: toolMeta("Planning", "Plan ready"),
718
+ description: "Preview how Corent would handle a plain-language request without generating anything (a fraction of a cent). Returns image vs video, tier, aspect ratio, duration and an estimated cost. Covers image and video only; for speech call generate_speech. Impossible asks come back with can_fulfill false and a reason.",
693
719
  inputSchema: {
694
720
  intent: z
695
721
  .string()
@@ -703,8 +729,8 @@ export function createCorentServer(config = {}) {
703
729
  }, wrap(async ({ intent }) => corent("/v1/intent", { method: "POST", body: JSON.stringify({ intent }) })));
704
730
  server.registerTool("create", {
705
731
  title: "Create (plan + generate, budget-capped)",
706
- _meta: WIDGET_TOOL_META,
707
- description: "Describe what you want in plain language and Corent plans AND generates it — choosing image vs video, tier, aspect ratio, and duration for you (the 'zero decisions' path). You MUST pass max_cost_cents as a spend ceiling; if the estimated cost exceeds it, nothing is generated and you're told the estimate so you can raise the ceiling. Image results come back with a URL; video results come back as a job id to poll with get_job. Failed generations are never billed.",
732
+ _meta: toolMeta("Creating", "Created", true),
733
+ description: "Describe what you want in plain language; Corent plans and generates it, choosing image vs video, tier, shape and duration. max_cost_cents is a required spend ceiling: above it nothing is generated and you are told the estimate. Images return a URL; videos return a job id for get_job. Failed generations are never billed.",
708
734
  inputSchema: {
709
735
  intent: z.string().describe("What you want, in natural language"),
710
736
  max_cost_cents: z
@@ -734,5 +760,151 @@ export function createCorentServer(config = {}) {
734
760
  }
735
761
  return res;
736
762
  }));
763
+ // --- Image tools (v0.8.0): edit what already exists instead of generating
764
+ // anew. All three are synchronous, flat-priced, and replay-safe under an
765
+ // Idempotency-Key exactly like generate_image. ---
766
+ server.registerTool("upscale_image", {
767
+ title: "Upscale image",
768
+ _meta: toolMeta("Upscaling image", "Upscaled image ready", true),
769
+ description: "Make an existing image bigger and sharper from its public https URL. Synchronous, returns a new permanent URL. Flat price, no tier; failed runs are never billed. Optional prompt guides the added detail; omit it to enlarge faithfully. Upload a local file with upload_image first.",
770
+ inputSchema: {
771
+ image_url: z.string().url().describe("Public https URL of the image to enlarge and sharpen"),
772
+ prompt: z.string().max(2000).optional().describe("Optional guidance for the added detail; omit to enlarge faithfully"),
773
+ },
774
+ outputSchema: imageOutput,
775
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
776
+ }, wrap(async (args) => corent("/v1/images/upscale", {
777
+ method: "POST",
778
+ headers: { "Idempotency-Key": idempotencyKey() },
779
+ body: JSON.stringify(args),
780
+ })));
781
+ server.registerTool("remove_background", {
782
+ title: "Remove background",
783
+ _meta: toolMeta("Removing background", "Cutout ready", true),
784
+ description: "Cut the subject out of an image and return a PNG with a real transparent background, for logos, product shots and compositing. Synchronous, flat price, no tier; failed runs are never billed. Takes a public https URL; upload a local file with upload_image first.",
785
+ inputSchema: {
786
+ image_url: z.string().url().describe("Public https URL of the image to cut out"),
787
+ },
788
+ outputSchema: imageOutput,
789
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
790
+ }, wrap(async (args) => corent("/v1/images/remove-background", {
791
+ method: "POST",
792
+ headers: { "Idempotency-Key": idempotencyKey() },
793
+ body: JSON.stringify(args),
794
+ })));
795
+ server.registerTool("inpaint_image", {
796
+ title: "Inpaint image (masked edit)",
797
+ _meta: toolMeta("Inpainting image", "Edited image ready", true),
798
+ description: "Regenerate ONLY the white region of a mask and leave every pixel outside it byte-for-byte unchanged, so a product or logo stays exactly your file. Takes image_url, a same-size black-and-white mask_url (white = redo, black = keep) and a prompt for the masked area. Synchronous; failed runs are never billed.",
799
+ inputSchema: {
800
+ image_url: z.string().url().describe("Public https URL of the image to edit"),
801
+ mask_url: z
802
+ .string()
803
+ .url()
804
+ .describe("Public https URL of a black-and-white mask the same size as the image: WHITE is regenerated, BLACK is preserved byte for byte"),
805
+ prompt: z.string().min(1).max(2000).describe("What to generate inside the white (masked) region"),
806
+ tier: z.enum(TIERS).optional().describe("Quality tier for the regenerated region; omit for the default"),
807
+ },
808
+ outputSchema: imageOutput,
809
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
810
+ }, wrap(async (args) => corent("/v1/images/edit", {
811
+ method: "POST",
812
+ headers: { "Idempotency-Key": idempotencyKey() },
813
+ body: JSON.stringify(args),
814
+ })));
815
+ // --- Memory: what the account already made, so an assistant can find "the
816
+ // beach one from yesterday" and reuse the URL instead of paying again. ---
817
+ const MEDIA_TYPES = ["image", "video", "speech", "text"];
818
+ const PROMPT_PREVIEW_CHARS = 120;
819
+ server.registerTool("list_media", {
820
+ title: "List recent media",
821
+ _meta: toolMeta("Searching your media", "Media listed"),
822
+ description: "Find media the account already made, so a result can be reused instead of paid for again. Reads the last 100 jobs, filtered by type and a prompt keyword (e.g. 'beach'), newest first. Returns id, type, status, date, tier, cost and URL per job. Read-only and free.",
823
+ inputSchema: {
824
+ type: z.enum(MEDIA_TYPES).optional().describe("Only this lane: image, video, speech or text"),
825
+ limit: z.number().int().min(1).max(100).default(20).describe("How many rows to return, 1-100 (default 20)"),
826
+ query: z.string().optional().describe("Case-insensitive substring to match against each job's prompt"),
827
+ },
828
+ outputSchema: {
829
+ items: z
830
+ .array(z
831
+ .object({
832
+ id: z.string(),
833
+ type: z.string().nullable().optional(),
834
+ status: z.string().nullable().optional(),
835
+ created_at: z.string().nullable().optional(),
836
+ tier: z.string().nullable().optional(),
837
+ cost_cents: z.number().nullable().optional(),
838
+ prompt: z.string().nullable().optional(),
839
+ url: z.string().nullable().optional(),
840
+ })
841
+ .passthrough())
842
+ .optional(),
843
+ count: z.number().optional(),
844
+ scanned: z.number().optional(),
845
+ error: z.string().optional(),
846
+ },
847
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
848
+ }, wrap(async ({ type, limit, query }) => {
849
+ const usage = await corent("/v1/account/usage");
850
+ const recent = Array.isArray(usage?.recent_jobs) ? usage.recent_jobs : [];
851
+ const needle = query?.trim().toLowerCase();
852
+ const items = recent
853
+ .filter((j) => !type || j.type === type)
854
+ .filter((j) => !needle || String(j.prompt ?? "").toLowerCase().includes(needle))
855
+ .slice(0, limit ?? 20)
856
+ .map((j) => {
857
+ const prompt = typeof j.prompt === "string" ? j.prompt : null;
858
+ return {
859
+ id: String(j.id),
860
+ type: j.type ?? null,
861
+ status: j.status ?? null,
862
+ created_at: j.created_at ?? null,
863
+ tier: j.model ?? null,
864
+ cost_cents: j.charge_cents ?? null,
865
+ prompt: prompt && prompt.length > PROMPT_PREVIEW_CHARS ? `${prompt.slice(0, PROMPT_PREVIEW_CHARS - 1)}…` : prompt,
866
+ url: j.output_url ?? null,
867
+ };
868
+ });
869
+ return { items, count: items.length, scanned: recent.length };
870
+ }));
871
+ // --- Upload: turn a local file into a URL the other tools accept. ---
872
+ const UPLOAD_MAX_BYTES = 25 * 1024 * 1024;
873
+ server.registerTool("upload_image", {
874
+ title: "Upload image",
875
+ _meta: toolMeta("Uploading image", "Upload complete"),
876
+ description: "Upload a local image (base64) to Corent and get back a public https URL. Use that URL as image_url, mask_url, source_image_url or a reference in the other tools. Max 25 MB. Free; the file is stored on the account.",
877
+ inputSchema: {
878
+ data_base64: z.string().min(1).describe("The file bytes, base64 encoded (no data: prefix)"),
879
+ content_type: z
880
+ .string()
881
+ .regex(/^image\/[a-z0-9.+-]+$/i)
882
+ .describe('MIME type, e.g. "image/png" or "image/jpeg"'),
883
+ filename: z.string().max(255).optional().describe("Optional file name, e.g. product.png"),
884
+ },
885
+ outputSchema: {
886
+ url: z.string().optional(),
887
+ id: z.string().optional(),
888
+ content_type: z.string().optional(),
889
+ bytes: z.number().optional(),
890
+ error: z.string().optional(),
891
+ },
892
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
893
+ }, wrap(async ({ data_base64, content_type, filename }) => {
894
+ // Strip an accidental data: URL prefix so a copy-pasted value still works.
895
+ const raw = data_base64.replace(/^data:[^;]+;base64,/, "").trim();
896
+ // Decoded size, computed without decoding: 3 bytes per 4 characters,
897
+ // minus padding. Refusing here saves shipping 30 MB to the API for a 413.
898
+ const padding = raw.endsWith("==") ? 2 : raw.endsWith("=") ? 1 : 0;
899
+ const bytes = Math.floor((raw.length * 3) / 4) - padding;
900
+ if (bytes > UPLOAD_MAX_BYTES) {
901
+ throw new CorentApiError(413, { message: `File is ${bytes} bytes; the upload limit is 25 MB.` });
902
+ }
903
+ return corent("/v1/uploads", {
904
+ method: "POST",
905
+ body: JSON.stringify({ data_base64: raw, content_type, filename }),
906
+ });
907
+ }));
908
+ registerPrompts(server);
737
909
  return server;
738
910
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "corent-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",