Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.00% covered (success)
99.00%
99 / 100
87.50% covered (success)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
FaqSearchTool
99.00% covered (success)
99.00%
99 / 100
87.50% covered (success)
87.50%
7 / 8
17
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefinition
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
1
 execute
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
7
 getSearch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFaq
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatResultsAsJson
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 buildFaqUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createResult
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * phpMyFAQ MCP Server - FAQ Search Tool
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-03-16
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Service\McpServer;
21
22use Exception;
23use phpMyFAQ\Category;
24use phpMyFAQ\Configuration;
25use phpMyFAQ\Faq;
26use phpMyFAQ\Search;
27
28readonly class FaqSearchTool implements McpToolExecutorInterface
29{
30    public function __construct(
31        private Configuration $configuration,
32        private Search $search,
33        private Faq $faq,
34    ) {
35    }
36
37    public function getDefinition(): McpToolDefinition
38    {
39        return new McpToolDefinition(
40            name: 'faq_search',
41            description: 'Search through the phpMyFAQ knowledge base to find relevant FAQ entries that can answer questions. '
42            . 'This tool searches both questions and answers in the FAQ database to provide comprehensive results.',
43            title: 'FAQ Search',
44            inputSchema: [
45                'type' => 'object',
46                'properties' => [
47                    'query' => [
48                        'type' => 'string',
49                        'description' => 'The search query or question to find relevant FAQ entries for',
50                    ],
51                    'category_id' => [
52                        'type' => 'integer',
53                        'description' => 'Optional category ID to limit search to a specific FAQ category',
54                        'minimum' => 1,
55                    ],
56                    'limit' => [
57                        'type' => 'integer',
58                        'description' => 'Maximum number of results to return (default: 10)',
59                        'default' => 10,
60                        'minimum' => 1,
61                        'maximum' => 50,
62                    ],
63                    'all_languages' => [
64                        'type' => 'boolean',
65                        'description' => 'Whether to search in all languages or just the current language (default: false)',
66                        'default' => false,
67                    ],
68                ],
69                'required' => ['query'],
70            ],
71            outputSchema: [
72                'type' => 'object',
73                'properties' => [
74                    'results' => [
75                        'type' => 'array',
76                        'description' => 'Array of FAQ search results',
77                    ],
78                    'total_found' => [
79                        'type' => 'integer',
80                        'description' => 'Total number of FAQ entries found',
81                    ],
82                ],
83            ],
84        );
85    }
86
87    public function execute(array $arguments): array
88    {
89        $query = $arguments['query'] ?? '';
90        $categoryId = $arguments['category_id'] ?? null;
91        $limit = $arguments['limit'] ?? 10;
92        $allLanguages = $arguments['all_languages'] ?? false;
93
94        if (trim((string) $query) === '') {
95            return $this->createResult('Error: Search query cannot be empty.');
96        }
97
98        try {
99            $this->faq->setUser(-1);
100            $this->faq->setGroups([-1]);
101
102            $category = new Category($this->configuration, [-1]);
103            $category->setUser(-1);
104            $this->search->setCategory($category);
105
106            $this->search->setCategoryId($categoryId !== null ? (int) $categoryId : null);
107
108            $searchResults = $this->search->search((string) $query, (bool) $allLanguages);
109
110            if ($searchResults === []) {
111                return $this->createResult($this->formatResultsAsJson([]));
112            }
113
114            $validResults = [];
115            foreach ($searchResults as $searchResult) {
116                $validResults[] = [
117                    'id' => $searchResult->id,
118                    'language' => $searchResult->lang,
119                    'question' => $searchResult->question ?? '',
120                    'answer' => $searchResult->answer ?? '',
121                    'category_id' => $searchResult->category_id ?? null,
122                    'relevance_score' => $searchResult->score ?? 0.0,
123                    'url' => $this->buildFaqUrl((int) $searchResult->id, (string) $searchResult->lang),
124                ];
125            }
126
127            $limitedResults = array_slice($validResults, offset: 0, length: (int) $limit);
128
129            if ($limitedResults === []) {
130                return $this->createResult('No accessible FAQ entries found for the given query.');
131            }
132
133            return $this->createResult($this->formatResultsAsJson($limitedResults));
134        } catch (Exception $exception) {
135            return $this->createResult('Error searching FAQ database: ' . $exception->getMessage());
136        }
137    }
138
139    public function getSearch(): Search
140    {
141        return $this->search;
142    }
143
144    public function getFaq(): Faq
145    {
146        return $this->faq;
147    }
148
149    /**
150     * @param array<int, array<string, mixed>> $results
151     */
152    private function formatResultsAsJson(array $results): string
153    {
154        if ($results === []) {
155            $jsonData = [
156                'results' => [],
157                'total_found' => 0,
158            ];
159
160            $emptyJson = json_encode($jsonData);
161
162            return $emptyJson === false ? '{"results":[],"total_found":0}' : $emptyJson;
163        }
164
165        $jsonData = [
166            'results' => $results,
167            'total_found' => count($results),
168        ];
169
170        $json = json_encode($jsonData, JSON_PRETTY_PRINT);
171
172        return $json === false ? '{"results":[],"total_found":0}' : $json;
173    }
174
175    private function buildFaqUrl(int $faqId, string $language): string
176    {
177        return $this->configuration->getDefaultUrl() . 'content/' . $faqId . '/' . $language;
178    }
179
180    /**
181     * @return array{content: string, type: string, mimeType: string}
182     */
183    private function createResult(string $content): array
184    {
185        return [
186            'content' => $content,
187            'type' => 'text',
188            'mimeType' => 'application/json',
189        ];
190    }
191}