Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.61% covered (warning)
82.61%
76 / 92
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
DocumentRead
82.61% covered (warning)
82.61%
76 / 92
33.33% covered (danger)
33.33%
1 / 3
34.73
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 read
95.00% covered (success)
95.00%
38 / 40
0.00% covered (danger)
0.00%
0 / 1
16
 extract_pdf_text
44.00% covered (danger)
44.00%
11 / 25
0.00% covered (danger)
0.00%
0 / 1
42.68
1<?php
2
3declare( strict_types=1 );
4
5namespace BLU\Abilities;
6
7/**
8 * Document read ability — exposes uploaded temp documents to the AI.
9 */
10class 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}