@vidofy/mcp 0.1.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/dist/http.js ADDED
@@ -0,0 +1,885 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The REMOTE entry point — the same nine tools over Streamable HTTP.
4
+ *
5
+ * Why this file exists at all: a web client cannot run a process on the user's
6
+ * machine, so claude.ai and ChatGPT can never reach the stdio package however
7
+ * well it works. They speak to a URL or to nothing. This is that URL.
8
+ *
9
+ * node dist/http.js → listens on VIDOFY_MCP_PORT, path /mcp-app
10
+ * node dist/index.js → the stdio server, unchanged
11
+ *
12
+ * In production nginx terminates TLS on vidofy.ai and proxies /mcp-app here;
13
+ * this process never sees the internet directly and never holds a certificate.
14
+ *
15
+ * IT IS OAUTH NOW — and the two paragraphs that used to stand here said the
16
+ * opposite
17
+ * ------------------------------------------------------------------------
18
+ * They described a staging step ("it is not OAuth… a fixed bearer header… NOT
19
+ * the shipping shape") and were true for about a day. They are left mentioned
20
+ * rather than silently deleted because the thing they got wrong is worth
21
+ * knowing: the staged shape is now **impossible**, not merely superseded. A
22
+ * hand-made `vmt_` from the tokens page declares NO resource, this
23
+ * endpoint always declares a resource (see MCP_PATH below), and the server
24
+ * refuses that pairing outright. So "No sign-in
25
+ * + Request headers" cannot work here any more, and a reader who trusted those
26
+ * paragraphs would spend an afternoon finding out.
27
+ *
28
+ * What is built: authorization (`/mcp-app/authorize`), the consent hand-off to
29
+ * PHP, the code exchange (`/mcp-app/token`), Client ID Metadata Documents, PKCE
30
+ * S256, and RFC 8707 audience binding. What is NOT built: token revocation over
31
+ * the protocol — revocation lives on the website's own tokens page, which is why
32
+ * `revocation_endpoint` is absent from the metadata below rather than advertised
33
+ * and unrouted.
34
+ *
35
+ * STATELESS ON PURPOSE
36
+ * --------------------
37
+ * sessionIdGenerator is undefined, so every request stands alone. That is a
38
+ * measured fit rather than a simplification: this server never pushes a
39
+ * notification (zero sendNotification calls in the package), because the card
40
+ * polls get_status through the host instead. Sessions exist to carry
41
+ * server-initiated messages; with none to carry, holding per-session state would
42
+ * be a memory leak with a session id on it.
43
+ */
44
+ import { createHash } from 'node:crypto';
45
+ import { createServer } from 'node:http';
46
+ import { createMcpHandler } from '@modelcontextprotocol/server';
47
+ import { buildServer, log, readVersion } from './index.js';
48
+ import { request, VidofyError } from './backend.js';
49
+ import { configForToken, ConfigError, resolveBaseUrl } from './config.js';
50
+ import { handleAuthorize, handleAuthorizeDecide } from './oauth/authorize.js';
51
+ import { handleToken } from './oauth/token.js';
52
+ import { startHeartbeat } from './heartbeat.js';
53
+ import { hitLimit, AUTHORIZE_PER_IP, AUTHORIZE_PER_CLIENT, DECIDE_PER_IP, TOKEN_PER_IP, } from './oauth/ratelimit.js';
54
+ /**
55
+ * Is this a development run?
56
+ *
57
+ * Decided from the site origin this process talks to, which is the same signal
58
+ * config.ts already uses to allow plain http — not a separate flag that could
59
+ * disagree with it. Anything pointing at vidofy.ai is production by definition.
60
+ */
61
+ function isLocalDevelopment() {
62
+ const host = new URL(resolveBaseUrl()).hostname.toLowerCase();
63
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1'
64
+ || host.endsWith('.local') || host.endsWith('.localhost') || host.endsWith('.test');
65
+ }
66
+ /** The path nginx proxies here. Not `/mcp` — that prefix would also match the landing page. */
67
+ const MCP_PATH = '/mcp-app';
68
+ const DEFAULT_PORT = 2097;
69
+ /** Ceiling on a request body. See the enforcement note at the MCP endpoint. */
70
+ const MAX_BODY_BYTES = 1024 * 1024;
71
+ /**
72
+ * The one MCP handler, built on first use.
73
+ *
74
+ * `legacy: 'stateless'` is the default and is stated anyway, because it is the
75
+ * whole compatibility story in one word: a host speaking the 2026-07-28 envelope
76
+ * gets a modern instance and its capabilities arrive per request; a host still
77
+ * speaking 2025 gets a fresh stateless instance exactly as before this migration.
78
+ * One factory serves both, so the tools are defined once — and the alternative,
79
+ * `legacy: 'reject'`, would have cut off every client that has not moved yet.
80
+ *
81
+ * The factory runs PER REQUEST, so nothing about the stateless design changed. What
82
+ * changed is that the library owns the transport instead of this file.
83
+ *
84
+ * Lazily built because `version` is read at boot and this is module scope; memoised
85
+ * because a handler per request would defeat the point of it owning anything.
86
+ */
87
+ let handlerSingleton = null;
88
+ function mcpHandler(version) {
89
+ if (handlerSingleton !== null)
90
+ return handlerSingleton;
91
+ handlerSingleton = createMcpHandler((ctx) => {
92
+ /* The Config we already built and verified, handed over rather than
93
+ * re-derived. FAIL CLOSED if it is missing: serving a request with a
94
+ * default or a guessed credential is how one user spends another's
95
+ * coins, so a wiring mistake must be an error and never a fallback. */
96
+ const cfg = ctx.authInfo?.extra?.cfg;
97
+ if (cfg === undefined) {
98
+ throw new Error('MCP handler reached with no Config in authInfo.extra — refusing.');
99
+ }
100
+ /* Logged because it is the single most useful fact when a card does not
101
+ appear: `modern` means the client's capabilities ride every request and
102
+ the UI gate can see them; `legacy` means they cannot, by construction,
103
+ and no amount of looking at the card code will explain it. */
104
+ log(`serving ${ctx.era} era`);
105
+ return buildServer(cfg, version);
106
+ }, {
107
+ legacy: 'stateless',
108
+ onerror: (err) => log(`mcp: ${err.message}`),
109
+ });
110
+ return handlerSingleton;
111
+ }
112
+ /**
113
+ * This server's own public origin — the one a client sees.
114
+ *
115
+ * It matters because OAuth discovery is built on identifiers, not on paths: the
116
+ * `resource` in the protected-resource document and the `issuer` in the
117
+ * authorization-server document must be the URLs the client actually used, or
118
+ * the client rejects the documents as belonging to someone else.
119
+ *
120
+ * VIDOFY_MCP_PUBLIC_URL wins when set. The header fallback exists for the
121
+ * cloudflared tunnel, whose hostname is issued fresh on every run and so cannot
122
+ * be configured ahead of time.
123
+ *
124
+ * ⚠ THE OLD VERSION OF THIS FUNCTION CALLED THAT FALLBACK "development only" AND
125
+ * GATED IT ON NOTHING. Measured 2026-09-12 against the real process, with the
126
+ * env var unset — which was production's default, since the variable appears in
127
+ * no deploy artifact:
128
+ *
129
+ * X-Forwarded-Host: attacker.example
130
+ * → {"issuer":"https://attacker.example",
131
+ * "token_endpoint":"https://attacker.example/mcp-app/token"}
132
+ * X-Forwarded-Proto: javascript → "issuer":"javascript://evil"
133
+ *
134
+ * Two consequences, and the second is worse than the first. A client that trusts
135
+ * the authorization-server document is sent to the attacker's token endpoint. And
136
+ * the same value becomes the canonical `resource` bound into every token this
137
+ * flow mints, so a proxy that merely ADDS X-Forwarded-Host — no attacker needed —
138
+ * mints tokens whose audience never matches, and every one of them is refused
139
+ * later with the single word "audience".
140
+ *
141
+ * So the fallback is now gated on isLocalDevelopment(), which is derived from the
142
+ * site origin rather than from anything a request can set, and the value is run
143
+ * through resolveBaseUrl() — the same validation VIDOFY_API_BASE gets — so a
144
+ * scheme like `javascript:` or a host with a quote in it cannot survive. In
145
+ * production a missing variable is a BOOT FAILURE (see main), not a silent
146
+ * fallback to whatever the last hop claimed.
147
+ */
148
+ function publicOrigin(req) {
149
+ const configured = (process.env['VIDOFY_MCP_PUBLIC_URL'] ?? '').trim().replace(/\/+$/, '');
150
+ if (configured !== '')
151
+ return configured;
152
+ /* Unreachable in production: main() refuses to boot without the variable.
153
+ Kept as a guard rather than an assertion because this function is also
154
+ called from the 401 path, and a 401 that throws is a 500. */
155
+ if (!isLocalDevelopment())
156
+ return resolveBaseUrl();
157
+ const hdr = (name) => {
158
+ const v = req.headers[name];
159
+ return (Array.isArray(v) ? v[0] : v) ?? '';
160
+ };
161
+ const host = hdr('x-forwarded-host') || hdr('host') || '127.0.0.1';
162
+ const proto = hdr('x-forwarded-proto') || (host.startsWith('127.0.0.1') || host.startsWith('localhost') ? 'http' : 'https');
163
+ /* Validated even here. A tunnel hostname is still a header value, and the
164
+ development path is where this code is exercised most — a rule that only
165
+ runs in production is a rule nobody has tested. */
166
+ try {
167
+ return resolveBaseUrl({ VIDOFY_API_BASE: `${proto}://${host}` });
168
+ }
169
+ catch {
170
+ return resolveBaseUrl();
171
+ }
172
+ }
173
+ /**
174
+ * OAuth discovery — the two documents.
175
+ *
176
+ * ⚠ THEY ARE HAND-WRITTEN, RIGHT HERE. That needs saying because the plan
177
+ * document and a commit message both claimed the SDK generates them
178
+ * ("server/auth/router.js:97,99, so no JSON is written by hand and it cannot
179
+ * drift from reality"). The SDK *can*; we do not use it — `grep -r "server/auth"
180
+ * src/` is zero. So the failure mode that claim ruled out is exactly the one we
181
+ * have: **every path below is duplicated from the routes further down this file,
182
+ * and nothing checks that the two agree.** One already disagreed —
183
+ * `revocation_endpoint` was advertised here with no route behind it.
184
+ *
185
+ * So: change a route, change it here, in the same edit.
186
+ *
187
+ * It began as a PROBE rather than a feature (owner decision 2026-09-12): publish
188
+ * the documents first and watch what each host asks for next, instead of writing
189
+ * the flow they would drive. That is how we learned claude.ai wants protocol
190
+ * 2026-07-28 — a fact no amount of reading produced. The flow behind them is
191
+ * built now.
192
+ *
193
+ * The probe ran on 2026-09-12 and answered the question it was built for.
194
+ * Advertising both client-registration mechanisms at once — `registration_endpoint`
195
+ * (Dynamic Client Registration) and `client_id_metadata_document_supported` — made
196
+ * it report a preference rather than a capability, and both hosts picked the same
197
+ * one:
198
+ *
199
+ * claude.ai client_id = https://claude.ai/oauth/mcp-oauth-client-metadata
200
+ * ChatGPT client_id = https://chatgpt.com/oauth/<per-connector>/client.json
201
+ * calls to /register = ZERO, from either
202
+ *
203
+ * So `registration_endpoint` is GONE from the document below. It was honest while
204
+ * it was an instrument; keeping it now would advertise a path we will not build
205
+ * to clients that never ask for it.
206
+ *
207
+ * (That sentence used to be followed by "the remaining endpoints are still
208
+ * unbuilt". They are built — authorize, the consent hand-off, and token.)
209
+ */
210
+ function discoveryDocuments(origin) {
211
+ const resource = `${origin}${MCP_PATH}`;
212
+ return {
213
+ // RFC 9728. `resource` MUST be the canonical URI the client called.
214
+ protectedResource: {
215
+ resource,
216
+ authorization_servers: [origin],
217
+ scopes_supported: ['vidofy.generate'],
218
+ bearer_methods_supported: ['header'],
219
+ resource_name: 'Vidofy',
220
+ resource_documentation: 'https://vidofy.ai/en/mcp',
221
+ },
222
+ // RFC 8414.
223
+ authorizationServer: {
224
+ issuer: origin,
225
+ authorization_endpoint: `${origin}${MCP_PATH}/authorize`,
226
+ token_endpoint: `${origin}${MCP_PATH}/token`,
227
+ /* No `revocation_endpoint`. It was advertised here pointing at
228
+ `${MCP_PATH}/revoke`, which 404s — the same false advertisement that
229
+ got `registration_endpoint` deleted above, made twice in one
230
+ document. Revocation is real but it lives on the website
231
+ (/en/studio/account/mcp-tokens), where the user can see which client
232
+ a token belongs to before killing it; RFC 7009 is optional and a
233
+ client that cannot revoke loses nothing it had. */
234
+ response_types_supported: ['code'],
235
+ /* `refresh_token` is NOT advertised: the decision is one long-lived
236
+ access token and no refresh, which the spec
237
+ permits outright — "MCP Clients MUST NOT assume refresh tokens
238
+ will be issued; the AS retains discretion". Both hosts list
239
+ refresh_token in their own metadata, which says what they accept,
240
+ not what they require. */
241
+ grant_types_supported: ['authorization_code'],
242
+ // PKCE is mandatory in OAuth 2.1, and S256 is the only method worth
243
+ // advertising — `plain` exists in the RFC for clients that cannot
244
+ // hash, which no MCP client is.
245
+ code_challenge_methods_supported: ['S256'],
246
+ token_endpoint_auth_methods_supported: ['none'],
247
+ scopes_supported: ['vidofy.generate'],
248
+ client_id_metadata_document_supported: true,
249
+ },
250
+ };
251
+ }
252
+ /**
253
+ * The caller's address, for rate limiting.
254
+ *
255
+ * ⚠ THE LAST X-Forwarded-For ENTRY, NOT THE FIRST — and this is the opposite of
256
+ * the usual advice, so it needs the reason.
257
+ *
258
+ * nginx is configured with `proxy_set_header X-Forwarded-For
259
+ * $proxy_add_x_forwarded_for`, which APPENDS the peer it actually observed to
260
+ * whatever the client sent. So the header arrives as
261
+ *
262
+ * <anything the caller invented>, <the address nginx saw>
263
+ *
264
+ * Reading the first entry reads the attacker's own string, which means every
265
+ * request can claim a different address and the limiter counts nothing. The last
266
+ * entry is the only one nginx wrote, and the only one worth trusting.
267
+ *
268
+ * ⚠ AND IT DEPENDS ON THAT DIRECTIVE EXISTING. Whoever deploys this behind a
269
+ * proxy must set it. Without the header, every request looks like
270
+ * 127.0.0.1 and the per-IP bucket becomes one global bucket for all users —
271
+ * a limiter that locks everyone out together. That is why a loopback peer with no
272
+ * header is LOGGED, loudly and once: it is a misconfiguration that would otherwise
273
+ * look like a mysterious 429 storm.
274
+ */
275
+ let warnedAboutMissingForwardedFor = false;
276
+ function clientAddress(req) {
277
+ const raw = req.headers['x-forwarded-for'];
278
+ const header = (Array.isArray(raw) ? raw[0] : raw) ?? '';
279
+ if (header.trim() !== '') {
280
+ const parts = header.split(',').map((s) => s.trim()).filter((s) => s !== '');
281
+ const last = parts[parts.length - 1];
282
+ if (last !== undefined && last !== '')
283
+ return last;
284
+ }
285
+ const peer = req.socket.remoteAddress ?? 'unknown';
286
+ const isLoopback = peer === '127.0.0.1' || peer === '::1' || peer === '::ffff:127.0.0.1';
287
+ if (isLoopback && !warnedAboutMissingForwardedFor) {
288
+ warnedAboutMissingForwardedFor = true;
289
+ log('⚠ no X-Forwarded-For and the peer is loopback, so every caller looks like one '
290
+ + 'address and the per-IP rate limits are effectively global. Add '
291
+ + '`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` to the nginx '
292
+ + 'block. (Harmless in local development, where there is one caller.)');
293
+ }
294
+ return peer;
295
+ }
296
+ /**
297
+ * Apply a rate limit and answer 429 if it is spent.
298
+ *
299
+ * @returns true when the caller may proceed.
300
+ */
301
+ async function rateLimited(res, bucket, spec) {
302
+ const verdict = await hitLimit(bucket, spec.limit, spec.windowSec);
303
+ if (verdict.allowed)
304
+ return false;
305
+ /* Retry-After is exact — seconds until this fixed window resets — which is the
306
+ same contract the Partners API documents. A guessed number sends the caller
307
+ back into the same refusal. */
308
+ res.writeHead(429, {
309
+ 'content-type': 'application/json; charset=utf-8',
310
+ 'retry-after': String(verdict.retryAfter),
311
+ });
312
+ res.end(JSON.stringify({
313
+ error: 'rate_limited',
314
+ error_description: `Too many requests. Try again in ${verdict.retryAfter}s.`,
315
+ }, null, 2));
316
+ log(`429 ${bucket} — ${verdict.count < 0 ? 'limiter unavailable' : `${verdict.count} in window`}`);
317
+ return true;
318
+ }
319
+ /** Bearer token from the Authorization header, or '' when absent/malformed. */
320
+ function bearerToken(req) {
321
+ const raw = req.headers['authorization'];
322
+ const header = Array.isArray(raw) ? (raw[0] ?? '') : (raw ?? '');
323
+ const m = /^Bearer\s+(.+)$/i.exec(header.trim());
324
+ return m?.[1]?.trim() ?? '';
325
+ }
326
+ /**
327
+ * A 401 that tells the client what to do.
328
+ *
329
+ * It DOES carry `resource_metadata` — an earlier version of this comment said the
330
+ * omission was deliberate "until OAuth exists", and OAuth exists.
331
+ */
332
+ function unauthorized(res, message, resourceMetadata) {
333
+ /* The header is FIXED and the explanation goes in the body only.
334
+ *
335
+ * Interpolating `message` here threw on the very first request and answered
336
+ * "500 Unauthorized" — a status line that names two different outcomes,
337
+ * which is about the worst thing to hand someone debugging a connector. The
338
+ * cause: our own messages contain a typographic ellipsis ("vmt_…"), and Node
339
+ * rejects any header value outside latin1 with ERR_INVALID_CHAR, so
340
+ * writeHead threw after having already set statusMessage from the 401.
341
+ *
342
+ * Sanitising the string would fix the throw and keep the hazard: every
343
+ * future message becomes a chance to break the response by adding a dash or
344
+ * a quote. So nothing variable goes into a header. `WWW-Authenticate` is for
345
+ * the client's parser and carries only what the parser uses; the human
346
+ * sentence travels in JSON, where UTF-8 is the point. */
347
+ /* `resource_metadata` is the ONE variable part allowed in. It turns a dead
348
+ * 401 into a discoverable one: the client reads that URL to find the
349
+ * authorization server (RFC 9728 §5.1), the step claude.ai failed at before
350
+ * these documents existed.
351
+ *
352
+ * ⚠ AND THE PARAGRAPH ABOVE WAS WRONG ABOUT IT. It said this value is safe
353
+ * because it is "a URL — ASCII by construction". It is not constructed; it is
354
+ * derived from a request header, so it was neither ASCII nor quote-free.
355
+ * Measured 2026-09-12:
356
+ *
357
+ * Host: a", error="injected
358
+ * → www-authenticate: Bearer error="invalid_token",
359
+ * resource_metadata="https://a", error="injected/.well-known/…"
360
+ *
361
+ * An injected auth-param, in the one header a client's auth logic parses. The
362
+ * value is now URL-parsed and re-serialised through encodeURI, and refused
363
+ * outright if it still contains a quote, a backslash, a control character or
364
+ * anything outside latin1. Belt and braces on purpose: publicOrigin() already
365
+ * validates its input, and this is the place where being wrong is a protocol
366
+ * bug in someone else's parser rather than ours. */
367
+ const safeMetadata = (() => {
368
+ if (resourceMetadata === undefined)
369
+ return null;
370
+ let parsed;
371
+ try {
372
+ parsed = new URL(resourceMetadata);
373
+ }
374
+ catch {
375
+ return null;
376
+ }
377
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
378
+ return null;
379
+ /* encodeURI escapes `"` to %22 and `\` to %5C, percent-encodes every
380
+ control character, and percent-encodes non-ASCII as UTF-8 — so the
381
+ result cannot close the quoted string, cannot append a parameter, and
382
+ cannot reintroduce ERR_INVALID_CHAR. The previous version of this line
383
+ tested the same thing with LITERAL control bytes in the source (NUL,
384
+ 0x1F, 0x7F, U+FFFF inside a character class) — correct, and unreadable,
385
+ and impossible to review in a diff. */
386
+ const encoded = encodeURI(parsed.toString());
387
+ // The assertion, not the defence: printable ASCII, no quote, no backslash.
388
+ const safe = /^[\x21-\x7E]+$/.test(encoded)
389
+ && !encoded.includes('"') && !encoded.includes('\\');
390
+ return safe ? encoded : null;
391
+ })();
392
+ res.writeHead(401, {
393
+ 'content-type': 'application/json; charset=utf-8',
394
+ 'www-authenticate': 'Bearer error="invalid_token"'
395
+ + (safeMetadata !== null ? `, resource_metadata="${safeMetadata}"` : '')
396
+ + ', scope="vidofy.generate"',
397
+ });
398
+ res.end(JSON.stringify({
399
+ jsonrpc: '2.0',
400
+ error: { code: -32001, message },
401
+ id: null,
402
+ }));
403
+ }
404
+ /** Serve a discovery document as JSON. */
405
+ function sendJson(res, body) {
406
+ const text = JSON.stringify(body, null, 2);
407
+ res.writeHead(200, {
408
+ 'content-type': 'application/json; charset=utf-8',
409
+ // Clients cache these; a short TTL keeps a corrected document reachable
410
+ // without making every handshake pay for a fetch.
411
+ 'cache-control': 'public, max-age=300',
412
+ });
413
+ res.end(text);
414
+ }
415
+ async function handle(req, res, version) {
416
+ const url = new URL(req.url ?? '/', 'http://localhost');
417
+ const origin = publicOrigin(req);
418
+ /* PROBE LOG — the reason this step exists at all.
419
+ *
420
+ * Every request, with the method and path, and whether a credential came
421
+ * with it. NOT the credential itself: a log that quotes a bearer token turns
422
+ * a debugging aid into a leak, and this file's whole job is handling
423
+ * credentials. Presence answers every question the value would. */
424
+ log(`→ ${req.method ?? '?'} ${url.pathname}${url.search}`
425
+ + (req.headers['authorization'] !== undefined ? ' [auth: present]' : ' [auth: none]'));
426
+ const docs = discoveryDocuments(origin);
427
+ /* RFC 9728 appends the resource's own path to the well-known name, so a host
428
+ can carry several protected resources. The bare form is served too because
429
+ some clients look there first — same document either way, since this server
430
+ has exactly one resource. */
431
+ if (url.pathname === `/.well-known/oauth-protected-resource${MCP_PATH}`
432
+ || url.pathname === '/.well-known/oauth-protected-resource') {
433
+ sendJson(res, docs.protectedResource);
434
+ return;
435
+ }
436
+ if (url.pathname === '/.well-known/oauth-authorization-server') {
437
+ sendJson(res, docs.authorizationServer);
438
+ return;
439
+ }
440
+ if (url.pathname === `${MCP_PATH}/token`) {
441
+ if (await rateLimited(res, `token:ip:${clientAddress(req)}`, TOKEN_PER_IP))
442
+ return;
443
+ await handleToken(req, res);
444
+ return;
445
+ }
446
+ if (url.pathname === `${MCP_PATH}/authorize/decide`) {
447
+ if (await rateLimited(res, `decide:ip:${clientAddress(req)}`, DECIDE_PER_IP))
448
+ return;
449
+ await handleAuthorizeDecide(res, url);
450
+ return;
451
+ }
452
+ if (url.pathname === `${MCP_PATH}/authorize`) {
453
+ /* GET only. The endpoint reads the query string and nothing else, so any
454
+ other method is either a mistake or someone shipping a body we would
455
+ accept and ignore — and this is the one route that opens an outbound
456
+ connection, so it gets the cheapest possible guards first. */
457
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
458
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8', allow: 'GET' });
459
+ res.end(JSON.stringify({
460
+ error: 'invalid_request',
461
+ error_description: 'The authorization endpoint accepts GET.',
462
+ }, null, 2));
463
+ return;
464
+ }
465
+ /* TWO buckets, and the order matters: per-IP first because it is the one
466
+ * that stops the ordinary single-source flood, then per-client_id, which is
467
+ * what survives an attacker spread across many addresses.
468
+ *
469
+ * The client_id is read straight from the query and used as a bucket name,
470
+ * so it is HASHED rather than concatenated: it is an arbitrary URL from an
471
+ * unauthenticated caller, and letting it into a Redis key verbatim would let
472
+ * someone choose our key space — length, colons, whatever ':' means to a
473
+ * pattern someone later writes. A short hash is enough to separate clients
474
+ * and cannot be steered. */
475
+ if (await rateLimited(res, `authorize:ip:${clientAddress(req)}`, AUTHORIZE_PER_IP))
476
+ return;
477
+ const rawClientId = (url.searchParams.get('client_id') ?? '').trim();
478
+ if (rawClientId !== '') {
479
+ const clientBucket = createHash('sha256').update(rawClientId).digest('hex').slice(0, 16);
480
+ if (await rateLimited(res, `authorize:client:${clientBucket}`, AUTHORIZE_PER_CLIENT))
481
+ return;
482
+ }
483
+ /* The canonical resource is built from THIS server's own origin, not from
484
+ the `resource` parameter — the parameter is what we check against it.
485
+ allowPrivateClients follows the same development exception the site's
486
+ own SSRF validator carries, so the flow can be exercised
487
+ against a local stub without weakening production. */
488
+ await handleAuthorize(res, url, `${origin}${MCP_PATH}`, {
489
+ ...(isLocalDevelopment() ? { allowPrivateClients: true } : {}),
490
+ });
491
+ return;
492
+ }
493
+ if (url.pathname !== MCP_PATH) {
494
+ /* The message lists what IS served, because the previous one ("serves
495
+ /mcp-app only") became false the moment the discovery documents were
496
+ added — and a 404 that misdescribes the server is what sends someone
497
+ looking for a routing bug that is not there. The probe hit it on
498
+ /mcp-app/authorize and the wrong text was the first thing read. */
499
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' });
500
+ res.end(JSON.stringify({
501
+ error: 'NOT_FOUND',
502
+ message: `No handler for ${url.pathname}.`,
503
+ served: [
504
+ MCP_PATH,
505
+ `/.well-known/oauth-protected-resource${MCP_PATH}`,
506
+ '/.well-known/oauth-authorization-server',
507
+ ],
508
+ }));
509
+ return;
510
+ }
511
+ /* Refuse an oversized body BEFORE anything costly — before the token check
512
+ * below, which is a network round trip.
513
+ *
514
+ * The transport reads the whole body before parsing and imposes no limit of
515
+ * its own on this path (the SDK's 4 MB cap lives only in the SSE transport).
516
+ * Measured 2026-09-12: an 80 MB POST was accepted and took the process from
517
+ * 92 MB to 403 MB resident. A handful in parallel is the whole connector, and
518
+ * the same process serves the OAuth endpoints.
519
+ *
520
+ * 1 MB is generous for the largest legitimate request here — a tool call whose
521
+ * arguments are text and URLs. Uploads do not travel this way: generate sends
522
+ * file paths and the backend fetches them.
523
+ *
524
+ * content-length is only a claim, so this is the cheap half; the stream is
525
+ * counted too, further down. */
526
+ const declaredLength = Number(req.headers['content-length'] ?? '0');
527
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
528
+ res.writeHead(413, { 'content-type': 'application/json; charset=utf-8' });
529
+ res.end(JSON.stringify({
530
+ jsonrpc: '2.0',
531
+ error: { code: -32600, message: `Request body may not exceed ${MAX_BODY_BYTES} bytes.` },
532
+ id: null,
533
+ }));
534
+ return;
535
+ }
536
+ /* Build the caller's Config BEFORE the transport touches the body. A bad
537
+ credential is answered with one 401 rather than becoming a JSON-RPC error
538
+ inside a stream the client then has to unwrap. */
539
+ let cfg;
540
+ try {
541
+ /* The connector declares its own canonical resource, so /app/v1 can
542
+ check the token's audience. A hand-made token has none recorded and is
543
+ therefore refused HERE, at the connector — which is the direction of
544
+ the check that matters, since the caller cannot change what we send. */
545
+ cfg = configForToken(bearerToken(req), process.env, version, `${origin}${MCP_PATH}`);
546
+ }
547
+ catch (err) {
548
+ unauthorized(res, err instanceof ConfigError ? err.message : 'Authentication failed.', `${origin}/.well-known/oauth-protected-resource${MCP_PATH}`);
549
+ return;
550
+ }
551
+ /* ── And now PROVE the token, instead of trusting its first four characters ──
552
+ *
553
+ * configForToken above checks the shape: it starts with `vmt_`. That is all it
554
+ * can check — only the site can verify a token. For a while
555
+ * that was the whole gate, and the consequence was measured on 2026-09-12:
556
+ *
557
+ * Authorization: Bearer vmt_totally_fake_never_issued
558
+ * → the full nine-tool tools/list, 200 OK
559
+ *
560
+ * Every tool that spends money authenticates again at /app/v1, so the wallet
561
+ * was never open. But four tools read anonymous /app/v1/info/* routes and
562
+ * needed no credential at all, tools/list needed none, and the 401 this file
563
+ * takes such care over was decoration on those paths. The MCP authorization
564
+ * spec is not ambiguous about it either: the resource server MUST validate
565
+ * that the token was issued for it.
566
+ *
567
+ * So one authenticated call, before anything is dispatched. `account/balance`
568
+ * is the probe rather than a new endpoint, deliberately: it is already what
569
+ * get_balance calls, it requires the session token, and because request()
570
+ * sends X-Vidofy-MCP-Resource it validates the AUDIENCE in the same round
571
+ * trip — a hand-made token and an OAuth token for a different connector both
572
+ * fail here, in PHP, which is the only place that can tell.
573
+ *
574
+ * The cost is one extra round trip per MCP request (owner decision A,
575
+ * 2026-09-12, with the alternative — leave PHP as the only authority and
576
+ * correct the claim instead — considered and declined). It buys a connector
577
+ * whose 401 means what it says. */
578
+ try {
579
+ /* ONE attempt, TEN seconds — not the defaults, and this is load-bearing.
580
+ *
581
+ * request() defaults to MAX_ATTEMPTS 4 at DEFAULT_TIMEOUT_MS 60_000 each,
582
+ * and attempts compose by adding (see the note above DEFAULT_TIMEOUT_MS).
583
+ * With those defaults a backend answering 503 would make this check alone
584
+ * take up to four minutes — in front of EVERY MCP request, and in front of
585
+ * `generate`, whose own 50s budget exists because the client gives up at
586
+ * 60. The gate would have become the outage.
587
+ *
588
+ * One attempt is right rather than merely cheap: the answer that matters
589
+ * here is 401/403, which isRetryableStatus already excludes from retrying,
590
+ * so a retry could only ever help a transient 5xx — and for that, failing
591
+ * fast with an honest 503 the client can retry whole is better than
592
+ * holding its connection open.
593
+ *
594
+ * WHY TEN AND NOT FIVE (owner decision, 2026-09-13). Five was chosen on the
595
+ * shape of the work — a single indexed read answers in milliseconds — and
596
+ * then measured against reality on a development Mac: EVERY call to a
597
+ * `.local` host costs 5.0s before the request even starts, because macOS
598
+ * routes that suffix through mDNS and `/etc/hosts` does not short-circuit
599
+ * it (`dscacheutil` on a name that IS in the file: 5.013s; the same request
600
+ * with `--resolve`: 0.085s). So the budget was spent entirely on name
601
+ * resolution and every local tool call answered 503 — a healthy connector
602
+ * refusing a valid token, for a reason nowhere near the code.
603
+ *
604
+ * The narrower fix was an override set only in the local supervisor
605
+ * program, keeping production at five. The owner chose ten for every
606
+ * environment instead, and the trade it makes is worth stating plainly:
607
+ * when the site really is unreachable, a caller now waits ten seconds for
608
+ * the refusal rather than five. That is the whole cost — this path cannot
609
+ * hold longer than one attempt, and nothing downstream shortens. */
610
+ await request(cfg, { method: 'GET', path: 'account/balance', maxAttempts: 1, timeoutMs: 10_000 });
611
+ }
612
+ catch (err) {
613
+ /* Only an auth failure is a 401. A backend outage must not be reported as
614
+ "your token is invalid" — that sends the user to revoke a good token and
615
+ re-run consent for nothing. VidofyError carries the HTTP status; 401/403
616
+ are the token's fault, anything else is ours. */
617
+ const status = err instanceof VidofyError ? err.httpStatus : null;
618
+ if (status === 401 || status === 403) {
619
+ unauthorized(res, 'This token is not valid for this connector. It may have been revoked, '
620
+ + 'expired, or issued for a different server — reconnect to get a new one.', `${origin}/.well-known/oauth-protected-resource${MCP_PATH}`);
621
+ return;
622
+ }
623
+ log(`token check could not complete: ${err instanceof Error ? err.message : String(err)}`);
624
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' });
625
+ res.end(JSON.stringify({
626
+ jsonrpc: '2.0',
627
+ error: { code: -32003, message: 'Vidofy is not reachable right now. Try again shortly.' },
628
+ id: null,
629
+ }));
630
+ return;
631
+ }
632
+ /* ── Hand the request to the v2 handler ──────────────────────────────────
633
+ *
634
+ * One `createMcpHandler` for the process (built at module scope below), not a
635
+ * transport per request. The FACTORY is still per request — that is the shape
636
+ * v2 provides — so nothing about the stateless design changed; what changed is
637
+ * who owns the plumbing.
638
+ *
639
+ * The Config travels in `authInfo.extra`, which is the library's own
640
+ * pass-through: "the handler never populates this from request headers and
641
+ * performs no token verification of its own". That division is exactly ours —
642
+ * we verified the bearer above, twice (shape here, then PHP), and the handler
643
+ * is told the answer rather than asked to find it. `resource` is set too
644
+ * because it is the RFC 8707 field by name, and a reader of a log or a dump
645
+ * should see the audience where the spec puts it. */
646
+ const body = await readBodyCapped(req, res);
647
+ if (body === null)
648
+ return; // already answered (413) and the socket cut
649
+ /* From here the request is v2's to judge, and on the modern route it judges
650
+ * FOUR things this endpoint does not check itself — deliberately, because
651
+ * checking them twice would put two sets of rules on one wire:
652
+ *
653
+ * 1. an `Mcp-Method` header, 2. naming the same method as the body,
654
+ * 3. `_meta["io.modelcontextprotocol/protocolVersion"]`,
655
+ * 4. `_meta["io.modelcontextprotocol/clientCapabilities"]`.
656
+ *
657
+ * Three of the four are the client's job. Ours is only to not lose them, and
658
+ * the one way we could is `toWebRequest` — so that is asserted in
659
+ * the wire-contract suite in the project's own gate, along with each requirement
660
+ * proven by removing it.
661
+ *
662
+ * ⚠ THE FAILURE TO RECOGNISE. Strip `Mcp-Method` at the proxy and the answer
663
+ * is `-32020`, "the body names method tools/list but the required Mcp-Method
664
+ * header is absent" — a message that blames the client for a header it sent.
665
+ * Legacy clients need none of the four and keep working throughout, so the
666
+ * server looks healthy while every modern client fails. nginx forwards these
667
+ * without configuration (they are hyphenated, so `underscores_in_headers`
668
+ * does not apply), and nothing in this file may add them to a skip list.
669
+ *
670
+ * And the era is decided by the BODY envelope, not the header: a request
671
+ * whose `_meta` names 2026-07-28 stays on the modern route even with no
672
+ * version header at all, so a proxy that drops or rewrites that header
673
+ * cannot quietly downgrade a modern client to legacy. It fails instead.
674
+ * All measured 2026-09-13 against server@2.0.0. */
675
+ const webRequest = toWebRequest(req, origin, body);
676
+ let response;
677
+ try {
678
+ response = await mcpHandler(version).fetch(webRequest, {
679
+ authInfo: {
680
+ token: cfg.credential,
681
+ clientId: 'mcp-connector',
682
+ scopes: ['vidofy.generate'],
683
+ resource: new URL(`${origin}${MCP_PATH}`),
684
+ extra: { cfg },
685
+ },
686
+ });
687
+ }
688
+ catch (err) {
689
+ log(`mcp handler failed: ${err instanceof Error ? err.message : String(err)}`);
690
+ if (!res.headersSent) {
691
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
692
+ res.end(JSON.stringify({
693
+ jsonrpc: '2.0', error: { code: -32603, message: 'Request failed.' }, id: null,
694
+ }));
695
+ }
696
+ return;
697
+ }
698
+ await sendWebResponse(res, response);
699
+ }
700
+ /**
701
+ * Read the body with the ceiling enforced, or answer 413 and return null.
702
+ *
703
+ * v2's handler takes a Web `Request`, which needs the body up front — so the cap
704
+ * that used to ride the stream while the old transport read it now has to be
705
+ * applied HERE, before the Request is built. Same ceiling, same reason (measured:
706
+ * an 80 MB POST took the process from 92 MB to 403 MB resident), enforced at the
707
+ * only remaining place that sees the bytes arrive.
708
+ *
709
+ * The socket is destroyed rather than drained on overflow: draining a megabyte we
710
+ * have already refused is doing the attacker's work for them.
711
+ */
712
+ async function readBodyCapped(req, res) {
713
+ const chunks = [];
714
+ let size = 0;
715
+ try {
716
+ for await (const chunk of req) {
717
+ const buf = chunk;
718
+ size += buf.length;
719
+ if (size > MAX_BODY_BYTES) {
720
+ log(`request body exceeded ${MAX_BODY_BYTES} bytes — refused`);
721
+ if (!res.headersSent) {
722
+ res.writeHead(413, { 'content-type': 'application/json; charset=utf-8' });
723
+ res.end(JSON.stringify({
724
+ jsonrpc: '2.0',
725
+ error: { code: -32600, message: `Request body may not exceed ${MAX_BODY_BYTES} bytes.` },
726
+ id: null,
727
+ }));
728
+ }
729
+ req.destroy();
730
+ return null;
731
+ }
732
+ chunks.push(buf);
733
+ }
734
+ }
735
+ catch {
736
+ return null; // the socket died; nothing to answer to
737
+ }
738
+ return Buffer.concat(chunks);
739
+ }
740
+ /** A Node request as the Web `Request` v2 expects. */
741
+ function toWebRequest(req, origin, body) {
742
+ const headers = new Headers();
743
+ for (const [k, v] of Object.entries(req.headers)) {
744
+ if (v === undefined)
745
+ continue;
746
+ /* Hop-by-hop and pseudo headers that a Web Request refuses or that would be
747
+ wrong to forward. `host` in particular: the Request URL already carries
748
+ the origin, and Headers rejects a second authority.
749
+ ⚠ This is a SKIP list on purpose — never turn it into an allow-list, and
750
+ never add an `mcp-` name to it. `Mcp-Method` is required on every modern
751
+ request and `Mcp-Name` must agree with the body, so dropping either
752
+ breaks all 2026-07-28 clients while legacy ones keep working. Both
753
+ mistakes are caught by the wire-contract suite in the project's own gate. */
754
+ if (k === 'connection' || k === 'host' || k === 'transfer-encoding' || k.startsWith(':'))
755
+ continue;
756
+ for (const one of Array.isArray(v) ? v : [v])
757
+ headers.append(k, one);
758
+ }
759
+ const method = req.method ?? 'GET';
760
+ const hasBody = method !== 'GET' && method !== 'HEAD' && body.length > 0;
761
+ return new Request(`${origin}${req.url ?? MCP_PATH}`, {
762
+ method,
763
+ headers,
764
+ ...(hasBody ? { body } : {}),
765
+ /* An abort signal wired to the socket, which is what makes cancellation
766
+ work at all: v2 reads `request.signal` and surfaces it as
767
+ `ctx.mcpReq.signal`, and that is the signal `generate` checks before it
768
+ spends anything. Measured 2026-09-13 — a client disconnect aborts it
769
+ immediately and v2 answers 499. Without this line the money guard added
770
+ the same day would be a no-op that still reads correctly in the diff. */
771
+ signal: abortOnSocketClose(req),
772
+ });
773
+ }
774
+ /** An AbortSignal that fires when the client goes away. */
775
+ function abortOnSocketClose(req) {
776
+ const ac = new AbortController();
777
+ /* `aborted` fires when the client disconnects mid-request; `close` covers the
778
+ socket ending for any other reason. Either way the caller is gone. Firing
779
+ twice is harmless — abort() after abort() is a no-op. */
780
+ req.once('aborted', () => ac.abort());
781
+ req.once('close', () => ac.abort());
782
+ return ac.signal;
783
+ }
784
+ /** Copy a Web `Response` onto the Node response. */
785
+ async function sendWebResponse(res, response) {
786
+ if (res.headersSent)
787
+ return;
788
+ const headers = {};
789
+ response.headers.forEach((value, key) => { headers[key] = value; });
790
+ res.writeHead(response.status, headers);
791
+ if (response.body === null) {
792
+ res.end();
793
+ return;
794
+ }
795
+ /* Streamed rather than buffered: an SSE response has no end until the exchange
796
+ does, so awaiting arrayBuffer() would hold it open and deliver nothing. */
797
+ const reader = response.body.getReader();
798
+ try {
799
+ for (;;) {
800
+ const { done, value } = await reader.read();
801
+ if (done)
802
+ break;
803
+ if (!res.write(Buffer.from(value))) {
804
+ await new Promise((resolve) => res.once('drain', resolve));
805
+ }
806
+ }
807
+ }
808
+ catch {
809
+ /* The client went away mid-stream. Nothing to report — the socket is gone
810
+ and the tool handler's own signal has already been aborted. */
811
+ }
812
+ finally {
813
+ res.end();
814
+ }
815
+ }
816
+ function main() {
817
+ const version = readVersion();
818
+ const port = Number(process.env['VIDOFY_MCP_PORT'] ?? DEFAULT_PORT);
819
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
820
+ log(`VIDOFY_MCP_PORT must be a port number — got "${process.env['VIDOFY_MCP_PORT']}"`);
821
+ process.exit(1);
822
+ }
823
+ /* Refuse to boot in production without a configured public origin.
824
+ *
825
+ * This is deliberately a crash and not a warning. Without the variable,
826
+ * publicOrigin() used to fall back to the Host header, which means the issuer
827
+ * in the authorization-server document and the audience bound into every
828
+ * minted token were both whatever the last hop claimed. A warning in a log
829
+ * nobody reads would have produced exactly what was measured: forged
830
+ * discovery documents, or tokens that all fail later with the word
831
+ * "audience". A process that will not start is a five-minute deploy problem;
832
+ * the alternative is a week of unexplained refusals.
833
+ *
834
+ * Validated through resolveBaseUrl so the same rules that guard
835
+ * VIDOFY_API_BASE guard this one: origin only, https except for local hosts,
836
+ * no credentials, no path, no whitespace. */
837
+ const declaredPublic = (process.env['VIDOFY_MCP_PUBLIC_URL'] ?? '').trim();
838
+ if (declaredPublic === '') {
839
+ if (!isLocalDevelopment()) {
840
+ log('VIDOFY_MCP_PUBLIC_URL is required. Without it the issuer and the token '
841
+ + 'audience would be taken from a request header, which the caller controls. '
842
+ + `Set it to this connector's public origin (e.g. https://vidofy.ai).`);
843
+ process.exit(1);
844
+ }
845
+ log('VIDOFY_MCP_PUBLIC_URL is unset — development run, origin will be read from '
846
+ + 'the request headers. Never do this in production.');
847
+ }
848
+ else {
849
+ try {
850
+ const normalised = resolveBaseUrl({ VIDOFY_API_BASE: declaredPublic });
851
+ log(`public origin: ${normalised}`);
852
+ }
853
+ catch (err) {
854
+ log(`VIDOFY_MCP_PUBLIC_URL is not usable: ${err instanceof Error ? err.message : String(err)}`);
855
+ process.exit(1);
856
+ }
857
+ }
858
+ const http = createServer((req, res) => {
859
+ handle(req, res, version).catch((err) => {
860
+ /* Never let a throw here hang the socket. The client would sit on an
861
+ open connection with no answer, which reads as "Vidofy is down"
862
+ rather than as one failed request. */
863
+ log(`request failed: ${err instanceof Error ? err.message : String(err)}`);
864
+ if (!res.headersSent) {
865
+ res.writeHead(500, { 'content-type': 'application/json' });
866
+ res.end(JSON.stringify({ error: 'INTERNAL', message: 'Request failed.' }));
867
+ }
868
+ else {
869
+ res.end();
870
+ }
871
+ });
872
+ });
873
+ /* 127.0.0.1, not 0.0.0.0 — nginx is the only thing that should reach this.
874
+ Binding every interface would expose an unauthenticated-by-default port on
875
+ whatever network the box sits on, and TLS terminates at nginx so traffic
876
+ arriving here directly would be plaintext anyway. */
877
+ http.listen(port, '127.0.0.1', () => {
878
+ log(`remote MCP listening on http://127.0.0.1:${port}${MCP_PATH} — v${version}`);
879
+ /* After listen, not before: the heartbeat claims this connector is
880
+ serving, and until the socket is bound that claim is not yet true. */
881
+ startHeartbeat();
882
+ });
883
+ }
884
+ main();
885
+ //# sourceMappingURL=http.js.map