pagebolt-mcp 1.8.1 → 1.8.2

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/server.json +3 -3
  3. package/src/index.mjs +240 -174
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagebolt-mcp",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
4
4
  "description": "MCP server for PageBolt — take screenshots, generate PDFs, create OG images, inspect pages, record demo videos with Audio Guide narration, from AI coding assistants like Claude, Cursor, and Windsurf.",
5
5
  "main": "src/index.mjs",
6
6
  "module": "src/index.mjs",
package/server.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.Custodia-Admin/pagebolt",
4
- "description": "Take screenshots, generate PDFs, and create OG images from your AI assistant.",
4
+ "description": "Screenshots, PDFs, OG images, page inspection, and narrated video recording for Claude and Cursor.",
5
5
  "repository": {
6
6
  "url": "https://github.com/Custodia-Admin/pagebolt-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.0.2",
9
+ "version": "1.8.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "pagebolt-mcp",
14
- "version": "1.0.2",
14
+ "version": "1.8.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
package/src/index.mjs CHANGED
@@ -50,35 +50,62 @@ function requireApiKey() {
50
50
  }
51
51
  }
52
52
 
53
- // ─── HTTP helper ─────────────────────────────────────────────────
53
+ // ─── HTTP helper (with timeout + retry) ─────────────────────────
54
+ const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
55
+ const MAX_RETRIES = 1;
56
+ const REQUEST_TIMEOUT_MS = 120_000;
57
+
54
58
  async function callApi(endpoint, options = {}) {
55
59
  requireApiKey();
56
60
  const url = `${BASE_URL}${endpoint}`;
57
61
  const method = options.method || 'GET';
58
62
  const headers = {
59
63
  'x-api-key': API_KEY,
60
- 'user-agent': 'pagebolt-mcp/1.7.0',
64
+ 'user-agent': 'pagebolt-mcp/1.8.2',
61
65
  ...(options.body ? { 'Content-Type': 'application/json' } : {}),
62
66
  };
67
+ const body = options.body ? JSON.stringify(options.body) : undefined;
63
68
 
64
- const res = await fetch(url, {
65
- method,
66
- headers,
67
- body: options.body ? JSON.stringify(options.body) : undefined,
68
- });
69
+ let lastError;
70
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
71
+ const controller = new AbortController();
72
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
69
73
 
70
- if (!res.ok) {
71
- let errorMsg;
72
74
  try {
73
- const errJson = await res.json();
74
- errorMsg = errJson.error || JSON.stringify(errJson);
75
- } catch {
76
- errorMsg = `HTTP ${res.status} ${res.statusText}`;
75
+ const res = await fetch(url, { method, headers, body, signal: controller.signal });
76
+ clearTimeout(timer);
77
+
78
+ if (res.ok) return res;
79
+
80
+ if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
81
+ const retryAfter = parseInt(res.headers.get('retry-after'), 10);
82
+ const delayMs = retryAfter > 0 ? retryAfter * 1000 : 1000 * (attempt + 1);
83
+ await new Promise(r => setTimeout(r, Math.min(delayMs, 10_000)));
84
+ continue;
85
+ }
86
+
87
+ let errorMsg;
88
+ try {
89
+ const errJson = await res.json();
90
+ errorMsg = errJson.error || JSON.stringify(errJson);
91
+ } catch {
92
+ errorMsg = `HTTP ${res.status} ${res.statusText}`;
93
+ }
94
+ throw new Error(`PageBolt API error: ${errorMsg}`);
95
+ } catch (err) {
96
+ clearTimeout(timer);
97
+ if (err.name === 'AbortError') {
98
+ throw new Error(`PageBolt API error: request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
99
+ }
100
+ lastError = err;
101
+ if (attempt < MAX_RETRIES && !err.message.startsWith('PageBolt API error:')) {
102
+ await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
103
+ continue;
104
+ }
105
+ throw err;
77
106
  }
78
- throw new Error(`PageBolt API error: ${errorMsg}`);
79
107
  }
80
-
81
- return res;
108
+ throw lastError;
82
109
  }
83
110
 
84
111
  // ─── MIME type helper ────────────────────────────────────────────
@@ -219,7 +246,7 @@ Use blockBanners on almost every request to get clean captures. Combine blockAds
219
246
  function createConfiguredServer() {
220
247
  const srv = new McpServer({
221
248
  name: 'pagebolt',
222
- version: '1.7.0',
249
+ version: '1.8.2',
223
250
  }, {
224
251
  instructions: SERVER_INSTRUCTIONS,
225
252
  });
@@ -314,35 +341,38 @@ server.tool(
314
341
  return { content: [{ type: 'text', text: 'Error: One of "url", "html", or "markdown" is required.' }], isError: true };
315
342
  }
316
343
 
317
- const res = await callApi('/api/v1/screenshot', {
318
- method: 'POST',
319
- body: { ...params, response_type: 'json' },
320
- });
321
-
322
- const data = await res.json();
323
- const format = params.format || 'png';
324
-
325
- const content = [
326
- {
327
- type: 'image',
328
- data: data.data,
329
- mimeType: imageMimeType(format),
330
- },
331
- {
332
- type: 'text',
333
- text: `Screenshot captured successfully. Format: ${format}, Size: ${data.size_bytes} bytes, Duration: ${data.duration_ms}ms`,
334
- },
335
- ];
336
-
337
- // Include metadata if extracted
338
- if (data.metadata) {
339
- content.push({
340
- type: 'text',
341
- text: `Metadata:\n${JSON.stringify(data.metadata, null, 2)}`,
344
+ try {
345
+ const res = await callApi('/api/v1/screenshot', {
346
+ method: 'POST',
347
+ body: { ...params, response_type: 'json' },
342
348
  });
343
- }
344
349
 
345
- return { content };
350
+ const data = await res.json();
351
+ const format = params.format || 'png';
352
+
353
+ const content = [
354
+ {
355
+ type: 'image',
356
+ data: data.data,
357
+ mimeType: imageMimeType(format),
358
+ },
359
+ {
360
+ type: 'text',
361
+ text: `Screenshot captured successfully. Format: ${format}, Size: ${data.size_bytes} bytes, Duration: ${data.duration_ms}ms`,
362
+ },
363
+ ];
364
+
365
+ if (data.metadata) {
366
+ content.push({
367
+ type: 'text',
368
+ text: `Metadata:\n${JSON.stringify(data.metadata, null, 2)}`,
369
+ });
370
+ }
371
+
372
+ return { content };
373
+ } catch (err) {
374
+ return { content: [{ type: 'text', text: `Screenshot error: ${err.message}` }], isError: true };
375
+ }
346
376
  }
347
377
  );
348
378
 
@@ -381,49 +411,51 @@ server.tool(
381
411
  return { content: [{ type: 'text', text: 'Error: Either "url" or "html" is required.' }], isError: true };
382
412
  }
383
413
 
384
- const { saveTo, ...apiParams } = params;
385
- const res = await callApi('/api/v1/pdf', {
386
- method: 'POST',
387
- body: { ...apiParams, response_type: 'json' },
388
- });
414
+ try {
415
+ const { saveTo, ...apiParams } = params;
416
+ const res = await callApi('/api/v1/pdf', {
417
+ method: 'POST',
418
+ body: { ...apiParams, response_type: 'json' },
419
+ });
389
420
 
390
- const data = await res.json();
421
+ const data = await res.json();
391
422
 
392
- // Best-effort save to disk (may fail in hosted/sandboxed environments)
393
- let savedPath = null;
394
- try {
395
- const outputPath = safePath(saveTo, './output.pdf');
396
- const buffer = Buffer.from(data.data, 'base64');
397
- writeFileSync(outputPath, buffer);
398
- savedPath = outputPath;
399
- } catch (_diskErr) {
400
- // Disk write failed (e.g. hosted environment, read-only FS) — data is
401
- // still returned as an embedded resource below, so the client gets it.
402
- }
423
+ let savedPath = null;
424
+ try {
425
+ const outputPath = safePath(saveTo, './output.pdf');
426
+ const buffer = Buffer.from(data.data, 'base64');
427
+ writeFileSync(outputPath, buffer);
428
+ savedPath = outputPath;
429
+ } catch (_diskErr) {
430
+ // Disk write failed — data still returned as embedded resource
431
+ }
403
432
 
404
- const fileNote = savedPath
405
- ? ` File: ${savedPath}`
406
- : ` File: (not saved to disk — use the embedded resource data below)`;
433
+ const fileNote = savedPath
434
+ ? ` File: ${savedPath}`
435
+ : ` File: (not saved to disk — use the embedded resource data below)`;
407
436
 
408
- return {
409
- content: [
410
- {
411
- type: 'resource',
412
- resource: {
413
- uri: 'pagebolt://pdf/output.pdf',
414
- mimeType: 'application/pdf',
415
- blob: data.data, // base64-encoded PDF — always delivered to client
437
+ return {
438
+ content: [
439
+ {
440
+ type: 'resource',
441
+ resource: {
442
+ uri: 'pagebolt://pdf/output.pdf',
443
+ mimeType: 'application/pdf',
444
+ blob: data.data,
445
+ },
416
446
  },
417
- },
418
- {
419
- type: 'text',
420
- text: `PDF generated successfully.\n` +
421
- `${fileNote}\n` +
422
- ` Size: ${data.size_bytes} bytes\n` +
423
- ` Duration: ${data.duration_ms}ms`,
424
- },
425
- ],
426
- };
447
+ {
448
+ type: 'text',
449
+ text: `PDF generated successfully.\n` +
450
+ `${fileNote}\n` +
451
+ ` Size: ${data.size_bytes} bytes\n` +
452
+ ` Duration: ${data.duration_ms}ms`,
453
+ },
454
+ ],
455
+ };
456
+ } catch (err) {
457
+ return { content: [{ type: 'text', text: `PDF error: ${err.message}` }], isError: true };
458
+ }
427
459
  }
428
460
  );
429
461
 
@@ -448,27 +480,31 @@ server.tool(
448
480
  format: z.enum(['png', 'jpeg', 'webp']).optional().describe('Image format (default: png)'),
449
481
  },
450
482
  async (params) => {
451
- const res = await callApi('/api/v1/og-image', {
452
- method: 'POST',
453
- body: { ...params, response_type: 'json' },
454
- });
483
+ try {
484
+ const res = await callApi('/api/v1/og-image', {
485
+ method: 'POST',
486
+ body: { ...params, response_type: 'json' },
487
+ });
455
488
 
456
- const data = await res.json();
457
- const format = params.format || 'png';
489
+ const data = await res.json();
490
+ const format = params.format || 'png';
458
491
 
459
- return {
460
- content: [
461
- {
462
- type: 'image',
463
- data: data.data,
464
- mimeType: imageMimeType(format),
465
- },
466
- {
467
- type: 'text',
468
- text: `OG image created successfully. Format: ${format}, Size: ${data.size_bytes} bytes, Duration: ${data.duration_ms}ms`,
469
- },
492
+ return {
493
+ content: [
494
+ {
495
+ type: 'image',
496
+ data: data.data,
497
+ mimeType: imageMimeType(format),
498
+ },
499
+ {
500
+ type: 'text',
501
+ text: `OG image created successfully. Format: ${format}, Size: ${data.size_bytes} bytes, Duration: ${data.duration_ms}ms`,
502
+ },
470
503
  ],
471
504
  };
505
+ } catch (err) {
506
+ return { content: [{ type: 'text', text: `OG image error: ${err.message}` }], isError: true };
507
+ }
472
508
  }
473
509
  );
474
510
 
@@ -546,9 +582,19 @@ server.tool(
546
582
  text: `[${output.name}] Screenshot — ${output.format}, ${output.size_bytes} bytes, step ${output.step_index}`,
547
583
  });
548
584
  } else if (output.type === 'pdf') {
585
+ if (output.data) {
586
+ content.push({
587
+ type: 'resource',
588
+ resource: {
589
+ uri: `pagebolt://sequence-pdf/${output.name || `step-${output.step_index}`}`,
590
+ mimeType: 'application/pdf',
591
+ blob: output.data,
592
+ },
593
+ });
594
+ }
549
595
  content.push({
550
596
  type: 'text',
551
- text: `[${output.name}] PDF generated — ${output.format}, ${output.size_bytes} bytes, step ${output.step_index} (base64 data available in raw response)`,
597
+ text: `[${output.name}] PDF generated — ${output.size_bytes} bytes, step ${output.step_index}`,
552
598
  });
