dolibarr 25.0.0-alpha
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 public $loadedTools = [];
58
63 public $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
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); // Return the list (separated by coma) of all enabled tools
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
178 public function loadTools()
179 {
180 $this->loadNativeTools(); // Tools found into directory ai/tools/
181 $this->loadExternalTools(); // Tools provided by external module and hook addMcpTools
182 }
183
194 private function loadNativeTools()
195 {
196 $toolsDir = DOL_DOCUMENT_ROOT . '/ai/tools/';
197 if (!is_dir($toolsDir)) {
198 dol_syslog('[McpHandler] MCP tools directory not found: ' . $toolsDir, LOG_INFO);
199 return;
200 }
201
202 $files = glob($toolsDir . '*.php');
203 foreach ($files as $file) {
204 try {
205 // Validate the file path before inclusion
206 $realFilePath = realpath($file);
207 if ($realFilePath === false || strpos($realFilePath, realpath($toolsDir)) !== 0) {
208 dol_syslog('[McpHandler] Attempted to load tool outside of allowed directory: ' . $file, LOG_WARNING);
209 continue;
210 }
211
212 require_once $realFilePath;
213
214 $basename = basename($file, '.class.php');
215 $className = 'Tool' . str_replace(' ', '', ucwords(str_replace('_', ' ', $basename)));
216
217 if (!class_exists($className)) {
218 dol_syslog("[McpHandler] Tool class '{$className}' not found in file '{$file}'.", LOG_WARNING);
219 continue;
220 }
221
222 $toolInstance = new $className($this->db, $this->user, $this->conf);
223
224 if ($toolInstance instanceof McpTool) {
225 $this->registerTool($basename, $toolInstance);
226 } else {
227 dol_syslog("[McpHandler] Tool class '{$className}' does not extend McpTool.", LOG_ERR);
228 }
229 } catch (\Throwable $e) {
230 dol_syslog("[McpHandler] Failed to load tool from file '{$file}': " . $e->getMessage(), LOG_ERR);
231 }
232 }
233 }
243 private function loadExternalTools()
244 {
245 global $hookmanager;
246 if (!is_object($hookmanager)) {
247 require_once DOL_DOCUMENT_ROOT . '/core/class/hookmanager.class.php';
248 $hookmanager = new HookManager($this->db);
249 }
250
251 $hookmanager->initHooks(['aimcp']);
252
253 $parameters = ['db' => $this->db, 'user' => $this->user, 'conf' => $this->conf];
254 $action = '';
255
256 try {
257 $hookmanager->executeHooks('addMcpTools', $parameters, $this, $action);
258
259 if (!is_array($hookmanager->resArray)) {
260 return;
261 }
262
263 foreach ($hookmanager->resArray as $moduleTools) {
264 if ($moduleTools instanceof McpTool) {
265 // Tolerance: a module that set results = array($tool)
266 // instead of array(array($tool)) still works - the
267 // HookManager flattens results into resArray, so bare
268 // instances are the natural mistake to make.
269 $this->registerTool(get_class($moduleTools), $moduleTools);
270 continue;
271 }
272 if (!is_array($moduleTools)) {
273 continue;
274 }
275 foreach ($moduleTools as $toolInstance) {
276 if ($toolInstance instanceof McpTool) {
277 $this->registerTool(get_class($toolInstance), $toolInstance);
278 } else {
279 dol_syslog('[McpHandler] A module provided a tool that is not an instance of McpTool.', LOG_WARNING);
280 }
281 }
282 }
283 } catch (\Throwable $e) {
284 dol_syslog('[McpHandler] Error during \'addMcpTools\' hook execution: ' . $e->getMessage(), LOG_ERR);
285 }
286 }
287
296 private function registerTool(string $key, McpTool $toolInstance)
297 {
298 $this->loadedTools[$key] = $toolInstance;
299
300 // Populate the lookup map
301 foreach ($toolInstance->getDefinitions() as $def) {
302 if (isset($def['name'])) {
303 if (isset($this->toolsByName[$def['name']])) {
305 "[McpHandler] Tool name conflict: '{$def['name']}' is already registered by '" . get_class($this->toolsByName[$def['name']]) . "'. Skipping registration from '" . get_class($toolInstance) . "'.",
306 LOG_WARNING
307 );
308 } else {
309 $this->toolsByName[$def['name']] = $toolInstance;
310 }
311 }
312 }
313 dol_syslog('[McpHandler] Successfully registered MCP tool: ' . get_class($toolInstance), LOG_INFO);
314 }
315
324 public function getToolsSchemaUnfiltered()
325 {
326 $schema = array();
327
328 foreach ($this->loadedTools as $tool) {
329 $isSystem = $this->isSystemTool($tool);
330 $className = get_class($tool);
331
332 foreach ($tool->getDefinitions() as $def) {
333 $def['is_system'] = $isSystem;
334 $def['class_name'] = $className;
335 if (empty($def['categories'])) {
336 $def['categories'] = $tool->getCategories(); // class-level fallback; a tool may set finer per-definition categories
337 }
338 $schema[] = $def;
339 }
340 }
341
342 return $schema;
343 }
344
357 public function getToolsSchema(): array
358 {
359 $allowed = $this->getAllowedToolsList(); // Return list of "allowed" tools for the current context $this->toolcontext (Chat or MCP)
360 $schema = [];
361
362 foreach ($this->loadedTools as $tool) {
363 $isSystem = $this->isSystemTool($tool);
364
365 foreach ($tool->getDefinitions() as $def) {
366 $name = isset($def['name']) ? $def['name'] : '';
367
368 if ($isSystem) {
369 // Always include system tools but tag them so parse_intent.php
370 // can strip them from $toolsForLLM while keeping them available
371 // for the validation check (executeTool must still be able to
372 // run respond_to_user, ask_for_clarification, etc.).
373 $def['is_system'] = true;
374 if (empty($def['categories'])) {
375 $def['categories'] = $tool->getCategories(); // class-level fallback; a tool may set finer per-definition categories
376 }
377 $schema[] = $def;
378 continue;
379 }
380
381 $def['is_system'] = false;
382
383 // Same rule as getToolsSchemaForLLM(): this is what tools/list on the
384 // MCP server returns, so a caller must not be offered what it cannot run.
385 if ($this->checkToolRights($tool, $name) !== '') {
386 continue;
387 }
388
389 if (empty($allowed)) {
390 // No restriction configured — include everything
391 if (empty($def['categories'])) {
392 $def['categories'] = $tool->getCategories(); // class-level fallback; a tool may set finer per-definition categories
393 }
394 $schema[] = $def;
395 continue;
396 }
397
398 if (in_array($name, $allowed, true)) {
399 if (empty($def['categories'])) {
400 $def['categories'] = $tool->getCategories(); // class-level fallback; a tool may set finer per-definition categories
401 }
402 $schema[] = $def;
403 }
404 // Not in $allowed — silently omitted; LLM never sees this tool
405 }
406 }
407
408 return $schema;
409 }
410
426 public function getToolsSchemaForLLM()
427 {
428 $allowed = $this->getAllowedToolsList();
429 $schema = array();
430
431 foreach ($this->loadedTools as $tool) {
432 // Check isSystem() class method first (requires conversation.class.php
433 // to implement it). This is the preferred path for future extensibility.
434 if ($this->isSystemTool($tool)) {
435 continue;
436 }
437
438 foreach ($tool->getDefinitions() as $def) {
439 $name = isset($def['name']) ? $def['name'] : '';
440
441 // Check is_system flag in the definition array itself.
442 // This is set directly in conversation.class.php getDefinitions()
443 // and works even if the isSystem() class method is not yet deployed.
444 if (!empty($def['is_system'])) {
445 continue;
446 }
447
448 // Do not advertise what this user cannot run.
449 if ($this->checkToolRights($tool, $name) !== '') {
450 continue;
451 }
452
453 if (empty($allowed)) {
454 // No restriction configured — include everything
455 if (empty($def['categories'])) {
456 $def['categories'] = $tool->getCategories(); // class-level fallback; a tool may set finer per-definition categories
457 }
458 $schema[] = $def;
459 continue;
460 }
461
462 if (in_array($name, $allowed, true)) {
463 if (empty($def['categories'])) {
464 $def['categories'] = $tool->getCategories(); // class-level fallback; a tool may set finer per-definition categories
465 }
466 $schema[] = $def;
467 }
468 }
469 }
470
471 return $schema;
472 }
473
481 private function checkToolRights($tool, $toolName)
482 {
483 if ($this->isSystemTool($tool)) {
484 return '';
485 }
486
487 $declared = method_exists($tool, 'getRequiredRights') ? $tool->getRequiredRights($toolName) : McpTool::RIGHTS_UNDECLARED;
488
489 if ($declared === McpTool::RIGHTS_ENFORCED_DOWNSTREAM) {
490 return ''; // REST API classes check DolibarrApiAccess::$user themselves
491 }
492 if ($declared === McpTool::RIGHTS_UNDECLARED) {
493 dol_syslog("[McpHandler] Tool '".$toolName."' declares no rights: denied.", LOG_WARNING);
494
495 return 'undeclared';
496 }
497 foreach ((array) $declared as $right) {
498 $right = (array) $right;
499 $module = isset($right[0]) ? $right[0] : '';
500 $perm = isset($right[1]) ? $right[1] : '';
501 $subperm = isset($right[2]) ? $right[2] : '';
502 if ($module === '' || !$this->user->hasRight($module, $perm, $subperm)) {
503 return $module.'/'.$perm.($subperm !== '' ? '/'.$subperm : '');
504 }
505 }
506
507 return '';
508 }
521 private function checkWriteConfirmation($tool, $toolName, array $args)
522 {
523 if (!method_exists($tool, 'writeConfirmationPreview')) {
524 return null;
525 }
526 $preview = (string) $tool->writeConfirmationPreview($toolName, $args);
527 if ($preview === McpTool::NO_WRITE) {
528 return null; // read tool
529 }
530
531 require_once DOL_DOCUMENT_ROOT.'/ai/class/writeconfirmation.class.php';
532 $gate = new AiWriteConfirmation($this->db);
533
534 $state = isset($args['requestState']) ? (string) $args['requestState'] : '';
535 if ($state !== '') {
536 if ($gate->consume($this->user, $toolName, $args, $state)) {
537 return null; // confirmed: the write runs
538 }
539
540 return array('error' => $gate->error);
541 }
542
543 $issued = $gate->issue($this->user, $toolName, $args, $preview);
544 if ($issued === '') {
545 return array('error' => $gate->error);
546 }
547
548 return array(
549 'resultType' => 'input_required',
550 'inputRequests' => array(
551 array(
552 'type' => 'confirmation',
553 'prompt' => $preview
554 )
555 ),
556 'requestState' => $issued
557 );
558 }
559
571 public function executeTool(string $toolName, array $args): array
572 {
573 if (!isset($this->toolsByName[$toolName])) {
574 // LLMs routinely emit near-miss tool names (create_invoice for
575 // create_customer_invoice). Recover when the real name is
576 // UNAMBIGUOUS: same action verb (segment before the first '_')
577 // and every underscore token of the requested name appears in the
578 // candidate. Exactly one match executes (logged); zero or several
579 // keep the clean error - never guess between candidates.
580 $reqTokens = explode('_', dol_strtolower($toolName));
581 $verb = $reqTokens[0];
582 $candidates = array();
583 foreach (array_keys($this->toolsByName) as $realName) {
584 if (strpos($realName, $verb.'_') !== 0 || $this->isSystemTool($this->toolsByName[$realName])) {
585 continue;
586 }
587 $realTokens = explode('_', $realName);
588 if (!array_diff($reqTokens, $realTokens)) {
589 $candidates[] = $realName;
590 }
591 }
592 if (count($candidates) === 1) {
593 dol_syslog("[McpHandler] Tool name '".$toolName."' recovered to '".$candidates[0]."'.", LOG_INFO);
594 $toolName = $candidates[0];
595 } else {
596 return ["error" => "Tool '{$toolName}' not found."];
597 }
598 }
599
600 $toolInstance = $this->toolsByName[$toolName];
601
602 // enforce tool context allow-list (system tools always pass through)
603 if (!$this->isSystemTool($toolInstance)) {
604 $allowed = $this->getAllowedToolsList();
605
606 if (!empty($allowed) && !in_array($toolName, $allowed, true)) {
608 "[McpHandler] Blocked execution of tool '$toolName' in tool context '{$this->toolcontext}' (not in allow-list).",
609 LOG_WARNING
610 );
611 return array('error' => "Tool '" . $toolName . "' is not available in this tool context.");
612 }
613 }
614
615 // execute
616 try {
617 dol_syslog('[McpHandler] Executing tool \'' . $toolName . '\' with args: ' . json_encode($args), LOG_INFO);
618 $missingRight = $this->checkToolRights($toolInstance, $toolName);
619 if ($missingRight !== '') {
620 return array('error' => ($missingRight === 'undeclared')
621 ? "Tool '".$toolName."' cannot run: it declares no required rights."
622 : "Permission denied: '".$toolName."' requires the right ".$missingRight.".");
623 }
624
625 // Writes do not execute on the first call: the caller gets a preview and
626 // a confirmation state, and comes back with it. Reads are unaffected.
627 $pending = $this->checkWriteConfirmation($toolInstance, $toolName, $args);
628 if ($pending !== null) {
629 return $pending;
630 }
631
632 $result = $toolInstance->execute($toolName, $args);
633 dol_syslog('[McpHandler] Tool \'' . $toolName . '\' executed successfully.', LOG_INFO);
634 return $result;
635 } catch (\Throwable $e) {
636 dol_syslog('[McpHandler] Error executing tool \'' . $toolName . '\': ' . $e->getMessage(), LOG_ERR);
637 return ["error" => "An internal error occurred while executing the tool '{$toolName}'. Details have been logged."];
638 }
639 }
640}
Class AiWriteConfirmation.
Class to manage hooks.
Class to handle MCP (Model Context Protocol).
Definition mcp.class.php:40
checkToolRights($tool, $toolName)
Check the rights a tool declared for the acting user.
__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.
checkWriteConfirmation($tool, $toolName, array $args)
Stop a write until it is confirmed, on the MCP multi-round-trip pattern.
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.
const RIGHTS_UNDECLARED
Default of getRequiredRights(): the class never declared anything, deny.
const NO_WRITE
Returned by writeConfirmationPreview() when the tool writes nothing.
if(! $sortfield) if(! $sortorder) $module
Definition list.php:193
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $conf
The main.inc.php has been included so the following variable are now defined:
dol_strtolower($string, $encoding="UTF-8")
Convert a string to lower.
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, $realpathconf=null)
Load conf file (file must exists)
Definition inc.php:431
$conf db user
Active Directory does not allow anonymous connections.
Definition repair.php:141