dolibarr 25.0.0-alpha
mcp_server.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 * Copyright (C) 2026 Jose Martinez <jose.martinez@pichinov.com>
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 3 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
19 * or see https://www.gnu.org/
20 */
21
28if (!defined('NOTOKENRENEWAL')) {
29 define('NOTOKENRENEWAL', 1);
30}
31if (!defined('NOREQUIREMENU')) {
32 define('NOREQUIREMENU', 1);
33}
34if (!defined('NOREQUIREHTML')) {
35 define('NOREQUIREHTML', 1);
36}
37if (!defined('NOREQUIREAJAX')) {
38 define('NOREQUIREAJAX', 1);
39}
40if (!defined('NOCSRFCHECK')) {
41 define('NOCSRFCHECK', 1);
42}
43define('NOLOGIN', 1);
44
45require '../../main.inc.php';
51require_once DOL_DOCUMENT_ROOT . '/ai/class/mcp_protocol.class.php';
52
53while (ob_get_level()) {
54 ob_end_clean();
55}
56
57// Security check (a test on api_key is also done later)
58if (!isModEnabled('ai') || !getDolGlobalString('AI_MCP_ENABLED')) {
59 http_response_code(503);
60 echo json_encode([
61 "jsonrpc" => "2.0",
62 "error" => ["code" => -32000, "message" => "MCP Server Disabled"]
63 ]);
64 exit;
65}
66
67
68/*
69 * View
70 */
71
72// Headers
73header('Content-Type: application/json');
74header('X-Content-Type-Options: nosniff');
75
76$headers = function_exists('getallheaders') ? getallheaders() : [];
77$headers = array_change_key_case($headers, CASE_LOWER);
78
79$authHeader = $headers['authorization'] ?? '';
80$apiKeyHeader = $headers['x-api-key'] ?? '';
81// Fallback: also accept the key in a query string parameter (?api_key=XXX or ?key=XXX).
82// Required for MCP clients that don't support custom auth headers in their connector UI
83// (e.g. Claude Desktop "Custom Connectors" in beta only exposes OAuth fields).
84// SECURITY NOTE: query-string keys appear in webserver access logs and possibly in Referer
85// headers. Header-based auth (X-API-Key / Authorization) remains preferred and is tried first.
86// Administrators relying on the fallback should restrict access at the webserver level
87// and/or rotate AI_MCP_API_KEY regularly.
88$apiKeyQuery = $_GET['api_key'] ?? $_GET['key'] ?? '';
89$storedKey = getDolGlobalString('AI_MCP_API_KEY');
90
91$valid = false;
92
93if (!empty($storedKey)) {
94 // X-API-Key header (preferred)
95 if (!empty($apiKeyHeader)) {
96 $valid = hash_equals($storedKey, $apiKeyHeader);
97 }
98
99 // Authorization: Bearer <token>
100 if (!$valid && !empty($authHeader)) {
101 $matches = array();
102 if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
103 $token = trim($matches[1]);
104 $valid = hash_equals($storedKey, $token);
105 }
106 }
107
108 // Query-string fallback (last resort for header-less clients)
109 if (!$valid && !empty($apiKeyQuery)) {
110 $valid = hash_equals($storedKey, $apiKeyQuery);
111 }
112}
113
114if (!$valid) {
115 dol_syslog('[MCP Server] Unauthorized access attempt. IP=' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'), LOG_WARNING);
116
117 http_response_code(401);
118 echo json_encode([
119 "jsonrpc" => "2.0",
120 "error" => ["code" => -32000, "message" => "Unauthorized"]
121 ]);
122 exit;
123}
124
125
126// Load service user
127$userId = getDolGlobalInt('AI_MCP_USER_ID');
128$serviceUser = new User($db);
129
130if ($userId > 0) {
131 $result = $serviceUser->fetch($userId);
132
133 if ($result > 0) {
134 $serviceUser->loadRights();
135 // Promote the service user to the global $user so MCP tools that
136 // legitimately rely on the `global $user` pattern (Dolibarr core
137 // convention) see an authenticated user. Without this, there is
138 // no PHP web session in HTTP MCP context and any tool reading
139 // `global $user` would treat the request as unauthenticated even
140 // though authentication via X-API-Key/Bearer succeeded above.
141 global $user;
142 $user = $serviceUser;
143 } else {
144 http_response_code(500);
145 echo json_encode([
146 "jsonrpc" => "2.0",
147 "error" => ["code" => -32000, "message" => "MCP Service User not found"]
148 ]);
149 exit;
150 }
151} else {
152 http_response_code(503);
153 echo json_encode([
154 "jsonrpc" => "2.0",
155 "error" => ["code" => -32000, "message" => "MCP Server Misconfigured: AI_MCP_USER_ID not set"]
156 ]);
157 exit;
158}
159
160// Load the AI request log helper so we can persist tools/call invocations to
161// llx_ai_request_log (same table the AI Assistant web UI logs to). This gives
162// administrators a single place to audit external MCP client activity.
163require_once DOL_DOCUMENT_ROOT . '/ai/lib/ai.lib.php';
164
177function mcp_log_request(array $req, $resp, float $tStart, string $rawInput): void
178{
179 global $db, $serviceUser;
180
181 $method = isset($req['method']) ? (string) $req['method'] : '';
182 if ($method !== 'tools/call' || !function_exists('ai_log_request')) {
183 return;
184 }
185
186 $params = isset($req['params']) && is_array($req['params']) ? $req['params'] : [];
187 $toolName = isset($params['name']) ? (string) $params['name'] : '';
188 $toolArgs = isset($params['arguments']) ? $params['arguments'] : [];
189
190 $argsJson = is_string($toolArgs) ? $toolArgs : (string) json_encode($toolArgs);
191 $query = '[MCP] ' . $toolName . ' ' . dol_substr($argsJson, 0, 1000);
192
193 $responseShape = ['tool' => $toolName, 'arguments' => $toolArgs];
194
195 $status = 'Success';
196 $errorMsg = '';
197 if (is_array($resp) && isset($resp['error'])) {
198 $status = 'Error';
199 $errorMsg = is_array($resp['error']) && isset($resp['error']['message'])
200 ? (string) $resp['error']['message']
201 : (string) json_encode($resp['error']);
202 } elseif (is_array($resp) && isset($resp['result']['isError']) && $resp['result']['isError']) {
203 $status = 'Error';
204 $errorMsg = is_array($resp['result']['content'] ?? null) ? (string) json_encode($resp['result']['content']) : '';
205 }
206
207 $rawResStr = is_string($resp) ? $resp : (string) json_encode($resp);
208
210 $db,
211 $serviceUser,
212 $query,
213 $responseShape,
214 'mcp',
215 microtime(true) - $tStart,
216 1.0,
217 $status,
218 $errorMsg,
219 $rawInput,
220 $rawResStr
221 );
222}
223
224// Request handling
225try {
226 $tStart = microtime(true);
227
228 // Basic payload size limit
229 $rawInput = file_get_contents('php://input');
230 if ($rawInput === false || strlen($rawInput) > 1024 * 1024) {
231 throw new Exception("Invalid or too large request");
232 }
233
234 $request = json_decode($rawInput, true);
235
236 if (json_last_error() !== JSON_ERROR_NONE) {
237 throw new Exception("Parse Error");
238 }
239
240 $server = new MCPServer($db, $conf, $serviceUser);
241
242 // Batch request handling
243 if (is_array($request) && array_keys($request) === range(0, count($request) - 1)) {
244 // Limit batch size
245 if (count($request) > 20) {
246 http_response_code(413);
247 echo json_encode([
248 "jsonrpc" => "2.0",
249 "error" => ["code" => -32000, "message" => "Batch too large"]
250 ]);
251 exit;
252 }
253
254 $responses = [];
255
256 // Answer to all MCP requests following the MCP protocol
257 foreach ($request as $req) {
258 if (!is_array($req)) {
259 continue;
260 }
261
262 $reqStart = microtime(true);
263 $res = $server->handleRequest($req);
264
265 if ($res !== null) {
266 $responses[] = $res;
267 }
268
269 // Log each tools/call separately so the admin log viewer shows them individually.
270 mcp_log_request($req, $res, $reqStart, (string) json_encode($req));
271 }
272
273 echo json_encode($responses);
274 } else {
275 // Single request
276 if (!is_array($request)) {
277 throw new Exception("Invalid request format");
278 }
279
280 $response = $server->handleRequest($request);
281
282 if ($response !== null) {
283 echo json_encode($response);
284 }
285
286 // Log this tools/call to llx_ai_request_log (no-op unless AI_LOG_REQUESTS is enabled
287 // and the method is tools/call).
288 mcp_log_request($request, $response, $tStart, $rawInput);
289 }
290} catch (Exception $e) {
292 '[MCP Server] Fatal error: ' . $e->getMessage(),
293 LOG_ERR
294 );
295
296 echo json_encode([
297 "jsonrpc" => "2.0",
298 "id" => null,
299 "error" => [
300 "code" => -32700,
301 "message" => "Parse error"
302 ]
303 ]);
304}
ai_log_request($db, $user, $query, array $response, $provider, float $time, float $confidence, $status, $error='', $rawReq='', $rawRes='')
Log AI Request with Raw Payloads.
Definition ai.lib.php:319
MCPServer Class.
Class to manage Dolibarr users.
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $conf
The main.inc.php has been included so the following variable are now defined:
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $db
API class for accounts.
getDolGlobalInt($key, $default=0)
Return a Dolibarr global constant int value.
dol_substr($string, $start, $length=null, $stringencoding='', $trunconbytes=0)
Make a substring.
getDolGlobalString($key, $default='')
Return a Dolibarr global constant string value.
isModEnabled($module)
Is Dolibarr module enabled.
dol_syslog($message, $level=LOG_INFO, $ident=0, $suffixinfilename='', $restricttologhandler='', $logcontext=null)
Write log message into outputs.
print $langs trans("Show") . '< td style="' . $timeColor . '" align="center"> s</td > badge status0 badge status4 badge status3 Error badge status8< td align="center">< span class="badge ' . $badge . '"></span ></td >< td align="center">< a href="#" class="button button-small" onclick="openLogModal(this)" data-req="' . dol_escape_htmltag($reqSafe) . '" data-res="' . dol_escape_htmltag($resSafe) . '" data-err="' . dol_escape_htmltag($errSafe) . '">< span class="fa fa-search-plus"></span ></a ></td ></tr >< tr >< td colspan="' . $colspan . '" class="opacitymedium"></td ></tr ></table ></div ></form > logModal none logModal none s a JSON string
buildzip.php
mcp_log_request(array $req, $resp, float $tStart, string $rawInput)
Persist an MCP tools/call invocation to the llx_ai_request_log table.