553
599
  }
554
600
  }
@@ -890,26 +936,29 @@ server.tool(
890
936
  'List all available device presets for viewport emulation (e.g. iphone_14_pro, macbook_pro_14). Use the returned device names with the viewportDevice parameter in take_screenshot.',
891
937
  {},
892
938
  async () => {
893
- const res = await callApi('/api/v1/devices');
894
- const data = await res.json();
939
+ try {
940
+ const res = await callApi('/api/v1/devices');
941
+ const data = await res.json();
895
942
 
896
- const lines = data.devices.map((d) => {
897
- const touch = d.hasTouch ? ', touch' : '';
898
- const mobile = d.isMobile ? ', mobile' : '';
899
- return ` ${d.name} — ${d.viewport.width}x${d.viewport.height} @${d.viewport.deviceScaleFactor}x${mobile}${touch}`;
900
- });
943
+ const lines = data.devices.map((d) => {
944
+ const mobile = d.mobile ? ', mobile' : '';
945
+ return ` ${d.id} ${d.name} ${d.width}x${d.height} @${d.deviceScaleFactor}x${mobile}`;
946
+ });
901
947
 
902
- return {
903
- content: [
904
- {
905
- type: 'text',
906
- text:
907
- `Available device presets (${data.devices.length}):\n` +
908
- lines.join('\n') +
909
- `\n\nUse the device name as the "viewportDevice" parameter in take_screenshot.`,
910
- },
911
- ],
912
- };
948
+ return {
949
+ content: [
950
+ {
951
+ type: 'text',
952
+ text:
953
+ `Available device presets (${data.devices.length}):\n` +
954
+ lines.join('\n') +
955
+ `\n\nUse the device name as the "viewportDevice" parameter in take_screenshot.`,
956
+ },
957
+ ],
958
+ };
959
+ } catch (err) {
960
+ return { content: [{ type: 'text', text: `List devices error: ${err.message}` }], isError: true };
961
+ }
913
962
  }
914
963
  );
