dolibarr 24.0.0-beta
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 *
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
27if (!defined('NOTOKENRENEWAL')) {
28 define('NOTOKENRENEWAL', 1);
29}
30if (!defined('NOREQUIREMENU')) {
31 define('NOREQUIREMENU', 1);
32}
33if (!defined('NOREQUIREHTML')) {
34 define('NOREQUIREHTML', 1);
35}
36if (!defined('NOREQUIREAJAX')) {
37 define('NOREQUIREAJAX', 1);
38}
39if (!defined('NOCSRFCHECK')) {
40 define('NOCSRFCHECK', 1);
41}
42define('NOLOGIN', 1);
43
44require '../../main.inc.php';
50require_once DOL_DOCUMENT_ROOT . '/ai/class/mcp_protocol.class.php';
51
52while (ob_get_level()) {
53 ob_end_clean();
54}
55
56// Security check (a test on api_key is also done later)
57if (!isModEnabled('ai') || !getDolGlobalString('AI_MCP_ENABLED')) {
58 http_response_code(503);
59 echo json_encode([
60 "jsonrpc" => "2.0",
61 "error" => ["code" => -32000, "message" => "MCP Server Disabled"]
62 ]);
63 exit;
64}
65
66
67/*
68 * View
69 */
70
71// Headers
72header('Content-Type: application/json');
73header('X-Content-Type-Options: nosniff');
74
75$headers = function_exists('getallheaders') ? getallheaders() : [];
76$headers = array_change_key_case($headers, CASE_LOWER);
77
78$authHeader = $headers['authorization'] ?? '';
79$apiKeyHeader = $headers['x-api-key'] ?? '';
80// Fallback: also accept the key in a query string parameter (?api_key=XXX or ?key=XXX).
81// Required for MCP clients that don't support custom auth headers in their connector UI
82// (e.g. Claude Desktop "Custom Connectors" in beta only exposes OAuth fields).
83// SECURITY NOTE: query-string keys appear in webserver access logs and possibly in Referer
84// headers. Header-based auth (X-API-Key / Authorization) remains preferred and is tried first.
85// Administrators relying on the fallback should restrict access at the webserver level
86// and/or rotate AI_MCP_API_KEY regularly.
87$apiKeyQuery = $_GET['api_key'] ?? $_GET['key'] ?? '';
88$storedKey = getDolGlobalString('AI_MCP_API_KEY');
89
90$valid = false;
91
92if (!empty($storedKey)) {
93 // X-API-Key header (preferred)
94 if (!empty($apiKeyHeader)) {
95 $valid = hash_equals($storedKey, $apiKeyHeader);
96 }
97
98 // Authorization: Bearer <token>
99 if (!$valid && !empty($authHeader)) {
100 $matches = array();
101 if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $matches)) {
102 $token = trim($matches[1]);
103 $valid = hash_equals($storedKey, $token);
104 }
105 }
106
107 // Query-string fallback (last resort for header-less clients)
108 if (!$valid && !empty($apiKeyQuery)) {
109 $valid = hash_equals($storedKey, $apiKeyQuery);
110 }
111}
112
113if (!$valid) {
114 dol_syslog('[MCP Server] Unauthorized access attempt. IP=' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'), LOG_WARNING);
115
116 http_response_code(401);
117 echo json_encode([
118 "jsonrpc" => "2.0",
119 "error" => ["code" => -32000, "message" => "Unauthorized"]
120 ]);
121 exit;
122}
123
124
125// Load service user
126$userId = getDolGlobalInt('AI_MCP_USER_ID');
127$serviceUser = new User($db);
128
129if ($userId > 0) {
130 $result = $serviceUser->fetch($userId);
131
132 if ($result > 0) {
133 $serviceUser->loadRights();
134 } else {
135 http_response_code(500);
136 echo json_encode([
137 "jsonrpc" => "2.0",
138 "error" => ["code" => -32000, "message" => "MCP Service User not found"]
139 ]);
140 exit;
141 }
142} else {
143 http_response_code(503);
144 echo json_encode([
145 "jsonrpc" => "2.0",
146 "error" => ["code" => -32000, "message" => "MCP Server Misconfigured: AI_MCP_USER_ID not set"]
147 ]);
148 exit;
149}
150
151// Load the AI request log helper so we can persist tools/call invocations to
152// llx_ai_request_log (same table the AI Assistant web UI logs to). This gives
153// administrators a single place to audit external MCP client activity.
154require_once DOL_DOCUMENT_ROOT . '/ai/lib/ai.lib.php';
155
168function mcp_log_request(array $req, $resp, float $tStart, string $rawInput): void
169{
170 global $db, $serviceUser;
171
172 $method = isset($req['method']) ? (string) $req['method'] : '';
173 if ($method !== 'tools/call' || !function_exists('ai_log_request')) {
174 return;
175 }
176
177 $params = isset($req['params']) && is_array($req['params']) ? $req['params'] : [];
178 $toolName = isset($params['name']) ? (string) $params['name'] : '';
179 $toolArgs = isset($params['arguments']) ? $params['arguments'] : [];
180
181 $argsJson = is_string($toolArgs) ? $toolArgs : (string) json_encode($toolArgs);
182 $query = '[MCP] ' . $toolName . ' ' . dol_substr($argsJson, 0, 1000);
183
184 $responseShape = ['tool' => $toolName, 'arguments' => $toolArgs];
185
186 $status = 'Success';
187 $errorMsg = '';
188 if (is_array($resp) && isset($resp['error'])) {
189 $status = 'Error';
190 $errorMsg = is_array($resp['error']) && isset($resp['error']['message'])
191 ? (string) $resp['error']['message']
192 : (string) json_encode($resp['error']);
193 } elseif (is_array($resp) && isset($resp['result']['isError']) && $resp['result']['isError']) {
194 $status = 'Error';
195 $errorMsg = is_array($resp['result']['content'] ?? null) ? (string) json_encode($resp['result']['content']) : '';
196 }
197
198 $rawResStr = is_string($resp) ? $resp : (string) json_encode($resp);
199
201 $db,
202 $serviceUser,
203 $query,
204 $responseShape,
205 'mcp',
206 microtime(true) - $tStart,
207 1.0,
208 $status,
209 $errorMsg,
210 $rawInput,
211 $rawResStr
212 );
213}
214
215// Request handling
216try {
217 $tStart = microtime(true);
218
219 // Basic payload size limit
220 $rawInput = file_get_contents('php://input');
221 if ($rawInput === false || strlen($rawInput) > 1024 * 1024) {
222 throw new Exception("Invalid or too large request");
223 }
224
225 $request = json_decode($rawInput, true);
226
227 if (json_last_error() !== JSON_ERROR_NONE) {
228 throw new Exception("Parse Error");
229 }
230
231 $server = new MCPServer($db, $conf, $serviceUser);
232
233 // Batch request handling
234 if (is_array($request) && array_keys($request) === range(0, count($request) - 1)) {
235 // Limit batch size
236 if (count($request) > 20) {
237 http_response_code(413);
238 echo json_encode([
239 "jsonrpc" => "2.0",
240 "error" => ["code" => -32000, "message" => "Batch too large"]
241 ]);
242 exit;
243 }
244
245 $responses = [];
246
247 // Answer to all MCP requests following the MCP protocol
248 foreach ($request as $req) {
249 if (!is_array($req)) {
250 continue;
251 }
252
253 $reqStart = microtime(true);
254 $res = $server->handleRequest($req);
255
256 if ($res !== null) {
257 $responses[] = $res;
258 }
259
260 // Log each tools/call separately so the admin log viewer shows them individually.
261 mcp_log_request($req, $res, $reqStart, (string) json_encode($req));
262 }
263
264 echo json_encode($responses);
265 } else {
266 // Single request
267 if (!is_array($request)) {
268 throw new Exception("Invalid request format");
269 }
270
271 $response = $server->handleRequest($request);
272
273 if ($response !== null) {
274 echo json_encode($response);
275 }
276
277 // Log this tools/call to llx_ai_request_log (no-op unless AI_LOG_REQUESTS is enabled
278 // and the method is tools/call).
279 mcp_log_request($request, $response, $tStart, $rawInput);
280 }
281} catch (Exception $e) {
283 '[MCP Server] Fatal error: ' . $e->getMessage(),
284 LOG_ERR
285 );
286
287 echo json_encode([
288 "jsonrpc" => "2.0",
289 "id" => null,
290 "error" => [
291 "code" => -32700,
292 "message" => "Parse error"
293 ]
294 ]);
295}
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.