dolibarr/htdocs/ai/class/mcp.class.php
MDW 0715c93e68
Qual: Avoid variable scope clash (param $conf vs. global $conf) (#38969)
# Qual: Avoid variable scope clash (param $conf vs. global $conf)

- Renamed parameter `$conf` to `$conf_obj` to avoid targeted name clash with global statement in if clause.
2026-06-23 20:16:24 +02:00

487 lines
15 KiB
PHP

<?php
/* Copyright (C) 2026 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2026 Nick Fragoulis
* Copyright (C) 2026 MDW <mdeweerd@users.noreply.github.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY, without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* or see https://www.gnu.org/
*/
/**
* \file htdocs/ai/class/mcp.class.php
* \ingroup ai
* \brief File of class to handle MCP (Model Context Protocol).
*/
require_once DOL_DOCUMENT_ROOT . "/ai/class/mcptool.class.php";
/**
* Class to handle MCP (Model Context Protocol).
*
* This class is responsible for discovering, loading, and executing tools that implement
* the Model Context Protocol. It supports both native tools from the ai/tools directory
* and external tools registered via hooks.
*
* Context-aware: pass McpHandler::CTX_ASSISTANT (default) or McpHandler::CTX_MCP_SERVER
* to the constructor so that the correct allow-list constant is applied.
*/
class McpHandler
{
const CTX_ASSISTANT = 'assistant';
const CTX_MCP_SERVER = 'mcp_server';
/** @var DoliDB Database handler */
private $db;
/** @var User User object */
private $user;
/** @var Conf Configuration object */
private $conf;
/**
* @var McpTool[] Array of loaded tool instances, keyed by their base filename or class name.
*/
private $loadedTools = [];
/**
* @var McpTool[] Associative array mapping tool *names* (from schema) to their instances.
* This provides O(1) lookup for execution.
*/
private $toolsByName = [];
/**
* The active tool context. Determines which allow-list constant is read.
* @var string
*/
private $toolcontext;
/**
* Constructor.
*
* @param DoliDB $db Database handler object
* @param User $user User object
* @param Conf|null $conf_obj Configuration object. Falls back to global $conf when null.
* @param string $toolcontext Pass McpHandler::CTX_ASSISTANT or McpHandler::CTX_MCP_SERVER.
* Defaults to CTX_ASSISTANT when empty.
*/
public function __construct($db, $user, $conf_obj = null, $toolcontext = '')
{
$this->db = $db;
$this->user = $user;
if ($conf_obj === null) {
global $conf;
$conf_obj = $conf;
}
$this->conf = $conf_obj;
$this->toolcontext = (!empty($toolcontext)) ? $toolcontext : self::CTX_ASSISTANT;
$this->loadTools();
}
/**
* Returns true if the given tool instance declares itself as a system tool.
*
* Detection is entirely delegated to the tool class via isSystem() — no tool
* names are hardcoded here. Any tool class that overrides isSystem() returning
* true is treated as a system tool automatically.
*
* @param McpTool $toolInstance The tool instance to evaluate.
* @return bool
*/
private function isSystemTool($toolInstance)
{
return (method_exists($toolInstance, 'isSystem') && $toolInstance->isSystem());
}
/**
* Returns the configured allow-list for the current context as an array of
* tool names.
*
* Logic:
* constant not set / empty string → no restriction → returns array()
* constant = 'NONE' → all blocked → returns array('__blocked__')
* otherwise → returns the list of allowed names
*
* The sentinel '__blocked__' will never match any real tool name so
* in_array() checks against it always return false.
*
* @return string[]
*/
private function getAllowedToolsList()
{
if ($this->toolcontext === self::CTX_MCP_SERVER) {
$constName = 'AI_MCP_SERVER_ALLOWED_TOOLS';
} else {
$constName = 'AI_ASSISTANT_ALLOWED_TOOLS';
}
$raw = getDolGlobalString($constName);
if ($raw === '') {
// Constant not yet configured — allow everything
return array();
}
if ($raw === 'NONE') {
// Admin explicitly disabled all tools via the preset button
return array('__blocked__');
}
return array_values(array_filter(array_map('trim', explode(',', $raw))));
}
/**
* Resolves a raw allow-list constant value into an explicit PHP array of tool names.
*
* @param string $raw Raw value of the constant from getDolGlobalString().
* @param string[] $allDiscoveredTools Full list of all non-system tool names discovered.
* @return string[] Explicit list of currently allowed tool names.
*/
public static function resolveAllowList($raw, $allDiscoveredTools)
{
if ($raw === '') {
// Not yet configured → implicitly all tools are allowed
return $allDiscoveredTools;
}
if ($raw === 'NONE') {
// Admin explicitly disabled everything
return array();
}
// Explicit list stored by a previous save
return array_values(array_filter(array_map('trim', explode(',', $raw))));
}
/**
* Load all available MCP tools.
*
* This method scans the ai/tools directory for native tools and executes the
* 'addMcpTools' hook to allow external modules to register their own tools.
*
* @return void
*/
private function loadTools()
{
$this->loadNativeTools();
$this->loadExternalTools();
}
/**
* Load native tools from the specific tools directory.
*
* Scans the ai/tools/ directory for PHP files. It validates the file paths
* for security, attempts to load the corresponding class (following the
* convention "Tool" + PascalCase filename), and registers the tool if it
* is a valid instance of McpTool.
*
* @return void
*/
private function loadNativeTools()
{
$toolsDir = DOL_DOCUMENT_ROOT . '/ai/tools/';
if (!is_dir($toolsDir)) {
dol_syslog('[McpHandler] MCP tools directory not found: ' . $toolsDir, LOG_INFO);
return;
}
$files = glob($toolsDir . '*.php');
foreach ($files as $file) {
try {
// Validate the file path before inclusion
$realFilePath = realpath($file);
if ($realFilePath === false || strpos($realFilePath, realpath($toolsDir)) !== 0) {
dol_syslog('[McpHandler] Attempted to load tool outside of allowed directory: ' . $file, LOG_WARNING);
continue;
}
require_once $realFilePath;
$basename = basename($file, '.class.php');
$className = 'Tool' . str_replace(' ', '', ucwords(str_replace('_', ' ', $basename)));
if (!class_exists($className)) {
dol_syslog("[McpHandler] Tool class '{$className}' not found in file '{$file}'.", LOG_WARNING);
continue;
}
$toolInstance = new $className($this->db, $this->user, $this->conf);
if ($toolInstance instanceof McpTool) {
$this->registerTool($basename, $toolInstance);
} else {
dol_syslog("[McpHandler] Tool class '{$className}' does not extend McpTool.", LOG_ERR);
}
} catch (\Throwable $e) {
dol_syslog("[McpHandler] Failed to load tool from file '{$file}': " . $e->getMessage(), LOG_ERR);
}
}
}
/**
* Loads external tools registered via the 'addMcpTools' hook.
*
* Initializes the HookManager for the 'aimcp' context and executes the
* 'addMcpTools' hook. It expects modules to populate the result array
* with arrays containing valid McpTool instances.
*
* @return void
*/
private function loadExternalTools()
{
global $hookmanager;
if (!is_object($hookmanager)) {
require_once DOL_DOCUMENT_ROOT . '/core/class/hookmanager.class.php';
$hookmanager = new HookManager($this->db);
}
$hookmanager->initHooks(['aimcp']);
$parameters = ['db' => $this->db, 'user' => $this->user, 'conf' => $this->conf];
$action = '';
try {
$hookmanager->executeHooks('addMcpTools', $parameters, $this, $action);
if (!is_array($hookmanager->resArray)) {
return;
}
foreach ($hookmanager->resArray as $moduleTools) {
if (!is_array($moduleTools)) {
continue;
}
foreach ($moduleTools as $toolInstance) {
if ($toolInstance instanceof McpTool) {
$this->registerTool(get_class($toolInstance), $toolInstance);
} else {
dol_syslog('[McpHandler] A module provided a tool that is not an instance of McpTool.', LOG_WARNING);
}
}
}
} catch (\Throwable $e) {
dol_syslog('[McpHandler] Error during \'addMcpTools\' hook execution: ' . $e->getMessage(), LOG_ERR);
}
}
/**
* Helper method to register a tool instance and populate lookup arrays.
*
* @param string $key A unique key for the tool (e.g., filename or class name).
* @param McpTool $toolInstance The instantiated tool object.
*
* @return void
*/
private function registerTool(string $key, McpTool $toolInstance)
{
$this->loadedTools[$key] = $toolInstance;
// Populate the lookup map
foreach ($toolInstance->getDefinitions() as $def) {
if (isset($def['name'])) {
if (isset($this->toolsByName[$def['name']])) {
dol_syslog(
"[McpHandler] Tool name conflict: '{$def['name']}' is already registered by '" . get_class($this->toolsByName[$def['name']]) . "'. Skipping registration from '" . get_class($toolInstance) . "'.",
LOG_WARNING
);
} else {
$this->toolsByName[$def['name']] = $toolInstance;
}
}
}
dol_syslog('[McpHandler] Successfully registered MCP tool: ' . get_class($toolInstance), LOG_INFO);
}
/**
* Returns the full schema of every loaded tool with no allow-list filtering.
* Adds is_system and class_name metadata needed by admin/configure_tools.php.
*
* Must not be called from any user-facing entry point — admin use only.
*
* @return array<int, array<string, mixed>>
*/
public function getToolsSchemaUnfiltered()
{
$schema = array();
foreach ($this->loadedTools as $tool) {
$isSystem = $this->isSystemTool($tool);
$className = get_class($tool);
foreach ($tool->getDefinitions() as $def) {
$def['is_system'] = $isSystem;
$def['class_name'] = $className;
$def['categories'] = $tool->getCategories();
$schema[] = $def;
}
}
return $schema;
}
/**
* Returns the schema of all tools permitted in the current context.
*
* System tools (isSystem() = true) are always included and tagged with
* is_system = true so callers can identify and exclude them from the schema
* sent to the LLM (system tools are parse_intent.php infrastructure —
* they must never be called directly by the model).
*
* All other tools are filtered against the tool context allow-list.
*
* @return array<int, array<string, mixed>> Array of tool schemas
*/
public function getToolsSchema(): array
{
$allowed = $this->getAllowedToolsList();
$schema = [];
foreach ($this->loadedTools as $tool) {
$isSystem = $this->isSystemTool($tool);
foreach ($tool->getDefinitions() as $def) {
$name = isset($def['name']) ? $def['name'] : '';
if ($isSystem) {
// Always include system tools but tag them so parse_intent.php
// can strip them from $toolsForLLM while keeping them available
// for the validation check (executeTool must still be able to
// run respond_to_user, ask_for_clarification, etc.).
$def['is_system'] = true;
$def['categories'] = $tool->getCategories();
$schema[] = $def;
continue;
}
$def['is_system'] = false;
if (empty($allowed)) {
// No restriction configured — include everything
$def['categories'] = $tool->getCategories();
$schema[] = $def;
continue;
}
if (in_array($name, $allowed, true)) {
$def['categories'] = $tool->getCategories();
$schema[] = $def;
}
// Not in $allowed — silently omitted; LLM never sees this tool
}
}
return $schema;
}
/**
* Returns the schema of tools permitted in the current context, with system
* tools completely excluded. This is the exact list sent to the LLM.
*
* System tools (ask_for_confirmation, respond_to_user, etc.) must NEVER be
* visible to the model. If the LLM sees ask_for_confirmation in its schema
* it will call it directly with wrong arguments instead of the real action
* tool, causing an infinite confirmation loop on the client side.
*
* parse_intent.php uses this method to build $toolsForLLM, and keeps
* getToolsSchema() separately only for the post-LLM validation step
* (where system tools must still be accepted as valid responses).
*
* @return array<int, array<string, mixed>>
*/
public function getToolsSchemaForLLM()
{
$allowed = $this->getAllowedToolsList();
$schema = array();
foreach ($this->loadedTools as $tool) {
// Check isSystem() class method first (requires conversation.class.php
// to implement it). This is the preferred path for future extensibility.
if ($this->isSystemTool($tool)) {
continue;
}
foreach ($tool->getDefinitions() as $def) {
$name = isset($def['name']) ? $def['name'] : '';
// Check is_system flag in the definition array itself.
// This is set directly in conversation.class.php getDefinitions()
// and works even if the isSystem() class method is not yet deployed.
if (!empty($def['is_system'])) {
continue;
}
if (empty($allowed)) {
// No restriction configured — include everything
$def['categories'] = $tool->getCategories();
$schema[] = $def;
continue;
}
if (in_array($name, $allowed, true)) {
$def['categories'] = $tool->getCategories();
$schema[] = $def;
}
}
}
return $schema;
}
/**
* Execute a specific tool by its name.
*
* Enforces the tool context allow-list as a second gate so that even a crafted
* direct request cannot run a tool that was disabled in the admin UI.
*
* @param string $toolName The name of the tool to execute.
* @param array<string, mixed> $args The arguments to pass to the tool.
*
* @return array<string, mixed> The result of the tool execution or an error array.
*/
public function executeTool(string $toolName, array $args): array
{
if (!isset($this->toolsByName[$toolName])) {
return ["error" => "Tool '{$toolName}' not found."];
}
$toolInstance = $this->toolsByName[$toolName];
// enforce tool context allow-list (system tools always pass through)
if (!$this->isSystemTool($toolInstance)) {
$allowed = $this->getAllowedToolsList();
if (!empty($allowed) && !in_array($toolName, $allowed, true)) {
dol_syslog(
"[McpHandler] Blocked execution of tool '$toolName' in tool context '{$this->toolcontext}' (not in allow-list).",
LOG_WARNING
);
return array('error' => "Tool '" . $toolName . "' is not available in this tool context.");
}
}
// execute
try {
dol_syslog('[McpHandler] Executing tool \'' . $toolName . '\' with args: ' . json_encode($args), LOG_INFO);
$result = $toolInstance->execute($toolName, $args);
dol_syslog('[McpHandler] Tool \'' . $toolName . '\' executed successfully.', LOG_INFO);
return $result;
} catch (\Throwable $e) {
dol_syslog('[McpHandler] Error executing tool \'' . $toolName . '\': ' . $e->getMessage(), LOG_ERR);
return ["error" => "An internal error occurred while executing the tool '{$toolName}'. Details have been logged."];
}
}
}