915
964
 
@@ -921,25 +970,29 @@ server.tool(
921
970
  'Check your current PageBolt API usage and plan limits.',
922
971
  {},
923
972
  async () => {
924
- const res = await callApi('/api/v1/usage');
925
- const data = await res.json();
973
+ try {
974
+ const res = await callApi('/api/v1/usage');
975
+ const data = await res.json();
926
976
 
927
- const { plan, usage } = data;
928
- const pct = usage.limit > 0 ? Math.round((usage.current / usage.limit) * 100) : 0;
977
+ const { plan, usage } = data;
978
+ const pct = usage.limit > 0 ? Math.round((usage.current / usage.limit) * 100) : 0;
929
979
 
930
- return {
931
- content: [
932
- {
933
- type: 'text',
934
- text:
935
- `PageBolt Usage\n` +
936
- ` Plan: ${plan}\n` +
937
- ` Used: ${usage.current.toLocaleString()} / ${usage.limit.toLocaleString()} requests\n` +
938
- ` Remaining: ${usage.remaining.toLocaleString()}\n` +
939
- ` Usage: ${pct}%`,
940
- },
941
- ],
942
- };
980
+ return {
981
+ content: [
982
+ {
983
+ type: 'text',
984
+ text:
985
+ `PageBolt Usage\n` +
986
+ ` Plan: ${plan}\n` +
987
+ ` Used: ${usage.current.toLocaleString()} / ${usage.limit.toLocaleString()} requests\n` +
988
+ ` Remaining: ${usage.remaining.toLocaleString()}\n` +
989
+ ` Usage: ${pct}%`,
990
+ },
991
+ ],
992
+ };
993
+ } catch (err) {
994
+ return { content: [{ type: 'text', text: `Usage check error: ${err.message}` }], isError: true };
995
+ }
943
996
  }
944
997
  );
