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 +51 -9
- package/dist/http-app.js +227 -0
- package/dist/http.js +12 -198
- package/dist/prompts.js +123 -0
- package/dist/server.js +194 -22
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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).
|
package/dist/http-app.js
ADDED
|
@@ -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 {
|
|
24
|
-
import {
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
});
|
package/dist/prompts.js
ADDED
|
@@ -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
|
|
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.
|
|
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:
|
|
244
|
-
description: "Generate an image from a text prompt. Returns a permanent public URL. Synchronous
|
|
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:
|
|
324
|
-
description: "Start
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
659
|
-
description: "Check
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
707
|
-
description: "Describe what you want in plain language
|
|
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