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