Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
82.61% |
76 / 92 |
|
33.33% |
1 / 3 |
CRAP | |
0.00% |
0 / 1 |
| DocumentRead | |
82.61% |
76 / 92 |
|
33.33% |
1 / 3 |
34.73 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
1 | |||
| read | |
95.00% |
38 / 40 |
|
0.00% |
0 / 1 |
16 | |||
| extract_pdf_text | |
44.00% |
11 / 25 |
|
0.00% |
0 / 1 |
42.68 | |||
| 1 | <?php |
| 2 | |
| 3 | declare( strict_types=1 ); |
| 4 | |
| 5 | namespace BLU\Abilities; |
| 6 | |
| 7 | /** |
| 8 | * Document read ability — exposes uploaded temp documents to the AI. |
| 9 | */ |
| 10 | class DocumentRead { |
| 11 | |
| 12 | /** |
| 13 | * Constructor — registers the blu/read-document ability. |
| 14 | */ |
| 15 | public function __construct() { |
| 16 | blu_register_ability( |
| 17 | 'blu/read-document', |
| 18 | array( |
| 19 | 'label' => 'Read Document', |
| 20 | 'description' => 'Read the text content of an uploaded document (txt, md, csv, pdf) so it can be used to update page content. Call this when the user wants to apply the content of an uploaded file to text blocks on the page.', |
| 21 | 'category' => 'blu-mcp', |
| 22 | 'input_schema' => array( |
| 23 | 'type' => 'object', |
| 24 | 'properties' => array( |
| 25 | 'source_url' => array( |
| 26 | 'type' => 'string', |
| 27 | 'description' => 'URL of the uploaded document from the [User uploaded documents] list.', |
| 28 | ), |
| 29 | ), |
| 30 | 'required' => array( 'source_url' ), |
| 31 | ), |
| 32 | 'execute_callback' => array( $this, 'read' ), |
| 33 | 'permission_callback' => fn() => current_user_can( 'edit_posts' ), |
| 34 | 'meta' => array( |
| 35 | 'annotations' => array( |
| 36 | 'readonly' => true, |
| 37 | 'destructive' => false, |
| 38 | 'idempotent' => true, |
| 39 | ), |
| 40 | ), |
| 41 | ) |
| 42 | ); |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Read document content from the temp upload directory. |
| 47 | * |
| 48 | * @param array $input Tool input parameters. |
| 49 | * @return array Standardized ability response. |
| 50 | */ |
| 51 | public function read( array $input ): array { |
| 52 | $raw_url = (string) ( $input['source_url'] ?? '' ); |
| 53 | $url = esc_url_raw( $raw_url ); |
| 54 | |
| 55 | if ( empty( $url ) || ! filter_var( $raw_url, FILTER_VALIDATE_URL ) ) { |
| 56 | return blu_prepare_ability_response( 400, __( 'A valid source_url is required.', 'wp-module-mcp' ) ); |
| 57 | } |
| 58 | |
| 59 | // Resolve to filesystem path — must be inside nfd-chat-temp (SSRF / path-traversal guard). |
| 60 | $upload_dir = wp_upload_dir(); |
| 61 | $base_path = realpath( $upload_dir['basedir'] . '/nfd-chat-temp' ); |
| 62 | if ( false === $base_path ) { |
| 63 | return blu_prepare_ability_response( 404, __( 'Document not found or not accessible.', 'wp-module-mcp' ) ); |
| 64 | } |
| 65 | |
| 66 | $url_path = wp_parse_url( $url, PHP_URL_PATH ); |
| 67 | $abs_file = realpath( $base_path . '/' . basename( (string) $url_path ) ); |
| 68 | |
| 69 | if ( false === $abs_file || strpos( $abs_file, $base_path ) !== 0 || ! is_file( $abs_file ) ) { |
| 70 | return blu_prepare_ability_response( 404, __( 'Document not found or not accessible.', 'wp-module-mcp' ) ); |
| 71 | } |
| 72 | |
| 73 | $mime = is_callable( 'mime_content_type' ) ? strtolower( (string) mime_content_type( $abs_file ) ) : ''; |
| 74 | $text_types = array( 'text/plain', 'text/markdown', 'text/csv' ); |
| 75 | |
| 76 | // Guard against very large files before reading into memory. |
| 77 | $file_size = filesize( $abs_file ); |
| 78 | $max_file_size = 50 * 1024 * 1024; // 50 MB |
| 79 | if ( false !== $file_size && $file_size > $max_file_size ) { |
| 80 | return blu_prepare_ability_response( 400, __( 'Document exceeds the 50 MB size limit.', 'wp-module-mcp' ) ); |
| 81 | } |
| 82 | |
| 83 | if ( in_array( $mime, $text_types, true ) ) { |
| 84 | $content = file_get_contents( $abs_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions |
| 85 | } elseif ( 'application/pdf' === $mime ) { |
| 86 | $content = $this->extract_pdf_text( $abs_file ); |
| 87 | } else { |
| 88 | return blu_prepare_ability_response( 400, __( 'Unsupported document type.', 'wp-module-mcp' ) ); |
| 89 | } |
| 90 | |
| 91 | if ( false === $content || '' === $content ) { |
| 92 | return blu_prepare_ability_response( 500, __( 'Could not read document content.', 'wp-module-mcp' ) ); |
| 93 | } |
| 94 | |
| 95 | $max_chars = 30000; |
| 96 | $truncated = mb_strlen( $content ) > $max_chars; |
| 97 | if ( $truncated ) { |
| 98 | $content = mb_substr( $content, 0, $max_chars ); |
| 99 | } |
| 100 | |
| 101 | return blu_prepare_ability_response( |
| 102 | 200, |
| 103 | array( |
| 104 | 'content' => $content, |
| 105 | 'type' => $mime, |
| 106 | 'truncated' => $truncated, |
| 107 | 'message' => $truncated |
| 108 | ? __( 'Document content (truncated to 30 000 characters).', 'wp-module-mcp' ) |
| 109 | : __( 'Document content read successfully.', 'wp-module-mcp' ), |
| 110 | ) |
| 111 | ); |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Extract plain text from a PDF file. |
| 116 | * |
| 117 | * Tries smalot/pdfparser first (pure PHP, no system dependency), then falls |
| 118 | * back to pdftotext (poppler-utils) if the library is unavailable. |
| 119 | * |
| 120 | * @param string $path Absolute filesystem path to the PDF. |
| 121 | * @return string Extracted text or fallback message. |
| 122 | */ |
| 123 | private function extract_pdf_text( string $path ): string { |
| 124 | // smalot/pdfparser ships in wp-module-mcp's own vendor. If the parent |
| 125 | // plugin's autoloader doesn't include it (common in monorepo setups), |
| 126 | // register a targeted PSR-0 autoloader for the Smalot\ namespace only — |
| 127 | // avoids reloading shared packages (e.g. lucatume/wp-browser) that would |
| 128 | // trigger "Cannot redeclare" fatals. |
| 129 | if ( ! class_exists( '\Smalot\PdfParser\Parser' ) ) { |
| 130 | $smalot_src = dirname( __DIR__, 2 ) . '/vendor/smalot/pdfparser/src'; |
| 131 | if ( is_dir( $smalot_src ) ) { |
| 132 | spl_autoload_register( |
| 133 | static function ( $class_name ) use ( $smalot_src ) { |
| 134 | if ( 0 === strpos( $class_name, 'Smalot\\' ) ) { |
| 135 | $file = $smalot_src . DIRECTORY_SEPARATOR . str_replace( '\\', DIRECTORY_SEPARATOR, $class_name ) . '.php'; |
| 136 | if ( file_exists( $file ) ) { |
| 137 | require_once $file; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | ); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // Primary: smalot/pdfparser — works on any PHP host, no binaries needed. |
| 146 | if ( class_exists( '\Smalot\PdfParser\Parser' ) ) { |
| 147 | try { |
| 148 | $parser = new \Smalot\PdfParser\Parser(); |
| 149 | $pdf = $parser->parseFile( $path ); |
| 150 | $text = $pdf->getText(); |
| 151 | if ( is_string( $text ) && '' !== trim( $text ) ) { |
| 152 | return $text; |
| 153 | } |
| 154 | } catch ( \Exception $e ) { |
| 155 | // Fall through to pdftotext. |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // Fallback: pdftotext (poppler-utils), available on some servers. |
| 160 | $disabled = array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) ); |
| 161 | if ( function_exists( 'shell_exec' ) && ! in_array( 'shell_exec', $disabled, true ) ) { |
| 162 | $escaped = escapeshellarg( $path ); |
| 163 | // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions |
| 164 | $result = shell_exec( "pdftotext $escaped - 2>/dev/null" ); |
| 165 | if ( is_string( $result ) && '' !== trim( $result ) ) { |
| 166 | return $result; |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | return 'PDF content could not be extracted automatically on this server. Ask the user to upload a .txt version of the document instead.'; |
| 171 | } |
| 172 | } |