@captionbolt/mcp 0.0.0-stage → 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.mjs ADDED
@@ -0,0 +1,1681 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import {
5
+ InsufficientScopeError as InsufficientScopeError2,
6
+ UnauthorizedError as UnauthorizedError3
7
+ } from "@modelcontextprotocol/client";
8
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
9
+
10
+ // ../integration-contracts/mcp.ts
11
+ import {
12
+ McpServer,
13
+ requireScopes
14
+ } from "@modelcontextprotocol/server";
15
+ import { z as z2 } from "zod";
16
+
17
+ // ../integration-contracts/index.ts
18
+ import { z } from "zod";
19
+ var integrationScopes = [
20
+ "projects:read",
21
+ "projects:write",
22
+ "exports:write",
23
+ "webhooks:manage"
24
+ ];
25
+ var integrationEvents = [
26
+ "project.ready",
27
+ "project.completed",
28
+ "project.failed",
29
+ "project.cancelled"
30
+ ];
31
+ var createApiKeySchema = z.object({
32
+ name: z.string().trim().min(1).max(80),
33
+ scopes: z.array(z.enum(integrationScopes)).min(1).max(4),
34
+ expiresInDays: z.number().int().min(1).max(365).nullable().default(90)
35
+ }).strict();
36
+ var endpointSchema = z.object({
37
+ url: z.string().url().max(2048),
38
+ events: z.array(z.enum(integrationEvents)).min(1).max(4).default(["project.completed", "project.failed"])
39
+ }).strict();
40
+ var pageSchema = z.object({
41
+ search: z.string().max(200).optional(),
42
+ offset: z.number().int().min(0).max(1e5).default(0),
43
+ limit: z.number().int().min(1).max(100).default(50)
44
+ }).strict();
45
+ var projectIdSchema = z.object({ projectId: z.string().uuid() }).strict();
46
+ var outputFormatSchema = z.enum(["mp4", "srt", "vtt", "ass", "txt"]);
47
+ var exportProjectSchema = projectIdSchema.extend({
48
+ outputFormats: z.array(outputFormatSchema).min(1).max(5).default(["mp4"])
49
+ });
50
+ var uploadIdSchema = z.object({ uploadId: z.string().uuid() }).strict();
51
+ var createUploadSchema = z.object({
52
+ fileName: z.string().trim().min(1).max(512),
53
+ fileSize: z.number().int().positive().safe(),
54
+ lastModifiedMs: z.number().int().nonnegative().safe(),
55
+ fingerprint: z.string().regex(/^[a-f0-9]{64}$/u),
56
+ durationSec: z.number().positive().finite().optional(),
57
+ contentType: z.enum([
58
+ "video/mp4",
59
+ "video/quicktime",
60
+ "video/webm",
61
+ "video/x-msvideo"
62
+ ])
63
+ }).strict();
64
+ var createProjectSchema = z.object({
65
+ uploadSessionId: z.string().uuid(),
66
+ language: z.string().min(2).max(5).optional(),
67
+ styleId: z.string().min(1).max(100).optional(),
68
+ presetId: z.string().min(1).max(100).optional(),
69
+ mode: z.enum(["review", "auto-export"]).default("review")
70
+ }).strict().refine((value) => !(value.styleId && value.presetId), {
71
+ message: "Choose a style or a preset"
72
+ });
73
+ var operationSchema = z.object({
74
+ id: z.string(),
75
+ resourceId: z.string(),
76
+ state: z.enum(["pending", "running", "completed", "failed"]),
77
+ error: z.string().nullable(),
78
+ pollAfterSeconds: z.number()
79
+ });
80
+ var projectSchema = z.object({
81
+ id: z.string(),
82
+ fileName: z.string().nullable(),
83
+ status: z.enum([
84
+ "processing",
85
+ "ready",
86
+ "exporting",
87
+ "completed",
88
+ "failed",
89
+ "cancelled"
90
+ ]),
91
+ progress: z.number(),
92
+ createdAt: z.string(),
93
+ expiresAt: z.string(),
94
+ error: z.string().nullable(),
95
+ pollAfterSeconds: z.number(),
96
+ autoExport: operationSchema.nullable().optional()
97
+ });
98
+ var uploadSchema = z.object({
99
+ id: z.string(),
100
+ projectId: z.string(),
101
+ status: z.enum([
102
+ "pending",
103
+ "uploading",
104
+ "completing",
105
+ "uploaded",
106
+ "consumed",
107
+ "aborted",
108
+ "expired"
109
+ ]),
110
+ fileName: z.string(),
111
+ fileSize: z.number(),
112
+ contentType: z.string(),
113
+ lastModifiedMs: z.number(),
114
+ fingerprint: z.string(),
115
+ partSize: z.number(),
116
+ partCount: z.number(),
117
+ completedPartNumbers: z.array(z.number()),
118
+ expiresAt: z.string()
119
+ });
120
+ var accountSchema = z.object({
121
+ credits: z.object({
122
+ totalCreditsSec: z.number(),
123
+ usedCreditsSec: z.number(),
124
+ reservedCreditsSec: z.number(),
125
+ availableCreditsSec: z.number(),
126
+ periodStart: z.string().nullable(),
127
+ periodEnd: z.string().nullable()
128
+ }),
129
+ upload: z.object({
130
+ maxFileSizeBytes: z.number(),
131
+ maxDurationSec: z.number()
132
+ }),
133
+ outputFormats: z.array(outputFormatSchema),
134
+ maxConcurrentJobs: z.number(),
135
+ watermark: z.boolean(),
136
+ projectAccessDays: z.number()
137
+ });
138
+ var styleListSchema = z.object({
139
+ items: z.array(
140
+ z.object({
141
+ id: z.string(),
142
+ name: z.string(),
143
+ description: z.string(),
144
+ category: z.string(),
145
+ tags: z.array(z.string())
146
+ })
147
+ ),
148
+ total: z.number()
149
+ });
150
+ var presetListSchema = z.object({
151
+ items: z.array(z.object({ id: z.string(), name: z.string() })),
152
+ total: z.number()
153
+ });
154
+ var projectListSchema = z.object({
155
+ items: z.array(projectSchema),
156
+ total: z.number().nullable()
157
+ });
158
+ var resultSchema = z.object({
159
+ projectId: z.string(),
160
+ files: z.array(
161
+ z.object({ format: outputFormatSchema, url: z.string().url() })
162
+ ),
163
+ expiresAt: z.string()
164
+ });
165
+ var signedPartsSchema = z.object({
166
+ completedPartNumbers: z.array(z.number()),
167
+ signedParts: z.array(
168
+ z.object({
169
+ partNumber: z.number(),
170
+ contentLength: z.number(),
171
+ url: z.string().url()
172
+ })
173
+ )
174
+ });
175
+ var webhookSchema = z.object({
176
+ id: z.string(),
177
+ url: z.string().url(),
178
+ events: z.array(z.string()),
179
+ enabled: z.boolean(),
180
+ createdAt: z.date()
181
+ });
182
+ var deliverySchema = z.object({
183
+ id: z.string(),
184
+ eventId: z.string(),
185
+ type: z.string(),
186
+ state: z.string(),
187
+ attempts: z.number(),
188
+ statusCode: z.number().nullable(),
189
+ errorCode: z.string().nullable(),
190
+ createdAt: z.date(),
191
+ nextAttemptAt: z.date(),
192
+ finishedAt: z.date().nullable()
193
+ });
194
+
195
+ // ../integration-contracts/mcp.ts
196
+ var IntegrationRequestError = class extends Error {
197
+ constructor(message, options) {
198
+ super(message);
199
+ this.name = "IntegrationRequestError";
200
+ this.cause = options.cause;
201
+ this.kind = options.kind;
202
+ this.status = options.status;
203
+ this.code = options.code;
204
+ this.requestId = options.requestId;
205
+ this.retryAfterSeconds = options.retryAfterSeconds;
206
+ }
207
+ };
208
+ function getIntegrationRequestErrorDetails(error) {
209
+ if (error instanceof IntegrationRequestError) {
210
+ return {
211
+ message: error.message,
212
+ kind: error.kind,
213
+ ...error.status === void 0 ? {} : { status: error.status },
214
+ ...error.code === void 0 ? {} : { code: error.code },
215
+ ...error.requestId === void 0 ? {} : { requestId: error.requestId },
216
+ ...error.retryAfterSeconds === void 0 ? {} : { retryAfterSeconds: error.retryAfterSeconds }
217
+ };
218
+ }
219
+ return { message: "Request failed", kind: "invalid-response" };
220
+ }
221
+ function createIntegrationMcpErrorResult(error) {
222
+ const details = getIntegrationRequestErrorDetails(error);
223
+ return {
224
+ isError: true,
225
+ content: [
226
+ {
227
+ type: "text",
228
+ text: JSON.stringify({ error: details })
229
+ }
230
+ ],
231
+ structuredContent: { error: details }
232
+ };
233
+ }
234
+ function parseRetryAfterSeconds(value, now = Date.now()) {
235
+ if (!value) {
236
+ return void 0;
237
+ }
238
+ const numeric = Number(value);
239
+ if (Number.isFinite(numeric) && numeric >= 0) {
240
+ const seconds2 = Math.ceil(numeric);
241
+ return Number.isSafeInteger(seconds2) ? seconds2 : void 0;
242
+ }
243
+ const date = Date.parse(value);
244
+ if (!Number.isFinite(date)) {
245
+ return void 0;
246
+ }
247
+ const seconds = Math.max(0, Math.ceil((date - now) / 1e3));
248
+ return Number.isSafeInteger(seconds) ? seconds : void 0;
249
+ }
250
+ var commandId = {
251
+ idempotencyKey: z2.string().min(1).max(128).describe(
252
+ "Reuse this exact key when retrying the same request. Use a new key for a new action."
253
+ )
254
+ };
255
+ function createIntegrationMcpServer(request, options = {}) {
256
+ const server = new McpServer({ name: "captionbolt", version: "0.1.0" });
257
+ function tool(name, description, schema, method, path, readOnly) {
258
+ server.registerTool(
259
+ name,
260
+ {
261
+ description,
262
+ scopeChallenge: name === "retry_project" && options.retryScopeChallenge ? options.retryScopeChallenge : name === "create_project" ? (context) => requireScopes(
263
+ "projects:write",
264
+ ...context.request.params?.arguments?.mode === "auto-export" ? ["exports:write"] : []
265
+ )(context) : name === "export_project" ? requireScopes("exports:write") : requireScopes(
266
+ readOnly ? "projects:read" : "projects:write"
267
+ ),
268
+ inputSchema: schema,
269
+ annotations: {
270
+ readOnlyHint: readOnly,
271
+ destructiveHint: name === "cancel_project",
272
+ idempotentHint: true,
273
+ openWorldHint: true
274
+ }
275
+ },
276
+ async (raw) => {
277
+ if (options.executeTool) {
278
+ try {
279
+ return await options.executeTool(
280
+ name,
281
+ raw
282
+ );
283
+ } catch (error) {
284
+ return createIntegrationMcpErrorResult(error);
285
+ }
286
+ }
287
+ const { projectId, id, idempotencyKey, ...input } = raw;
288
+ const url = path.replace(
289
+ "{projectId}",
290
+ encodeURIComponent(String(projectId))
291
+ ).replace("{id}", encodeURIComponent(String(id)));
292
+ try {
293
+ let result = await request(
294
+ method,
295
+ url,
296
+ input,
297
+ typeof idempotencyKey === "string" ? idempotencyKey : void 0
298
+ );
299
+ if (options.editorBaseUrl && name === "get_project") {
300
+ result = {
301
+ ...result,
302
+ editorUrl: new URL(
303
+ `/editor/${encodeURIComponent(String(projectId))}`,
304
+ options.editorBaseUrl
305
+ ).href
306
+ };
307
+ }
308
+ return {
309
+ content: [
310
+ { type: "text", text: JSON.stringify(result) }
311
+ ],
312
+ structuredContent: result
313
+ };
314
+ } catch (error) {
315
+ return createIntegrationMcpErrorResult(error);
316
+ }
317
+ }
318
+ );
319
+ }
320
+ tool(
321
+ "get_account",
322
+ "Check the connected account's remaining processing minutes, upload limits and export formats. Safe to call during setup.",
323
+ z2.object({}).strict(),
324
+ "GET",
325
+ "/v1/account",
326
+ true
327
+ );
328
+ tool(
329
+ "list_styles",
330
+ "Find available caption styles. All plans have the full style catalog.",
331
+ pageSchema,
332
+ "GET",
333
+ "/v1/styles",
334
+ true
335
+ );
336
+ tool(
337
+ "list_presets",
338
+ "Find usable presets saved by this account.",
339
+ pageSchema,
340
+ "GET",
341
+ "/v1/presets",
342
+ true
343
+ );
344
+ tool(
345
+ "list_projects",
346
+ "List video projects within their 14-day access window.",
347
+ pageSchema,
348
+ "GET",
349
+ "/v1/projects",
350
+ true
351
+ );
352
+ tool(
353
+ "get_project",
354
+ "Check a project's processing status and editorUrl. Poll at pollAfterSeconds; processing continues when the agent disconnects.",
355
+ projectIdSchema,
356
+ "GET",
357
+ "/v1/projects/{projectId}",
358
+ true
359
+ );
360
+ tool(
361
+ "get_operation",
362
+ "Read an accepted command. Its resourceId identifies the upload or project; a completed command does not imply a completed video.",
363
+ z2.object({ id: z2.string().uuid() }).strict(),
364
+ "GET",
365
+ "/v1/operations/{id}",
366
+ true
367
+ );
368
+ tool(
369
+ "create_project",
370
+ "Create a project from a completed uploadSessionId. Prefer review so the user can inspect captions in the Editor; use auto-export only on explicit request.",
371
+ createProjectSchema.safeExtend(commandId),
372
+ "POST",
373
+ "/v1/projects",
374
+ false
375
+ );
376
+ tool(
377
+ "export_project",
378
+ "Export the latest saved project document. Completed projects return their existing result. Unsaved editor changes are excluded.",
379
+ exportProjectSchema.extend(commandId),
380
+ "POST",
381
+ "/v1/projects/{projectId}/export",
382
+ false
383
+ );
384
+ tool(
385
+ "retry_project",
386
+ "Explicitly retry a failed project after addressing the cause. Shares the account's current quota.",
387
+ projectIdSchema.extend(commandId),
388
+ "POST",
389
+ "/v1/projects/{projectId}/retry",
390
+ false
391
+ );
392
+ tool(
393
+ "cancel_project",
394
+ "Cancel the current project run. Completed outputs are preserved.",
395
+ projectIdSchema.extend(commandId),
396
+ "POST",
397
+ "/v1/projects/{projectId}/cancel",
398
+ false
399
+ );
400
+ tool(
401
+ "get_result",
402
+ "Get short-lived download URLs for a completed export. Download before expiresAt; request fresh URLs as needed.",
403
+ projectIdSchema,
404
+ "GET",
405
+ "/v1/projects/{projectId}/result",
406
+ true
407
+ );
408
+ return server;
409
+ }
410
+
411
+ // src/index.ts
412
+ import { z as z4 } from "zod";
413
+
414
+ // src/client.ts
415
+ import {
416
+ InsufficientScopeError,
417
+ UnauthorizedError as UnauthorizedError2,
418
+ withOAuth
419
+ } from "@modelcontextprotocol/client";
420
+
421
+ // src/oauth.ts
422
+ import { spawn } from "node:child_process";
423
+ import { randomUUID } from "node:crypto";
424
+ import { createServer } from "node:http";
425
+ import {
426
+ Client,
427
+ RegistrationRejectedError,
428
+ StreamableHTTPClientTransport,
429
+ UnauthorizedError
430
+ } from "@modelcontextprotocol/client";
431
+ function apiOrigin(value) {
432
+ const url = new URL(value);
433
+ const loopback = url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
434
+ if (!loopback && url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
435
+ throw new Error(
436
+ "CAPTIONBOLT_API_URL must be an HTTPS origin (HTTP loopback for development)"
437
+ );
438
+ }
439
+ return url;
440
+ }
441
+ async function entry(origin2) {
442
+ try {
443
+ const { Entry } = await import("@napi-rs/keyring");
444
+ return new Entry("captionbolt-mcp-oauth", origin2.origin);
445
+ } catch {
446
+ throw new IntegrationRequestError(
447
+ "OS credential store unavailable. Enable a desktop keyring before connecting CaptionBolt.",
448
+ { kind: "invalid-response", code: "CREDENTIAL_STORE_UNAVAILABLE" }
449
+ );
450
+ }
451
+ }
452
+ async function readSaved(origin2) {
453
+ const value = (await entry(origin2)).getPassword();
454
+ if (!value) {
455
+ return {};
456
+ }
457
+ try {
458
+ return JSON.parse(value);
459
+ } catch {
460
+ throw new IntegrationRequestError(
461
+ "Saved CaptionBolt authorization is invalid. Run captionbolt-mcp logout, then login.",
462
+ { kind: "invalid-response", code: "CREDENTIAL_INVALID" }
463
+ );
464
+ }
465
+ }
466
+ function credentialStore(origin2) {
467
+ return {
468
+ read: () => readSaved(origin2),
469
+ async save(value) {
470
+ (await entry(origin2)).setPassword(JSON.stringify(value));
471
+ },
472
+ async clear() {
473
+ (await entry(origin2)).deletePassword();
474
+ }
475
+ };
476
+ }
477
+ async function hasAuthorization(origin2) {
478
+ return Boolean((await readSaved(origin2)).tokens?.access_token);
479
+ }
480
+ async function logout(origin2) {
481
+ await credentialStore(origin2).clear();
482
+ }
483
+ function oauthProvider(origin2, redirectUrl, onAuthorize, options = {}) {
484
+ let verifier = "";
485
+ let discovery;
486
+ const store = options.store ?? credentialStore(origin2);
487
+ const updateSaved = async (patch) => {
488
+ await store.save({ ...await store.read(), ...patch });
489
+ };
490
+ return {
491
+ redirectUrl,
492
+ clientMetadata: {
493
+ client_name: "CaptionBolt local MCP",
494
+ redirect_uris: [redirectUrl],
495
+ grant_types: ["authorization_code", "refresh_token"],
496
+ response_types: ["code"],
497
+ token_endpoint_auth_method: "none",
498
+ scope: "projects:read projects:write exports:write offline_access"
499
+ },
500
+ state: () => {
501
+ const state = randomUUID();
502
+ options.onState?.(state);
503
+ return state;
504
+ },
505
+ async clientInformation(ctx) {
506
+ const saved = await store.read();
507
+ return !ctx || saved.issuer === ctx.issuer ? saved.client : void 0;
508
+ },
509
+ async saveClientInformation(client, ctx) {
510
+ await updateSaved({
511
+ client,
512
+ ...ctx ? { issuer: ctx.issuer } : {}
513
+ });
514
+ },
515
+ async tokens(ctx) {
516
+ const saved = await store.read();
517
+ return !ctx || saved.issuer === ctx.issuer ? saved.tokens : void 0;
518
+ },
519
+ async saveTokens(tokens, ctx) {
520
+ await updateSaved({
521
+ tokens,
522
+ ...ctx ? { issuer: ctx.issuer } : {}
523
+ });
524
+ },
525
+ async invalidateCredentials(scope) {
526
+ if (scope === "all") {
527
+ verifier = "";
528
+ discovery = void 0;
529
+ await store.clear();
530
+ return;
531
+ }
532
+ if (scope === "verifier") {
533
+ verifier = "";
534
+ return;
535
+ }
536
+ if (scope === "discovery") {
537
+ discovery = void 0;
538
+ return;
539
+ }
540
+ const saved = await store.read();
541
+ delete saved.tokens;
542
+ if (scope === "client") {
543
+ delete saved.client;
544
+ }
545
+ await store.save(saved);
546
+ },
547
+ redirectToAuthorization: onAuthorize,
548
+ saveCodeVerifier(value) {
549
+ verifier = value;
550
+ },
551
+ codeVerifier() {
552
+ return verifier;
553
+ },
554
+ saveDiscoveryState(state) {
555
+ discovery = state;
556
+ },
557
+ discoveryState() {
558
+ return discovery;
559
+ }
560
+ };
561
+ }
562
+ function loginFailureMessage(error) {
563
+ if (error instanceof IntegrationRequestError) {
564
+ return error.message;
565
+ }
566
+ if (error instanceof RegistrationRejectedError) {
567
+ return error.status === 403 || error.status === 429 ? `CaptionBolt blocked this sign-in request (HTTP ${error.status}). If you use a VPN, proxy or managed network, try another network, then run captionbolt-mcp login again.` : `CaptionBolt could not register this client (HTTP ${error.status}). Run captionbolt-mcp login again later.`;
568
+ }
569
+ const message = error instanceof Error ? error.message.trim() : "";
570
+ return message && message.length <= 300 && !/[<\n]/u.test(message) ? `CaptionBolt authorization failed: ${message}` : "CaptionBolt authorization failed. Check your connection, then run captionbolt-mcp login again.";
571
+ }
572
+ async function createLoginCallback() {
573
+ const server = createServer();
574
+ let expectedState;
575
+ let accepted = false;
576
+ let resolvePending = () => {
577
+ };
578
+ const pending = new Promise((resolve2) => {
579
+ resolvePending = resolve2;
580
+ });
581
+ server.on("request", (request, response) => {
582
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
583
+ const state = url.searchParams.getAll("state");
584
+ const code = url.searchParams.getAll("code");
585
+ const error = url.searchParams.getAll("error");
586
+ const validResult = code.length === 1 && Boolean(code[0]) && error.length === 0 || error.length === 1 && Boolean(error[0]) && code.length === 0;
587
+ if (request.method !== "GET" || url.pathname !== "/callback" || !expectedState || state.length !== 1 || state[0] !== expectedState || !validResult) {
588
+ response.writeHead(400).end("Invalid authorization callback");
589
+ return;
590
+ }
591
+ if (accepted) {
592
+ response.writeHead(409).end("Authorization callback already received");
593
+ return;
594
+ }
595
+ accepted = true;
596
+ let responded = false;
597
+ resolvePending({
598
+ params: url.searchParams,
599
+ respond(success) {
600
+ if (responded) {
601
+ return;
602
+ }
603
+ responded = true;
604
+ response.writeHead(success ? 200 : 400, {
605
+ "Content-Type": "text/plain; charset=utf-8",
606
+ "Cache-Control": "no-store"
607
+ }).end(
608
+ success ? "CaptionBolt connected. You can close this tab." : "CaptionBolt authorization failed. Return to the terminal for details."
609
+ );
610
+ }
611
+ });
612
+ });
613
+ await new Promise((resolve2, reject) => {
614
+ server.once("error", reject);
615
+ server.listen(0, "127.0.0.1", () => {
616
+ server.off("error", reject);
617
+ resolve2();
618
+ });
619
+ });
620
+ const address = server.address();
621
+ if (!address || typeof address === "string") {
622
+ server.close();
623
+ throw new Error("Loopback callback unavailable");
624
+ }
625
+ return {
626
+ redirectUrl: `http://127.0.0.1:${address.port}/callback`,
627
+ pending,
628
+ expectState(state) {
629
+ expectedState = state;
630
+ },
631
+ close() {
632
+ server.close();
633
+ }
634
+ };
635
+ }
636
+ function remoteClient(origin2, provider) {
637
+ const client = new Client({ name: "captionbolt-local", version: "0.1.0" });
638
+ const transport = new StreamableHTTPClientTransport(
639
+ new URL("/mcp", origin2),
640
+ {
641
+ authProvider: provider,
642
+ onInsufficientScope: "throw"
643
+ }
644
+ );
645
+ return { client, transport };
646
+ }
647
+ function openBrowser(url) {
648
+ const command2 = process.platform === "darwin" ? ["open", url.href] : process.platform === "win32" ? ["cmd", "/c", "start", "", url.href] : ["xdg-open", url.href];
649
+ const child = spawn(command2[0] ?? "xdg-open", command2.slice(1), {
650
+ stdio: "ignore",
651
+ detached: true
652
+ });
653
+ child.on("error", () => {
654
+ });
655
+ child.unref();
656
+ process.stderr.write(
657
+ `Authorize CaptionBolt in your browser: ${url.href}
658
+ `
659
+ );
660
+ }
661
+ async function login(origin2) {
662
+ const callback = await createLoginCallback();
663
+ const provider = oauthProvider(origin2, callback.redirectUrl, openBrowser, {
664
+ onState: callback.expectState
665
+ });
666
+ const { client, transport } = remoteClient(origin2, provider);
667
+ try {
668
+ try {
669
+ await client.connect(transport);
670
+ const account = await client.callTool({
671
+ name: "get_account",
672
+ arguments: {}
673
+ });
674
+ if (account.isError) {
675
+ throw new Error("Connected but account verification failed");
676
+ }
677
+ process.stderr.write("CaptionBolt is already authorized.\n");
678
+ return;
679
+ } catch (error) {
680
+ if (!(error instanceof UnauthorizedError)) {
681
+ throw error;
682
+ }
683
+ }
684
+ let timeout;
685
+ let accepted;
686
+ try {
687
+ accepted = await Promise.race([
688
+ callback.pending,
689
+ new Promise((_, reject) => {
690
+ timeout = setTimeout(
691
+ () => reject(new Error("Authorization timed out")),
692
+ 3e5
693
+ );
694
+ })
695
+ ]);
696
+ } finally {
697
+ if (timeout) {
698
+ clearTimeout(timeout);
699
+ }
700
+ }
701
+ try {
702
+ await transport.finishAuth(accepted.params);
703
+ const fresh = remoteClient(origin2, provider);
704
+ try {
705
+ await fresh.client.connect(fresh.transport);
706
+ const account = await fresh.client.callTool({
707
+ name: "get_account",
708
+ arguments: {}
709
+ });
710
+ if (account.isError) {
711
+ throw new Error(
712
+ "Connected but account verification failed"
713
+ );
714
+ }
715
+ } finally {
716
+ await fresh.client.close();
717
+ }
718
+ accepted.respond(true);
719
+ process.stderr.write(
720
+ "CaptionBolt connected and account verified.\n"
721
+ );
722
+ } catch (error) {
723
+ accepted.respond(false);
724
+ throw error;
725
+ }
726
+ } finally {
727
+ try {
728
+ await client.close();
729
+ } finally {
730
+ callback.close();
731
+ }
732
+ }
733
+ }
734
+
735
+ // src/client.ts
736
+ var CaptionBoltApiError = class extends IntegrationRequestError {
737
+ constructor(message, options) {
738
+ super(message, options);
739
+ this.name = "CaptionBoltApiError";
740
+ }
741
+ };
742
+ function thrownRequestKind(error) {
743
+ const name = error instanceof Error ? error.name : "";
744
+ return name === "TimeoutError" ? "timeout" : name === "AbortError" ? "aborted" : null;
745
+ }
746
+ async function parseJsonResponse(response) {
747
+ let result;
748
+ try {
749
+ result = await response.json();
750
+ } catch (cause) {
751
+ const interruptedKind = thrownRequestKind(cause);
752
+ throw new CaptionBoltApiError(
753
+ interruptedKind === "timeout" ? "CaptionBolt response timed out" : interruptedKind === "aborted" ? "CaptionBolt response was cancelled" : response.ok ? "CaptionBolt returned an invalid JSON response" : `CaptionBolt request failed (${response.status})`,
754
+ {
755
+ kind: interruptedKind ?? (response.ok ? "invalid-response" : "http"),
756
+ status: response.status,
757
+ requestId: response.headers.get("x-request-id") ?? void 0,
758
+ retryAfterSeconds: parseRetryAfterSeconds(
759
+ response.headers.get("retry-after")
760
+ ),
761
+ cause
762
+ }
763
+ );
764
+ }
765
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
766
+ throw new CaptionBoltApiError(
767
+ "CaptionBolt returned an invalid JSON response",
768
+ {
769
+ kind: "invalid-response",
770
+ status: response.status,
771
+ requestId: response.headers.get("x-request-id") ?? void 0,
772
+ retryAfterSeconds: parseRetryAfterSeconds(
773
+ response.headers.get("retry-after")
774
+ )
775
+ }
776
+ );
777
+ }
778
+ return result;
779
+ }
780
+ var MACHINE_CODE = /^[A-Z][A-Z0-9_]{0,63}$/u;
781
+ function machineCode(value) {
782
+ for (const key of ["code", "error"]) {
783
+ const candidate = stringField(value, key);
784
+ if (candidate && MACHINE_CODE.test(candidate)) {
785
+ return candidate;
786
+ }
787
+ }
788
+ return void 0;
789
+ }
790
+ function safeServerMessage(value, code) {
791
+ const message = code ? stringField(value, "message")?.trim() : void 0;
792
+ if (!message || message.length > 240 || message.includes("<") || message.includes(">") || Array.from(message).some((character) => character.charCodeAt(0) < 32) || message.includes("cb_live_")) {
793
+ return void 0;
794
+ }
795
+ return message;
796
+ }
797
+ function stringField(value, key) {
798
+ return typeof value[key] === "string" ? value[key] : void 0;
799
+ }
800
+ function createApiClient(baseUrl, provider) {
801
+ const base = apiOrigin(baseUrl);
802
+ const authorizedFetch = withOAuth(provider, new URL("/mcp", base))(fetch);
803
+ return async (method, path, input, idempotencyKey) => {
804
+ if (!path.startsWith("/v1/uploads") && !/^\/v1\/operations\/[0-9a-fA-F-]{36}$/u.test(path)) {
805
+ throw new Error("Invalid API path");
806
+ }
807
+ const url = new URL(path.replace(/^\/v1\//u, "/mcp/"), base);
808
+ if (url.origin !== base.origin || !url.pathname.startsWith("/mcp/")) {
809
+ throw new Error("Invalid API path");
810
+ }
811
+ if (method === "GET") {
812
+ for (const [key, value] of Object.entries(input ?? {})) {
813
+ if (value !== void 0) {
814
+ url.searchParams.set(key, String(value));
815
+ }
816
+ }
817
+ }
818
+ let response;
819
+ try {
820
+ response = await authorizedFetch(url, {
821
+ method,
822
+ headers: {
823
+ "Content-Type": "application/json",
824
+ ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}
825
+ },
826
+ ...method === "GET" ? {} : { body: JSON.stringify(input ?? {}) },
827
+ redirect: "error",
828
+ signal: AbortSignal.timeout(3e4)
829
+ });
830
+ } catch (cause) {
831
+ if (cause instanceof IntegrationRequestError) {
832
+ throw cause;
833
+ }
834
+ if (cause instanceof UnauthorizedError2 || cause instanceof Error && cause.message.includes("captionbolt-mcp login")) {
835
+ await provider.invalidateCredentials?.("discovery");
836
+ throw new CaptionBoltApiError(
837
+ "Run captionbolt-mcp login to authorize this computer, then retry.",
838
+ {
839
+ kind: "http",
840
+ status: 401,
841
+ code: "AUTH_REQUIRED"
842
+ }
843
+ );
844
+ }
845
+ if (cause instanceof InsufficientScopeError) {
846
+ throw new CaptionBoltApiError(
847
+ "CaptionBolt connection lacks permission for this upload. Reconnect with projects:write.",
848
+ { kind: "http", status: 403, code: "INSUFFICIENT_SCOPE" }
849
+ );
850
+ }
851
+ const interruptedKind = thrownRequestKind(cause);
852
+ const kind = interruptedKind ?? "network";
853
+ throw new CaptionBoltApiError(
854
+ kind === "timeout" ? "CaptionBolt request timed out" : kind === "aborted" ? "CaptionBolt request was cancelled" : "CaptionBolt is unavailable",
855
+ { kind, cause }
856
+ );
857
+ }
858
+ const result = await parseJsonResponse(response);
859
+ if (!response.ok) {
860
+ const code = machineCode(result);
861
+ throw new CaptionBoltApiError(
862
+ safeServerMessage(result, code) ?? `CaptionBolt request failed (${response.status})`,
863
+ {
864
+ kind: "http",
865
+ status: response.status,
866
+ code,
867
+ requestId: response.headers.get("x-request-id") ?? void 0,
868
+ retryAfterSeconds: parseRetryAfterSeconds(
869
+ response.headers.get("retry-after")
870
+ )
871
+ }
872
+ );
873
+ }
874
+ return result;
875
+ };
876
+ }
877
+
878
+ // src/uploads.ts
879
+ import { createHash, randomUUID as randomUUID2 } from "node:crypto";
880
+ import { constants } from "node:fs";
881
+ import { lstat, open, realpath } from "node:fs/promises";
882
+ import { basename, isAbsolute, resolve } from "node:path";
883
+ import { setTimeout as delay } from "node:timers/promises";
884
+ import { z as z3 } from "zod";
885
+
886
+ // src/media-duration.ts
887
+ import mediaInfoFactory from "mediainfo.js";
888
+ var VideoDurationError = class extends Error {
889
+ constructor() {
890
+ super(
891
+ "The video duration could not be read. Select a readable original video or upload through CaptionBolt."
892
+ );
893
+ }
894
+ };
895
+ async function readVideoDuration(file, fileSize, signal) {
896
+ signal.throwIfAborted();
897
+ const mediaInfo = await mediaInfoFactory({
898
+ format: "object",
899
+ chunkSize: 256 * 1024
900
+ });
901
+ try {
902
+ let readBytes = 0;
903
+ const deadline = Date.now() + 3e4;
904
+ const result = await mediaInfo.analyzeData(
905
+ fileSize,
906
+ async (size, offset) => {
907
+ signal.throwIfAborted();
908
+ const length = Math.min(size, fileSize - offset);
909
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length <= 0 || length > 256 * 1024) {
910
+ throw new Error("Invalid video metadata read");
911
+ }
912
+ if (readBytes + length > 32 * 1024 * 1024 || Date.now() > deadline) {
913
+ return new Uint8Array(0);
914
+ }
915
+ readBytes += length;
916
+ const bytes = Buffer.alloc(length);
917
+ const { bytesRead } = await file.read(bytes, 0, length, offset);
918
+ if (bytesRead !== length) {
919
+ throw new Error(
920
+ "The selected file changed while reading metadata"
921
+ );
922
+ }
923
+ return bytes;
924
+ }
925
+ );
926
+ signal.throwIfAborted();
927
+ const video = result.media?.track.find(
928
+ (track) => track["@type"] === "Video"
929
+ );
930
+ const general = result.media?.track.find(
931
+ (track) => track["@type"] === "General"
932
+ );
933
+ const durationSec = video?.Duration ?? general?.Duration;
934
+ if (!video || typeof durationSec !== "number" || !Number.isFinite(durationSec) || durationSec <= 0) {
935
+ throw new Error(
936
+ "Select a video with a readable, positive duration"
937
+ );
938
+ }
939
+ return durationSec;
940
+ } catch (error) {
941
+ if (signal.aborted) {
942
+ throw error;
943
+ }
944
+ throw new VideoDurationError();
945
+ } finally {
946
+ mediaInfo.close();
947
+ }
948
+ }
949
+
950
+ // src/uploads.ts
951
+ var SIGNATURE_BATCH_SIZE = 8;
952
+ var PART_UPLOAD_CONCURRENCY = 2;
953
+ var PART_MAX_ATTEMPTS = 3;
954
+ var API_RETRY_WAIT_BUDGET_SECONDS = 300;
955
+ var R2_RETRY_WAIT_BUDGET_SECONDS = 30;
956
+ var CONTENT_TYPES = {
957
+ mp4: "video/mp4",
958
+ mov: "video/quicktime",
959
+ webm: "video/webm",
960
+ avi: "video/x-msvideo"
961
+ };
962
+ var UploadError = class extends Error {
963
+ name = "UploadError";
964
+ };
965
+ function fileAccessError(error) {
966
+ const code = error instanceof Error && "code" in error ? String(error.code) : "";
967
+ return new UploadError(
968
+ code === "ENOENT" || code === "ENOTDIR" ? "The selected file was not found. Check its absolute path" : code === "EACCES" || code === "EPERM" ? "The selected file cannot be read. Check its permissions" : code === "ELOOP" ? "Select a regular file without symbolic links" : "The selected file cannot be opened"
969
+ );
970
+ }
971
+ async function openUploadFile(path) {
972
+ if (!isAbsolute(path) || /^\\\\[.?]\\/u.test(path)) {
973
+ throw new UploadError(
974
+ "Select one absolute path to a regular video file"
975
+ );
976
+ }
977
+ const normalized = resolve(path);
978
+ let canonical;
979
+ let beforeOpen;
980
+ try {
981
+ canonical = await realpath(normalized);
982
+ beforeOpen = await lstat(normalized);
983
+ } catch (error) {
984
+ throw fileAccessError(error);
985
+ }
986
+ const compare = (value) => process.platform === "win32" ? value.toLowerCase() : value;
987
+ if (beforeOpen.isDirectory()) {
988
+ throw new UploadError("Select a video file, not a folder");
989
+ }
990
+ if (compare(canonical) !== compare(normalized) || !beforeOpen.isFile()) {
991
+ throw new UploadError("Select a regular file without symbolic links");
992
+ }
993
+ let file;
994
+ try {
995
+ file = await open(
996
+ normalized,
997
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
998
+ );
999
+ } catch (error) {
1000
+ throw fileAccessError(error);
1001
+ }
1002
+ try {
1003
+ const stat = await file.stat();
1004
+ if (stat.dev !== beforeOpen.dev || stat.ino !== beforeOpen.ino || compare(await realpath(normalized)) !== compare(canonical)) {
1005
+ throw new UploadError(
1006
+ "The selected path changed while opening the file"
1007
+ );
1008
+ }
1009
+ if (!stat.isFile() || stat.size <= 0) {
1010
+ throw new UploadError("Select a nonempty regular file");
1011
+ }
1012
+ const fileName = basename(normalized);
1013
+ const contentType = CONTENT_TYPES[fileName.split(".").pop()?.toLowerCase() ?? ""];
1014
+ if (!contentType) {
1015
+ throw new UploadError("Select an MP4, MOV, WebM or AVI file");
1016
+ }
1017
+ const lastModifiedMs = Math.floor(stat.mtimeMs);
1018
+ const first = Buffer.alloc(Math.min(64 * 1024, stat.size));
1019
+ await file.read(first, 0, first.length, 0);
1020
+ const lastStart = Math.max(64 * 1024, stat.size - 64 * 1024);
1021
+ const last = Buffer.alloc(Math.max(0, stat.size - lastStart));
1022
+ if (last.length) {
1023
+ await file.read(last, 0, last.length, lastStart);
1024
+ }
1025
+ const fingerprint = createHash("sha256").update(
1026
+ `captionbolt-upload-v1\0${fileName}\0${stat.size}\0${lastModifiedMs}\0${contentType}`
1027
+ ).update(first).update(last).digest("hex");
1028
+ return {
1029
+ file,
1030
+ fileName,
1031
+ fileSize: stat.size,
1032
+ lastModifiedMs,
1033
+ fingerprint,
1034
+ contentType
1035
+ };
1036
+ } catch (error) {
1037
+ await file.close();
1038
+ throw error;
1039
+ }
1040
+ }
1041
+ var signedPartsSchema2 = z3.object({
1042
+ signedParts: z3.array(
1043
+ z3.object({
1044
+ partNumber: z3.number().int().positive(),
1045
+ contentLength: z3.number().int().positive(),
1046
+ url: z3.string().url()
1047
+ })
1048
+ ),
1049
+ completedPartNumbers: z3.array(z3.number())
1050
+ });
1051
+ function isTransientStatus(status) {
1052
+ return status === 408 || status === 429 || status !== void 0 && status >= 500;
1053
+ }
1054
+ async function runWithConcurrency(items, concurrency, run, onError) {
1055
+ let nextIndex = 0;
1056
+ let firstError;
1057
+ await Promise.all(
1058
+ Array.from(
1059
+ { length: Math.min(concurrency, items.length) },
1060
+ async () => {
1061
+ try {
1062
+ while (firstError === void 0 && nextIndex < items.length) {
1063
+ const item = items[nextIndex];
1064
+ nextIndex += 1;
1065
+ if (item !== void 0) {
1066
+ await run(item);
1067
+ }
1068
+ }
1069
+ } catch (error) {
1070
+ if (firstError === void 0) {
1071
+ firstError = error;
1072
+ onError?.();
1073
+ }
1074
+ }
1075
+ }
1076
+ )
1077
+ );
1078
+ if (firstError !== void 0) {
1079
+ throw firstError;
1080
+ }
1081
+ }
1082
+ function stoppedMessage(transfer, error) {
1083
+ if (error instanceof UploadError || error instanceof VideoDurationError) {
1084
+ return error.message;
1085
+ }
1086
+ const retry = transfer.uploadId ? "resume_upload with the same file and uploadId" : "upload_file with the same path and idempotencyKey";
1087
+ return error instanceof IntegrationRequestError ? `Upload stopped by a CaptionBolt API error; see errorDetails. Retry with ${retry} after resolving it.` : `Upload stopped. Check the selected file and connection, then retry with ${retry}.`;
1088
+ }
1089
+ var LocalUploads = class {
1090
+ constructor(request) {
1091
+ this.request = request;
1092
+ }
1093
+ request;
1094
+ transfers = /* @__PURE__ */ new Map();
1095
+ start(path, idempotencyKey, uploadId) {
1096
+ if ([...this.transfers.values()].some(
1097
+ (item) => ["preparing", "uploading"].includes(item.state)
1098
+ )) {
1099
+ throw new UploadError("Wait for or pause the current local upload");
1100
+ }
1101
+ if (this.transfers.size >= 100) {
1102
+ const oldest = this.transfers.keys().next().value;
1103
+ if (oldest) {
1104
+ this.transfers.delete(oldest);
1105
+ }
1106
+ }
1107
+ const transfer = {
1108
+ id: randomUUID2(),
1109
+ state: "preparing",
1110
+ uploadedBytes: 0,
1111
+ uploadId,
1112
+ controller: new AbortController(),
1113
+ done: Promise.resolve()
1114
+ };
1115
+ this.transfers.set(transfer.id, transfer);
1116
+ transfer.done = this.upload(transfer, path, idempotencyKey).catch(
1117
+ (error) => {
1118
+ const paused = transfer.controller.signal.aborted;
1119
+ transfer.state = paused ? "paused" : "failed";
1120
+ transfer.error = paused ? void 0 : stoppedMessage(transfer, error);
1121
+ transfer.errorDetails = !paused && error instanceof IntegrationRequestError ? getIntegrationRequestErrorDetails(error) : void 0;
1122
+ }
1123
+ );
1124
+ return this.status(transfer.id);
1125
+ }
1126
+ status(id) {
1127
+ const transfer = this.transfers.get(id);
1128
+ if (!transfer) {
1129
+ throw new UploadError(
1130
+ "Local transfer not found. Query the server uploadId or reselect the file to resume."
1131
+ );
1132
+ }
1133
+ const {
1134
+ id: transferId,
1135
+ controller: _controller,
1136
+ done: _done,
1137
+ ...publicState
1138
+ } = transfer;
1139
+ return {
1140
+ transferId,
1141
+ ...publicState,
1142
+ pollAfterSeconds: ["preparing", "uploading"].includes(
1143
+ transfer.state
1144
+ ) ? 2 : 0
1145
+ };
1146
+ }
1147
+ async pause(id) {
1148
+ const transfer = this.transfers.get(id);
1149
+ if (!transfer) {
1150
+ throw new UploadError("Local transfer not found");
1151
+ }
1152
+ transfer.controller.abort();
1153
+ await transfer.done;
1154
+ return this.status(id);
1155
+ }
1156
+ async close() {
1157
+ await Promise.all(
1158
+ [...this.transfers.values()].map(
1159
+ (transfer) => this.pause(transfer.id)
1160
+ )
1161
+ );
1162
+ }
1163
+ async abort(uploadId) {
1164
+ for (const transfer of this.transfers.values()) {
1165
+ if (transfer.uploadId === uploadId && ["preparing", "uploading"].includes(transfer.state)) {
1166
+ await this.pause(transfer.id);
1167
+ }
1168
+ }
1169
+ return this.request("DELETE", `/v1/uploads/${uploadId}`);
1170
+ }
1171
+ async upload(transfer, path, idempotencyKey) {
1172
+ const source = await openUploadFile(path);
1173
+ try {
1174
+ transfer.totalBytes = source.fileSize;
1175
+ const { file, ...metadata } = source;
1176
+ const signal = transfer.controller.signal;
1177
+ signal.throwIfAborted();
1178
+ if (!transfer.uploadId) {
1179
+ const durationSec = await readVideoDuration(
1180
+ file,
1181
+ source.fileSize,
1182
+ signal
1183
+ );
1184
+ const current = await file.stat();
1185
+ if (current.size !== source.fileSize || Math.floor(current.mtimeMs) !== source.lastModifiedMs) {
1186
+ throw new UploadError(
1187
+ "The selected file changed while reading metadata"
1188
+ );
1189
+ }
1190
+ let operation = operationSchema.parse(
1191
+ await this.request(
1192
+ "POST",
1193
+ "/v1/uploads",
1194
+ { ...metadata, durationSec },
1195
+ idempotencyKey
1196
+ )
1197
+ );
1198
+ transfer.operationId = operation.id;
1199
+ while (["pending", "running"].includes(operation.state)) {
1200
+ await delay(
1201
+ Math.max(2, operation.pollAfterSeconds) * 1e3,
1202
+ void 0,
1203
+ { signal }
1204
+ );
1205
+ operation = operationSchema.parse(
1206
+ await this.request(
1207
+ "GET",
1208
+ `/v1/operations/${operation.id}`
1209
+ )
1210
+ );
1211
+ }
1212
+ if (operation.state !== "completed") {
1213
+ throw new UploadError(
1214
+ operation.error ? `Upload reservation failed: ${operation.error}` : "Upload reservation failed"
1215
+ );
1216
+ }
1217
+ transfer.uploadId = operation.resourceId;
1218
+ }
1219
+ let session = uploadSchema.parse(
1220
+ await this.request("GET", `/v1/uploads/${transfer.uploadId}`)
1221
+ );
1222
+ if (session.fileName !== source.fileName || session.fileSize !== source.fileSize || session.lastModifiedMs !== source.lastModifiedMs || session.fingerprint !== source.fingerprint || session.contentType !== source.contentType) {
1223
+ throw new UploadError(
1224
+ "The selected file does not match this upload"
1225
+ );
1226
+ }
1227
+ signal.throwIfAborted();
1228
+ session = uploadSchema.parse(
1229
+ await this.request(
1230
+ "POST",
1231
+ `/v1/uploads/${transfer.uploadId}/resume`
1232
+ )
1233
+ );
1234
+ if (["uploaded", "consumed"].includes(session.status)) {
1235
+ transfer.state = "uploaded";
1236
+ transfer.uploadedBytes = source.fileSize;
1237
+ return;
1238
+ }
1239
+ if (session.status !== "uploading") {
1240
+ throw new UploadError(
1241
+ `Upload session is ${session.status} and no longer accepts parts. Start a new upload with upload_file`
1242
+ );
1243
+ }
1244
+ transfer.state = "uploading";
1245
+ const confirmed = /* @__PURE__ */ new Set();
1246
+ const partSize = (number) => Math.min(
1247
+ session.partSize,
1248
+ source.fileSize - (number - 1) * session.partSize
1249
+ );
1250
+ const markConfirmed = (partNumber) => {
1251
+ if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > session.partCount) {
1252
+ throw new Error("Invalid server upload plan");
1253
+ }
1254
+ if (!confirmed.has(partNumber)) {
1255
+ confirmed.add(partNumber);
1256
+ transfer.uploadedBytes += partSize(partNumber);
1257
+ }
1258
+ };
1259
+ transfer.uploadedBytes = 0;
1260
+ for (const completed of session.completedPartNumbers) {
1261
+ markConfirmed(completed);
1262
+ }
1263
+ const requestParts = async (partNumbers, requestSignal = signal) => {
1264
+ let lastError;
1265
+ for (let attempt = 0; attempt < PART_MAX_ATTEMPTS; attempt++) {
1266
+ requestSignal.throwIfAborted();
1267
+ try {
1268
+ return signedPartsSchema2.parse(
1269
+ await this.request(
1270
+ "POST",
1271
+ `/v1/uploads/${session.id}/parts`,
1272
+ { partNumbers }
1273
+ )
1274
+ );
1275
+ } catch (error) {
1276
+ lastError = error;
1277
+ const retryable = error instanceof CaptionBoltApiError && (error.kind === "network" || error.kind === "timeout" || isTransientStatus(error.status));
1278
+ if (!retryable || attempt + 1 >= PART_MAX_ATTEMPTS) {
1279
+ throw error;
1280
+ }
1281
+ if (error.retryAfterSeconds !== void 0 && error.retryAfterSeconds > API_RETRY_WAIT_BUDGET_SECONDS) {
1282
+ throw error;
1283
+ }
1284
+ await delay(
1285
+ (error.retryAfterSeconds ?? 2 ** attempt / 4) * 1e3,
1286
+ void 0,
1287
+ { signal: requestSignal }
1288
+ );
1289
+ }
1290
+ }
1291
+ throw lastError;
1292
+ };
1293
+ const validatePart = (part, partNumber) => {
1294
+ if (part.partNumber !== partNumber || part.contentLength !== partSize(partNumber)) {
1295
+ throw new Error("Invalid server upload plan");
1296
+ }
1297
+ const target = new URL(part.url);
1298
+ if (target.protocol !== "https:" || !target.hostname.endsWith(".r2.cloudflarestorage.com") || target.username || target.password) {
1299
+ throw new Error("Invalid R2 upload target");
1300
+ }
1301
+ return target;
1302
+ };
1303
+ const uploadPart = async (initialPart, partSignal) => {
1304
+ const partNumber = initialPart.partNumber;
1305
+ let part = initialPart;
1306
+ const bytes = Buffer.alloc(part.contentLength);
1307
+ const { bytesRead } = await file.read(
1308
+ bytes,
1309
+ 0,
1310
+ bytes.length,
1311
+ (partNumber - 1) * session.partSize
1312
+ );
1313
+ if (bytesRead !== bytes.length) {
1314
+ throw new UploadError(
1315
+ "The selected file changed during upload"
1316
+ );
1317
+ }
1318
+ let lastError;
1319
+ for (let attempt = 0; attempt < PART_MAX_ATTEMPTS; attempt++) {
1320
+ partSignal.throwIfAborted();
1321
+ const current = await file.stat();
1322
+ if (current.size !== source.fileSize || Math.floor(current.mtimeMs) !== source.lastModifiedMs) {
1323
+ throw new UploadError(
1324
+ "The selected file changed during upload"
1325
+ );
1326
+ }
1327
+ const target = validatePart(part, partNumber);
1328
+ let response;
1329
+ try {
1330
+ response = await fetch(target, {
1331
+ method: "PUT",
1332
+ body: bytes,
1333
+ headers: {
1334
+ "Content-Length": String(bytes.length)
1335
+ },
1336
+ signal: AbortSignal.any([
1337
+ partSignal,
1338
+ AbortSignal.timeout(12e4)
1339
+ ]),
1340
+ redirect: "error"
1341
+ });
1342
+ } catch (error) {
1343
+ lastError = error;
1344
+ if (attempt + 1 >= PART_MAX_ATTEMPTS) {
1345
+ throw error;
1346
+ }
1347
+ await delay(250 * 2 ** attempt, void 0, {
1348
+ signal: partSignal
1349
+ });
1350
+ continue;
1351
+ }
1352
+ if (response.ok) {
1353
+ await response.body?.cancel();
1354
+ markConfirmed(partNumber);
1355
+ return;
1356
+ }
1357
+ lastError = new Error("R2 rejected the upload part");
1358
+ const needsFreshSignature = response.status === 401 || response.status === 403;
1359
+ const retryable = needsFreshSignature || isTransientStatus(response.status);
1360
+ const retryAfterSeconds = parseRetryAfterSeconds(
1361
+ response.headers.get("retry-after")
1362
+ );
1363
+ await response.body?.cancel();
1364
+ if (!retryable || attempt + 1 >= PART_MAX_ATTEMPTS) {
1365
+ throw lastError;
1366
+ }
1367
+ if (retryAfterSeconds !== void 0 && retryAfterSeconds > R2_RETRY_WAIT_BUDGET_SECONDS) {
1368
+ throw new IntegrationRequestError(
1369
+ "Storage asked the upload to retry later",
1370
+ {
1371
+ kind: "http",
1372
+ status: response.status,
1373
+ code: "UPLOAD_RETRY_DEFERRED",
1374
+ retryAfterSeconds
1375
+ }
1376
+ );
1377
+ }
1378
+ if (needsFreshSignature) {
1379
+ const refreshed = await requestParts(
1380
+ [partNumber],
1381
+ partSignal
1382
+ );
1383
+ for (const completed of refreshed.completedPartNumbers) {
1384
+ markConfirmed(completed);
1385
+ }
1386
+ if (confirmed.has(partNumber)) {
1387
+ return;
1388
+ }
1389
+ const replacement = refreshed.signedParts.find(
1390
+ (candidate) => candidate.partNumber === partNumber
1391
+ );
1392
+ if (!replacement) {
1393
+ throw new Error("Invalid server upload plan");
1394
+ }
1395
+ part = replacement;
1396
+ }
1397
+ await delay(
1398
+ retryAfterSeconds === void 0 ? 250 * 2 ** attempt : retryAfterSeconds * 1e3,
1399
+ void 0,
1400
+ { signal: partSignal }
1401
+ );
1402
+ }
1403
+ throw lastError;
1404
+ };
1405
+ const missingPartNumbers = Array.from(
1406
+ { length: session.partCount },
1407
+ (_, index) => index + 1
1408
+ ).filter((partNumber) => !confirmed.has(partNumber));
1409
+ for (let offset = 0; offset < missingPartNumbers.length; offset += SIGNATURE_BATCH_SIZE) {
1410
+ signal.throwIfAborted();
1411
+ const requested = missingPartNumbers.slice(
1412
+ offset,
1413
+ offset + SIGNATURE_BATCH_SIZE
1414
+ );
1415
+ const response = await requestParts(requested);
1416
+ for (const completed of response.completedPartNumbers) {
1417
+ markConfirmed(completed);
1418
+ }
1419
+ const signedByNumber = new Map(
1420
+ response.signedParts.map((part) => [part.partNumber, part])
1421
+ );
1422
+ const planned = requested.filter((partNumber) => !confirmed.has(partNumber)).map((partNumber) => {
1423
+ const part = signedByNumber.get(partNumber);
1424
+ if (!part) {
1425
+ throw new Error("Invalid server upload plan");
1426
+ }
1427
+ validatePart(part, partNumber);
1428
+ return part;
1429
+ });
1430
+ const batchController = new AbortController();
1431
+ const batchSignal = AbortSignal.any([
1432
+ signal,
1433
+ batchController.signal
1434
+ ]);
1435
+ await runWithConcurrency(
1436
+ planned,
1437
+ PART_UPLOAD_CONCURRENCY,
1438
+ (part) => uploadPart(part, batchSignal),
1439
+ () => batchController.abort()
1440
+ );
1441
+ }
1442
+ signal.throwIfAborted();
1443
+ await this.request("POST", `/v1/uploads/${session.id}/complete`);
1444
+ transfer.state = "uploaded";
1445
+ } finally {
1446
+ await source.file.close();
1447
+ }
1448
+ }
1449
+ };
1450
+
1451
+ // src/index.ts
1452
+ var origin = apiOrigin(
1453
+ process.env.CAPTIONBOLT_API_URL ?? "https://api.captionbolt.com"
1454
+ );
1455
+ var command = process.argv[2];
1456
+ if (command === "login") {
1457
+ try {
1458
+ await login(origin);
1459
+ } catch (error) {
1460
+ process.stderr.write(`${loginFailureMessage(error)}
1461
+ `);
1462
+ process.exitCode = 1;
1463
+ }
1464
+ } else if (command === "logout") {
1465
+ await logout(origin);
1466
+ process.stderr.write(
1467
+ "Local CaptionBolt authorization removed. Revoke server access in account settings if needed.\n"
1468
+ );
1469
+ } else {
1470
+ if (command) {
1471
+ throw new Error("Usage: captionbolt-mcp [login|logout]");
1472
+ }
1473
+ const provider = oauthProvider(
1474
+ origin,
1475
+ "http://127.0.0.1:1/callback",
1476
+ () => {
1477
+ throw new Error(
1478
+ "Run captionbolt-mcp login to authorize this computer, then retry."
1479
+ );
1480
+ }
1481
+ );
1482
+ const request = createApiClient(origin.href, provider);
1483
+ const uploads = new LocalUploads(request);
1484
+ const handle = serveStdio(
1485
+ () => {
1486
+ const server = createIntegrationMcpServer(request, {
1487
+ executeTool: async (name, input) => {
1488
+ if (!await hasAuthorization(origin)) {
1489
+ throw new IntegrationRequestError(
1490
+ "Run captionbolt-mcp login to authorize this computer, then retry.",
1491
+ {
1492
+ kind: "http",
1493
+ status: 401,
1494
+ code: "AUTH_REQUIRED"
1495
+ }
1496
+ );
1497
+ }
1498
+ const { client, transport } = remoteClient(
1499
+ origin,
1500
+ provider
1501
+ );
1502
+ try {
1503
+ await client.connect(transport);
1504
+ const result = await client.callTool({
1505
+ name,
1506
+ arguments: input
1507
+ });
1508
+ return {
1509
+ content: result.content.filter(
1510
+ (item) => item.type === "text"
1511
+ ),
1512
+ structuredContent: result.structuredContent,
1513
+ isError: result.isError
1514
+ };
1515
+ } catch (error) {
1516
+ if (error instanceof UnauthorizedError3 || error instanceof Error && error.message.includes("captionbolt-mcp login")) {
1517
+ await provider.invalidateCredentials?.("discovery");
1518
+ throw new IntegrationRequestError(
1519
+ "Run captionbolt-mcp login to authorize this computer, then retry.",
1520
+ {
1521
+ kind: "http",
1522
+ status: 401,
1523
+ code: "AUTH_REQUIRED"
1524
+ }
1525
+ );
1526
+ }
1527
+ if (error instanceof InsufficientScopeError2) {
1528
+ throw new IntegrationRequestError(
1529
+ "CaptionBolt connection lacks permission for this action. Reconnect with the required scopes.",
1530
+ {
1531
+ kind: "http",
1532
+ status: 403,
1533
+ code: "INSUFFICIENT_SCOPE"
1534
+ }
1535
+ );
1536
+ }
1537
+ throw error;
1538
+ } finally {
1539
+ await client.close();
1540
+ }
1541
+ }
1542
+ });
1543
+ function localTool(name, description, schema, readOnly, run) {
1544
+ server.registerTool(
1545
+ name,
1546
+ {
1547
+ description,
1548
+ inputSchema: schema,
1549
+ annotations: {
1550
+ readOnlyHint: readOnly,
1551
+ destructiveHint: name === "abort_upload",
1552
+ openWorldHint: true
1553
+ }
1554
+ },
1555
+ async (input) => {
1556
+ try {
1557
+ const result = await run(
1558
+ input
1559
+ );
1560
+ return {
1561
+ content: [
1562
+ {
1563
+ type: "text",
1564
+ text: JSON.stringify(result)
1565
+ }
1566
+ ],
1567
+ structuredContent: result
1568
+ };
1569
+ } catch (error) {
1570
+ if (error instanceof IntegrationRequestError) {
1571
+ return createIntegrationMcpErrorResult(error);
1572
+ }
1573
+ return createIntegrationMcpErrorResult(
1574
+ new IntegrationRequestError(
1575
+ error instanceof UploadError ? error.message : "Upload action failed. Check the selected file and connection.",
1576
+ { kind: "invalid-response" }
1577
+ )
1578
+ );
1579
+ }
1580
+ }
1581
+ );
1582
+ }
1583
+ localTool(
1584
+ "upload_file",
1585
+ "Upload one video explicitly selected by the user using its absolute local path. Returns transferId immediately; poll get_upload_progress until uploaded, then create_project with uploadId. Never scan directories or send video bytes to the model.",
1586
+ z4.object({
1587
+ path: z4.string().min(1),
1588
+ idempotencyKey: z4.string().min(1).max(128)
1589
+ }).strict(),
1590
+ false,
1591
+ async (input) => {
1592
+ if (!await hasAuthorization(origin)) {
1593
+ throw new IntegrationRequestError(
1594
+ "Run captionbolt-mcp login to authorize this computer, then retry.",
1595
+ {
1596
+ kind: "http",
1597
+ status: 401,
1598
+ code: "AUTH_REQUIRED"
1599
+ }
1600
+ );
1601
+ }
1602
+ return uploads.start(
1603
+ String(input.path),
1604
+ String(input.idempotencyKey)
1605
+ );
1606
+ }
1607
+ );
1608
+ localTool(
1609
+ "resume_upload",
1610
+ "Resume an existing server upload after the user explicitly reselects the exact original file. Never read files automatically after restart.",
1611
+ z4.object({
1612
+ path: z4.string().min(1),
1613
+ uploadId: z4.string().uuid()
1614
+ }).strict(),
1615
+ false,
1616
+ async (input) => {
1617
+ if (!await hasAuthorization(origin)) {
1618
+ throw new IntegrationRequestError(
1619
+ "Run captionbolt-mcp login to authorize this computer, then retry.",
1620
+ {
1621
+ kind: "http",
1622
+ status: 401,
1623
+ code: "AUTH_REQUIRED"
1624
+ }
1625
+ );
1626
+ }
1627
+ return uploads.start(
1628
+ String(input.path),
1629
+ `resume-${input.uploadId}`,
1630
+ String(input.uploadId)
1631
+ );
1632
+ }
1633
+ );
1634
+ localTool(
1635
+ "get_upload_progress",
1636
+ "Read transfer state and uploadedBytes. Poll every few seconds while preparing/uploading; when uploaded, pass uploadId to create_project. The server uploadId survives this process.",
1637
+ z4.object({ transferId: z4.string().uuid() }).strict(),
1638
+ true,
1639
+ (input) => uploads.status(String(input.transferId))
1640
+ );
1641
+ localTool(
1642
+ "get_upload",
1643
+ "Read a durable server upload without changing its state.",
1644
+ z4.object({ uploadId: z4.string().uuid() }).strict(),
1645
+ true,
1646
+ (input) => request("GET", `/v1/uploads/${input.uploadId}`)
1647
+ );
1648
+ localTool(
1649
+ "pause_upload",
1650
+ "Stop local file transfer and keep its durable server session available for explicit resume.",
1651
+ z4.object({ transferId: z4.string().uuid() }).strict(),
1652
+ false,
1653
+ (input) => uploads.pause(String(input.transferId))
1654
+ );
1655
+ localTool(
1656
+ "abort_upload",
1657
+ "Pause any matching local transfer, cancel the server upload and release its reservation.",
1658
+ z4.object({ uploadId: z4.string().uuid() }).strict(),
1659
+ false,
1660
+ (input) => uploads.abort(String(input.uploadId))
1661
+ );
1662
+ return server;
1663
+ },
1664
+ {
1665
+ legacy: "serve",
1666
+ onerror: () => process.stderr.write("CaptionBolt MCP protocol error.\n")
1667
+ }
1668
+ );
1669
+ let stopping = false;
1670
+ async function shutdown() {
1671
+ if (stopping) {
1672
+ return;
1673
+ }
1674
+ stopping = true;
1675
+ await uploads.close();
1676
+ await handle.close();
1677
+ }
1678
+ process.once("SIGTERM", () => void shutdown());
1679
+ process.once("SIGINT", () => void shutdown());
1680
+ process.stdin.once("end", () => void shutdown());
1681
+ }