@sylphx/pdf-reader-mcp 1.2.0 → 1.3.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.
@@ -70,7 +70,7 @@ const processSingleSource = async (source, options) => {
70
70
  let errorMessage = `Failed to process PDF from ${sourceDescription}.`;
71
71
  if (error instanceof McpError) {
72
72
  errorMessage = error.message;
73
- }
73
+ } /* c8 ignore next */
74
74
  else if (error instanceof Error) {
75
75
  errorMessage += ` Reason: ${error.message}`;
76
76
  }
@@ -93,9 +93,11 @@ export const handleReadPdfFunc = async (args) => {
93
93
  }
94
94
  catch (error) {
95
95
  if (error instanceof z.ZodError) {
96
- throw new McpError(ErrorCode.InvalidParams, `Invalid arguments: ${error.errors.map((e) => `${e.path.join('.')} (${e.message})`).join(', ')}`);
96
+ throw new McpError(ErrorCode.InvalidParams, `Invalid arguments: ${error.issues.map((e) => `${e.path.join('.')} (${e.message})`).join(', ')}`);
97
97
  }
98
+ /* c8 ignore next */
98
99
  const message = error instanceof Error ? error.message : String(error);
100
+ /* c8 ignore next */
99
101
  throw new McpError(ErrorCode.InvalidParams, `Argument validation failed: ${message}`);
100
102
  }
101
103
  const { sources, include_full_text, include_metadata, include_page_count, include_images } = parsedArgs;
@@ -147,11 +149,11 @@ export const handleReadPdfFunc = async (args) => {
147
149
  });
148
150
  }
149
151
  else if (item.type === 'image' && item.imageData) {
150
- // Add image content part
152
+ // Add image content part (all images are now encoded as PNG)
151
153
  content.push({
152
154
  type: 'image',
153
155
  data: item.imageData.data,
154
- mimeType: item.imageData.format === 'rgba' ? 'image/png' : 'image/jpeg',
156
+ mimeType: 'image/png',
155
157
  });
156
158
  }
157
159
  }
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { allToolDefinitions } from './handlers/index.js';
11
11
  // --- Server Setup ---
