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 +102 -10
- package/dist/http-app.js +227 -0
- package/dist/http.js +12 -198
- package/dist/prompts.js +123 -0
- package/dist/server.js +542 -52
- 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
|
|
|
@@ -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
|
|
15
|
-
| `
|
|
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` |
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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).
|
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
|
});
|