@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/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