From 69dbf17f5e50445b1c2bfcf628bd9379f8b3c490 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Sat, 25 Jul 2026 11:44:27 +0200 Subject: [PATCH] Add PDF text extraction to MCP read_file and search_files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP's read_file previously hard-required UTF-8 text decoding, so any PDF in an allowed folder failed with "Cannot read file as UTF-8 text" — chat attachments already supported PDFs (raw bytes to vision-capable models), but the AI couldn't read one on its own during agentic file-tool use. search_files' content_search had the same gap, silently skipping PDFs. Adds MCPService.extractPDFText(atPath:) using PDFKit (built into macOS, no new dependency) to pull text from a PDF's text layer. Wired into both read_file and search_files' content search. Returns a clear error for scanned/image-only PDFs with no text layer. Automatically covers the Research Agents sub-agent tool loop too, since it shares the same executeTool dispatcher. Verified live: asked the AI to read a real PDF containing "The secret code is PINEAPPLE-42." via read_file — it extracted the text correctly through the MCP tool-call path. Co-Authored-By: Claude Sonnet 5 --- oAI/Services/MCPService.swift | 42 +++++++++++++++---- oAITests/MCPServicePDFTests.swift | 68 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 oAITests/MCPServicePDFTests.swift diff --git a/oAI/Services/MCPService.swift b/oAI/Services/MCPService.swift index b4afc75..4a7ce74 100644 --- a/oAI/Services/MCPService.swift +++ b/oAI/Services/MCPService.swift @@ -23,6 +23,7 @@ import Foundation import os +import PDFKit @Observable class MCPService { @@ -155,7 +156,7 @@ class MCPService { var tools: [Tool] = [ makeTool( name: "read_file", - description: "Read the contents of a file. Returns the text content of the file. Maximum file size is 10MB.", + description: "Read the contents of a file. Returns the text content of the file. PDFs are supported (text layer is extracted automatically) — scanned/image-only PDFs with no text layer will return an error. Maximum file size is 10MB.", properties: [ "file_path": prop("string", "The absolute path to the file to read") ], @@ -172,7 +173,7 @@ class MCPService { ), makeTool( name: "search_files", - description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents.", + description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents (PDF text layers are searched too).", properties: [ "pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"), "search_path": prop("string", "Directory to search in (defaults to first allowed folder)"), @@ -536,8 +537,17 @@ class MCPService { return ["error": "File too large (\(sizeMB) MB, max 10 MB)"] } - guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else { - return ["error": "Cannot read file as UTF-8 text: \(filePath)"] + let content: String + if (resolved as NSString).pathExtension.lowercased() == "pdf" { + guard let extracted = extractPDFText(atPath: resolved) else { + return ["error": "Could not extract text from PDF (it may be scanned/image-only with no text layer, encrypted, or corrupted): \(filePath)"] + } + content = extracted + } else { + guard let text = try? String(contentsOfFile: resolved, encoding: .utf8) else { + return ["error": "Cannot read file as UTF-8 text: \(filePath)"] + } + content = text } var finalContent = content @@ -554,6 +564,19 @@ class MCPService { return ["content": finalContent, "path": resolved, "size": fileSize] } + /// Extracts plain text from a PDF's text layer, page by page. Returns nil for + /// scanned/image-only PDFs (no text layer), encrypted, or otherwise unreadable files. + func extractPDFText(atPath path: String) -> String? { + guard let document = PDFDocument(url: URL(fileURLWithPath: path)) else { return nil } + var text = "" + for i in 0.. [String: Any] { let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath @@ -660,10 +683,13 @@ class MCPService { // Content search if requested if let searchText = contentSearch, !searchText.isEmpty { - guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else { - continue + let content: String? + if (fullPath as NSString).pathExtension.lowercased() == "pdf" { + content = extractPDFText(atPath: fullPath) + } else { + content = try? String(contentsOfFile: fullPath, encoding: .utf8) } - if !content.localizedCaseInsensitiveContains(searchText) { + guard let content, content.localizedCaseInsensitiveContains(searchText) else { continue } } @@ -944,7 +970,7 @@ class MCPService { let readOnlyTools: [Tool] = [ makeTool( name: "read_file", - description: "Read the contents of a file. Maximum file size is 10MB.", + description: "Read the contents of a file. PDFs are supported (text layer extracted automatically). Maximum file size is 10MB.", properties: ["file_path": prop("string", "The absolute path to the file to read")], required: ["file_path"] ), diff --git a/oAITests/MCPServicePDFTests.swift b/oAITests/MCPServicePDFTests.swift new file mode 100644 index 0000000..794f22b --- /dev/null +++ b/oAITests/MCPServicePDFTests.swift @@ -0,0 +1,68 @@ +// +// MCPServicePDFTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +import CoreGraphics +import AppKit +@testable import oAI + +@Suite("MCPService PDF text extraction") +struct MCPServicePDFTests { + + /// Builds a real, well-formed single-page PDF (via CoreGraphics) containing the given text, + /// writes it to a temp file, and returns the path. Using CoreGraphics rather than hand-rolled + /// PDF bytes avoids flakiness from malformed xref tables etc. + private func makeTestPDF(text: String) throws -> String { + let path = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("pdf") + + let pdfData = NSMutableData() + let consumer = CGDataConsumer(data: pdfData as CFMutableData)! + var mediaBox = CGRect(x: 0, y: 0, width: 200, height: 200) + let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil)! + + context.beginPDFPage(nil) + let attributedString = NSAttributedString(string: text, attributes: [.font: NSFont.systemFont(ofSize: 24)]) + let line = CTLineCreateWithAttributedString(attributedString) + context.textPosition = CGPoint(x: 10, y: 100) + CTLineDraw(line, context) + context.endPDFPage() + context.closePDF() + + try (pdfData as Data).write(to: path) + return path.path + } + + @Test("Extracts text from a real PDF's text layer") + func extractsTextFromRealPDF() throws { + let path = try makeTestPDF(text: "Hello World") + defer { try? FileManager.default.removeItem(atPath: path) } + + let extracted = MCPService.shared.extractPDFText(atPath: path) + #expect(extracted?.contains("Hello World") == true) + } + + @Test("Returns nil for a nonexistent path") + func returnsNilForMissingFile() { + let extracted = MCPService.shared.extractPDFText(atPath: "/tmp/definitely-does-not-exist-\(UUID().uuidString).pdf") + #expect(extracted == nil) + } + + @Test("Returns nil for a file that isn't a valid PDF") + func returnsNilForGarbageBytes() throws { + let path = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("pdf") + try Data("not a real pdf".utf8).write(to: path) + defer { try? FileManager.default.removeItem(at: path) } + + let extracted = MCPService.shared.extractPDFText(atPath: path.path) + #expect(extracted == nil) + } +}