@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/index.js
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @vidofy/mcp — the Vidofy MCP server.
|
|
4
|
+
*
|
|
5
|
+
* Lets a desktop AI client (Claude Desktop, Cursor, …) generate media with
|
|
6
|
+
* Vidofy, billed to the user's own account. Runs locally over stdio; the
|
|
7
|
+
* client launches it, so nothing here listens on a port.
|
|
8
|
+
*
|
|
9
|
+
* Nine tools: list_modes → list_models → get_model to choose, estimate_cost to
|
|
10
|
+
* price it, generate to run it, get_status and get_result to follow it, plus
|
|
11
|
+
* get_balance and get_usage.
|
|
12
|
+
*
|
|
13
|
+
* Eight of the nine are read-only. `generate` is the only one that spends the
|
|
14
|
+
* user's balance, and it is the only one whose annotations say so — which is
|
|
15
|
+
* what a client reads to decide whether to ask the user first.
|
|
16
|
+
*
|
|
17
|
+
* `upload_file` is deliberately absent: /app/v1 has no upload route at all
|
|
18
|
+
* (it is /api/v1 + an API key only), so on the account door files travel
|
|
19
|
+
* multipart with the submit itself, exactly as the studio posts them. It was
|
|
20
|
+
* planned as a key-mode tool; key mode is not served (see the refusal below),
|
|
21
|
+
* so nothing would ever call it. Nine tools is the whole set, not nine of ten.
|
|
22
|
+
*
|
|
23
|
+
* WHY stdout IS OFF LIMITS
|
|
24
|
+
* ------------------------
|
|
25
|
+
* stdio transport means stdout IS the protocol channel — one stray
|
|
26
|
+
* console.log() writes a non-JSON-RPC line into the stream and the client
|
|
27
|
+
* drops the connection with an error that names nothing. Every diagnostic in
|
|
28
|
+
* this package goes to stderr, which the client shows in its logs and ignores
|
|
29
|
+
* otherwise. There is a `log()` helper below; use it and never console.log.
|
|
30
|
+
*/
|
|
31
|
+
/* @modelcontextprotocol/server v2, not @modelcontextprotocol/sdk v1.
|
|
32
|
+
*
|
|
33
|
+
* A DIFFERENT npm name for the same project's next major version, which is the
|
|
34
|
+
* detail that cost a day: `npm view @modelcontextprotocol/sdk` reports 1.30.0 with
|
|
35
|
+
* no prerelease, so the conclusion "nothing implements 2026-07-28 yet" looked
|
|
36
|
+
* measured and was an artefact of searching one name.
|
|
37
|
+
*
|
|
38
|
+
* What the move buys is one thing, and it is the reason the card was missing on
|
|
39
|
+
* claude.ai: in the 2026-07-28 revision the client's capabilities travel in `_meta`
|
|
40
|
+
* on EVERY request instead of once at initialize, so a STATELESS server — which is
|
|
41
|
+
* what a remote connector is — can finally read them. Under the old revision they
|
|
42
|
+
* existed only on the initialize request, handled by a different Server object, and
|
|
43
|
+
* `hostRendersUi()` could never see them. See the note on that function.
|
|
44
|
+
*
|
|
45
|
+
* Handler registration changed shape and nothing else: a method NAME where v1 took
|
|
46
|
+
* a Zod schema constant. The tool table, the schemas, the card and every tool body
|
|
47
|
+
* are untouched.
|
|
48
|
+
*
|
|
49
|
+
* The v1 package is UNINSTALLED (2026-09-13) — the two were installed side by side
|
|
50
|
+
* only while the migration was in flight, so the build stayed green between steps.
|
|
51
|
+
* Nothing imports `@modelcontextprotocol/sdk` any more; `npm ls` no longer has it.
|
|
52
|
+
* Result schemas for tests come from this package's own `specTypeSchemas` — the
|
|
53
|
+
* very objects v2 validates against — not from `@modelcontextprotocol/core`,
|
|
54
|
+
* which is a transitive dependency that only resolves by npm's hoisting.
|
|
55
|
+
*/
|
|
56
|
+
import { Server } from '@modelcontextprotocol/server';
|
|
57
|
+
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
58
|
+
import { readFileSync } from 'node:fs';
|
|
59
|
+
import { dirname, join } from 'node:path';
|
|
60
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
61
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
62
|
+
import { loadConfig, ConfigError } from './config.js';
|
|
63
|
+
import { VidofyError } from './backend.js';
|
|
64
|
+
import { listModes, listModelsInput, listModels, getModelInput, getModel } from './tools/info.js';
|
|
65
|
+
import { estimateCostInput, estimateCost, generateInput, generate, generationIdInput, getStatus, getResult, generationResultInput, takePreview, cardState, } from './tools/generation.js';
|
|
66
|
+
import { getBalance, getUsageInput, getUsage } from './tools/account.js';
|
|
67
|
+
/* stderr, always — see the note above about stdout. Moved to its own leaf module
|
|
68
|
+
so `tools/` and `oauth/` can log without importing this file, which would make a
|
|
69
|
+
cycle; imported AND re-exported here so existing importers keep working and the
|
|
70
|
+
six call sites in this file still resolve. */
|
|
71
|
+
import { log } from './log.js';
|
|
72
|
+
export { log };
|
|
73
|
+
/** The version from package.json, so the User-Agent cannot drift from the release. */
|
|
74
|
+
export function readVersion() {
|
|
75
|
+
try {
|
|
76
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
77
|
+
// dist/index.js → ../package.json
|
|
78
|
+
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
|
|
79
|
+
return pkg.version ?? '0.0.0';
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return '0.0.0';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The tool table.
|
|
87
|
+
*
|
|
88
|
+
* Annotations are not decoration — a client uses `readOnlyHint` to decide
|
|
89
|
+
* whether it may call something without asking the user first. Every tool here
|
|
90
|
+
* is read-only EXCEPT `generate`, which spends the user's balance and says so
|
|
91
|
+
* with readOnlyHint:false and idempotentHint:false.
|
|
92
|
+
*/
|
|
93
|
+
/* ── MCP Apps (SEP-1865) ──────────────────────────────────────────────────
|
|
94
|
+
*
|
|
95
|
+
* A generation is a slow thing with a picture at the end, and a tool result is
|
|
96
|
+
* neither. Base64 in the result hits the host's 1 MB ceiling — 81% of real
|
|
97
|
+
* images are over it — and a URL is invisible to the model. This extension is
|
|
98
|
+
* the way out: the tool returns a small object, the host renders an HTML view
|
|
99
|
+
* in a sandboxed iframe, and the image loads straight from R2 with an <img>.
|
|
100
|
+
* No bytes in the result at all, so no ceiling.
|
|
101
|
+
*
|
|
102
|
+
* The CLIENT declares support, in its initialize request; the server declares
|
|
103
|
+
* nothing and is told to check before registering UI-enabled tools. Measured
|
|
104
|
+
* on this machine 2026-09-08: Claude Desktop declares it to a LOCAL STDIO
|
|
105
|
+
* server, which is what made this worth building.
|
|
106
|
+
*
|
|
107
|
+
* Everything here is conditional on that declaration. A client without it gets
|
|
108
|
+
* exactly the tools it got before — same names, same shapes, image block and
|
|
109
|
+
* link included — because a server that only works with a UI is a server that
|
|
110
|
+
* breaks for every text-only client.
|
|
111
|
+
*/
|
|
112
|
+
const CARD_URI = 'ui://vidofy/generation-card';
|
|
113
|
+
/**
|
|
114
|
+
* Whether this host can render an MCP Apps view.
|
|
115
|
+
*
|
|
116
|
+
* ⚠ THIS ANSWERS `false` ON THE REMOTE CONNECTOR, ALWAYS — and not because the
|
|
117
|
+
* host is text-only. Measured 2026-09-12 against a server built exactly like
|
|
118
|
+
* http.ts:
|
|
119
|
+
*
|
|
120
|
+
* initialize → getClientCapabilities() = {"extensions":{"io.modelcontextprotocol/ui":…}}
|
|
121
|
+
* tools/list → getClientCapabilities() = undefined
|
|
122
|
+
*
|
|
123
|
+
* `/mcp-app` is stateless: a fresh buildServer() and a fresh transport per HTTP
|
|
124
|
+
* request. So `initialize` and `tools/list` are answered by two DIFFERENT Server
|
|
125
|
+
* objects, and the one that lists the tools never saw a handshake. The SDK v2
|
|
126
|
+
* documents the same thing on its own capability accessor: "Per-request
|
|
127
|
+
* instances that never saw an initialize (stateless legacy) hold nothing, so
|
|
128
|
+
* gates refuse there."
|
|
129
|
+
*
|
|
130
|
+
* That is the whole reason the card shows in Claude Desktop and not on
|
|
131
|
+
* claude.ai — stdio is one long-lived server, a remote connector is not. It is
|
|
132
|
+
* NOT claude.ai declining to declare support; `extensions` is a declared field
|
|
133
|
+
* of ClientCapabilitiesSchema (sdk types.js:455) and survives parsing intact.
|
|
134
|
+
*
|
|
135
|
+
* The fix is protocol 2026-07-28, where client capabilities ride `_meta` on
|
|
136
|
+
* EVERY request, via @modelcontextprotocol/server@2.0.0 — measured to deliver
|
|
137
|
+
* them to a handler with no initialize at all. Until that migration lands this
|
|
138
|
+
* gate is honest for stdio and structurally shut for the connector, which is
|
|
139
|
+
* why nothing here is "fixed" by loosening it: attaching the view to a host
|
|
140
|
+
* that cannot render it trades a missing card for a blank frame.
|
|
141
|
+
*/
|
|
142
|
+
function hostRendersUi(server) {
|
|
143
|
+
const caps = server.getClientCapabilities();
|
|
144
|
+
return caps?.extensions?.['io.modelcontextprotocol/ui'] !== undefined;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* A tool's inputSchema, narrowed to the object schema the protocol requires.
|
|
148
|
+
*
|
|
149
|
+
* zodToJsonSchema is typed to return the WHOLE JSON-Schema union — a string schema,
|
|
150
|
+
* a number schema, anything — while v2's ToolSchema requires `type: "object"`
|
|
151
|
+
* literally. v1 accepted the union, which is why this is new: the stricter type is
|
|
152
|
+
* the newer library being right.
|
|
153
|
+
*
|
|
154
|
+
* Asserted at runtime rather than cast. Every call passes a `z.object`, so the
|
|
155
|
+
* narrowing is sound today, and a cast would stay silent on the day somebody
|
|
156
|
+
* declares a tool input as `z.string()` — the failure would then surface at the
|
|
157
|
+
* protocol boundary as a rejected tools/list with nothing naming the tool. This
|
|
158
|
+
* throws at startup, naming it.
|
|
159
|
+
*
|
|
160
|
+
* ⚠ The inner call is `zodToJsonSchema`, not this function. It was written as a
|
|
161
|
+
* self-call once (2026-09-13) and the result was total: infinite recursion, so
|
|
162
|
+
* `buildServer` threw before constructing a single tool and every request — stdio
|
|
163
|
+
* and HTTP alike — answered nothing. `tsc` cannot see it (the types are sound, a
|
|
164
|
+
* function may call itself), the build succeeds, and the line reads correctly at a
|
|
165
|
+
* glance. Only running the server catches it.
|
|
166
|
+
*/
|
|
167
|
+
function objectSchema(zod) {
|
|
168
|
+
const js = zodToJsonSchema(zod);
|
|
169
|
+
if (js['type'] !== 'object') {
|
|
170
|
+
throw new Error(`A tool inputSchema must be an object schema; got type=${JSON.stringify(js['type'])}. `
|
|
171
|
+
+ 'Wrap the input in z.object({ … }).');
|
|
172
|
+
}
|
|
173
|
+
return js;
|
|
174
|
+
}
|
|
175
|
+
/** The card's HTML, read from dist/ui — copied there by build/copy-ui.mjs. */
|
|
176
|
+
function cardHtml() {
|
|
177
|
+
return readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'ui', 'generation-card.html'), 'utf8');
|
|
178
|
+
}
|
|
179
|
+
function registerTools(server, cfg) {
|
|
180
|
+
const tools = [
|
|
181
|
+
{
|
|
182
|
+
name: 'list_modes',
|
|
183
|
+
description: 'List what Vidofy can generate — text-to-image, image-to-video, lipsync, ' +
|
|
184
|
+
'text-to-speech and so on. Start here, then call list_models with the mode code.',
|
|
185
|
+
inputSchema: { type: 'object', properties: {} },
|
|
186
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
187
|
+
run: async () => listModes(cfg),
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'list_models',
|
|
191
|
+
description: 'List the models available in one mode, with the rough duration of each and ' +
|
|
192
|
+
'credits_from — the CHEAPEST that model can cost, for comparing models against ' +
|
|
193
|
+
'each other. It is not the price of a request and is often several times under ' +
|
|
194
|
+
'it; only estimate_cost answers that. Use the mode code from list_modes (e.g. "t2i").',
|
|
195
|
+
inputSchema: objectSchema(listModelsInput),
|
|
196
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
197
|
+
run: async (args) => listModels(cfg, listModelsInput.parse(args)),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'get_model',
|
|
201
|
+
description: 'Everything needed to call generate on one model: a JSON Schema for its inputs, ' +
|
|
202
|
+
'which file slots it takes and their size limits, and notes the schema cannot ' +
|
|
203
|
+
'express. Call this after list_models and before generate. Its credits_from is ' +
|
|
204
|
+
'the model\'s cheapest possible price, not this request\'s — quote estimate_cost.',
|
|
205
|
+
inputSchema: objectSchema(getModelInput),
|
|
206
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
207
|
+
run: async (args) => getModel(cfg, getModelInput.parse(args)),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'estimate_cost',
|
|
211
|
+
description: 'What a generation will cost, before running it. Pass the SAME input you will ' +
|
|
212
|
+
'pass to generate — on some models the price varies 20x with the settings. ' +
|
|
213
|
+
'Show this to the user before spending their balance.',
|
|
214
|
+
inputSchema: objectSchema(estimateCostInput),
|
|
215
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
216
|
+
run: async (args) => estimateCost(cfg, estimateCostInput.parse(args)),
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: 'generate',
|
|
220
|
+
description: 'Run a generation. THIS SPENDS THE USER\'S BALANCE — call estimate_cost first ' +
|
|
221
|
+
'and tell them the price. Charged when the job is submitted, not when it ' +
|
|
222
|
+
'succeeds. Returns immediately with an id; the media is not ready yet. Check ' +
|
|
223
|
+
'get_status — which waits for you — and obey the check_again_in_seconds it ' +
|
|
224
|
+
'returns, then call get_result. Typical waits are 30 seconds for audio and one ' +
|
|
225
|
+
'to three minutes for an image or a video, so tell the user it is running ' +
|
|
226
|
+
'rather than checking over and over. Output is private unless public:true — ' +
|
|
227
|
+
'except on a free account, where it is always published and watermarked. ' +
|
|
228
|
+
'To chain — animate an image you just made, lipsync a video, and so on — pass ' +
|
|
229
|
+
'{"from_generation": "<earlier id>"} as the file input instead of a path. ' +
|
|
230
|
+
'Never download a Vidofy result just to upload it back.',
|
|
231
|
+
inputSchema: objectSchema(generateInput),
|
|
232
|
+
/* NOT readOnlyHint — this is the one tool that costs money, and
|
|
233
|
+
that flag is what a client uses to decide whether to run
|
|
234
|
+
something without asking. destructiveHint stays false: it
|
|
235
|
+
creates, it never removes. idempotentHint false because two
|
|
236
|
+
identical calls are two generations and two charges — the
|
|
237
|
+
Idempotency-Key makes ONE call's retries safe, which is a
|
|
238
|
+
different claim. */
|
|
239
|
+
annotations: {
|
|
240
|
+
readOnlyHint: false,
|
|
241
|
+
destructiveHint: false,
|
|
242
|
+
idempotentHint: false,
|
|
243
|
+
openWorldHint: true,
|
|
244
|
+
},
|
|
245
|
+
/* The only tool that takes the second argument. Every other `run`
|
|
246
|
+
below declares one parameter and stays valid — a function that
|
|
247
|
+
ignores an argument satisfies a signature that provides one. */
|
|
248
|
+
run: async (args, ctx) => generate(cfg, generateInput.parse(args), ctx),
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'get_balance',
|
|
252
|
+
description: 'The account balance, and how much of it expires with the current subscription. ' +
|
|
253
|
+
'Check before a costly generation.',
|
|
254
|
+
inputSchema: { type: 'object', properties: {} },
|
|
255
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
256
|
+
run: async () => getBalance(cfg),
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: 'get_usage',
|
|
260
|
+
description: 'Recent generations and what they cost: totals for the window plus the rows ' +
|
|
261
|
+
'behind them. Use it to answer "what have I spent".',
|
|
262
|
+
inputSchema: objectSchema(getUsageInput),
|
|
263
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
264
|
+
run: async (args) => getUsage(cfg, getUsageInput.parse(args)),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: 'get_status',
|
|
268
|
+
description: 'Whether a generation has finished. Returns done:true once it reaches a final ' +
|
|
269
|
+
'state, then call get_result. This call WAITS for up to 10 seconds before ' +
|
|
270
|
+
'answering, so it is never instant and never needs repeating straight away. ' +
|
|
271
|
+
'While a job is running the answer carries check_again_in_seconds — wait at ' +
|
|
272
|
+
'least that long before calling again. Nothing finishes in under 10 seconds and ' +
|
|
273
|
+
'most media takes one to three minutes, so calling in a tight loop only spends ' +
|
|
274
|
+
'the user\'s context to learn nothing.',
|
|
275
|
+
inputSchema: objectSchema(generationIdInput),
|
|
276
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
277
|
+
run: async (args) => getStatus(cfg, generationIdInput.parse(args)),
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: 'get_result',
|
|
281
|
+
description: 'The finished media, returned as an image you can actually see — an image ' +
|
|
282
|
+
'comes back as itself, a video as its poster frame. Also gives the link: ' +
|
|
283
|
+
'whether it lasts depends on the generation, a private one is signed and ' +
|
|
284
|
+
'expires in about 8 hours, a public one is a permanent CDN URL. The url_note ' +
|
|
285
|
+
'field says which — read it before telling the user to save the link, and ' +
|
|
286
|
+
'call this again for a fresh one if it expired. Pass include_preview:false to ' +
|
|
287
|
+
'get the link alone.',
|
|
288
|
+
inputSchema: objectSchema(generationResultInput),
|
|
289
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
290
|
+
run: async (args) => getResult(cfg, generationResultInput.parse(args)),
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
/* The card's own eyes. Registered app-only — the model never sees
|
|
294
|
+
* it, so a two-minute generation costs no tokens to wait through
|
|
295
|
+
* and leaves no trail of polling in the transcript.
|
|
296
|
+
*
|
|
297
|
+
* It exists because the alternative is worse: the view could fetch
|
|
298
|
+
* /app/v1 directly, but then this page — HTML the host renders in
|
|
299
|
+
* an iframe — would have to hold the user's MCP token. Routing the
|
|
300
|
+
* poll through the host to the server keeps the credential where
|
|
301
|
+
* it already is. */
|
|
302
|
+
name: 'generation_card_state',
|
|
303
|
+
description: 'Internal: current state of a generation, for the card view.',
|
|
304
|
+
inputSchema: objectSchema(generationIdInput),
|
|
305
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
306
|
+
appOnly: true,
|
|
307
|
+
run: async (args) => cardState(cfg, generationIdInput.parse(args)),
|
|
308
|
+
},
|
|
309
|
+
];
|
|
310
|
+
server.setRequestHandler('tools/list', async () => {
|
|
311
|
+
const ui = hostRendersUi(server);
|
|
312
|
+
return {
|
|
313
|
+
tools: tools
|
|
314
|
+
// A host that cannot render the view must not be shown a tool
|
|
315
|
+
// built only for it — it would be dead weight in the model's
|
|
316
|
+
// list, and one it might well try to call.
|
|
317
|
+
.filter((t) => ui || !('appOnly' in t && t.appOnly))
|
|
318
|
+
.map(({ name, description, inputSchema, annotations, ...rest }) => ({
|
|
319
|
+
name,
|
|
320
|
+
description,
|
|
321
|
+
inputSchema,
|
|
322
|
+
annotations,
|
|
323
|
+
/* The view is attached HERE, at list time, rather than
|
|
324
|
+
* baked into the definition: capabilities are known only
|
|
325
|
+
* after the handshake, and this handler runs after it.
|
|
326
|
+
*
|
|
327
|
+
* `generate` ONLY — deliberately not get_result. A card
|
|
328
|
+
* follows its own generation to the end, so putting one on
|
|
329
|
+
* get_result too meant the model calling it produced a
|
|
330
|
+
* SECOND card below the first: the result appeared
|
|
331
|
+
* somewhere new instead of in the card the user was already
|
|
332
|
+
* watching, and the finished card sat above it, stale.
|
|
333
|
+
* get_result keeps its text and its image block, which is
|
|
334
|
+
* what lets the model actually look at the output. */
|
|
335
|
+
...(ui && (name === 'generate' || 'appOnly' in rest)
|
|
336
|
+
? {
|
|
337
|
+
_meta: {
|
|
338
|
+
ui: {
|
|
339
|
+
resourceUri: CARD_URI,
|
|
340
|
+
visibility: 'appOnly' in rest ? ['app'] : ['model', 'app'],
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
: {}),
|
|
345
|
+
})),
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
/* The view itself.
|
|
349
|
+
*
|
|
350
|
+
* resourceDomains is the field that decides whether the picture appears:
|
|
351
|
+
* without it the host applies its default img-src 'self' data: and the
|
|
352
|
+
* card renders an empty frame however correct everything else is. Two
|
|
353
|
+
* hosts are needed because an output lives in one of two places — the
|
|
354
|
+
* public CDN when the user published it, and the private R2 bucket behind
|
|
355
|
+
* a signed URL otherwise, whose subdomain carries the account id and so
|
|
356
|
+
* differs per deployment; the wildcard covers it without pinning ours into
|
|
357
|
+
* a package other people install.
|
|
358
|
+
*
|
|
359
|
+
* connectDomains stays EMPTY on purpose. The card talks to the host, never
|
|
360
|
+
* to the network — see generation_card_state above. Granting it network
|
|
361
|
+
* reach it does not use would be handing an iframe a capability for
|
|
362
|
+
* nothing. */
|
|
363
|
+
server.setRequestHandler('resources/read', async (req) => {
|
|
364
|
+
if (req.params.uri !== CARD_URI) {
|
|
365
|
+
throw new VidofyError('RESOURCE_NOT_FOUND', `No resource at ${req.params.uri}.`);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
contents: [
|
|
369
|
+
{
|
|
370
|
+
uri: CARD_URI,
|
|
371
|
+
mimeType: 'text/html;profile=mcp-app',
|
|
372
|
+
text: cardHtml(),
|
|
373
|
+
_meta: {
|
|
374
|
+
ui: {
|
|
375
|
+
csp: {
|
|
376
|
+
connectDomains: [],
|
|
377
|
+
resourceDomains: [
|
|
378
|
+
'https://cdn.vidofy.ai',
|
|
379
|
+
'https://*.r2.cloudflarestorage.com',
|
|
380
|
+
/* The site's own origin, derived rather than
|
|
381
|
+
* written down: model logos are served from
|
|
382
|
+
* it (the server prefixes the stored path
|
|
383
|
+
* with its own origin), and it differs
|
|
384
|
+
* between a local instance and production.
|
|
385
|
+
* Omit it and the card falls
|
|
386
|
+
* back to a plain mark, which is a smaller
|
|
387
|
+
* failure than a blank frame but still a
|
|
388
|
+
* silent one. */
|
|
389
|
+
new URL(cfg.baseUrl).origin,
|
|
390
|
+
],
|
|
391
|
+
},
|
|
392
|
+
prefersBorder: false,
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
};
|
|
398
|
+
});
|
|
399
|
+
/* Listed, though the spec says UI-only resources MAY be omitted. Showing it
|
|
400
|
+
* costs one line and makes the server legible to anyone poking at it with
|
|
401
|
+
* an inspector, which is worth more than the line. */
|
|
402
|
+
server.setRequestHandler('resources/list', async () => ({
|
|
403
|
+
resources: hostRendersUi(server)
|
|
404
|
+
? [{ uri: CARD_URI, name: 'Vidofy generation card', mimeType: 'text/html;profile=mcp-app' }]
|
|
405
|
+
: [],
|
|
406
|
+
}));
|
|
407
|
+
server.setRequestHandler('tools/call', async (req, ctx) => {
|
|
408
|
+
const tool = tools.find((t) => t.name === req.params.name);
|
|
409
|
+
if (!tool) {
|
|
410
|
+
return {
|
|
411
|
+
content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }],
|
|
412
|
+
isError: true,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
/* `extra` was not even received here until 2026-09-13, so the
|
|
417
|
+
* cancellation signal the SDK provides was discarded.
|
|
418
|
+
*
|
|
419
|
+
* It is real and it is prompt — measured: a client that disconnects
|
|
420
|
+
* mid-call has this signal aborted 5 ms later, because http.ts closes
|
|
421
|
+
* the server on the response's `close` event and Protocol._onclose
|
|
422
|
+
* aborts every in-flight request handler.
|
|
423
|
+
*
|
|
424
|
+
* Passed to every tool, honoured by `generate` alone, and that is
|
|
425
|
+
* deliberate rather than unfinished: for a read-only tool, stopping
|
|
426
|
+
* early saves a few hundred milliseconds of work nobody is waiting
|
|
427
|
+
* for. For `generate` it is the difference between charging a user's
|
|
428
|
+
* coins for a request they abandoned and not charging them. See the
|
|
429
|
+
* checkpoints in tools/generation.ts — and the note there about why
|
|
430
|
+
* the signal must NOT be wired into the submit itself.
|
|
431
|
+
*
|
|
432
|
+
* ⚠ `ctx.mcpReq.signal` in v2, NOT `ctx.signal` as it was in v1 — the
|
|
433
|
+
* v2 context nests everything about the request under `mcpReq`. tsc
|
|
434
|
+
* caught the rename, which is the only reason it was not a silent
|
|
435
|
+
* regression: `undefined?.signal` is `undefined`, `abandoned()` would
|
|
436
|
+
* have read false forever, and the money guard would have become a
|
|
437
|
+
* no-op that still looked present in the diff. */
|
|
438
|
+
const result = await tool.run(req.params.arguments ?? {}, { signal: ctx?.mcpReq?.signal });
|
|
439
|
+
/* A tool may attach media to be SEEN, not just described.
|
|
440
|
+
*
|
|
441
|
+
* The model cannot look at a URL — it is a string. An image content
|
|
442
|
+
* block is the protocol's way to put the pixels in front of both
|
|
443
|
+
* the user and the model, and get_result uses it so an agent can
|
|
444
|
+
* say the light is wrong or which of two takes is better instead of
|
|
445
|
+
* handing over an address it has never opened.
|
|
446
|
+
*
|
|
447
|
+
* Held BESIDE the result in a WeakMap, never as a key on it. It was
|
|
448
|
+
* a Symbol key for a while — chosen so JSON.stringify would skip it
|
|
449
|
+
* — and that is precisely what broke every get_result carrying an
|
|
450
|
+
* image: the SDK validates the whole result against
|
|
451
|
+
* CallToolResultSchema, structuredContent is a record keyed by
|
|
452
|
+
* strings, and Zod walks own symbol keys too. The response was
|
|
453
|
+
* refused before it left as -32602, and the host said only "Failed
|
|
454
|
+
* to call tool". A WeakMap puts no key on the object at all. */
|
|
455
|
+
const image = takePreview(result);
|
|
456
|
+
/* The last word on size, and the only one that weighs what is
|
|
457
|
+
* actually sent.
|
|
458
|
+
*
|
|
459
|
+
* getResult estimates before fetching and again after encoding, but
|
|
460
|
+
* neither sees THIS object: the text is serialised with indentation
|
|
461
|
+
* here, structuredContent repeats it, and the frame carries its own
|
|
462
|
+
* JSON-RPC envelope. Under-count by a few kilobytes and the host
|
|
463
|
+
* rejects the whole result — the caller loses the url, the id and
|
|
464
|
+
* the status, not merely the picture — which is exactly the
|
|
465
|
+
* "Failed to call tool" an agent reported.
|
|
466
|
+
*
|
|
467
|
+
* Measured rather than reasoned about: build it, serialise it, and
|
|
468
|
+
* if it will not fit, send the same result without the image and
|
|
469
|
+
* say so. */
|
|
470
|
+
const text = JSON.stringify(result, null, 2);
|
|
471
|
+
const withImage = image !== null &&
|
|
472
|
+
Buffer.byteLength(text, 'utf8') * 2 + image.data.length + 4_096 <= 1_000_000;
|
|
473
|
+
return {
|
|
474
|
+
content: [
|
|
475
|
+
{
|
|
476
|
+
type: 'text',
|
|
477
|
+
text: image !== null && !withImage
|
|
478
|
+
? text +
|
|
479
|
+
'\n\n(The image was left out — the result would have exceeded the ' +
|
|
480
|
+
'1 MB a tool result may carry. Open the url.)'
|
|
481
|
+
: text,
|
|
482
|
+
},
|
|
483
|
+
...(withImage && image !== null
|
|
484
|
+
? [{ type: 'image', data: image.data, mimeType: image.mimeType }]
|
|
485
|
+
: []),
|
|
486
|
+
],
|
|
487
|
+
/* The same object again, as data rather than prose.
|
|
488
|
+
*
|
|
489
|
+
* This is how the generation card is fed: the host forwards it
|
|
490
|
+
* to the view as ui/notifications/tool-result, and the view's
|
|
491
|
+
* tools/call reads it back as res.structuredContent. It is
|
|
492
|
+
* specified as NOT entering the model's context, so the model
|
|
493
|
+
* still reads the text block above and nothing it sees
|
|
494
|
+
* changes — the duplication is the point, not an oversight. */
|
|
495
|
+
...(result !== null && typeof result === 'object' && !Array.isArray(result)
|
|
496
|
+
? { structuredContent: result }
|
|
497
|
+
: {}),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
/* isError, not a thrown exception. A thrown error becomes a
|
|
502
|
+
protocol-level failure the model cannot read or recover from;
|
|
503
|
+
an isError result puts the reason in front of it, so it can fix
|
|
504
|
+
the argument and try again. */
|
|
505
|
+
const text = err instanceof VidofyError
|
|
506
|
+
? `${err.code}: ${err.message}` +
|
|
507
|
+
(Object.keys(err.details).length ? `\n${JSON.stringify(err.details)}` : '')
|
|
508
|
+
: err instanceof Error
|
|
509
|
+
? err.message
|
|
510
|
+
: String(err);
|
|
511
|
+
log(`tool ${req.params.name} failed — ${text}`);
|
|
512
|
+
return {
|
|
513
|
+
content: [{ type: 'text', text }],
|
|
514
|
+
isError: true,
|
|
515
|
+
/* An error needs a BODY, because the card is bound to `generate`
|
|
516
|
+
* at list time (see _meta.ui above) — so a generate that fails
|
|
517
|
+
* still renders one. With content alone the view had nothing to
|
|
518
|
+
* read: structuredContent came through undefined, so it found no
|
|
519
|
+
* id, printed "checking…", called poll(), and poll() returned on
|
|
520
|
+
* its first line because there was no id to poll. No timer was
|
|
521
|
+
* ever set. The card stayed at "checking…" for the life of the
|
|
522
|
+
* conversation, over a generation that had already failed.
|
|
523
|
+
*
|
|
524
|
+
* These are the field names generation_card_state already
|
|
525
|
+
* returns, so the view's existing failure path reads them with
|
|
526
|
+
* no special case. `refunded` is deliberately absent rather than
|
|
527
|
+
* false: whether the coins came back is not known here, and the
|
|
528
|
+
* view prints that line only when it is told so.
|
|
529
|
+
*
|
|
530
|
+
* Harmless on the tools that have no card — nothing reads it.
|
|
531
|
+
* It does not reach the model either: structuredContent is
|
|
532
|
+
* specified as not entering the model's context, and the same
|
|
533
|
+
* text is already in the content block above. */
|
|
534
|
+
structuredContent: { status: 'error', done: true, error: text },
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Build a fully wired Server for one credential — everything except the
|
|
541
|
+
* transport.
|
|
542
|
+
*
|
|
543
|
+
* Extracted from main() so the remote transport can reuse it (src/http.ts).
|
|
544
|
+
* The split matters for one reason that is easy to miss: over stdio this
|
|
545
|
+
* process serves exactly ONE account for its whole life, while over HTTP each
|
|
546
|
+
* request carries a different person's token. So the Server cannot be a
|
|
547
|
+
* module-level singleton — it is built per caller, around that caller's Config,
|
|
548
|
+
* and `cfg` being a parameter rather than a global is what makes spending the
|
|
549
|
+
* wrong account's coins impossible by construction.
|
|
550
|
+
*/
|
|
551
|
+
export function buildServer(cfg, version) {
|
|
552
|
+
const server = new Server({ name: 'vidofy', version },
|
|
553
|
+
// `resources` is declared because the generation card is served as one
|
|
554
|
+
// (ui://vidofy/generation-card). A client that supports MCP Apps reads
|
|
555
|
+
// it through resources/read, and a server that never advertised the
|
|
556
|
+
// capability is one it will not ask.
|
|
557
|
+
{ capabilities: { tools: {}, resources: {} } });
|
|
558
|
+
/* Anything the transport itself rejects — a malformed JSON-RPC frame, for
|
|
559
|
+
one — is discarded silently by Protocol._onerror unless a handler is
|
|
560
|
+
assigned. Silence during a handshake failure is the hardest kind of bug
|
|
561
|
+
to report, so it goes to the client's log. */
|
|
562
|
+
server.onerror = (err) => {
|
|
563
|
+
log(`protocol error: ${err instanceof Error ? err.message : String(err)}`);
|
|
564
|
+
};
|
|
565
|
+
if (cfg.mode === 'key') {
|
|
566
|
+
/* This server is a PERSONAL product and stays one (owner decision,
|
|
567
|
+
* 2026-09-11): it spends the user's own coins, and an API key bills a
|
|
568
|
+
* balance it does not serve. Key mode is detected only so the refusal
|
|
569
|
+
* can explain itself rather than surfacing later as a bare 401.
|
|
570
|
+
*
|
|
571
|
+
* So no tools are registered here — and the message says the door is
|
|
572
|
+
* closed rather than "not yet". It read "not implemented yet"
|
|
573
|
+
* until the decision, which promised something that is not coming and
|
|
574
|
+
* left a partner waiting for it instead of using the API they already
|
|
575
|
+
* have.
|
|
576
|
+
*
|
|
577
|
+
* Nothing is registered rather than registering six tools that fail:
|
|
578
|
+
* they speak /app/v1 shapes, and /api/v1/info/model-info returns only
|
|
579
|
+
* the options — no name, model_key, credits or media_type (measured
|
|
580
|
+
* 2026-09-11) — so get_model alone could not fill half its answer.
|
|
581
|
+
* Six broken tools are worse than none. */
|
|
582
|
+
log('VIDOFY_API_KEY is not supported: this server is for personal Vidofy accounts '
|
|
583
|
+
+ 'and bills your own coins. Set VIDOFY_TOKEN (vmt_…) instead — create one at '
|
|
584
|
+
+ 'https://vidofy.ai/en/studio/account/mcp-tokens');
|
|
585
|
+
// Still answer tools/list. We advertised the `tools` capability, so a
|
|
586
|
+
// client WILL ask; an empty list is a valid answer, whereas leaving the
|
|
587
|
+
// method unhandled returns -32601 and reads as a broken server.
|
|
588
|
+
server.setRequestHandler('tools/list', async () => ({ tools: [] }));
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
registerTools(server, cfg);
|
|
592
|
+
}
|
|
593
|
+
/* Record what the host says it can do, once, after the handshake.
|
|
594
|
+
*
|
|
595
|
+
* This is the cheapest possible answer to a question that otherwise costs a
|
|
596
|
+
* feature to answer: can this client render an MCP Apps UI (SEP-1865), so a
|
|
597
|
+
* generation could show its own progress and its image inline instead of
|
|
598
|
+
* riding as base64 inside a tool result, where the host's 1 MB ceiling
|
|
599
|
+
* rejects 81% of real outputs?
|
|
600
|
+
*
|
|
601
|
+
* The extension is declared by the CLIENT, in its initialize request, under
|
|
602
|
+
* capabilities.extensions["io.modelcontextprotocol/ui"] — the server
|
|
603
|
+
* declares nothing, and the spec says servers SHOULD check this before
|
|
604
|
+
* registering UI-enabled tools. So the presence of that key IS the answer,
|
|
605
|
+
* and no HTML has to be written to find it out. It also matters per
|
|
606
|
+
* transport: the extension's docs state no restriction either way, and this
|
|
607
|
+
* server is local stdio while every sighting so far has been a remote one.
|
|
608
|
+
*
|
|
609
|
+
* Left in permanently rather than removed after the experiment: when the UI
|
|
610
|
+
* lands it has to be registered conditionally anyway, and a line in the
|
|
611
|
+
* client's own log is how anyone diagnoses "why is there no widget". */
|
|
612
|
+
server.oninitialized = () => {
|
|
613
|
+
try {
|
|
614
|
+
const caps = server.getClientCapabilities() ?? {};
|
|
615
|
+
const ui = caps.extensions?.['io.modelcontextprotocol/ui'];
|
|
616
|
+
log(`client capabilities: ${JSON.stringify(caps)} — MCP Apps (ui): ` +
|
|
617
|
+
(ui === undefined ? 'NOT declared' : `declared ${JSON.stringify(ui)}`));
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
log(`could not read client capabilities: ${err instanceof Error ? err.message : String(err)}`);
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
return server;
|
|
624
|
+
}
|
|
625
|
+
/** The stdio entry point: ONE account, read from the environment, for the life of the process. */
|
|
626
|
+
async function main() {
|
|
627
|
+
const version = readVersion();
|
|
628
|
+
let cfg;
|
|
629
|
+
try {
|
|
630
|
+
cfg = loadConfig(process.env, version);
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
/* A misconfiguration is the single most likely reason someone lands
|
|
634
|
+
here, so it gets a readable line and a non-zero exit instead of a
|
|
635
|
+
stack trace the client would swallow. */
|
|
636
|
+
if (err instanceof ConfigError) {
|
|
637
|
+
log(`configuration error: ${err.message}`);
|
|
638
|
+
process.exit(1);
|
|
639
|
+
}
|
|
640
|
+
throw err;
|
|
641
|
+
}
|
|
642
|
+
const server = buildServer(cfg, version);
|
|
643
|
+
const transport = new StdioServerTransport();
|
|
644
|
+
await server.connect(transport);
|
|
645
|
+
log(`ready — mode=${cfg.mode} base=${cfg.baseUrl} v${version}`);
|
|
646
|
+
}
|
|
647
|
+
/* Run main() only when this file IS the program, not when it is imported.
|
|
648
|
+
*
|
|
649
|
+
* src/http.ts imports buildServer from here. Without this guard that import
|
|
650
|
+
* would START A STDIO SERVER as a side effect — which over HTTP means a second
|
|
651
|
+
* server reading VIDOFY_TOKEN from the environment and holding stdin open, and
|
|
652
|
+
* the symptom would be the remote process appearing to hang at boot with no
|
|
653
|
+
* error anywhere. The comparison is the standard ESM main-module test: argv[1]
|
|
654
|
+
* is the script node was given, import.meta.url is this module. */
|
|
655
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
656
|
+
main().catch((err) => {
|
|
657
|
+
log(`fatal: ${err instanceof Error ? err.message : String(err)}`);
|
|
658
|
+
process.exit(1);
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
//# sourceMappingURL=index.js.map
|