dolibarr/htdocs/ai/server/mcp_server.php
Pichinov-Jose 1aeec05d80
Fix: MCP server propagates authenticated service user to tools (#38294)
The MCP server (htdocs/ai/server/mcp_server.php) loads a service user
from AI_MCP_USER_ID but does not expose it as the PHP global $user.
Several tool classes call 'global $user' to identify the caller --
they therefore see no authenticated user in HTTP MCP context (no PHP
web session exists) and return 'User not authenticated' even though
authentication via X-API-Key/Bearer has already succeeded.

This breaks roughly 40% of the MCP tools shipped by the module when
called from external clients (Claude Desktop Custom Connectors,
Claude Code CLI, MCP Inspector, custom scripts).

Two complementary fixes applied:

1. mcp_server.php: after successfully loading the service user,
   assign it to $GLOBALS['user'] and the local $user variable. This
   acts as a safety net for any tool (current or third-party) that
   relies on the legacy 'global $user' pattern.

2. Three tool classes harmonize their constructor signatures with
   the McpHandler contract -- new $className($db, $user, $conf):
   - ToolCrudObjects (htdocs/ai/tools/crud_objects.class.php)
   - ToolInvoices (htdocs/ai/tools/invoices.class.php)
   - ToolReports (htdocs/ai/tools/reports.class.php)

   These now accept $user via constructor (matching the pattern
   already used in ToolThirdParty, ToolCategories, ToolProducts)
   and store it as $this->user. Methods that previously called
   'global $user' (execute, validateInvoice, payInvoice) now use
   the injected $this->user, with a fallback to the global for
   web-session contexts.

Tested with:
- create_other_document (proposal, supplier_invoice): works
- search_invoice, validate_invoice, pay_invoice: works
- get_sales_report, get_purchase_report: works
- AI Assistant web UI: unchanged, no regression
2026-07-13 06:57:42 +02:00

304 lines
8.8 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>
* Copyright (C) 2026 Jose Martinez <jose.martinez@pichinov.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/server/mcp_server.php
* \ingroup ai
* \brief File of the MCP Server service
*/
if (!defined('NOTOKENRENEWAL')) {
define('NOTOKENRENEWAL', 1);
}
if (!defined('NOREQUIREMENU')) {
define('NOREQUIREMENU', 1);
}
if (!defined('NOREQUIREHTML')) {
define('NOREQUIREHTML', 1);
}
if (!defined('NOREQUIREAJAX')) {
define('NOREQUIREAJAX', 1);
}
if (!defined('NOCSRFCHECK')) {
define('NOCSRFCHECK', 1);
}
define('NOLOGIN', 1);
require '../../main.inc.php';
/**
* @var DoliDB $db
* @var Conf $conf
* @var User $user
*/
require_once DOL_DOCUMENT_ROOT . '/ai/class/mcp_protocol.class.php';
while (ob_get_level()) {
ob_end_clean();
}
// Security check (a test on api_key is also done later)
if (!isModEnabled('ai') || !getDolGlobalString('AI_MCP_ENABLED')) {
http_response_code(503);
echo json_encode([
"jsonrpc" => "2.0",
"error" => ["code" => -32000, "message" => "MCP Server Disabled"]
]);
exit;
}
/*
* View
*/
// Headers
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
$headers = function_exists('getallheaders') ? getallheaders() : [];
$headers = array_change_key_case($headers, CASE_LOWER);
$authHeader = $headers['authorization'] ?? '';
$apiKeyHeader = $headers['x-api-key'] ?? '';
// Fallback: also accept the key in a query string parameter (?api_key=XXX or ?key=XXX).
// Required for MCP clients that don't support custom auth headers in their connector UI
// (e.g. Claude Desktop "Custom Connectors" in beta only exposes OAuth fields).
// SECURITY NOTE: query-string keys appear in webserver access logs and possibly in Referer
// headers. Header-based auth (X-API-Key / Authorization) remains preferred and is tried first.
// Administrators relying on the fallback should restrict access at the webserver level
// and/or rotate AI_MCP_API_KEY regularly.
$apiKeyQuery = $_GET['api_key'] ?? $_GET['key'] ?? '';
$storedKey = getDolGlobalString('AI_MCP_API_KEY');
$valid = false;
if (!empty($storedKey)) {
// X-API-Key header (preferred)
if (!empty($apiKeyHeader)) {
$valid = hash_equals($storedKey, $apiKeyHeader);
}
// Authorization: Bearer <token>
if (!$valid && !empty($authHeader)) {
$matches = array();
if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
$token = trim($matches[1]);
$valid = hash_equals($storedKey, $token);
}
}
// Query-string fallback (last resort for header-less clients)
if (!$valid && !empty($apiKeyQuery)) {
$valid = hash_equals($storedKey, $apiKeyQuery);
}
}
if (!$valid) {
dol_syslog('[MCP Server] Unauthorized access attempt. IP=' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'), LOG_WARNING);
http_response_code(401);
echo json_encode([
"jsonrpc" => "2.0",
"error" => ["code" => -32000, "message" => "Unauthorized"]
]);
exit;
}
// Load service user
$userId = getDolGlobalInt('AI_MCP_USER_ID');
$serviceUser = new User($db);
if ($userId > 0) {
$result = $serviceUser->fetch($userId);
if ($result > 0) {
$serviceUser->loadRights();
// Promote the service user to the global $user so MCP tools that
// legitimately rely on the `global $user` pattern (Dolibarr core
// convention) see an authenticated user. Without this, there is
// no PHP web session in HTTP MCP context and any tool reading
// `global $user` would treat the request as unauthenticated even
// though authentication via X-API-Key/Bearer succeeded above.
global $user;
$user = $serviceUser;
} else {
http_response_code(500);
echo json_encode([
"jsonrpc" => "2.0",
"error" => ["code" => -32000, "message" => "MCP Service User not found"]
]);
exit;
}
} else {
http_response_code(503);
echo json_encode([
"jsonrpc" => "2.0",
"error" => ["code" => -32000, "message" => "MCP Server Misconfigured: AI_MCP_USER_ID not set"]
]);
exit;
}
// Load the AI request log helper so we can persist tools/call invocations to
// llx_ai_request_log (same table the AI Assistant web UI logs to). This gives
// administrators a single place to audit external MCP client activity.
require_once DOL_DOCUMENT_ROOT . '/ai/lib/ai.lib.php';
/**
* Persist an MCP tools/call invocation to the llx_ai_request_log table.
* Mirrors what parse_intent.php does for the web AI Assistant.
* Only tools/call is logged -- lifecycle methods (initialize, ping,
* notifications/initialized, tools/list, ...) are skipped to avoid noise.
*
* @param array<string,mixed> $req The JSON-RPC request
* @param ?mixed $resp The JSON-RPC response (null for notifications)
* @param float $tStart microtime(true) captured before processing
* @param string $rawInput Raw request body
* @return void No return value, only logs the request
*/
function mcp_log_request(array $req, $resp, float $tStart, string $rawInput): void
{
global $db, $serviceUser;
$method = isset($req['method']) ? (string) $req['method'] : '';
if ($method !== 'tools/call' || !function_exists('ai_log_request')) {
return;
}
$params = isset($req['params']) && is_array($req['params']) ? $req['params'] : [];
$toolName = isset($params['name']) ? (string) $params['name'] : '';
$toolArgs = isset($params['arguments']) ? $params['arguments'] : [];
$argsJson = is_string($toolArgs) ? $toolArgs : (string) json_encode($toolArgs);
$query = '[MCP] ' . $toolName . ' ' . dol_substr($argsJson, 0, 1000);
$responseShape = ['tool' => $toolName, 'arguments' => $toolArgs];
$status = 'Success';
$errorMsg = '';
if (is_array($resp) && isset($resp['error'])) {
$status = 'Error';
$errorMsg = is_array($resp['error']) && isset($resp['error']['message'])
? (string) $resp['error']['message']
: (string) json_encode($resp['error']);
} elseif (is_array($resp) && isset($resp['result']['isError']) && $resp['result']['isError']) {
$status = 'Error';
$errorMsg = is_array($resp['result']['content'] ?? null) ? (string) json_encode($resp['result']['content']) : '';
}
$rawResStr = is_string($resp) ? $resp : (string) json_encode($resp);
ai_log_request(
$db,
$serviceUser,
$query,
$responseShape,
'mcp',
microtime(true) - $tStart,
1.0,
$status,
$errorMsg,
$rawInput,
$rawResStr
);
}
// Request handling
try {
$tStart = microtime(true);
// Basic payload size limit
$rawInput = file_get_contents('php://input');
if ($rawInput === false || strlen($rawInput) > 1024 * 1024) {
throw new Exception("Invalid or too large request");
}
$request = json_decode($rawInput, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception("Parse Error");
}
$server = new MCPServer($db, $conf, $serviceUser);
// Batch request handling
if (is_array($request) && array_keys($request) === range(0, count($request) - 1)) {
// Limit batch size
if (count($request) > 20) {
http_response_code(413);
echo json_encode([
"jsonrpc" => "2.0",
"error" => ["code" => -32000, "message" => "Batch too large"]
]);
exit;
}
$responses = [];
// Answer to all MCP requests following the MCP protocol
foreach ($request as $req) {
if (!is_array($req)) {
continue;
}
$reqStart = microtime(true);
$res = $server->handleRequest($req);
if ($res !== null) {
$responses[] = $res;
}
// Log each tools/call separately so the admin log viewer shows them individually.
mcp_log_request($req, $res, $reqStart, (string) json_encode($req));
}
echo json_encode($responses);
} else {
// Single request
if (!is_array($request)) {
throw new Exception("Invalid request format");
}
$response = $server->handleRequest($request);
if ($response !== null) {
echo json_encode($response);
}
// Log this tools/call to llx_ai_request_log (no-op unless AI_LOG_REQUESTS is enabled
// and the method is tools/call).
mcp_log_request($request, $response, $tStart, $rawInput);
}
} catch (Exception $e) {
dol_syslog(
'[MCP Server] Fatal error: ' . $e->getMessage(),
LOG_ERR
);
echo json_encode([
"jsonrpc" => "2.0",
"id" => null,
"error" => [
"code" => -32700,
"message" => "Parse error"
]
]);
}