@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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /mcp-app/token` — the authorization code becomes an access token.
|
|
3
|
+
*
|
|
4
|
+
* This is the only place a credential leaves the server, so everything it checks
|
|
5
|
+
* is a precondition for handing one over:
|
|
6
|
+
*
|
|
7
|
+
* 1. the code exists and has not been used (GETDEL — one code, one token)
|
|
8
|
+
* 2. the client asking is the client the code was issued to
|
|
9
|
+
* 3. the redirect_uri matches the one bound to the code
|
|
10
|
+
* 4. PKCE: SHA256(code_verifier) equals the challenge recorded at /authorize
|
|
11
|
+
*
|
|
12
|
+
* PKCE IS THE WHOLE PROTECTION HERE
|
|
13
|
+
* ---------------------------------
|
|
14
|
+
* The code travels through the user's browser — in a URL, through history, past
|
|
15
|
+
* whatever extensions are installed. Anyone who captures it could exchange it,
|
|
16
|
+
* and the client authenticates with `none` (a public client has no secret to
|
|
17
|
+
* prove itself with). What stops the exchange is that the thief does not have
|
|
18
|
+
* the verifier: it never left the client. So this check is not defence in depth,
|
|
19
|
+
* it is the door.
|
|
20
|
+
*
|
|
21
|
+
* WHY THERE IS NO TOKEN GENERATION IN THIS FILE
|
|
22
|
+
* ---------------------------------------------
|
|
23
|
+
* The `vmt_` token was minted by the site when the user approved, and rides in
|
|
24
|
+
* the code record. The site owns the credential format, the hash, the scope and
|
|
25
|
+
* the expiry; a second implementation here would be a second definition of one
|
|
26
|
+
* credential.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
29
|
+
import { consumeCode } from './store.js';
|
|
30
|
+
/** Read a urlencoded body, capped — a token request is a few hundred bytes. */
|
|
31
|
+
async function readForm(req) {
|
|
32
|
+
const MAX = 8 * 1024;
|
|
33
|
+
const chunks = [];
|
|
34
|
+
let size = 0;
|
|
35
|
+
for await (const chunk of req) {
|
|
36
|
+
const b = chunk;
|
|
37
|
+
size += b.length;
|
|
38
|
+
if (size > MAX)
|
|
39
|
+
throw new Error('body too large');
|
|
40
|
+
chunks.push(b);
|
|
41
|
+
}
|
|
42
|
+
return new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
|
|
43
|
+
}
|
|
44
|
+
function fail(res, status, error, description) {
|
|
45
|
+
/* RFC 6749 §5.2: the token endpoint's errors are JSON with no-store, and the
|
|
46
|
+
status matters — a client distinguishes "your code is bad" (400) from
|
|
47
|
+
"come back later". */
|
|
48
|
+
res.writeHead(status, {
|
|
49
|
+
'content-type': 'application/json; charset=utf-8',
|
|
50
|
+
'cache-control': 'no-store',
|
|
51
|
+
pragma: 'no-cache',
|
|
52
|
+
});
|
|
53
|
+
res.end(JSON.stringify({ error, error_description: description }));
|
|
54
|
+
}
|
|
55
|
+
/** Constant-time compare of two base64url strings, safe on length mismatch. */
|
|
56
|
+
function sameSecret(a, b) {
|
|
57
|
+
const ab = Buffer.from(a, 'utf8');
|
|
58
|
+
const bb = Buffer.from(b, 'utf8');
|
|
59
|
+
/* timingSafeEqual throws when the lengths differ, and returning early on
|
|
60
|
+
that is fine: the length of a PKCE challenge is not the secret. */
|
|
61
|
+
if (ab.length !== bb.length)
|
|
62
|
+
return false;
|
|
63
|
+
return timingSafeEqual(ab, bb);
|
|
64
|
+
}
|
|
65
|
+
export async function handleToken(req, res) {
|
|
66
|
+
if (req.method !== 'POST') {
|
|
67
|
+
fail(res, 405, 'invalid_request', 'The token endpoint accepts POST.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let form;
|
|
71
|
+
try {
|
|
72
|
+
form = await readForm(req);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
fail(res, 400, 'invalid_request', 'Request body could not be read.');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const get = (k) => (form.get(k) ?? '').trim();
|
|
79
|
+
if (get('grant_type') !== 'authorization_code') {
|
|
80
|
+
/* The RFC's own code for this, and the reason we advertise only
|
|
81
|
+
authorization_code: there is no refresh token to grant. */
|
|
82
|
+
fail(res, 400, 'unsupported_grant_type', 'Only grant_type=authorization_code is supported.');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const code = get('code');
|
|
86
|
+
const verifier = get('code_verifier');
|
|
87
|
+
const clientId = get('client_id');
|
|
88
|
+
const redirectUri = get('redirect_uri');
|
|
89
|
+
if (code === '' || verifier === '') {
|
|
90
|
+
fail(res, 400, 'invalid_request', 'code and code_verifier are required.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
/* Consumed before anything is validated, deliberately.
|
|
94
|
+
*
|
|
95
|
+
* A code that fails any check below is burned either way — OAuth 2.1 says a
|
|
96
|
+
* code must not be reusable, and a failed attempt is the clearest sign it
|
|
97
|
+
* may have been stolen. Validating first and deleting after would leave a
|
|
98
|
+
* stolen code alive for as many guesses as the attacker wants. */
|
|
99
|
+
const issued = await consumeCode(code);
|
|
100
|
+
if (issued === null) {
|
|
101
|
+
fail(res, 400, 'invalid_grant', 'The authorization code is invalid or expired.');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
/* The client must be the one the code belongs to, and the redirect_uri must be
|
|
105
|
+
* the one the code was bound to. Since these are public clients with no
|
|
106
|
+
* secret, client_id is all there is to compare.
|
|
107
|
+
*
|
|
108
|
+
* ⚠ BOTH CHECKS USED TO BE OPTIONAL, and the comments that stood here stated
|
|
109
|
+
* the requirement while the code declined to enforce it: they read
|
|
110
|
+
* `clientId !== '' && clientId !== issued.clientId`, so a token request that
|
|
111
|
+
* simply OMITTED client_id and redirect_uri skipped both. RFC 6749 §4.1.3
|
|
112
|
+
* makes client_id REQUIRED for a public client precisely so a code cannot be
|
|
113
|
+
* redeemed by whoever holds it.
|
|
114
|
+
*
|
|
115
|
+
* That permissive branch served no case anyone could name — the measured
|
|
116
|
+
* clients (claude.ai and ChatGPT) both send client_id, and the project's rule
|
|
117
|
+
* is that a lenient branch must prove the case it exists for or fail closed.
|
|
118
|
+
* It now fails closed. */
|
|
119
|
+
if (clientId === '') {
|
|
120
|
+
fail(res, 400, 'invalid_request', 'client_id is required.');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (clientId !== issued.clientId) {
|
|
124
|
+
fail(res, 400, 'invalid_grant', 'The code was not issued to this client.');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (redirectUri === '') {
|
|
128
|
+
fail(res, 400, 'invalid_request', 'redirect_uri is required.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (redirectUri !== issued.redirectUri) {
|
|
132
|
+
fail(res, 400, 'invalid_grant', 'redirect_uri does not match the authorization request.');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
/* PKCE S256: BASE64URL(SHA256(ASCII(verifier))) must equal the challenge. */
|
|
136
|
+
const computed = createHash('sha256').update(verifier, 'ascii').digest('base64url');
|
|
137
|
+
if (!sameSecret(computed, issued.codeChallenge)) {
|
|
138
|
+
fail(res, 400, 'invalid_grant', 'code_verifier does not match the challenge.');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
/* ── Everything checked. Hand over the token. ────────────────────────────
|
|
142
|
+
*
|
|
143
|
+
* No `refresh_token`, by decision and with the spec's blessing: "MCP Clients
|
|
144
|
+
* MUST NOT assume refresh tokens will be issued; the AS retains discretion".
|
|
145
|
+
* The access token is long-lived instead, and `expires_in` says so honestly
|
|
146
|
+
* rather than implying forever.
|
|
147
|
+
*
|
|
148
|
+
* One year, matching the expiry the site stamped on the row. The two are
|
|
149
|
+
* written in two places and that is a drift risk worth naming: if the
|
|
150
|
+
* site's '+1 year' changes, this number must change with it. */
|
|
151
|
+
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
|
|
152
|
+
res.writeHead(200, {
|
|
153
|
+
'content-type': 'application/json; charset=utf-8',
|
|
154
|
+
// Required by RFC 6749 §5.1 — a cached token response is a leaked token.
|
|
155
|
+
'cache-control': 'no-store',
|
|
156
|
+
pragma: 'no-cache',
|
|
157
|
+
});
|
|
158
|
+
res.end(JSON.stringify({
|
|
159
|
+
access_token: issued.rawToken,
|
|
160
|
+
token_type: 'Bearer',
|
|
161
|
+
expires_in: ONE_YEAR_SECONDS,
|
|
162
|
+
scope: issued.scope,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=token.js.map
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn one model's admin-authored `m_options` into a JSON Schema an agent can
|
|
3
|
+
* fill in, plus the wire names needed to actually submit it.
|
|
4
|
+
*
|
|
5
|
+
* This is the widest surface in the package. A human wrote the options for every
|
|
6
|
+
* model in the catalogue, so the rule here is: describe only what the data
|
|
7
|
+
* actually contains, and stay silent rather than invent. A property this file
|
|
8
|
+
* omits costs the agent a feature; a property it invents costs the user a
|
|
9
|
+
* rejected generation they already waited for.
|
|
10
|
+
*
|
|
11
|
+
* SHAPES, MEASURED ACROSS THE WHOLE ACTIVE CATALOGUE — not read off the docs:
|
|
12
|
+
* m_aspect_ratio ARRAY all (often empty)
|
|
13
|
+
* m_resolution_quality ARRAY all (often empty)
|
|
14
|
+
* m_duration OBJECT some KEYS are the durations; each value is
|
|
15
|
+
* the list of resolutions that duration
|
|
16
|
+
* allows ({"5": [], "10": []} = any)
|
|
17
|
+
* m_output_number INTEGER all
|
|
18
|
+
* m_dynamic_fields ARRAY all a long tail of field names
|
|
19
|
+
* m_multi_upload OBJECT all 8 key sets, TWO families (see below)
|
|
20
|
+
* m_negative_prompt / m_seed / m_generate_audio /
|
|
21
|
+
* m_camera_fixed / m_enhance_prompt all BOOLEAN
|
|
22
|
+
*
|
|
23
|
+
* DYNAMIC FIELD TYPES IN USE — only five, though the docs list nine:
|
|
24
|
+
* radio_group · select · toggle · slider · file_upload_image
|
|
25
|
+
* Unknown types degrade to a string rather than throwing: a model added
|
|
26
|
+
* tomorrow must not break the whole catalogue.
|
|
27
|
+
*/
|
|
28
|
+
/** Minimal JSON Schema draft-07 subset — everything this file emits. */
|
|
29
|
+
export interface JsonSchemaProperty {
|
|
30
|
+
type: 'string' | 'number' | 'integer' | 'boolean';
|
|
31
|
+
description?: string;
|
|
32
|
+
enum?: Array<string | number>;
|
|
33
|
+
default?: string | number | boolean;
|
|
34
|
+
minimum?: number;
|
|
35
|
+
maximum?: number;
|
|
36
|
+
multipleOf?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface FileInput {
|
|
39
|
+
/** The name the agent uses. */
|
|
40
|
+
name: string;
|
|
41
|
+
/** The multipart field the server reads. */
|
|
42
|
+
wire: string;
|
|
43
|
+
required: boolean;
|
|
44
|
+
accepts: string[];
|
|
45
|
+
maxSizeMb: number | null;
|
|
46
|
+
maxDurationSec: number | null;
|
|
47
|
+
description: string;
|
|
48
|
+
/**
|
|
49
|
+
* Whether this slot accepts `{"from_generation": "<id>"}` instead of a path.
|
|
50
|
+
*
|
|
51
|
+
* Not a property of the slot but of the SERVER: reuse travels in
|
|
52
|
+
* regen_source_map, and the submit path keeps a whitelist of field names it
|
|
53
|
+
* will honour — m_image, m_video,
|
|
54
|
+
* m_audio, m_first_frame, m_last_frame, m_multi_file_N, and any dynamic
|
|
55
|
+
* file field by its own name. A name outside it is dropped in silence, so
|
|
56
|
+
* the job runs with no input and 422s on a slot the agent believes it
|
|
57
|
+
* filled. The per-type multi slots (m_multi_<type>_file_N) are the ones
|
|
58
|
+
* that fall outside, and get_model now says so instead of leaving it to be
|
|
59
|
+
* discovered by spending credits.
|
|
60
|
+
*/
|
|
61
|
+
supportsReuse: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface ModelSchema {
|
|
64
|
+
model_key: string;
|
|
65
|
+
/**
|
|
66
|
+
* The scene identifier for an effect model, '' for everything else.
|
|
67
|
+
*
|
|
68
|
+
* A large share of the catalogue are effect models, and this is what tells
|
|
69
|
+
* the price handler and the worker WHICH effect. Sent on the wire, never
|
|
70
|
+
* shown as an agent-facing input: it is a property of the model the agent
|
|
71
|
+
* already chose, not a decision it makes.
|
|
72
|
+
*/
|
|
73
|
+
effect_key: string;
|
|
74
|
+
slug: string;
|
|
75
|
+
name: string;
|
|
76
|
+
/**
|
|
77
|
+
* Absolute URL of the provider's logo, or '' when the row has none.
|
|
78
|
+
*
|
|
79
|
+
* Served from the site's own origin, not the CDN — the server prefixes the
|
|
80
|
+
* stored relative path with its own origin before returning it. Every
|
|
81
|
+
* active model carries one (measured), so a card can label a generation
|
|
82
|
+
* with the mark of whoever made it instead of a coloured placeholder.
|
|
83
|
+
*/
|
|
84
|
+
icon: string;
|
|
85
|
+
/** Short code (t2v) — what the agent sees and what list_models accepts. */
|
|
86
|
+
mode: string;
|
|
87
|
+
/** Long key (text-to-video) — what m_mode means on the wire. Not for display. */
|
|
88
|
+
mode_wire: string;
|
|
89
|
+
media_type: string;
|
|
90
|
+
/**
|
|
91
|
+
* FLOOR, not price: the cheapest this model can cost across every option
|
|
92
|
+
* combination — the "from X credits" badge the website shows on its model
|
|
93
|
+
* picker. It is the model row's `m_coins`, which the server computes by
|
|
94
|
+
* pricing the whole option grid and taking the minimum.
|
|
95
|
+
*
|
|
96
|
+
* The real price of a specific request is routinely a MULTIPLE of it — a
|
|
97
|
+
* high resolution on the same model can cost several times the floor.
|
|
98
|
+
* Named `_from` because the bare word
|
|
99
|
+
* `credits` reads as "the price", and an agent that reports it as one
|
|
100
|
+
* quotes the user a number they will not be charged.
|
|
101
|
+
*
|
|
102
|
+
* Only estimate_cost answers what a given input costs.
|
|
103
|
+
*/
|
|
104
|
+
credits_from: number | null;
|
|
105
|
+
estimated_seconds: number | null;
|
|
106
|
+
inputSchema: {
|
|
107
|
+
type: 'object';
|
|
108
|
+
properties: Record<string, JsonSchemaProperty>;
|
|
109
|
+
required: string[];
|
|
110
|
+
additionalProperties: false;
|
|
111
|
+
};
|
|
112
|
+
/** clean name → the m_* form field the server actually reads. */
|
|
113
|
+
wire: Record<string, string>;
|
|
114
|
+
files: FileInput[];
|
|
115
|
+
/**
|
|
116
|
+
* This model is billed by the DURATION OF THE MEDIA THE USER UPLOADS, not
|
|
117
|
+
* by the settings alone.
|
|
118
|
+
*
|
|
119
|
+
* It matters because estimate_cost never sends the file — the pricing
|
|
120
|
+
* endpoint receives no upload and would discard one — so for these models
|
|
121
|
+
* the quote covers the settings and the real charge is higher. Saying so
|
|
122
|
+
* is the difference between a price and a number.
|
|
123
|
+
*
|
|
124
|
+
* Read from m_pricing_mode, which the server DERIVES for this purpose: the
|
|
125
|
+
* model-info endpoint runs the same pricing resolver the
|
|
126
|
+
* submit path uses for its ffprobe gate, and normalises the answer into
|
|
127
|
+
* m_pricing_mode='per_second' whether it came from m_pricing_rules or the
|
|
128
|
+
* legacy m_options gate. So one field covers both, and it cannot drift
|
|
129
|
+
* from what is actually charged.
|
|
130
|
+
*
|
|
131
|
+
* Measured over the whole active catalogue: the field a client sees agrees
|
|
132
|
+
* with the resolver everywhere. A minority are upload-billed, and some of
|
|
133
|
+
* those are invisible in the raw m_options — reading the raw option blob
|
|
134
|
+
* instead of the served field would have been silently wrong for them.
|
|
135
|
+
*/
|
|
136
|
+
billedByUploadDuration: boolean;
|
|
137
|
+
/** Things the agent must know that a schema cannot express. */
|
|
138
|
+
notes: string[];
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* @param payload The whole `/info/model-info/{slug}` body. The B2C door wraps
|
|
142
|
+
* the model under `.model` with m_-prefixed keys; the B2B door returns the
|
|
143
|
+
* options flat and unprefixed. Both are accepted, and which one arrived is
|
|
144
|
+
* decided by the PAYLOAD, never by the configured mode — a status lookup can
|
|
145
|
+
* legitimately return the other shape.
|
|
146
|
+
*/
|
|
147
|
+
export declare function buildModelSchema(payload: unknown): ModelSchema;
|