945
998
 
@@ -958,24 +1011,28 @@ server.tool(
958
1011
  stealth: z.boolean().optional().describe('Launch this session with stealth mode (bypasses bot detection). Note: stealth sessions use a dedicated browser and consume more memory.'),
959
1012
  },
960
1013
  async (params) => {
961
- const res = await callApi('/api/v1/sessions', {
962
- method: 'POST',
963
- body: params,
964
- });
965
- const data = await res.json();
966
- return {
967
- content: [
968
- {
969
- type: 'text',
970
- text:
971
- `Session created.\n` +
972
- ` session_id: ${data.session_id}\n` +
973
- ` expires_at: ${data.expires_at}\n\n` +
974
- `Pass session_id to take_screenshot or run_sequence to reuse this browser page.\n` +
975
- `Note: ${data.note || 'Sessions do not persist across server restarts.'}`,
976
- },
977
- ],
978
- };
1014
+ try {
1015
+ const res = await callApi('/api/v1/sessions', {
1016
+ method: 'POST',
1017
+ body: params,
1018
+ });
1019
+ const data = await res.json();
1020
+ return {
1021
+ content: [
1022
+ {
1023
+ type: 'text',
1024
+ text:
1025
+ `Session created.\n` +
1026
+ ` session_id: ${data.session_id}\n` +
1027
+ ` expires_at: ${data.expires_at}\n\n` +
1028
+ `Pass session_id to take_screenshot or run_sequence to reuse this browser page.\n` +
1029
+ `Note: ${data.note || 'Sessions do not persist across server restarts.'}`,
1030
+ },
1031
+ ],
1032
+ };
1033
+ } catch (err) {
1034
+ return { content: [{ type: 'text', text: `Create session error: ${err.message}` }], isError: true };
1035
+ }
979
1036
  }
