@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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/backend.d.ts +133 -0
- package/dist/backend.js +455 -0
- package/dist/config.d.ts +85 -0
- package/dist/config.js +207 -0
- package/dist/heartbeat.d.ts +11 -0
- package/dist/heartbeat.js +70 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +885 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +661 -0
- package/dist/log.d.ts +19 -0
- package/dist/log.js +22 -0
- package/dist/map/b2c.d.ts +139 -0
- package/dist/map/b2c.js +236 -0
- package/dist/media-duration.d.ts +33 -0
- package/dist/media-duration.js +137 -0
- package/dist/oauth/authorize.d.ts +61 -0
- package/dist/oauth/authorize.js +275 -0
- package/dist/oauth/clients.d.ts +137 -0
- package/dist/oauth/clients.js +621 -0
- package/dist/oauth/ratelimit.d.ts +92 -0
- package/dist/oauth/ratelimit.js +116 -0
- package/dist/oauth/store.d.ts +135 -0
- package/dist/oauth/store.js +277 -0
- package/dist/oauth/token.d.ts +29 -0
- package/dist/oauth/token.js +165 -0
- package/dist/schema.d.ts +147 -0
- package/dist/schema.js +629 -0
- package/dist/tools/account.d.ts +28 -0
- package/dist/tools/account.js +130 -0
- package/dist/tools/generation.d.ts +102 -0
- package/dist/tools/generation.js +1194 -0
- package/dist/tools/info.d.ts +50 -0
- package/dist/tools/info.js +95 -0
- package/dist/ui/generation-card.html +869 -0
- package/package.json +79 -0
- package/server.json +36 -0
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
|