corent-mcp 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Corent MCP Server
2
2
 
3
- Give any AI agent the ability to generate images, videos, voice, and text through the [Corent](https://corent.tech) API — one key, automatic model routing, provider fallback built in.
3
+ Give any AI agent the ability to generate images, videos, voice, and text through the [Corent](https://corent.tech) API, one key, automatic model routing, provider fallback built in.
4
4
 
5
5
  ## Tools
6
6
 
@@ -8,18 +8,54 @@ Give any AI agent the ability to generate images, videos, voice, and text throug
8
8
  |---|---|
9
9
  | `plan` | Describe a request in plain language; Corent returns the plan (image vs video, tier, settings) + cost estimate, without generating |
10
10
  | `create` | Describe what you want + a spend ceiling; Corent decides everything and generates it (the "zero decisions" path) |
11
- | `generate_image` | Text → image, synchronous, returns a permanent URL |
11
+ | `generate_image` | Text → image, synchronous, returns a permanent URL. Takes `reference_image_urls` for character and product consistency |
12
12
  | `generate_video` | Text (or image) → video, async job |
13
13
  | `generate_speech` | Text → spoken audio, synchronous |
14
- | `generate_text` | Prompt → text from a frontier language model, synchronous, billed per token |
15
- | `list_models` | The direct-access menu: every model that can be pinned by name, with quality and live status |
14
+ | `generate_text` | Prompt or full conversation → text from a frontier language model. Supports tool calling and JSON mode |
15
+ | `generate_image_batch` | Up to 50 images in one call, async |
16
+ | `generate_video_batch` | Up to 50 clips in one call, async |
17
+ | `get_batch` | Progress of a submitted batch |
18
+ | `list_models` | The direct-access menu: every model that can be pinned by name (`corent-*`), with quality and live status |
19
+ | `list_tiers` | The tier menu: prices, shapes, video resolutions, and which tiers accept reference images or render sound |
20
+ | `list_voices` | The voices `generate_speech` accepts, with previews and descriptors |
21
+ | `cancel_job` | Stop a running job and release its money hold. Costs nothing |
16
22
  | `get_job` | Poll a job until completed |
17
- | `get_balance` | Remaining account balance |
23
+ | `get_balance` | Balance, holds, and what a new request can actually spend |
18
24
  | `get_status` | Live tier health |
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.
19
55
 
20
56
  The **`plan`** and **`create`** tools are the agent-native path: an agent says
21
57
  what it wants ("a 10s vertical clip of a sunrise for TikTok") and Corent picks
22
- image-vs-video, the tier, aspect ratio, and duration — the agent never manages
58
+ image-vs-video, the tier, aspect ratio, and duration, the agent never manages
23
59
  models. `create` enforces a per-call spend ceiling so an autonomous agent can't
24
60
  overspend.
25
61
 
@@ -28,12 +64,66 @@ overspend.
28
64
  All four `generate_*` tools also take an optional **`model`**: pin an exact
29
65
  model from `list_models` and Corent runs that one, never a substitute (if it
30
66
  can't deliver, the call fails and nothing is billed). Use it only when the user
31
- asked for a particular model by name — otherwise omit it and let Corent route
67
+ asked for a particular model by name, otherwise omit it and let Corent route
32
68
  to the best fit for the prompt, which is what the tiers are for.
33
69
 
70
+ Every name in the menu is spelled `corent-*`: `corent-flux-schnell`,
71
+ `corent-seedance-2.0`, `corent-eleven-multilingual-v2`,
72
+ `corent-claude-opus-5`, and that is the name the receipt echoes back. Pass one
73
+ back verbatim.
74
+
34
75
  `generate_text` is the language-model lane: it puts every frontier lab on the
35
76
  same key and the same bill, so an agent can get a named model's answer or a
36
77
  second opinion from another lab without the user holding that lab's account.
78
+ Pass `messages` instead of `prompt` to continue a conversation or feed tool
79
+ results back, and `tools` / `response_format` for function calling and JSON.
80
+
81
+ ### Keeping a character or product consistent
82
+
83
+ `generate_image` takes **`reference_image_urls`**: 1 to 4 public https images
84
+ that the prompt is applied as an *edit* of, so the same face, character, or
85
+ product survives into a new scene. Use it whenever the user wants "the same
86
+ person again", a product placed somewhere, or a matching series. Edit-capable
87
+ models sit at premium and up, so pass `tier` `premium` / `pro` / `max_pro`, or
88
+ no tier at all: `air` and `lite` are refused with a message saying so.
89
+
90
+ ### What the generate tools can ask for
91
+
92
+ `generate_image` takes a `seed` (so a picture can be re-rendered and tweaked),
93
+ a `negative_prompt`, a `source_image_url` with `strength` to start from an
94
+ existing picture, `transparent` for logos and cutouts, an explicit `width` and
95
+ `height`, `output_format`, and `n` for several versions at once. `n` is n real
96
+ renders at full price, so confirm before spending on more than a couple.
97
+
98
+ `generate_video` takes `audio` for native sound (true routes only to models
99
+ that actually render it, so a silent model can never quietly serve the ask),
100
+ `end_image_url` for the frame to finish on, a `camera` move, `negative_prompt`,
101
+ `seed` and `fps`.
102
+
103
+ `generate_speech` takes `stability`, `similarity`, `style`, `speed` and
104
+ `language`. Call `list_voices` first whenever the user wants a particular
105
+ sounding narrator: `voice_id` cannot be guessed.
106
+
107
+ Anything the chosen model could not honour comes back in
108
+ `meta.unsupported_options`, so an ignored setting never reads as an applied
109
+ one. `enhance_prompt: false` sends the user's wording verbatim.
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
+
121
+ ### Spending safety
122
+
123
+ Every money-spending call carries an `Idempotency-Key`, and a 429 or 5xx is
124
+ retried under that same key, so a retry replays the original job instead of
125
+ buying a second one. Batches bill per item: a 30-item image batch costs 30
126
+ generations, so check `get_balance` first.
37
127
 
38
128
  ## Setup
39
129
 
@@ -57,7 +147,7 @@ For Claude Code: `claude mcp add corent -e CORENT_API_KEY=co_live_... -- npx -y
57
147
 
58
148
  The hosted server at `https://mcp.corent.tech/mcp` uses the Streamable HTTP
59
149
  transport and supports **OAuth**: in claude.ai (Settings → Connectors → Add
60
- custom connector) just enter the URL with no key — you'll be sent to a Corent
150
+ custom connector) just enter the URL with no key, you'll be sent to a Corent
61
151
  page to approve access. Your API key is verified once, sealed into an encrypted
62
152
  connection token, and never shown to the client. Revoking the key in your
63
153
  dashboard disconnects the client instantly.
@@ -79,7 +169,7 @@ Non-OAuth clients can instead pass an API key in the **Authorization header**:
79
169
 
80
170
  > **Smithery note:** for compatibility with Smithery, the hosted server also
81
171
  > accepts the key via query parameters (`?corent_api_key=...` or Smithery's
82
- > base64 `?config=`). Avoid this form anywhere else — URLs can be logged by
172
+ > base64 `?config=`). Avoid this form anywhere else, URLs can be logged by
83
173
  > proxies, gateways, and access logs, which would expose your key. Prefer the
84
174
  > Authorization header.
85
175
 
@@ -92,5 +182,7 @@ CORENT_API_KEY=... node dist/index.js
92
182
  ```
93
183
 
94
184
  The hosted entrypoint (`dist/http.js`) additionally needs `MCP_TOKEN_SECRET`
95
- (random string; encrypts OAuth tokens — rotating it logs every connector out)
185
+ (random string; encrypts OAuth tokens, rotating it logs every connector out)
96
186
  and optionally `MCP_PUBLIC_URL` (defaults to `https://mcp.corent.tech`).
187
+ Set `OPENAI_APPS_CHALLENGE_TOKEN` to serve the ChatGPT Apps domain
188
+ verification at `/.well-known/openai-apps-challenge` (404 while unset).
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The hosted server's request handler, split out of http.ts so tests can mount
3
+ * it on an ephemeral port. Everything about auth, OAuth and the /mcp endpoint
4
+ * is unchanged from the original entrypoint; see http.ts for the overview.
5
+ */
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { createCorentServer, SERVER_VERSION } from "./server.js";
8
+ import { handleOauth, isOauthToken, apiKeyFromAccessToken } from "./oauth.js";
9
+ // upload_image carries a whole file as base64 inside one JSON-RPC call: a
10
+ // 25 MB file is ~34 MB of base64. Below this cap the hosted transport would
11
+ // refuse an upload the API itself accepts (the previous 4 MB cap predates
12
+ // uploads). Anything larger is still cut off before it is buffered whole.
13
+ const MAX_BODY_BYTES = 36_000_000;
14
+ function extractApiKey(req, url) {
15
+ const auth = req.headers["authorization"];
16
+ if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
17
+ const v = auth.slice(7).trim();
18
+ // OAuth access tokens (coa_...) wrap the real API key; anything else is
19
+ // treated as a raw Corent key. Expired/forged tokens resolve to nothing,
20
+ // which falls through to the 401 + WWW-Authenticate below.
21
+ if (v && isOauthToken(v))
22
+ return apiKeyFromAccessToken(v) ?? undefined;
23
+ if (v)
24
+ return v;
25
+ }
26
+ const header = req.headers["x-corent-api-key"];
27
+ if (typeof header === "string" && header.trim())
28
+ return header.trim();
29
+ for (const k of ["corent_api_key", "corentApiKey", "api_key", "apiKey"]) {
30
+ const v = url.searchParams.get(k);
31
+ if (v)
32
+ return v;
33
+ }
34
+ // Smithery encodes the configSchema object as base64 JSON in ?config=
35
+ const cfg = url.searchParams.get("config");
36
+ if (cfg) {
37
+ try {
38
+ const decoded = JSON.parse(Buffer.from(cfg, "base64").toString("utf8"));
39
+ if (decoded && typeof decoded.corentApiKey === "string")
40
+ return decoded.corentApiKey;
41
+ if (decoded && typeof decoded.CORENT_API_KEY === "string")
42
+ return decoded.CORENT_API_KEY;
43
+ }
44
+ catch {
45
+ /* ignore malformed config */
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+ // Query params may carry the caller's API key (Smithery compatibility), so a
51
+ // raw req.url must NEVER be logged. Any request-level logging — now or added
52
+ // later — MUST go through this helper, which strips every param that can
53
+ // carry a key.
54
+ const SENSITIVE_PARAMS = ["corent_api_key", "corentApiKey", "api_key", "apiKey", "config"];
55
+ function redactedPath(url) {
56
+ const clean = new URL(url.toString());
57
+ for (const k of SENSITIVE_PARAMS) {
58
+ if (clean.searchParams.has(k))
59
+ clean.searchParams.set(k, "[redacted]");
60
+ }
61
+ return clean.pathname + clean.search;
62
+ }
63
+ function setCors(res) {
64
+ res.setHeader("Access-Control-Allow-Origin", "*");
65
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
66
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Corent-Api-Key, Mcp-Session-Id, Mcp-Protocol-Version");
67
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
68
+ }
69
+ function readBody(req) {
70
+ return new Promise((resolve, reject) => {
71
+ let data = "";
72
+ req.on("data", (chunk) => {
73
+ data += chunk;
74
+ if (data.length > MAX_BODY_BYTES)
75
+ reject(new Error("body too large"));
76
+ });
77
+ req.on("end", () => {
78
+ if (!data)
79
+ return resolve(undefined);
80
+ try {
81
+ resolve(JSON.parse(data));
82
+ }
83
+ catch (e) {
84
+ reject(e);
85
+ }
86
+ });
87
+ req.on("error", reject);
88
+ });
89
+ }
90
+ // Method-level request logging for diagnosing client behavior (which methods a
91
+ // host calls, whether it advertises the MCP Apps ui extension). Never logs
92
+ // prompts, arguments, keys, or results.
93
+ function logMcpRequest(body) {
94
+ for (const msg of Array.isArray(body) ? body : [body]) {
95
+ const m = msg;
96
+ if (!m || typeof m.method !== "string")
97
+ continue;
98
+ if (m.method === "initialize") {
99
+ const client = m.params?.clientInfo?.name ?? "unknown";
100
+ const uiCap = m.params?.capabilities?.extensions?.["io.modelcontextprotocol/ui"];
101
+ console.error(`[mcp] initialize from ${client} v${m.params?.clientInfo?.version ?? "?"} proto=${m.params?.protocolVersion} ui-extension=${uiCap ? JSON.stringify(uiCap) : "NOT advertised"}`);
102
+ }
103
+ else if (m.method === "resources/read") {
104
+ console.error(`[mcp] resources/read ${m.params?.uri ?? "?"}`);
105
+ }
106
+ else if (m.method === "tools/call") {
107
+ console.error(`[mcp] tools/call ${m.params?.name ?? "?"}`);
108
+ }
109
+ else if (m.method === "resources/list" || m.method === "tools/list") {
110
+ console.error(`[mcp] ${m.method}`);
111
+ }
112
+ }
113
+ }
114
+ export function createHttpApp(config) {
115
+ const API_URL = config.apiUrl;
116
+ const PUBLIC_URL = config.publicUrl;
117
+ return async (req, res) => {
118
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
119
+ setCors(res);
120
+ // ChatGPT Apps domain verification: OpenAI fetches this path and expects the
121
+ // plain-text token from the developer console. Unset token = 404, so nothing
122
+ // is ever served by accident. Auth-free by design; the token is not a secret.
123
+ if (req.method === "GET" && url.pathname === "/.well-known/openai-apps-challenge") {
124
+ const token = process.env.OPENAI_APPS_CHALLENGE_TOKEN?.trim();
125
+ if (!token) {
126
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
127
+ res.end("not found");
128
+ return;
129
+ }
130
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
131
+ res.end(token);
132
+ return;
133
+ }
134
+ if (req.method === "OPTIONS") {
135
+ res.writeHead(204);
136
+ res.end();
137
+ return;
138
+ }
139
+ // Favicon: some clients derive a connector icon from the server origin.
140
+ if (req.method === "GET" && url.pathname === "/favicon.ico") {
141
+ res.writeHead(302, {
142
+ Location: "https://corent.tech/brand/intro-mark.png",
143
+ "Cache-Control": "public, max-age=86400",
144
+ });
145
+ res.end();
146
+ return;
147
+ }
148
+ // Health check — used by Fly and easy to eyeball.
149
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) {
150
+ res.writeHead(200, { "Content-Type": "application/json" });
151
+ res.end(JSON.stringify({
152
+ status: "ok",
153
+ service: "corent-mcp",
154
+ transport: "streamable-http",
155
+ version: SERVER_VERSION,
156
+ endpoint: "/mcp",
157
+ }));
158
+ return;
159
+ }
160
+ // OAuth endpoints (discovery, registration, consent page, token exchange).
161
+ try {
162
+ if (await handleOauth(req, res, url, {
163
+ publicUrl: PUBLIC_URL,
164
+ apiUrl: API_URL,
165
+ supabaseUrl: config.supabaseUrl,
166
+ supabaseAnonKey: config.supabaseAnonKey,
167
+ }))
168
+ return;
169
+ }
170
+ catch (err) {
171
+ console.error(`oauth request failed: ${req.method} ${url.pathname}:`, err instanceof Error ? err.message : err);
172
+ if (!res.headersSent) {
173
+ res.writeHead(500, { "Content-Type": "application/json" });
174
+ res.end(JSON.stringify({ error: "server_error" }));
175
+ }
176
+ return;
177
+ }
178
+ if (url.pathname !== "/mcp") {
179
+ res.writeHead(404, { "Content-Type": "application/json" });
180
+ res.end(JSON.stringify({ error: "not found; MCP endpoint is /mcp" }));
181
+ return;
182
+ }
183
+ const apiKey = extractApiKey(req, url);
184
+ // No credentials -> standard OAuth challenge (RFC 9728). This is what makes
185
+ // claude.ai (and other remote MCP clients) open the Connect flow instead of
186
+ // silently failing tool calls later.
187
+ if (!apiKey) {
188
+ // Distinguish "no credentials at all" from "OAuth token present but bad".
189
+ const auth = req.headers["authorization"];
190
+ const hadOauthToken = typeof auth === "string" && isOauthToken(auth.replace(/^[Bb]earer\s+/, ""));
191
+ console.error(`[mcp] 401: ${hadOauthToken ? "oauth token invalid/expired" : "no credentials"}`);
192
+ res.writeHead(401, {
193
+ "Content-Type": "application/json",
194
+ "WWW-Authenticate": `Bearer resource_metadata="${PUBLIC_URL}/.well-known/oauth-protected-resource"`,
195
+ });
196
+ res.end(JSON.stringify({
197
+ error: "unauthorized",
198
+ error_description: "Authenticate via OAuth (connect this server in your MCP client), or pass a Corent API key as 'Authorization: Bearer co_live_...'. Keys: https://corent.tech/dashboard/api-keys",
199
+ }));
200
+ return;
201
+ }
202
+ const mcp = createCorentServer({ apiKey, apiUrl: API_URL });
203
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
204
+ res.on("close", () => {
205
+ transport.close().catch(() => { });
206
+ mcp.close().catch(() => { });
207
+ });
208
+ try {
209
+ await mcp.connect(transport);
210
+ const body = req.method === "POST" ? await readBody(req) : undefined;
211
+ logMcpRequest(body);
212
+ await transport.handleRequest(req, res, body);
213
+ }
214
+ catch (err) {
215
+ // Redacted path only — never req.url, which may carry an API key.
216
+ console.error(`request failed: ${req.method} ${redactedPath(url)}:`, err instanceof Error ? err.message : err);
217
+ if (!res.headersSent) {
218
+ res.writeHead(500, { "Content-Type": "application/json" });
219
+ res.end(JSON.stringify({
220
+ jsonrpc: "2.0",
221
+ error: { code: -32603, message: err instanceof Error ? err.message : "Internal server error" },
222
+ id: null,
223
+ }));
224
+ }
225
+ }
226
+ };
227
+ }
package/dist/http.js CHANGED
@@ -18,207 +18,21 @@
18
18
  *
19
19
  * Stateless mode: a fresh server + transport is created for each request, so
20
20
  * there is no cross-user session state to leak.
21
+ *
22
+ * The request handler itself lives in http-app.ts (exported, so tests can
23
+ * drive it on an ephemeral port); this file only reads the environment and
24
+ * listens. `dist/http.js` stays the deployed entrypoint.
21
25
  */
22
26
  import http from "node:http";
23
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
24
- import { createCorentServer, SERVER_VERSION } from "./server.js";
25
- import { handleOauth, isOauthToken, apiKeyFromAccessToken } from "./oauth.js";
27
+ import { createHttpApp } from "./http-app.js";
28
+ import { SERVER_VERSION } from "./server.js";
26
29
  const PORT = Number(process.env.PORT ?? 8080);
27
- const API_URL = process.env.CORENT_API_URL ?? "https://api.corent.tech";
28
- const PUBLIC_URL = process.env.MCP_PUBLIC_URL ?? "https://mcp.corent.tech";
29
- function extractApiKey(req, url) {
30
- const auth = req.headers["authorization"];
31
- if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
32
- const v = auth.slice(7).trim();
33
- // OAuth access tokens (coa_...) wrap the real API key; anything else is
34
- // treated as a raw Corent key. Expired/forged tokens resolve to nothing,
35
- // which falls through to the 401 + WWW-Authenticate below.
36
- if (v && isOauthToken(v))
37
- return apiKeyFromAccessToken(v) ?? undefined;
38
- if (v)
39
- return v;
40
- }
41
- const header = req.headers["x-corent-api-key"];
42
- if (typeof header === "string" && header.trim())
43
- return header.trim();
44
- for (const k of ["corent_api_key", "corentApiKey", "api_key", "apiKey"]) {
45
- const v = url.searchParams.get(k);
46
- if (v)
47
- return v;
48
- }
49
- // Smithery encodes the configSchema object as base64 JSON in ?config=
50
- const cfg = url.searchParams.get("config");
51
- if (cfg) {
52
- try {
53
- const decoded = JSON.parse(Buffer.from(cfg, "base64").toString("utf8"));
54
- if (decoded && typeof decoded.corentApiKey === "string")
55
- return decoded.corentApiKey;
56
- if (decoded && typeof decoded.CORENT_API_KEY === "string")
57
- return decoded.CORENT_API_KEY;
58
- }
59
- catch {
60
- /* ignore malformed config */
61
- }
62
- }
63
- return undefined;
64
- }
65
- // Query params may carry the caller's API key (Smithery compatibility), so a
66
- // raw req.url must NEVER be logged. Any request-level logging — now or added
67
- // later — MUST go through this helper, which strips every param that can
68
- // carry a key.
69
- const SENSITIVE_PARAMS = ["corent_api_key", "corentApiKey", "api_key", "apiKey", "config"];
70
- function redactedPath(url) {
71
- const clean = new URL(url.toString());
72
- for (const k of SENSITIVE_PARAMS) {
73
- if (clean.searchParams.has(k))
74
- clean.searchParams.set(k, "[redacted]");
75
- }
76
- return clean.pathname + clean.search;
77
- }
78
- function setCors(res) {
79
- res.setHeader("Access-Control-Allow-Origin", "*");
80
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
81
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Corent-Api-Key, Mcp-Session-Id, Mcp-Protocol-Version");
82
- res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
83
- }
84
- function readBody(req) {
85
- return new Promise((resolve, reject) => {
86
- let data = "";
87
- req.on("data", (chunk) => {
88
- data += chunk;
89
- if (data.length > 4_000_000)
90
- reject(new Error("body too large"));
91
- });
92
- req.on("end", () => {
93
- if (!data)
94
- return resolve(undefined);
95
- try {
96
- resolve(JSON.parse(data));
97
- }
98
- catch (e) {
99
- reject(e);
100
- }
101
- });
102
- req.on("error", reject);
103
- });
104
- }
105
- // Method-level request logging for diagnosing client behavior (which methods a
106
- // host calls, whether it advertises the MCP Apps ui extension). Never logs
107
- // prompts, arguments, keys, or results.
108
- function logMcpRequest(body) {
109
- for (const msg of Array.isArray(body) ? body : [body]) {
110
- const m = msg;
111
- if (!m || typeof m.method !== "string")
112
- continue;
113
- if (m.method === "initialize") {
114
- const client = m.params?.clientInfo?.name ?? "unknown";
115
- const uiCap = m.params?.capabilities?.extensions?.["io.modelcontextprotocol/ui"];
116
- console.error(`[mcp] initialize from ${client} v${m.params?.clientInfo?.version ?? "?"} proto=${m.params?.protocolVersion} ui-extension=${uiCap ? JSON.stringify(uiCap) : "NOT advertised"}`);
117
- }
118
- else if (m.method === "resources/read") {
119
- console.error(`[mcp] resources/read ${m.params?.uri ?? "?"}`);
120
- }
121
- else if (m.method === "tools/call") {
122
- console.error(`[mcp] tools/call ${m.params?.name ?? "?"}`);
123
- }
124
- else if (m.method === "resources/list" || m.method === "tools/list") {
125
- console.error(`[mcp] ${m.method}`);
126
- }
127
- }
128
- }
129
- const server = http.createServer(async (req, res) => {
130
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
131
- setCors(res);
132
- if (req.method === "OPTIONS") {
133
- res.writeHead(204);
134
- res.end();
135
- return;
136
- }
137
- // Favicon: some clients derive a connector icon from the server origin.
138
- if (req.method === "GET" && url.pathname === "/favicon.ico") {
139
- res.writeHead(302, { Location: "https://corent.tech/brand/intro-mark.png", "Cache-Control": "public, max-age=86400" });
140
- res.end();
141
- return;
142
- }
143
- // Health check — used by Fly and easy to eyeball.
144
- if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) {
145
- res.writeHead(200, { "Content-Type": "application/json" });
146
- res.end(JSON.stringify({
147
- status: "ok",
148
- service: "corent-mcp",
149
- transport: "streamable-http",
150
- version: SERVER_VERSION,
151
- endpoint: "/mcp",
152
- }));
153
- return;
154
- }
155
- // OAuth endpoints (discovery, registration, consent page, token exchange).
156
- try {
157
- if (await handleOauth(req, res, url, {
158
- publicUrl: PUBLIC_URL,
159
- apiUrl: API_URL,
160
- supabaseUrl: process.env.SUPABASE_URL,
161
- supabaseAnonKey: process.env.SUPABASE_ANON_KEY,
162
- }))
163
- return;
164
- }
165
- catch (err) {
166
- console.error(`oauth request failed: ${req.method} ${url.pathname}:`, err instanceof Error ? err.message : err);
167
- if (!res.headersSent) {
168
- res.writeHead(500, { "Content-Type": "application/json" });
169
- res.end(JSON.stringify({ error: "server_error" }));
170
- }
171
- return;
172
- }
173
- if (url.pathname !== "/mcp") {
174
- res.writeHead(404, { "Content-Type": "application/json" });
175
- res.end(JSON.stringify({ error: "not found; MCP endpoint is /mcp" }));
176
- return;
177
- }
178
- const apiKey = extractApiKey(req, url);
179
- // No credentials -> standard OAuth challenge (RFC 9728). This is what makes
180
- // claude.ai (and other remote MCP clients) open the Connect flow instead of
181
- // silently failing tool calls later.
182
- if (!apiKey) {
183
- // Distinguish "no credentials at all" from "OAuth token present but bad".
184
- const auth = req.headers["authorization"];
185
- const hadOauthToken = typeof auth === "string" && isOauthToken(auth.replace(/^[Bb]earer\s+/, ""));
186
- console.error(`[mcp] 401: ${hadOauthToken ? "oauth token invalid/expired" : "no credentials"}`);
187
- res.writeHead(401, {
188
- "Content-Type": "application/json",
189
- "WWW-Authenticate": `Bearer resource_metadata="${PUBLIC_URL}/.well-known/oauth-protected-resource"`,
190
- });
191
- res.end(JSON.stringify({
192
- error: "unauthorized",
193
- error_description: "Authenticate via OAuth (connect this server in your MCP client), or pass a Corent API key as 'Authorization: Bearer co_live_...'. Keys: https://corent.tech/dashboard/api-keys",
194
- }));
195
- return;
196
- }
197
- const mcp = createCorentServer({ apiKey, apiUrl: API_URL });
198
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
199
- res.on("close", () => {
200
- transport.close().catch(() => { });
201
- mcp.close().catch(() => { });
202
- });
203
- try {
204
- await mcp.connect(transport);
205
- const body = req.method === "POST" ? await readBody(req) : undefined;
206
- logMcpRequest(body);
207
- await transport.handleRequest(req, res, body);
208
- }
209
- catch (err) {
210
- // Redacted path only — never req.url, which may carry an API key.
211
- console.error(`request failed: ${req.method} ${redactedPath(url)}:`, err instanceof Error ? err.message : err);
212
- if (!res.headersSent) {
213
- res.writeHead(500, { "Content-Type": "application/json" });
214
- res.end(JSON.stringify({
215
- jsonrpc: "2.0",
216
- error: { code: -32603, message: err instanceof Error ? err.message : "Internal server error" },
217
- id: null,
218
- }));
219
- }
220
- }
221
- });
30
+ const server = http.createServer(createHttpApp({
31
+ apiUrl: process.env.CORENT_API_URL ?? "https://api.corent.tech",
32
+ publicUrl: process.env.MCP_PUBLIC_URL ?? "https://mcp.corent.tech",
33
+ supabaseUrl: process.env.SUPABASE_URL,
34
+ supabaseAnonKey: process.env.SUPABASE_ANON_KEY,
35
+ }));
222
36
  server.listen(PORT, () => {
223
37
  console.error(`corent-mcp (streamable-http) v${SERVER_VERSION} listening on :${PORT}${" -> /mcp"}`);
224
38
  });