980
1037
  );
981
1038
 
@@ -987,17 +1044,22 @@ server.tool(
987
1044
  'List all active persistent browser sessions for your API key. Returns session IDs, creation times, and expiry times. Useful for checking which sessions are still alive before reusing them.',
988
1045
  {},
989
1046
  async () => {
990
- const data = await callApi('/api/v1/sessions', { method: 'GET' });
991
- const sessions = data.sessions || [];
992
- if (sessions.length === 0) {
993
- return { content: [{ type: 'text', text: 'No active sessions.' }] };
1047
+ try {
1048
+ const res = await callApi('/api/v1/sessions', { method: 'GET' });
1049
+ const data = await res.json();
1050
+ const sessions = data.sessions || [];
1051
+ if (sessions.length === 0) {
1052
+ return { content: [{ type: 'text', text: 'No active sessions.' }] };
1053
+ }
1054
+ const lines = sessions.map(s =>
1055
+ `• ${s.session_id} expires: ${s.expires_at} created: ${s.created_at}`
1056
+ );
1057
+ return {
1058
+ content: [{ type: 'text', text: `Active sessions (${sessions.length}):\n${lines.join('\n')}` }],
1059
+ };
1060
+ } catch (err) {
1061
+ return { content: [{ type: 'text', text: `List sessions error: ${err.message}` }], isError: true };
994
1062
  }
995
- const lines = sessions.map(s =>
996
- `• ${s.session_id} expires: ${s.expires_at} created: ${s.created_at}`
997
- );
998
- return {
999
- content: [{ type: 'text', text: `Active sessions (${sessions.length}):\n${lines.join('\n')}` }],
1000
- };
1001
1063
  }
1002
1064
  );
1003
1065
 
@@ -1010,17 +1072,21 @@ server.tool(
1010
1072
  session_id: z.string().describe('The session ID to destroy (returned by create_session)'),
1011
1073
  },
1012
1074
  async (params) => {
1013
- await callApi(`/api/v1/sessions/${encodeURIComponent(params.session_id)}`, {
1014
- method: 'DELETE',
1015
- });
1016
- return {
1017
- content: [
1018
- {
1019
- type: 'text',
1020
- text: `Session ${params.session_id} destroyed successfully.`,
1021
- },
1022
- ],
1023
- };
1075
+ try {
1076
+ await callApi(`/api/v1/sessions/${encodeURIComponent(params.session_id)}`, {
1077
+ method: 'DELETE',
1078
+ });
1079
+ return {
1080
+ content: [
1081
+ {
1082
+ type: 'text',
1083
+ text: `Session ${params.session_id} destroyed successfully.`,
1084
+ },
1085
+ ],
1086
+ };
1087
+ } catch (err) {
1088
+ return { content: [{ type: 'text', text: `Destroy session error: ${err.message}` }], isError: true };
1089
+ }
1024
1090
  }
1025
1091
  );
1026
1092