27require_once DOL_DOCUMENT_ROOT .
"/ai/class/mcptool.class.php";
41 const CTX_ASSISTANT =
'assistant';
43 const CTX_MCP_SERVER =
'mcp_server';
57 public $loadedTools = [];
63 public $toolsByName = [];
81 public function __construct($db, $user, $conf_obj =
null, $toolcontext =
'')
86 if ($conf_obj ===
null) {
90 $this->
conf = $conf_obj;
92 $this->toolcontext = (!empty($toolcontext)) ? $toolcontext : self::CTX_ASSISTANT;
107 return (method_exists($toolInstance,
'isSystem') && $toolInstance->isSystem());
126 if ($this->toolcontext === self::CTX_MCP_SERVER) {
127 $constName =
'AI_MCP_SERVER_ALLOWED_TOOLS';
129 $constName =
'AI_ASSISTANT_ALLOWED_TOOLS';
139 if ($raw ===
'NONE') {
141 return array(
'__blocked__');
144 return array_values(array_filter(array_map(
'trim', explode(
',', $raw))));
158 return $allDiscoveredTools;
160 if ($raw ===
'NONE') {
165 return array_values(array_filter(array_map(
'trim', explode(
',', $raw))));
196 $toolsDir = DOL_DOCUMENT_ROOT .
'/ai/tools/';
197 if (!is_dir($toolsDir)) {
198 dol_syslog(
'[McpHandler] MCP tools directory not found: ' . $toolsDir, LOG_INFO);
202 $files = glob($toolsDir .
'*.php');
203 foreach ($files as $file) {
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);
212 require_once $realFilePath;
214 $basename = basename($file,
'.class.php');
215 $className =
'Tool' . str_replace(
' ',
'', ucwords(str_replace(
'_',
' ', $basename)));
217 if (!class_exists($className)) {
218 dol_syslog(
"[McpHandler] Tool class '{$className}' not found in file '{$file}'.", LOG_WARNING);
222 $toolInstance =
new $className($this->db, $this->
user, $this->
conf);
224 if ($toolInstance instanceof
McpTool) {
227 dol_syslog(
"[McpHandler] Tool class '{$className}' does not extend McpTool.", LOG_ERR);
229 }
catch (\Throwable $e) {
230 dol_syslog(
"[McpHandler] Failed to load tool from file '{$file}': " . $e->getMessage(), LOG_ERR);
246 if (!is_object($hookmanager)) {
247 require_once DOL_DOCUMENT_ROOT .
'/core/class/hookmanager.class.php';
251 $hookmanager->initHooks([
'aimcp']);
253 $parameters = [
'db' => $this->db,
'user' => $this->user,
'conf' => $this->conf];
257 $hookmanager->executeHooks(
'addMcpTools', $parameters, $this, $action);
259 if (!is_array($hookmanager->resArray)) {
263 foreach ($hookmanager->resArray as $moduleTools) {
264 if ($moduleTools instanceof
McpTool) {
269 $this->
registerTool(get_class($moduleTools), $moduleTools);
272 if (!is_array($moduleTools)) {
275 foreach ($moduleTools as $toolInstance) {
276 if ($toolInstance instanceof
McpTool) {
277 $this->
registerTool(get_class($toolInstance), $toolInstance);
279 dol_syslog(
'[McpHandler] A module provided a tool that is not an instance of McpTool.', LOG_WARNING);
283 }
catch (\Throwable $e) {
284 dol_syslog(
'[McpHandler] Error during \'addMcpTools\' hook execution: ' . $e->getMessage(), LOG_ERR);
298 $this->loadedTools[$key] = $toolInstance;
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) .
"'.",
309 $this->toolsByName[$def[
'name']] = $toolInstance;
313 dol_syslog(
'[McpHandler] Successfully registered MCP tool: ' . get_class($toolInstance), LOG_INFO);
328 foreach ($this->loadedTools as $tool) {
330 $className = get_class($tool);
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();
362 foreach ($this->loadedTools as $tool) {
365 foreach ($tool->getDefinitions() as $def) {
366 $name = isset($def[
'name']) ? $def[
'name'] :
'';
373 $def[
'is_system'] =
true;
374 if (empty($def[
'categories'])) {
375 $def[
'categories'] = $tool->getCategories();
381 $def[
'is_system'] =
false;
389 if (empty($allowed)) {
391 if (empty($def[
'categories'])) {
392 $def[
'categories'] = $tool->getCategories();
398 if (in_array($name, $allowed,
true)) {
399 if (empty($def[
'categories'])) {
400 $def[
'categories'] = $tool->getCategories();
431 foreach ($this->loadedTools as $tool) {
438 foreach ($tool->getDefinitions() as $def) {
439 $name = isset($def[
'name']) ? $def[
'name'] :
'';
444 if (!empty($def[
'is_system'])) {
453 if (empty($allowed)) {
455 if (empty($def[
'categories'])) {
456 $def[
'categories'] = $tool->getCategories();
462 if (in_array($name, $allowed,
true)) {
463 if (empty($def[
'categories'])) {
464 $def[
'categories'] = $tool->getCategories();
489 if ($declared === McpTool::RIGHTS_ENFORCED_DOWNSTREAM) {
493 dol_syslog(
"[McpHandler] Tool '".$toolName.
"' declares no rights: denied.", LOG_WARNING);
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] :
'';
503 return $module.
'/'.$perm.($subperm !==
'' ?
'/'.$subperm :
'');
523 if (!method_exists($tool,
'writeConfirmationPreview')) {
526 $preview = (string) $tool->writeConfirmationPreview($toolName, $args);
531 require_once DOL_DOCUMENT_ROOT.
'/ai/class/writeconfirmation.class.php';
534 $state = isset($args[
'requestState']) ? (string) $args[
'requestState'] :
'';
536 if ($gate->consume($this->user, $toolName, $args, $state)) {
540 return array(
'error' => $gate->error);
543 $issued = $gate->issue($this->
user, $toolName, $args, $preview);
544 if ($issued ===
'') {
545 return array(
'error' => $gate->error);
549 'resultType' =>
'input_required',
550 'inputRequests' => array(
552 'type' =>
'confirmation',
556 'requestState' => $issued
573 if (!isset($this->toolsByName[$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])) {
587 $realTokens = explode(
'_', $realName);
588 if (!array_diff($reqTokens, $realTokens)) {
589 $candidates[] = $realName;
592 if (count($candidates) === 1) {
593 dol_syslog(
"[McpHandler] Tool name '".$toolName.
"' recovered to '".$candidates[0].
"'.", LOG_INFO);
594 $toolName = $candidates[0];
596 return [
"error" =>
"Tool '{$toolName}' not found."];
600 $toolInstance = $this->toolsByName[$toolName];
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).",
611 return array(
'error' =>
"Tool '" . $toolName .
"' is not available in this tool context.");
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.".");
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) {
632 $result = $toolInstance->execute($toolName, $args);
633 dol_syslog('[
McpHandler] Tool \
'' . $toolName .
'\' executed successfully.
', LOG_INFO);
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."];
Class AiWriteConfirmation.
Class to handle MCP (Model Context Protocol).
checkToolRights($tool, $toolName)
Check the rights a tool declared for the acting user.
__construct($db, $user, $conf_obj=null, $toolcontext='')
Constructor.
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.
if(! $sortfield) if(! $sortorder) $module
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)
$conf db user
Active Directory does not allow anonymous connections.