corent-mcp 0.2.1 → 0.3.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 +25 -0
- package/dist/http.js +147 -0
- package/dist/index.js +7 -79
- package/dist/server.js +224 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,31 @@ Get an API key at [corent.tech](https://corent.tech), then add to your MCP clien
|
|
|
38
38
|
|
|
39
39
|
For Claude Code: `claude mcp add corent -e CORENT_API_KEY=co_live_... -- npx -y corent-mcp`
|
|
40
40
|
|
|
41
|
+
### Hosted server (no install)
|
|
42
|
+
|
|
43
|
+
The hosted server at `https://mcp.corent.tech/mcp` uses the Streamable HTTP
|
|
44
|
+
transport. Pass your API key in the **Authorization header** — this is the way
|
|
45
|
+
to authenticate:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"corent": {
|
|
51
|
+
"url": "https://mcp.corent.tech/mcp",
|
|
52
|
+
"headers": { "Authorization": "Bearer co_live_..." }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`X-Corent-Api-Key: co_live_...` is accepted as an equivalent header.
|
|
59
|
+
|
|
60
|
+
> **Smithery note:** for compatibility with Smithery, the hosted server also
|
|
61
|
+
> accepts the key via query parameters (`?corent_api_key=...` or Smithery's
|
|
62
|
+
> base64 `?config=`). Avoid this form anywhere else — URLs can be logged by
|
|
63
|
+
> proxies, gateways, and access logs, which would expose your key. Prefer the
|
|
64
|
+
> Authorization header.
|
|
65
|
+
|
|
41
66
|
## Development
|
|
42
67
|
|
|
43
68
|
```sh
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Corent MCP server — hosted HTTP entrypoint (Streamable HTTP transport).
|
|
4
|
+
*
|
|
5
|
+
* This is the deployed, "remote" form of the server (e.g. mcp.corent.tech/mcp).
|
|
6
|
+
* Unlike the stdio entrypoint, the API key is NOT read from the environment —
|
|
7
|
+
* it is resolved PER REQUEST from the caller, so many different users can share
|
|
8
|
+
* one hosted instance, each billed to their own Corent account.
|
|
9
|
+
*
|
|
10
|
+
* Key is accepted from any of (checked in this order):
|
|
11
|
+
* - Authorization: Bearer <key>
|
|
12
|
+
* - X-Corent-Api-Key: <key>
|
|
13
|
+
* - ?corent_api_key= / ?corentApiKey= / ?api_key= / ?apiKey=
|
|
14
|
+
* - ?config=<base64 JSON> with { corentApiKey } (Smithery passes config this way)
|
|
15
|
+
*
|
|
16
|
+
* Stateless mode: a fresh server + transport is created for each request, so
|
|
17
|
+
* there is no cross-user session state to leak.
|
|
18
|
+
*/
|
|
19
|
+
import http from "node:http";
|
|
20
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
21
|
+
import { createCorentServer, SERVER_VERSION } from "./server.js";
|
|
22
|
+
const PORT = Number(process.env.PORT ?? 8080);
|
|
23
|
+
const API_URL = process.env.CORENT_API_URL ?? "https://api.corent.tech";
|
|
24
|
+
function extractApiKey(req, url) {
|
|
25
|
+
const auth = req.headers["authorization"];
|
|
26
|
+
if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
|
|
27
|
+
const v = auth.slice(7).trim();
|
|
28
|
+
if (v)
|
|
29
|
+
return v;
|
|
30
|
+
}
|
|
31
|
+
const header = req.headers["x-corent-api-key"];
|
|
32
|
+
if (typeof header === "string" && header.trim())
|
|
33
|
+
return header.trim();
|
|
34
|
+
for (const k of ["corent_api_key", "corentApiKey", "api_key", "apiKey"]) {
|
|
35
|
+
const v = url.searchParams.get(k);
|
|
36
|
+
if (v)
|
|
37
|
+
return v;
|
|
38
|
+
}
|
|
39
|
+
// Smithery encodes the configSchema object as base64 JSON in ?config=
|
|
40
|
+
const cfg = url.searchParams.get("config");
|
|
41
|
+
if (cfg) {
|
|
42
|
+
try {
|
|
43
|
+
const decoded = JSON.parse(Buffer.from(cfg, "base64").toString("utf8"));
|
|
44
|
+
if (decoded && typeof decoded.corentApiKey === "string")
|
|
45
|
+
return decoded.corentApiKey;
|
|
46
|
+
if (decoded && typeof decoded.CORENT_API_KEY === "string")
|
|
47
|
+
return decoded.CORENT_API_KEY;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* ignore malformed config */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
// Query params may carry the caller's API key (Smithery compatibility), so a
|
|
56
|
+
// raw req.url must NEVER be logged. Any request-level logging — now or added
|
|
57
|
+
// later — MUST go through this helper, which strips every param that can
|
|
58
|
+
// carry a key.
|
|
59
|
+
const SENSITIVE_PARAMS = ["corent_api_key", "corentApiKey", "api_key", "apiKey", "config"];
|
|
60
|
+
function redactedPath(url) {
|
|
61
|
+
const clean = new URL(url.toString());
|
|
62
|
+
for (const k of SENSITIVE_PARAMS) {
|
|
63
|
+
if (clean.searchParams.has(k))
|
|
64
|
+
clean.searchParams.set(k, "[redacted]");
|
|
65
|
+
}
|
|
66
|
+
return clean.pathname + clean.search;
|
|
67
|
+
}
|
|
68
|
+
function setCors(res) {
|
|
69
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
70
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
71
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Corent-Api-Key, Mcp-Session-Id, Mcp-Protocol-Version");
|
|
72
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
73
|
+
}
|
|
74
|
+
function readBody(req) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
let data = "";
|
|
77
|
+
req.on("data", (chunk) => {
|
|
78
|
+
data += chunk;
|
|
79
|
+
if (data.length > 4_000_000)
|
|
80
|
+
reject(new Error("body too large"));
|
|
81
|
+
});
|
|
82
|
+
req.on("end", () => {
|
|
83
|
+
if (!data)
|
|
84
|
+
return resolve(undefined);
|
|
85
|
+
try {
|
|
86
|
+
resolve(JSON.parse(data));
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
reject(e);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
req.on("error", reject);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const server = http.createServer(async (req, res) => {
|
|
96
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
97
|
+
setCors(res);
|
|
98
|
+
if (req.method === "OPTIONS") {
|
|
99
|
+
res.writeHead(204);
|
|
100
|
+
res.end();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Health check — used by Fly and easy to eyeball.
|
|
104
|
+
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) {
|
|
105
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
106
|
+
res.end(JSON.stringify({
|
|
107
|
+
status: "ok",
|
|
108
|
+
service: "corent-mcp",
|
|
109
|
+
transport: "streamable-http",
|
|
110
|
+
version: SERVER_VERSION,
|
|
111
|
+
endpoint: "/mcp",
|
|
112
|
+
}));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (url.pathname !== "/mcp") {
|
|
116
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
117
|
+
res.end(JSON.stringify({ error: "not found; MCP endpoint is /mcp" }));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const apiKey = extractApiKey(req, url);
|
|
121
|
+
const mcp = createCorentServer({ apiKey, apiUrl: API_URL });
|
|
122
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
123
|
+
res.on("close", () => {
|
|
124
|
+
transport.close().catch(() => { });
|
|
125
|
+
mcp.close().catch(() => { });
|
|
126
|
+
});
|
|
127
|
+
try {
|
|
128
|
+
await mcp.connect(transport);
|
|
129
|
+
const body = req.method === "POST" ? await readBody(req) : undefined;
|
|
130
|
+
await transport.handleRequest(req, res, body);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
// Redacted path only — never req.url, which may carry an API key.
|
|
134
|
+
console.error(`request failed: ${req.method} ${redactedPath(url)}:`, err instanceof Error ? err.message : err);
|
|
135
|
+
if (!res.headersSent) {
|
|
136
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
137
|
+
res.end(JSON.stringify({
|
|
138
|
+
jsonrpc: "2.0",
|
|
139
|
+
error: { code: -32603, message: err instanceof Error ? err.message : "Internal server error" },
|
|
140
|
+
id: null,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
server.listen(PORT, () => {
|
|
146
|
+
console.error(`corent-mcp (streamable-http) v${SERVER_VERSION} listening on :${PORT}${" -> /mcp"}`);
|
|
147
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -1,89 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Corent MCP server —
|
|
4
|
-
* (https://corent.tech) as tools any
|
|
3
|
+
* Corent MCP server — stdio entrypoint (for local `npx corent-mcp` use).
|
|
4
|
+
* Exposes the Corent media generation API (https://corent.tech) as tools any
|
|
5
|
+
* MCP-compatible agent can call. The tool definitions live in ./server.ts.
|
|
5
6
|
*
|
|
6
7
|
* Auth: set CORENT_API_KEY in the environment.
|
|
7
8
|
* Optional: CORENT_API_URL to point at a different API host.
|
|
8
9
|
*/
|
|
9
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
10
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
-
import {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
console.error("CORENT_API_KEY environment variable is required. Get a key at https://corent.tech");
|
|
16
|
-
process.exit(1);
|
|
17
|
-
}
|
|
18
|
-
async function corent(path, init) {
|
|
19
|
-
const res = await fetch(`${API_URL}${path}`, {
|
|
20
|
-
...init,
|
|
21
|
-
headers: {
|
|
22
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
23
|
-
"Content-Type": "application/json",
|
|
24
|
-
...init?.headers,
|
|
25
|
-
},
|
|
26
|
-
});
|
|
27
|
-
const body = await res.json().catch(() => ({}));
|
|
28
|
-
if (!res.ok) {
|
|
29
|
-
throw new Error(`Corent API ${res.status}: ${JSON.stringify(body.detail ?? body)}`);
|
|
30
|
-
}
|
|
31
|
-
return body;
|
|
32
|
-
}
|
|
33
|
-
function textResult(data) {
|
|
34
|
-
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
35
|
-
}
|
|
36
|
-
const server = new McpServer({ name: "corent", version: "0.2.1" });
|
|
37
|
-
server.tool("generate_image", "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.", {
|
|
38
|
-
prompt: z.string().describe("What to generate, in plain language"),
|
|
39
|
-
preference: z
|
|
40
|
-
.enum(["fast", "cheap", "quality", "balanced"])
|
|
41
|
-
.default("balanced")
|
|
42
|
-
.describe("What to optimize for; Corent picks the best model tier automatically"),
|
|
43
|
-
style: z
|
|
44
|
-
.enum(["photorealistic", "artistic", "anime", "logo", "text_focused"])
|
|
45
|
-
.optional()
|
|
46
|
-
.describe("Optional content style hint"),
|
|
47
|
-
aspect_ratio: z.enum(["1:1", "16:9", "9:16", "4:3", "3:4"]).default("1:1"),
|
|
48
|
-
}, async ({ prompt, preference, style, aspect_ratio }) => {
|
|
49
|
-
const data = await corent("/v1/images/generate", {
|
|
50
|
-
method: "POST",
|
|
51
|
-
body: JSON.stringify({ prompt, preference, style, aspect_ratio }),
|
|
52
|
-
});
|
|
53
|
-
return textResult(data);
|
|
11
|
+
import { createCorentServer } from "./server.js";
|
|
12
|
+
const server = createCorentServer({
|
|
13
|
+
apiKey: process.env.CORENT_API_KEY,
|
|
14
|
+
apiUrl: process.env.CORENT_API_URL,
|
|
54
15
|
});
|
|
55
|
-
server.tool("generate_video", "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).", {
|
|
56
|
-
prompt: z.string().describe("What to generate, in plain language"),
|
|
57
|
-
preference: z.enum(["fast", "cheap", "quality", "balanced"]).default("balanced"),
|
|
58
|
-
aspect_ratio: z.enum(["16:9", "9:16", "1:1"]).default("16:9"),
|
|
59
|
-
duration_s: z.number().int().min(1).max(30).optional().describe("Requested duration in seconds"),
|
|
60
|
-
image_url: z.string().url().optional().describe("If set, animates this image instead of pure text-to-video"),
|
|
61
|
-
}, async ({ prompt, preference, aspect_ratio, duration_s, image_url }) => {
|
|
62
|
-
const data = await corent("/v1/videos/generate", {
|
|
63
|
-
method: "POST",
|
|
64
|
-
body: JSON.stringify({ prompt, preference, aspect_ratio, duration_s, image_url }),
|
|
65
|
-
});
|
|
66
|
-
return textResult(data);
|
|
67
|
-
});
|
|
68
|
-
server.tool("get_job", "Check the status of a generation job (mainly videos). When completed, the response includes the permanent media URL and the cost.", { job_id: z.string().describe("The job id returned by generate_video or generate_image") }, async ({ job_id }) => textResult(await corent(`/v1/jobs/${job_id}`)));
|
|
69
|
-
server.tool("get_balance", "Get the Corent account's remaining balance in cents. Useful before starting expensive video generations.", {}, async () => textResult(await corent("/v1/account/balance")));
|
|
70
|
-
server.tool("get_status", "Get live operational status of Corent's generation tiers (operational/degraded). No auth cost; useful to pick a healthy tier.", {}, async () => textResult(await corent("/v1/status")));
|
|
71
|
-
// --- Intent tools: Corent's differentiator. Describe what you want in plain
|
|
72
|
-
// language; Corent decides image-vs-video, tier, aspect ratio, and cleans the
|
|
73
|
-
// prompt. The agent never picks a model or tier. ---
|
|
74
|
-
server.tool("plan", "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. If the request is something Corent can't generate (audio, text, 3D, editing, real-world actions), can_fulfill is false with a reason.", {
|
|
75
|
-
intent: z.string().describe("What you want, in natural language, e.g. 'a 10s vertical clip of a sunrise for TikTok'"),
|
|
76
|
-
}, async ({ intent }) => textResult(await corent("/v1/intent", { method: "POST", body: JSON.stringify({ intent }) })));
|
|
77
|
-
server.tool("create", "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.", {
|
|
78
|
-
intent: z.string().describe("What you want, in natural language"),
|
|
79
|
-
max_cost_cents: z
|
|
80
|
-
.number()
|
|
81
|
-
.int()
|
|
82
|
-
.min(1)
|
|
83
|
-
.describe("Spend ceiling in cents for this one generation. Corent refuses if the estimate is higher."),
|
|
84
|
-
}, async ({ intent, max_cost_cents }) => textResult(await corent("/v1/intent/execute", {
|
|
85
|
-
method: "POST",
|
|
86
|
-
body: JSON.stringify({ intent, confirm_estimated_cost_cents_up_to: max_cost_cents }),
|
|
87
|
-
})));
|
|
88
16
|
const transport = new StdioServerTransport();
|
|
89
17
|
await server.connect(transport);
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Corent MCP server definition — the 7 tools, used by both the
|
|
3
|
+
* stdio entrypoint (index.ts, for local `npx` use) and the hosted HTTP
|
|
4
|
+
* entrypoint (http.ts). Keeping the tools in one place means the two
|
|
5
|
+
* transports can never drift apart.
|
|
6
|
+
*
|
|
7
|
+
* Agent-native hardening (v0.3.0):
|
|
8
|
+
* - Tool annotations (readOnlyHint etc), so MCP clients can skip confirmation
|
|
9
|
+
* prompts on read-only tools while still confirming the money-spending ones.
|
|
10
|
+
* - structuredContent + outputSchema on every tool, so agents consume typed
|
|
11
|
+
* objects instead of regex-parsing a JSON string out of a text block.
|
|
12
|
+
* - Machine-readable error codes (insufficient_balance, prompt_not_allowed,
|
|
13
|
+
* rate_limited, ...) so agents can branch on failures programmatically.
|
|
14
|
+
* Tool NAMES are unchanged — existing client configs keep working.
|
|
15
|
+
*/
|
|
16
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
export const DEFAULT_API_URL = "https://api.corent.tech";
|
|
19
|
+
export const SERVER_VERSION = "0.3.0";
|
|
20
|
+
class CorentApiError extends Error {
|
|
21
|
+
status;
|
|
22
|
+
detail;
|
|
23
|
+
constructor(status, detail) {
|
|
24
|
+
super(`Corent API ${status}: ${JSON.stringify(detail)}`);
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.detail = detail;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Map an API failure to a stable, machine-readable error code. */
|
|
30
|
+
function errorCode(err) {
|
|
31
|
+
const detail = JSON.stringify(err.detail ?? "").toLowerCase();
|
|
32
|
+
if (err.status === 401)
|
|
33
|
+
return "invalid_api_key";
|
|
34
|
+
if (err.status === 402)
|
|
35
|
+
return "insufficient_balance";
|
|
36
|
+
if (err.status === 422 && detail.includes("content"))
|
|
37
|
+
return "prompt_not_allowed";
|
|
38
|
+
if (err.status === 422)
|
|
39
|
+
return "invalid_request";
|
|
40
|
+
if (err.status === 409 || detail.includes("estimate"))
|
|
41
|
+
return "estimate_exceeds_ceiling";
|
|
42
|
+
if (err.status === 429)
|
|
43
|
+
return "rate_limited";
|
|
44
|
+
if (err.status === 503)
|
|
45
|
+
return "no_provider_available";
|
|
46
|
+
if (err.status === 502)
|
|
47
|
+
return "generation_failed";
|
|
48
|
+
return "api_error";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build a fully-configured Corent MCP server. The API key is resolved per
|
|
52
|
+
* instance, so the HTTP transport can create one server per request with that
|
|
53
|
+
* request's key, while stdio creates a single server from the environment.
|
|
54
|
+
*/
|
|
55
|
+
export function createCorentServer(config = {}) {
|
|
56
|
+
const apiUrl = config.apiUrl ?? DEFAULT_API_URL;
|
|
57
|
+
const apiKey = config.apiKey;
|
|
58
|
+
async function corent(path, init) {
|
|
59
|
+
// Key is enforced here (at call time) rather than at startup, so the server
|
|
60
|
+
// can advertise its tools to catalogs/agents without a key configured.
|
|
61
|
+
if (!apiKey) {
|
|
62
|
+
throw new CorentApiError(401, {
|
|
63
|
+
message: "CORENT_API_KEY is not set. Get a key at https://corent.tech and add it to your MCP client config.",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const res = await fetch(`${apiUrl}${path}`, {
|
|
67
|
+
...init,
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bearer ${apiKey}`,
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
...init?.headers,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const body = await res.json().catch(() => ({}));
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
throw new CorentApiError(res.status, body.detail ?? body);
|
|
77
|
+
}
|
|
78
|
+
return body;
|
|
79
|
+
}
|
|
80
|
+
/** Success: typed structuredContent plus a text fallback for older clients. */
|
|
81
|
+
function ok(data) {
|
|
82
|
+
return {
|
|
83
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
84
|
+
structuredContent: data,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Failure: isError with a stable code an agent can branch on. */
|
|
88
|
+
function fail(err) {
|
|
89
|
+
const payload = err instanceof CorentApiError
|
|
90
|
+
? { error: errorCode(err), status: err.status, message: err.detail }
|
|
91
|
+
: { error: "api_error", message: String(err) };
|
|
92
|
+
return {
|
|
93
|
+
isError: true,
|
|
94
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function wrap(fn) {
|
|
98
|
+
return async (args) => {
|
|
99
|
+
try {
|
|
100
|
+
return ok(await fn(args));
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
return fail(err);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const server = new McpServer({ name: "corent", version: SERVER_VERSION });
|
|
108
|
+
// Loose output shapes: every field optional so an additive API change can
|
|
109
|
+
// never break a client's schema validation.
|
|
110
|
+
const mediaMeta = z.object({ model: z.string().optional(), cost_cents: z.number().optional() }).passthrough();
|
|
111
|
+
const imageOutput = {
|
|
112
|
+
id: z.string().optional(),
|
|
113
|
+
status: z.string().optional(),
|
|
114
|
+
images: z.array(z.object({ url: z.string() }).passthrough()).optional(),
|
|
115
|
+
meta: mediaMeta.optional(),
|
|
116
|
+
};
|
|
117
|
+
const jobOutput = {
|
|
118
|
+
id: z.string().optional(),
|
|
119
|
+
status: z.string().optional(),
|
|
120
|
+
images: z.array(z.object({ url: z.string() }).passthrough()).optional(),
|
|
121
|
+
videos: z.array(z.object({ url: z.string() }).passthrough()).optional(),
|
|
122
|
+
meta: mediaMeta.optional(),
|
|
123
|
+
error: z.string().optional(),
|
|
124
|
+
};
|
|
125
|
+
server.registerTool("generate_image", {
|
|
126
|
+
title: "Generate image",
|
|
127
|
+
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.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
prompt: z.string().describe("What to generate, in plain language"),
|
|
130
|
+
preference: z
|
|
131
|
+
.enum(["fast", "cheap", "quality", "balanced"])
|
|
132
|
+
.default("balanced")
|
|
133
|
+
.describe("What to optimize for; Corent picks the best model tier automatically"),
|
|
134
|
+
style: z
|
|
135
|
+
.enum(["photorealistic", "artistic", "anime", "logo", "text_focused"])
|
|
136
|
+
.optional()
|
|
137
|
+
.describe("Optional content style hint"),
|
|
138
|
+
aspect_ratio: z.enum(["1:1", "16:9", "9:16", "4:3", "3:4"]).default("1:1"),
|
|
139
|
+
},
|
|
140
|
+
outputSchema: imageOutput,
|
|
141
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
142
|
+
}, wrap(async ({ prompt, preference, style, aspect_ratio }) => corent("/v1/images/generate", {
|
|
143
|
+
method: "POST",
|
|
144
|
+
body: JSON.stringify({ prompt, preference, style, aspect_ratio }),
|
|
145
|
+
})));
|
|
146
|
+
server.registerTool("generate_video", {
|
|
147
|
+
title: "Generate video",
|
|
148
|
+
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.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
prompt: z.string().describe("What to generate, in plain language"),
|
|
151
|
+
preference: z.enum(["fast", "cheap", "quality", "balanced"]).default("balanced"),
|
|
152
|
+
aspect_ratio: z.enum(["16:9", "9:16", "1:1"]).default("16:9"),
|
|
153
|
+
duration_s: z.number().int().min(1).max(30).optional().describe("Requested duration in seconds"),
|
|
154
|
+
image_url: z.string().url().optional().describe("If set, animates this image instead of pure text-to-video"),
|
|
155
|
+
},
|
|
156
|
+
outputSchema: jobOutput,
|
|
157
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
158
|
+
}, wrap(async ({ prompt, preference, aspect_ratio, duration_s, image_url }) => corent("/v1/videos/generate", {
|
|
159
|
+
method: "POST",
|
|
160
|
+
body: JSON.stringify({ prompt, preference, aspect_ratio, duration_s, image_url }),
|
|
161
|
+
})));
|
|
162
|
+
server.registerTool("get_job", {
|
|
163
|
+
title: "Check job status",
|
|
164
|
+
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.",
|
|
165
|
+
inputSchema: { job_id: z.string().describe("The job id returned by generate_video or generate_image") },
|
|
166
|
+
outputSchema: jobOutput,
|
|
167
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
168
|
+
}, wrap(async ({ job_id }) => corent(`/v1/jobs/${job_id}`)));
|
|
169
|
+
server.registerTool("get_balance", {
|
|
170
|
+
title: "Check account balance",
|
|
171
|
+
description: "Get the Corent account's remaining balance in cents. Useful before starting expensive video generations. Read-only and free.",
|
|
172
|
+
inputSchema: {},
|
|
173
|
+
outputSchema: { balance_cents: z.number().optional() },
|
|
174
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
175
|
+
}, wrap(async () => corent("/v1/account/balance")));
|
|
176
|
+
server.registerTool("get_status", {
|
|
177
|
+
title: "Check service status",
|
|
178
|
+
description: "Get live operational status of Corent's generation tiers (operational/degraded). Read-only and free; useful to pick a healthy tier.",
|
|
179
|
+
inputSchema: {},
|
|
180
|
+
outputSchema: { status: z.string().optional(), tiers: z.record(z.string(), z.any()).optional() },
|
|
181
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
182
|
+
}, wrap(async () => corent("/v1/status")));
|
|
183
|
+
// --- Intent tools: Corent's differentiator. Describe what you want in plain
|
|
184
|
+
// language; Corent decides image-vs-video, tier, aspect ratio, and cleans the
|
|
185
|
+
// prompt. The agent never picks a model or tier. ---
|
|
186
|
+
server.registerTool("plan", {
|
|
187
|
+
title: "Plan (cost preview, no generation)",
|
|
188
|
+
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. If the request is something Corent can't generate (audio, text, 3D, editing, real-world actions), can_fulfill is false with a reason.",
|
|
189
|
+
inputSchema: {
|
|
190
|
+
intent: z
|
|
191
|
+
.string()
|
|
192
|
+
.describe("What you want, in natural language, e.g. 'a 10s vertical clip of a sunrise for TikTok'"),
|
|
193
|
+
},
|
|
194
|
+
outputSchema: {
|
|
195
|
+
plan: z.object({ can_fulfill: z.boolean().optional() }).passthrough().optional(),
|
|
196
|
+
estimated_cost_cents: z.number().nullable().optional(),
|
|
197
|
+
},
|
|
198
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
199
|
+
}, wrap(async ({ intent }) => corent("/v1/intent", { method: "POST", body: JSON.stringify({ intent }) })));
|
|
200
|
+
server.registerTool("create", {
|
|
201
|
+
title: "Create (plan + generate, budget-capped)",
|
|
202
|
+
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.",
|
|
203
|
+
inputSchema: {
|
|
204
|
+
intent: z.string().describe("What you want, in natural language"),
|
|
205
|
+
max_cost_cents: z
|
|
206
|
+
.number()
|
|
207
|
+
.int()
|
|
208
|
+
.min(1)
|
|
209
|
+
.describe("Spend ceiling in cents for this one generation. Corent refuses if the estimate is higher."),
|
|
210
|
+
},
|
|
211
|
+
outputSchema: {
|
|
212
|
+
executed: z.boolean().optional(),
|
|
213
|
+
estimated_cost_cents: z.number().nullable().optional(),
|
|
214
|
+
actual_cost_cents: z.number().nullable().optional(),
|
|
215
|
+
result: z.object({}).passthrough().nullable().optional(),
|
|
216
|
+
message: z.string().nullable().optional(),
|
|
217
|
+
},
|
|
218
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
219
|
+
}, wrap(async ({ intent, max_cost_cents }) => corent("/v1/intent/execute", {
|
|
220
|
+
method: "POST",
|
|
221
|
+
body: JSON.stringify({ intent, confirm_estimated_cost_cents_up_to: max_cost_cents }),
|
|
222
|
+
})));
|
|
223
|
+
return server;
|
|
224
|
+
}
|
package/package.json
CHANGED