12
12
  const server = new Server({
13
13
  name: 'pdf-reader-mcp',
14
- version: '1.2.0',
14
+ version: '1.3.0',
15
15
  description: 'MCP Server for reading PDF files and extracting text, metadata, images, and page information.',
16
16
  }, {
17
17
  capabilities: { tools: {} },
@@ -1,5 +1,42 @@
1
1
  // PDF text and metadata extraction utilities
2
2
  import { OPS } from 'pdfjs-dist/legacy/build/pdf.mjs';
3
+ import { PNG } from 'pngjs';
4
+ /**
5
+ * Encode raw pixel data to PNG format
6
+ */
7
+ const encodePixelsToPNG = (pixelData, width, height, channels) => {
8
+ const png = new PNG({ width, height });
9
+ // Convert pixel data to RGBA format expected by pngjs
10
+ if (channels === 4) {
11
+ // Already RGBA
12
+ png.data = Buffer.from(pixelData);
13
+ }
14
+ else if (channels === 3) {
15
+ // RGB -> RGBA (add alpha channel)
16
+ for (let i = 0; i < width * height; i++) {
17
+ const srcIdx = i * 3;
18
+ const dstIdx = i * 4;
19
+ png.data[dstIdx] = pixelData[srcIdx] ?? 0; // R
20
+ png.data[dstIdx + 1] = pixelData[srcIdx + 1] ?? 0; // G
21
+ png.data[dstIdx + 2] = pixelData[srcIdx + 2] ?? 0; // B
22
+ png.data[dstIdx + 3] = 255; // A (fully opaque)
23
+ }
24
+ }
25
+ else if (channels === 1) {
26
+ // Grayscale -> RGBA
27
+ for (let i = 0; i < width * height; i++) {
28
+ const gray = pixelData[i] ?? 0;
29
+ const dstIdx = i * 4;
30
+ png.data[dstIdx] = gray; // R
31
+ png.data[dstIdx + 1] = gray; // G
32
+ png.data[dstIdx + 2] = gray; // B
33
+ png.data[dstIdx + 3] = 255; // A
34
+ }
35
+ }
36
+ // Encode to PNG and convert to base64
37
+ const pngBuffer = PNG.sync.write(png);
38
+ return pngBuffer.toString('base64');
39
+ };
3
40
  /**
4
41
  * Extract metadata and page count from a PDF document
5
42
  */
@@ -68,6 +105,7 @@ export const extractPageTexts = async (pdfDocument, pagesToProcess, sourceDescri
68
105
  */
69
106
  const extractImagesFromPage = async (page, pageNum) => {
70
107
  const images = [];
108
+ /* c8 ignore next */
71
109
  try {
72
110
  const operatorList = await page.getOperatorList();
73
111
  // Find all image painting operations
@@ -78,7 +116,7 @@ const extractImagesFromPage = async (page, pageNum) => {
78
116
  imageIndices.push(i);
79
117
  }
80
118
  }
81
- // Extract each image using Promise-based approach
119
+ // Extract each image - try sync first, then async if needed
82
120
  const imagePromises = imageIndices.map((imgIndex, arrayIndex) => new Promise((resolve) => {
83
121
  const argsArray = operatorList.argsArray[imgIndex];
84
122
  if (!argsArray || argsArray.length === 0) {
@@ -86,30 +124,75 @@ const extractImagesFromPage = async (page, pageNum) => {
86
124
  return;
87
125
  }
88
126
  const imageName = argsArray[0];
89
- // Use callback-based get() as images may not be resolved yet
90
- page.objs.get(imageName, (imageData) => {
127
+ // Helper to process image data
128
+ const processImageData = (imageData) => {
91
129
  if (!imageData || typeof imageData !== 'object') {
92
- resolve(null);
93
- return;
130
+ return null;
94
131
  }
95
132
  const img = imageData;
96
133
  if (!img.data || !img.width || !img.height) {
97
- resolve(null);
98
- return;
134
+ return null;
99
135
  }
100
- // Determine image format based on kind
101
- // kind === 1 = grayscale, 2 = RGB, 3 = RGBA
136
+ // Determine number of channels based on kind
137
+ // kind === 1 = grayscale (1 channel), 2 = RGB (3 channels), 3 = RGBA (4 channels)
138
+ const channels = img.kind === 1 ? 1 : img.kind === 3 ? 4 : 3;
102
139
  const format = img.kind === 1 ? 'grayscale' : img.kind === 3 ? 'rgba' : 'rgb';
103
- // Convert Uint8Array to base64
104
- const base64 = Buffer.from(img.data).toString('base64');
105
- resolve({
140
+ // Encode raw pixel data to PNG format
141
+ const pngBase64 = encodePixelsToPNG(img.data, img.width, img.height, channels);
142
+ return {
106
143
  page: pageNum,
107
144
  index: arrayIndex,
108
145
  width: img.width,
109
146
  height: img.height,
110
147
  format,
111
- data: base64,
112
- });
148
+ data: pngBase64,
149
+ };
150
+ };
151
+ // Try to get from commonObjs first if it starts with 'g_'
152
+ if (imageName.startsWith('g_')) {
153
+ try {
154
+ const imageData = page.commonObjs.get(imageName);
155
+ if (imageData) {
156
+ const result = processImageData(imageData);
157
+ resolve(result);
158
+ return;
159
+ }
160
+ }
161
+ catch (error) {
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ console.warn(`[PDF Reader MCP] Error getting image from commonObjs ${imageName}: ${message}`);
164
+ }
165
+ }
166
+ // Try synchronous get first - if image is already loaded
167
+ try {
168
+ const imageData = page.objs.get(imageName);
169
+ if (imageData !== undefined) {
170
+ const result = processImageData(imageData);
171
+ resolve(result);
172
+ return;
173
+ }
174
+ }
175
+ catch (error) {
176
+ // Synchronous get failed or not supported, fall through to async
177
+ const message = error instanceof Error ? error.message : String(error);
178
+ console.warn(`[PDF Reader MCP] Sync image get failed for ${imageName}, trying async: ${message}`);
179
+ }
180
+ // Fallback to async callback-based get with timeout
181
+ let resolved = false;
182
+ const timeout = setTimeout(() => {
183
+ if (!resolved) {
184
+ resolved = true;
185
+ console.warn(`[PDF Reader MCP] Image extraction timeout for ${imageName} on page ${String(pageNum)}`);
186
+ resolve(null);
187
+ }
188
+ }, 10000); // 10 second timeout as a safety net
189
+ page.objs.get(imageName, (imageData) => {
190
+ if (!resolved) {
191
+ resolved = true;
192
+ clearTimeout(timeout);
193
+ const result = processImageData(imageData);
194
+ resolve(result);
195
+ }
113
196
  });
114
197
  }));
115
198
  const resolvedImages = await Promise.all(imagePromises);
@@ -196,7 +279,7 @@ export const extractPageContent = async (pdfDocument, pageNum, includeImages, so
196
279
  imageIndices.push(i);
197
280
  }
198
281
  }
199
- // Extract each image with its Y-coordinate
282
+ // Extract each image with its Y-coordinate - try sync first, then async if needed
200
283
  const imagePromises = imageIndices.map((imgIndex, arrayIndex) => new Promise((resolve) => {
201
284
  const argsArray = operatorList.argsArray[imgIndex];
202
285
  if (!argsArray || argsArray.length === 0) {
@@ -205,32 +288,29 @@ export const extractPageContent = async (pdfDocument, pageNum, includeImages, so
205
288
  }
206
289
  const imageName = argsArray[0];
207
290
  // Get transform matrix from the args (if available)
208
- // The transform is typically in argsArray[1] for some ops
209
291
  let yPosition = 0;
210
292
  if (argsArray.length > 1 && Array.isArray(argsArray[1])) {
211
293
  const transform = argsArray[1];
212
- // transform[5] is the Y coordinate
213
294
  const yCoord = transform[5];
214
295
  if (yCoord !== undefined) {
215
296
  yPosition = Math.round(yCoord);
216
297
  }
217
298
  }
218
- // Use callback-based get() as images may not be resolved yet
219
- page.objs.get(imageName, (imageData) => {
299
+ // Helper to process image data
300
+ const processImageData = (imageData) => {
220
301
  if (!imageData || typeof imageData !== 'object') {
221
- resolve(null);
222
- return;
302
+ return null;
223
303
  }
224
304
  const img = imageData;
225
305
  if (!img.data || !img.width || !img.height) {
226
- resolve(null);
227
- return;
306
+ return null;
228
307
  }
229
- // Determine image format based on kind
308
+ // Determine number of channels based on kind
309
+ const channels = img.kind === 1 ? 1 : img.kind === 3 ? 4 : 3;
230
310
  const format = img.kind === 1 ? 'grayscale' : img.kind === 3 ? 'rgba' : 'rgb';
231
- // Convert Uint8Array to base64
232
- const base64 = Buffer.from(img.data).toString('base64');
233
- resolve({
311
+ // Encode raw pixel data to PNG format
312
+ const pngBase64 = encodePixelsToPNG(img.data, img.width, img.height, channels);
313
+ return {
234
314
  type: 'image',
235
315
  yPosition,
236
316
  imageData: {
@@ -239,9 +319,58 @@ export const extractPageContent = async (pdfDocument, pageNum, includeImages, so
239
319
  width: img.width,
240
320
  height: img.height,
241
321
  format,
242
- data: base64,
322
+ data: pngBase64,
243
323
  },
244
- });
324
+ };
325
+ };
326
+ // Try to get from commonObjs first if it starts with 'g_'
327
+ if (imageName.startsWith('g_')) {
328
+ try {
329
+ const imageData = page.commonObjs.get(imageName);
330
+ if (imageData) {
331
+ const result = processImageData(imageData);
332
+ resolve(result);
333
+ return;
334
+ }
335
+ /* c8 ignore next */
336
+ }
337
+ catch (error) {
338
+ /* c8 ignore next */ const message = error instanceof Error ? error.message : String(error);
339
+ /* c8 ignore next */ console.warn(
340
+ /* c8 ignore next */ `[PDF Reader MCP] Error getting image from commonObjs ${imageName}: ${message}`
341
+ /* c8 ignore next */
342
+ );
343
+ }
344
+ }
345
+ // Try synchronous get first - if image is already loaded
346
+ try {
347
+ const imageData = page.objs.get(imageName);
348
+ if (imageData !== undefined) {
349
+ const result = processImageData(imageData);
350
+ resolve(result);
351
+ return;
352
+ }
353
+ }
354
+ catch (error) {
355
+ const message = error instanceof Error ? error.message : String(error);
356
+ console.warn(`[PDF Reader MCP] Sync image get failed for ${imageName}, trying async: ${message}`);
357
+ }
358
+ // Fallback to async callback-based get with timeout
359
+ let resolved = false;
360
+ const timeout = setTimeout(() => {
361
+ if (!resolved) {
362
+ resolved = true;
363
+ console.warn(`[PDF Reader MCP] Image extraction timeout for ${imageName} on page ${String(pageNum)}`);
364
+ resolve(null);
365
+ }
366
+ }, 10000); // 10 second timeout as a safety net
367
+ page.objs.get(imageName, (imageData) => {
368
+ if (!resolved) {
369
+ resolved = true;
370
+ clearTimeout(timeout);
371
+ const result = processImageData(imageData);
372
+ resolve(result);
373
+ }
245
374
  });
246
375
  }));
247
376
  const resolvedImages = await Promise.all(imagePromises);
@@ -7,10 +7,9 @@ const MAX_RANGE_SIZE = 10000; // Prevent infinite loops for open ranges
7
7
  const parseRangePart = (part, pages) => {
8
8
  const trimmedPart = part.trim();
9
9
  if (trimmedPart.includes('-')) {
10
- const [startStr, endStr] = trimmedPart.split('-');
11
- if (startStr === undefined) {
12
- throw new Error(`Invalid page range format: ${trimmedPart}`);
13
- }
10
+ const splitResult = trimmedPart.split('-');
11
+ const startStr = splitResult[0] || '';
12
+ const endStr = splitResult[1];
14
13
  const start = parseInt(startStr, 10);
15
14
  const end = endStr === '' || endStr === undefined ? Infinity : parseInt(endStr, 10);
16
15
  if (Number.isNaN(start) || Number.isNaN(end) || start <= 0 || start > end) {
@@ -43,6 +42,9 @@ export const parsePageRanges = (ranges) => {
43
42
  for (const part of parts) {
44
43
  parseRangePart(part, pages);
45
44
  }
45
+ // This should never happen as parseRangePart would have thrown an error
46
+ // if no valid pages were found, but we keep this as a safety check
47
+ /* c8 ignore next */
46
48
  if (pages.size === 0) {
47
49
  throw new Error('Page range string resulted in zero valid pages.');
48
50
  }
@@ -14,7 +14,11 @@ export const pageSpecifierSchema = z.union([
14
14
  // Schema for a single PDF source (path or URL)
15
15
  export const pdfSourceSchema = z
16
16
  .object({
17
- path: z.string().min(1).optional().describe('Relative path to the local PDF file.'),
17
+ path: z
18
+ .string()
19
+ .min(1)
20
+ .optional()
21
+ .describe('Path to the local PDF file (absolute or relative to cwd).'),
18
22
  url: z.string().url().optional().describe('URL of the PDF file.'),
19
23
  pages: pageSpecifierSchema
20
24
  .optional()
@@ -6,10 +6,9 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
6
6
  export const PROJECT_ROOT = process.cwd();
7
7
  console.info(`[Filesystem MCP - pathUtils] Project Root determined from CWD: ${PROJECT_ROOT}`); // Use info instead of log
8
8
  /**
9
- * Resolves a user-provided relative path against the project root,
10
- * ensuring it stays within the project boundaries.
11
- * Throws McpError on invalid input, absolute paths, or path traversal.
12
- * @param userPath The relative path provided by the user.
9
+ * Resolves a user-provided path, accepting both absolute and relative paths.
10
+ * Relative paths are resolved against the current working directory (PROJECT_ROOT).
11
+ * @param userPath The path provided by the user (absolute or relative).
13
12
  * @returns The resolved absolute path.
14
13
  */
15
14
  export const resolvePath = (userPath) => {
@@ -17,14 +16,10 @@ export const resolvePath = (userPath) => {
17
16
  throw new McpError(ErrorCode.InvalidParams, 'Path must be a string.');
18
17
  }
19
18
  const normalizedUserPath = path.normalize(userPath);
19
+ // If absolute path, return it normalized
20
20
  if (path.isAbsolute(normalizedUserPath)) {
21
- throw new McpError(ErrorCode.InvalidParams, 'Absolute paths are not allowed.');
21
+ return normalizedUserPath;
22
22
  }
23
- // Resolve against the calculated PROJECT_ROOT
24
- const resolved = path.resolve(PROJECT_ROOT, normalizedUserPath);
25
- // Security check: Ensure the resolved path is still within the project root
26
- if (!resolved.startsWith(PROJECT_ROOT)) {
27
- throw new McpError(ErrorCode.InvalidRequest, 'Path traversal detected. Access denied.');
28
- }
29
- return resolved;
23
+ // If relative path, resolve against the PROJECT_ROOT (cwd)
24
+ return path.resolve(PROJECT_ROOT, normalizedUserPath);
30
25
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/pdf-reader-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "An MCP server providing tools to read PDF files.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,20 +39,48 @@
39
39
  "agent",
40
40
  "tool"
41
41
  ],
42
+ "scripts": {
43
+ "build": "tsc",
44
+ "watch": "tsc --watch",
45
+ "inspector": "npx @modelcontextprotocol/inspector dist/index.js",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest watch",
48
+ "test:cov": "vitest run --coverage --reporter=junit --outputFile=test-report.junit.xml",
49
+ "lint": "biome lint .",
50
+ "lint:fix": "biome lint --write .",
51
+ "format": "biome format --write .",
52
+ "check-format": "biome format .",
53
+ "check": "biome check .",
54
+ "check:fix": "biome check --write .",
55
+ "validate": "npm run check && npm run test",
56
+ "docs:dev": "vitepress dev docs",
57
+ "docs:build": "vitepress build docs",
58
+ "docs:preview": "vitepress preview docs",
59
+ "start": "node dist/index.js",
60
+ "typecheck": "tsc --noEmit",
61
+ "benchmark": "vitest bench",
62
+ "clean": "rm -rf dist coverage",
63
+ "docs:api": "typedoc --entryPoints src/index.ts --tsconfig tsconfig.json --plugin typedoc-plugin-markdown --out docs/api --readme none",
64
+ "prepublishOnly": "pnpm run clean && pnpm run build",
65
+ "release": "standard-version",
66
+ "prepare": "husky"
67
+ },
42
68
  "dependencies": {
43
- "@modelcontextprotocol/sdk": "1.20.2",
69
+ "@modelcontextprotocol/sdk": "^1.21.0",
44
70
  "glob": "^11.0.1",
45
71
  "pdfjs-dist": "^5.4.296",
46
- "zod": "^3.24.2",
47
- "zod-to-json-schema": "^3.24.5"
72
+ "pngjs": "^7.0.0",
73
+ "zod": "^3.25.76",
74
+ "zod-to-json-schema": "^3.24.6"
48
75
  },
49
76
  "devDependencies": {
50
77
  "@biomejs/biome": "^2.3.2",
51
- "@commitlint/cli": "^19.8.0",
52
- "@commitlint/config-conventional": "^19.8.0",
78
+ "@commitlint/cli": "^20.1.0",
79
+ "@commitlint/config-conventional": "^20.0.0",
53
80
  "@types/glob": "^8.1.0",
54
81
  "@types/node": "^24.0.7",
55
- "@vitest/coverage-v8": "^3.1.1",
82
+ "@types/pngjs": "^6.0.5",
83
+ "@vitest/coverage-v8": "^4.0.7",
56
84
  "husky": "^9.1.7",
57
85
  "lint-staged": "^16.2.6",
58
86
  "standard-version": "^9.5.0",
@@ -60,7 +88,7 @@
60
88
  "typedoc-plugin-markdown": "^4.9.0",
61
89
  "typescript": "^5.8.3",
62
90
  "vitepress": "^1.6.3",
63
- "vitest": "^3.1.1",
91
+ "vitest": "^4.0.7",
64
92
  "vue": "^3.5.13"
65
93
  },
66
94
  "commitlint": {
@@ -72,29 +100,5 @@
72
100
  "*.{ts,tsx,js,cjs,json}": [
73
101
  "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
74
102
  ]
75
- },
76
- "scripts": {
77
- "build": "tsc",
78
- "watch": "tsc --watch",
79
- "inspector": "npx @modelcontextprotocol/inspector dist/index.js",
80
- "test": "vitest run",
81
- "test:watch": "vitest watch",
82
- "test:cov": "vitest run --coverage --reporter=junit --outputFile=test-report.junit.xml",
83
- "lint": "biome lint .",
84
- "lint:fix": "biome lint --write .",
85
- "format": "biome format --write .",
86
- "check-format": "biome format .",
87
- "check": "biome check .",
88
- "check:fix": "biome check --write .",
89
- "validate": "npm run check && npm run test",
90
- "docs:dev": "vitepress dev docs",
91
- "docs:build": "vitepress build docs",
92
- "docs:preview": "vitepress preview docs",
93
- "start": "node dist/index.js",
94
- "typecheck": "tsc --noEmit",
95
- "benchmark": "vitest bench",
96
- "clean": "rm -rf dist coverage",
97
- "docs:api": "typedoc --entryPoints src/index.ts --tsconfig tsconfig.json --plugin typedoc-plugin-markdown --out docs/api --readme none",
98
- "release": "standard-version"
99
103
  }
100
- }
104
+ }