@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/config.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Where the server points, and which credential it carries.
3
+ *
4
+ * ONE credential, ONE door — never both. The two modes bill different wallets:
5
+ *
6
+ * VIDOFY_TOKEN (vmt_…) → the user's own coins (account mode — served)
7
+ * VIDOFY_API_KEY (vky_…) → a different balance (key mode — refused)
8
+ *
9
+ * Mixing them would mean a caller could not tell which balance a generation
10
+ * was going to spend until after it spent it, so a request carrying both is
11
+ * refused at startup rather than resolved by precedence.
12
+ *
13
+ * Key mode is DETECTED but NOT SERVED, and that is the settled shape of the
14
+ * product rather than a gap (owner decision, 2026-09-11): this server is for
15
+ * personal Vidofy accounts and spends their own coins. It is detected only so
16
+ * that a key can be refused with an explanation instead of failing later as an
17
+ * unexplained 401.
18
+ *
19
+ * Detected rather than ignored so that someone who sets VIDOFY_API_KEY is told
20
+ * where to go, instead of watching the server start and then fail on every
21
+ * call. See the refusal in index.ts.
22
+ */
23
+ export class ConfigError extends Error {
24
+ }
25
+ const DEFAULT_BASE = 'https://vidofy.ai';
26
+ /**
27
+ * Read the environment into a Config, or throw a message a human can act on.
28
+ *
29
+ * @param env Defaults to process.env; injectable so this is testable without
30
+ * mutating the real environment.
31
+ */
32
+ export function loadConfig(env = process.env, version = '0.0.0') {
33
+ const token = (env['VIDOFY_TOKEN'] ?? '').trim();
34
+ const apiKey = (env['VIDOFY_API_KEY'] ?? '').trim();
35
+ if (token !== '' && apiKey !== '') {
36
+ throw new ConfigError('Both VIDOFY_TOKEN and VIDOFY_API_KEY are set. They bill different balances — ' +
37
+ 'your personal coins and the API credit wallet — so pick one and remove the other.');
38
+ }
39
+ if (token === '' && apiKey === '') {
40
+ throw new ConfigError('No credential. Set VIDOFY_TOKEN to a personal MCP token from ' +
41
+ 'https://vidofy.ai/en/studio/account/mcp-tokens (this bills your own Vidofy coins).');
42
+ }
43
+ const mode = token !== '' ? 'account' : 'key';
44
+ const credential = token !== '' ? token : apiKey;
45
+ /* Catch a swapped credential HERE rather than as a 401 on the first call.
46
+ The prefixes are the server's own: vmt_ for a personal MCP token,
47
+ vky_ for a partner API key. */
48
+ if (mode === 'account' && !credential.startsWith('vmt_')) {
49
+ throw new ConfigError('VIDOFY_TOKEN does not look like a personal MCP token (they start with "vmt_"). ' +
50
+ (credential.startsWith('vky_')
51
+ ? 'That looks like an API key — set it as VIDOFY_API_KEY instead.'
52
+ : 'Create one at https://vidofy.ai/en/studio/account/mcp-tokens'));
53
+ }
54
+ if (mode === 'key' && !credential.startsWith('vky_')) {
55
+ throw new ConfigError('VIDOFY_API_KEY does not look like an API key (they start with "vky_"). ' +
56
+ (credential.startsWith('vmt_')
57
+ ? 'That looks like a personal MCP token — set it as VIDOFY_TOKEN instead.'
58
+ : 'Find yours in the API platform on vidofy.ai'));
59
+ }
60
+ return {
61
+ mode,
62
+ credential,
63
+ baseUrl: resolveBaseUrl(env),
64
+ version,
65
+ // Recorded server-side against the request in key mode and against the
66
+ // token in account mode, which is how "how much traffic came from MCP"
67
+ // is answered without any new tracking.
68
+ userAgent: `vidofy-mcp/${version}`,
69
+ };
70
+ }
71
+ /**
72
+ * A Config for ONE `vmt_` token, with the origin still taken from the process
73
+ * environment.
74
+ *
75
+ * This exists for the remote transport, where the two halves of a Config arrive
76
+ * from different places and at different times: the origin is server
77
+ * configuration, fixed at boot, while the credential belongs to whoever is
78
+ * making this particular request. loadConfig cannot serve that case — it reads
79
+ * the credential from the environment, which in a multi-user process would mean
80
+ * every caller spending one account's coins.
81
+ *
82
+ * The prefix check is deliberately the SAME one loadConfig applies. A remote
83
+ * caller pasting `vky_` into an Authorization header deserves the answer the
84
+ * local user gets, not a 401 that says nothing.
85
+ *
86
+ * @throws ConfigError — the caller turns it into a 401 with a readable message.
87
+ */
88
+ export function configForToken(token, env = process.env, version = '0.0.0', resource) {
89
+ const credential = token.trim();
90
+ if (credential === '') {
91
+ throw new ConfigError('No bearer token. Send Authorization: Bearer vmt_… with every request.');
92
+ }
93
+ if (!credential.startsWith('vmt_')) {
94
+ throw new ConfigError(credential.startsWith('vky_')
95
+ /* No full stop after either URL. A terminal that auto-links takes the
96
+ trailing period as part of the href, and the user clicks through to
97
+ a 404 — from a message whose whole job was to get them to that page. */
98
+ ? 'That is an API key (vky_…). This connector serves personal Vidofy accounts and bills '
99
+ + 'your own coins — set VIDOFY_TOKEN (vmt_…) instead, from '
100
+ + 'https://vidofy.ai/en/studio/account/mcp-tokens'
101
+ : 'Not a personal MCP token. They start with "vmt_" — create one at '
102
+ + 'https://vidofy.ai/en/studio/account/mcp-tokens');
103
+ }
104
+ return {
105
+ mode: 'account',
106
+ credential,
107
+ baseUrl: resolveBaseUrl(env),
108
+ version,
109
+ userAgent: `vidofy-mcp/${version}`,
110
+ ...(resource !== undefined && resource !== '' ? { resource } : {}),
111
+ };
112
+ }
113
+ /**
114
+ * Validate and normalise VIDOFY_API_BASE.
115
+ *
116
+ * Factored out of loadConfig so the remote transport cannot end up with a
117
+ * weaker check than the local one — the checks below are the whole reason a
118
+ * hostile value in claude_desktop_config.json cannot redirect a credential, and
119
+ * a second copy of them would be a second chance to get one wrong.
120
+ */
121
+ export function resolveBaseUrl(env = process.env) {
122
+ // VIDOFY_API_BASE exists for development against a local instance. Trailing
123
+ // slashes are stripped so callers can join paths without doubling them.
124
+ const baseUrl = ((env['VIDOFY_API_BASE'] ?? '').trim() || DEFAULT_BASE).replace(/\/+$/, '');
125
+ if (!/^https?:\/\//i.test(baseUrl)) {
126
+ throw new ConfigError(`VIDOFY_API_BASE must start with http:// or https:// — got "${baseUrl}"`);
127
+ }
128
+ /* The scheme check alone is not a check.
129
+ *
130
+ * This value is set in claude_desktop_config.json, which people copy and
131
+ * paste from install snippets they found somewhere — so it is exactly the
132
+ * field a hostile snippet would target, and the credential goes wherever it
133
+ * points. Measured, all of these passed the scheme test:
134
+ *
135
+ * https://vidofy.ai@evil.example → host is evil.example; the token ships there
136
+ * http://vidofy.ai → the vmt_ token crosses the network in clear
137
+ * https://vidofy.ai/# → every path lands on the homepage instead
138
+ * https://vidofy.ai?x=1 → same, and nothing says why
139
+ *
140
+ * The last two are not attacks, just silent breakage — which is worse to
141
+ * diagnose than a refusal at startup. */
142
+ let parsedBase;
143
+ try {
144
+ parsedBase = new URL(baseUrl);
145
+ }
146
+ catch {
147
+ throw new ConfigError(`VIDOFY_API_BASE is not a valid URL — got "${baseUrl}"`);
148
+ }
149
+ if (parsedBase.username !== '' || parsedBase.password !== '') {
150
+ throw new ConfigError(`VIDOFY_API_BASE must not contain a username or password. In "${baseUrl}" the real ` +
151
+ `host is "${parsedBase.host}", not what appears before the "@".`);
152
+ }
153
+ /* Tested on the RAW string, not on the parsed parts: a bare "#" produces
154
+ an empty `hash`, so `parsedBase.hash !== ''` waves through
155
+ "https://vidofy.ai/#" — which then swallows every path appended to it
156
+ and sends the whole API to the homepage. */
157
+ /* Whitespace is rejected on the RAW string too. WHATWG URL silently strips
158
+ a newline, so "https://vidofy.ai\nevil" parses as host `vidofy.aievil`
159
+ while the value a user skims in claude_desktop_config.json still reads
160
+ as vidofy.ai — the same smuggling this block exists to stop. */
161
+ if (/\s/.test(baseUrl)) {
162
+ throw new ConfigError('VIDOFY_API_BASE must not contain whitespace or line breaks.');
163
+ }
164
+ if (/[?#]/.test(baseUrl) || parsedBase.pathname !== '/') {
165
+ throw new ConfigError(`VIDOFY_API_BASE must be an origin only — no path, query or fragment. Got "${baseUrl}".`);
166
+ }
167
+ /* URL.hostname keeps the brackets on an IPv6 literal — `new URL('http://[::1]')`
168
+ gives '[::1]', not '::1' — so comparing against the bare form refused
169
+ IPv6 loopback although it is listed right here as allowed. Strip them
170
+ once and compare. `.localhost` and `.test` are RFC 6761 loopback/testing
171
+ names, and host.docker.internal is how a container reaches this host;
172
+ all three are development, none reaches the internet. */
173
+ const host = parsedBase.hostname.replace(/^\[|\]$/g, '').toLowerCase();
174
+ const localHost = host === 'localhost' ||
175
+ host === '127.0.0.1' ||
176
+ host === '::1' ||
177
+ host === 'host.docker.internal' ||
178
+ host.endsWith('.local') ||
179
+ host.endsWith('.localhost') ||
180
+ host.endsWith('.test');
181
+ if (parsedBase.protocol !== 'https:' && !localHost) {
182
+ throw new ConfigError(`VIDOFY_API_BASE must use https:// — "${baseUrl}" would send your credential in clear text. ` +
183
+ 'Plain http is accepted only for localhost and .local development hosts.');
184
+ }
185
+ // The parsed origin, not the raw string — so whatever survived the checks
186
+ // above is still normalised to scheme://host[:port] before anything
187
+ // concatenates a path onto it.
188
+ return parsedBase.origin;
189
+ }
190
+ /** The credential header for this mode. Kept next to loadConfig so the two cannot drift. */
191
+ export function authHeaders(cfg) {
192
+ const creds = cfg.mode === 'account'
193
+ ? { Authorization: `Bearer ${cfg.credential}` }
194
+ : { 'X-API-Key': cfg.credential };
195
+ /* The audience travels with the credential because it is part of deciding
196
+ whether the credential is valid HERE — see Config.resource. Omitted
197
+ entirely when there is none, since an empty header and no header are the
198
+ same thing to the reader but not to a proxy. */
199
+ return cfg.resource !== undefined && cfg.resource !== ''
200
+ ? { ...creds, 'X-Vidofy-MCP-Resource': cfg.resource }
201
+ : creds;
202
+ }
203
+ /** `/app/v1` or `/api/v1` — the door this mode speaks to. */
204
+ export function apiPrefix(cfg) {
205
+ return cfg.mode === 'account' ? '/app/v1' : '/api/v1';
206
+ }
207
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Start reporting liveness. Safe to call twice; the second call does nothing.
3
+ *
4
+ * Failure here must never take the connector down — it is a report, not the
5
+ * work. But it must not be silent either, because a silent failure looks exactly
6
+ * like a dead process on the dashboard and would send someone to restart a
7
+ * server that was serving traffic the whole time. So the first failure logs, and
8
+ * the rest do not (a Redis outage would otherwise write a line every 30s
9
+ * forever).
10
+ */
11
+ export declare function startHeartbeat(): void;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Liveness reporting, for the operator's own process dashboard.
3
+ *
4
+ * WHY THIS EXISTS. The remote connector runs as a long-lived managed process,
5
+ * and every such process on this platform is expected to report that it is
6
+ * alive. The dashboard does not interrogate the process manager — it reads a
7
+ * key the program writes for itself. A program that is registered and writes
8
+ * nothing therefore shows as **down forever** and raises a CRITICAL alert on a
9
+ * process that is perfectly healthy, which is worse than not registering it at
10
+ * all: the one alert nobody can act on is the one that teaches people to ignore
11
+ * the page.
12
+ *
13
+ * The contract is the same one every other worker on the platform writes, and
14
+ * the platform is the reference if the two ever disagree:
15
+ *
16
+ * value the unix timestamp, as a decimal string
17
+ * TTL 300s — deliberately longer than the dashboard's 120s down window, so a
18
+ * healthy program's key never expires between writes. Down-detection
19
+ * compares the STORED timestamp, not key presence, so an expired key and
20
+ * a stale one read the same.
21
+ * every 30s
22
+ *
23
+ * The `cache:` prefix is correct here, unlike `mcp_oauth:`: this value is a
24
+ * report about something else, so an operator clearing the cache erases one
25
+ * 30-second window and no state. A pending authorization erased would cost a
26
+ * user their sign-in, which is why that one sits outside the prefix.
27
+ */
28
+ import { redisClient } from './oauth/store.js';
29
+ import { log } from './log.js';
30
+ /** Matches the platform's own workers. The dashboard's window is 120s. */
31
+ const INTERVAL_MS = 30_000;
32
+ const TTL_SEC = 300;
33
+ /** The managed-process name — this IS the dashboard's key, so it must match. */
34
+ const PROGRAM = 'vidofy-mcp';
35
+ let timer = null;
36
+ let warned = false;
37
+ async function beat() {
38
+ const client = await redisClient();
39
+ await client.setEx(`cache:scheduled_tasks:hb:${PROGRAM}`, TTL_SEC, String(Math.floor(Date.now() / 1000)));
40
+ }
41
+ /**
42
+ * Start reporting liveness. Safe to call twice; the second call does nothing.
43
+ *
44
+ * Failure here must never take the connector down — it is a report, not the
45
+ * work. But it must not be silent either, because a silent failure looks exactly
46
+ * like a dead process on the dashboard and would send someone to restart a
47
+ * server that was serving traffic the whole time. So the first failure logs, and
48
+ * the rest do not (a Redis outage would otherwise write a line every 30s
49
+ * forever).
50
+ */
51
+ export function startHeartbeat() {
52
+ if (timer !== null)
53
+ return;
54
+ const tick = () => {
55
+ beat().catch((err) => {
56
+ if (warned)
57
+ return;
58
+ warned = true;
59
+ log(`heartbeat failed, so the process dashboard will read this connector as DOWN `
60
+ + `even while it serves traffic: ${err instanceof Error ? err.message : String(err)}`);
61
+ });
62
+ };
63
+ tick(); // the dashboard should not wait 30s for the first one
64
+ timer = setInterval(tick, INTERVAL_MS);
65
+ /* Unreferenced so it can never be the reason the process stays alive — the
66
+ HTTP server is what holds it open, and a lingering interval would keep a
67
+ shutting-down process from exiting. */
68
+ timer.unref();
69
+ }
70
+ //# sourceMappingURL=heartbeat.js.map
package/dist/http.d.ts ADDED
@@ -0,0 +1,44 @@
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
+ export {};