dolibarr 24.0.0-beta
mcp.class.php
Go to the documentation of this file.
1<?php
2/* Copyright (C) 2026 Laurent Destailleur <eldy@users.sourceforge.net>
3 * Copyright (C) 2026 Nick Fragoulis
4 * Copyright (C) 2026 MDW <mdeweerd@users.noreply.github.com>
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY, without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18 * or see https://www.gnu.org/
19 */
20
27require_once DOL_DOCUMENT_ROOT . "/ai/class/mcptool.class.php";
28
40{
41 const CTX_ASSISTANT = 'assistant';
42
43 const CTX_MCP_SERVER = 'mcp_server';
44
46 private $db;
47
49 private $user;
50
52 private $conf;
53
57 private $loadedTools = [];
58
63 private $toolsByName = [];
64
65
70 private $toolcontext;
71
81 public function __construct($db, $user, $conf_obj = null, $toolcontext = '')
82 {
83 $this->db = $db;
84 $this->user = $user;
85
86 if ($conf_obj === null) {
87 global $conf;
88 $conf_obj = $conf;
89 }
90 $this->conf = $conf_obj;
91
92 $this->toolcontext = (!empty($toolcontext)) ? $toolcontext : self::CTX_ASSISTANT;
93
94 $this->loadTools();
95 }
96
107 private function isSystemTool($toolInstance)
108 {
109 return (method_exists($toolInstance, 'isSystem') && $toolInstance->isSystem());
110 }
111
126 private function getAllowedToolsList()
127 {
128 if ($this->toolcontext === self::CTX_MCP_SERVER) {
129 $constName = 'AI_MCP_SERVER_ALLOWED_TOOLS';
130 } else {
131 $constName = 'AI_ASSISTANT_ALLOWED_TOOLS';
132 }
133
134 $raw = getDolGlobalString($constName);
135
136 if ($raw === '') {
137 // Constant not yet configured — allow everything
138 return array();
139 }
140
141 if ($raw === 'NONE') {
142 // Admin explicitly disabled all tools via the preset button
143 return array('__blocked__');
144 }
145
146 return array_values(array_filter(array_map('trim', explode(',', $raw))));
147 }
148
156 public static function resolveAllowList($raw, $allDiscoveredTools)
157 {
158 if ($raw === '') {
159 // Not yet configured → implicitly all tools are allowed
160 return $allDiscoveredTools;
161 }
162 if ($raw === 'NONE') {
163 // Admin explicitly disabled everything
164 return array();
165 }
166 // Explicit list stored by a previous save
167 return array_values(array_filter(array_map('trim', explode(',', $raw))));
168 }
169
170
179 private function loadTools()
180 {
181 $this->loadNativeTools();
182 $this->loadExternalTools();
183 }
184
195 private function loadNativeTools()
196 {
197 $toolsDir = DOL_DOCUMENT_ROOT . '/ai/tools/';
198 if (!is_dir($toolsDir)) {
199 dol_syslog('[McpHandler] MCP tools directory not found: ' . $toolsDir, LOG_INFO);
200 return;
201 }
202
203 $files = glob($toolsDir . '*.php');
204 foreach ($files as $file) {
205 try {
206 // Validate the file path before inclusion
207 $realFilePath = realpath($file);
208 if ($realFilePath === false || strpos($realFilePath, realpath($toolsDir)) !== 0) {
209 dol_syslog('[McpHandler] Attempted to load tool outside of allowed directory: ' . $file, LOG_WARNING);
210 continue;
211 }
212
213 require_once $realFilePath;
214
215 $basename = basename($file, '.class.php');
216 $className = 'Tool' . str_replace(' ', '', ucwords(str_replace('_', ' ', $basename)));
217
218 if (!class_exists($className)) {
219 dol_syslog("[McpHandler] Tool class '{$className}' not found in file '{$file}'.", LOG_WARNING);
220 continue;
221 }
222
223 $toolInstance = new $className($this->db, $this->user, $this->conf);
224
225 if ($toolInstance instanceof McpTool) {
226 $this->registerTool($basename, $toolInstance);
227 } else {
228 dol_syslog("[McpHandler] Tool class '{$className}' does not extend McpTool.", LOG_ERR);
229 }
230 } catch (\Throwable $e) {
231 dol_syslog("[McpHandler] Failed to load tool from file '{$file}': " . $e->getMessage(), LOG_ERR);
232 }
233 }
234 }
244 private function loadExternalTools()
245 {
246 global $hookmanager;
247 if (!is_object($hookmanager)) {
248 require_once DOL_DOCUMENT_ROOT . '/core/class/hookmanager.class.php';
249 $hookmanager = new HookManager($this->db);
250 }
251
252 $hookmanager->initHooks(['aimcp']);
253
254 $parameters = ['db' => $this->db, 'user' => $this->user, 'conf' => $this->conf];
255 $action = '';
256
257 try {
258 $hookmanager->executeHooks('addMcpTools', $parameters, $this, $action);
259
260 if (!is_array($hookmanager->resArray)) {
261 return;
262 }
263
264 foreach ($hookmanager->resArray as $moduleTools) {
265 if (!is_array($moduleTools)) {
266 continue;
267 }
268 foreach ($moduleTools as $toolInstance) {
269 if ($toolInstance instanceof McpTool) {
270 $this->registerTool(get_class($toolInstance), $toolInstance);
271 } else {
272 dol_syslog('[McpHandler] A module provided a tool that is not an instance of McpTool.', LOG_WARNING);
273 }
274 }
275 }
276 } catch (\Throwable $e) {
277 dol_syslog('[McpHandler] Error during \'addMcpTools\' hook execution: ' . $e->getMessage(), LOG_ERR);
278 }
279 }
280
289 private function registerTool(string $key, McpTool $toolInstance)
290 {
291 $this->loadedTools[$key] = $toolInstance;
292
293 // Populate the lookup map
294 foreach ($toolInstance->getDefinitions() as $def) {
295 if (isset($def['name'])) {
296 if (isset($this->toolsByName[$def['name']])) {
298 "[McpHandler] Tool name conflict: '{$def['name']}' is already registered by '" . get_class($this->toolsByName[$def['name']]) . "'. Skipping registration from '" . get_class($toolInstance) . "'.",
299 LOG_WARNING
300 );
301 } else {
302 $this->toolsByName[$def['name']] = $toolInstance;
303 }
304 }
305 }
306 dol_syslog('[McpHandler] Successfully registered MCP tool: ' . get_class($toolInstance), LOG_INFO);
307 }
308
317 public function getToolsSchemaUnfiltered()
318 {
319 $schema = array();
320
321 foreach ($this->loadedTools as $tool) {
322 $isSystem = $this->isSystemTool($tool);
323 $className = get_class($tool);
324
325 foreach ($tool->getDefinitions() as $def) {
326 $def['is_system'] = $isSystem;
327 $def['class_name'] = $className;
328 $def['categories'] = $tool->getCategories();
329 $schema[] = $def;
330 }
331 }
332
333 return $schema;
334 }
335
348 public function getToolsSchema(): array
349 {
350 $allowed = $this->getAllowedToolsList();
351 $schema = [];
352
353 foreach ($this->loadedTools as $tool) {
354 $isSystem = $this->isSystemTool($tool);
355
356 foreach ($tool->getDefinitions() as $def) {
357 $name = isset($def['name']) ? $def['name'] : '';
358
359 if ($isSystem) {
360 // Always include system tools but tag them so parse_intent.php
361 // can strip them from $toolsForLLM while keeping them available
362 // for the validation check (executeTool must still be able to
363 // run respond_to_user, ask_for_clarification, etc.).
364 $def['is_system'] = true;
365 $def['categories'] = $tool->getCategories();
366 $schema[] = $def;
367 continue;
368 }
369
370 $def['is_system'] = false;
371
372 if (empty($allowed)) {
373 // No restriction configured — include everything
374 $def['categories'] = $tool->getCategories();
375 $schema[] = $def;
376 continue;
377 }
378
379 if (in_array($name, $allowed, true)) {
380 $def['categories'] = $tool->getCategories();
381 $schema[] = $def;
382 }
383 // Not in $allowed — silently omitted; LLM never sees this tool
384 }
385 }
386
387 return $schema;
388 }
389
405 public function getToolsSchemaForLLM()
406 {
407 $allowed = $this->getAllowedToolsList();
408 $schema = array();
409
410 foreach ($this->loadedTools as $tool) {
411 // Check isSystem() class method first (requires conversation.class.php
412 // to implement it). This is the preferred path for future extensibility.
413 if ($this->isSystemTool($tool)) {
414 continue;
415 }
416
417 foreach ($tool->getDefinitions() as $def) {
418 $name = isset($def['name']) ? $def['name'] : '';
419
420 // Check is_system flag in the definition array itself.
421 // This is set directly in conversation.class.php getDefinitions()
422 // and works even if the isSystem() class method is not yet deployed.
423 if (!empty($def['is_system'])) {
424 continue;
425 }
426
427 if (empty($allowed)) {
428 // No restriction configured — include everything
429 $def['categories'] = $tool->getCategories();
430 $schema[] = $def;
431 continue;
432 }
433
434 if (in_array($name, $allowed, true)) {
435 $def['categories'] = $tool->getCategories();
436 $schema[] = $def;
437 }
438 }
439 }
440
441 return $schema;
442 }
443
455 public function executeTool(string $toolName, array $args): array
456 {
457 if (!isset($this->toolsByName[$toolName])) {
458 return ["error" => "Tool '{$toolName}' not found."];
459 }
460
461 $toolInstance = $this->toolsByName[$toolName];
462
463 // enforce tool context allow-list (system tools always pass through)
464 if (!$this->isSystemTool($toolInstance)) {
465 $allowed = $this->getAllowedToolsList();
466
467 if (!empty($allowed) && !in_array($toolName, $allowed, true)) {
469 "[McpHandler] Blocked execution of tool '$toolName' in tool context '{$this->toolcontext}' (not in allow-list).",
470 LOG_WARNING
471 );
472 return array('error' => "Tool '" . $toolName . "' is not available in this tool context.");
473 }
474 }
475
476 // execute
477 try {
478 dol_syslog('[McpHandler] Executing tool \'' . $toolName . '\' with args: ' . json_encode($args), LOG_INFO);
479 $result = $toolInstance->execute($toolName, $args);
480 dol_syslog('[McpHandler] Tool \'' . $toolName . '\' executed successfully.', LOG_INFO);
481 return $result;
482 } catch (\Throwable $e) {
483 dol_syslog('[McpHandler] Error executing tool \'' . $toolName . '\': ' . $e->getMessage(), LOG_ERR);
484 return ["error" => "An internal error occurred while executing the tool '{$toolName}'. Details have been logged."];
485 }
486 }
487}
Class to manage hooks.
Class to handle MCP (Model Context Protocol).
Definition mcp.class.php:40
__construct($db, $user, $conf_obj=null, $toolcontext='')
Constructor.
Definition mcp.class.php:81
isSystemTool($toolInstance)
Returns true if the given tool instance declares itself as a system tool.
getToolsSchemaForLLM()
Returns the schema of tools permitted in the current context, with system tools completely excluded.
getToolsSchemaUnfiltered()
Returns the full schema of every loaded tool with no allow-list filtering.
loadExternalTools()
Loads external tools registered via the 'addMcpTools' hook.
registerTool(string $key, McpTool $toolInstance)
Helper method to register a tool instance and populate lookup arrays.
getAllowedToolsList()
Returns the configured allow-list for the current context as an array of tool names.
loadTools()
Load all available MCP tools.
loadNativeTools()
Load native tools from the specific tools directory.
getToolsSchema()
Returns the schema of all tools permitted in the current context.
static resolveAllowList($raw, $allDiscoveredTools)
Resolves a raw allow-list constant value into an explicit PHP array of tool names.
executeTool(string $toolName, array $args)
Execute a specific tool by its name.
Abstract base class for all MCP (Model Context Protocol) tools.
getDefinitions()
Return the list of tools provided by this class.
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $conf
The main.inc.php has been included so the following variable are now defined:
getDolGlobalString($key, $default='')
Return a Dolibarr global constant string value.
dol_syslog($message, $level=LOG_INFO, $ident=0, $suffixinfilename='', $restricttologhandler='', $logcontext=null)
Write log message into outputs.
conf($dolibarr_main_document_root)
Load conf file (file must exists)
Definition inc.php:426
$conf db user
Active Directory does not allow anonymous connections.
Definition repair.php:134