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';
52require_once DOL_DOCUMENT_ROOT . '/ai/class/mcpauth.class.php';
53
54while (ob_get_level()) {
55 ob_end_clean();
56}
57
58// Security check (a test on api_key is also done later)
59if (!isModEnabled('ai') || !getDolGlobalString('AI_MCP_ENABLED')) {
60 http_response_code(503);
61 echo json_encode([
62 "jsonrpc" => "2.0",
63 "error" => ["code" => -32000, "message" => "MCP Server Disabled"]
64 ]);
65 exit;
66}
67
68
69/*
70 * View
71 */
72
73// Headers
74header('Content-Type: application/json');
75header('X-Content-Type-Options: nosniff');
76
77// Request headers, used by the transport-header validation further down.
78$headers = function_exists('getallheaders') ? getallheaders() : [];
79$headers = array_change_key_case($headers, CASE_LOWER);
80
81$mcpAuth = new McpAuth($db);
82
83if ($mcpAuth->authenticate() < 0) {
84 if ($mcpAuth->httpcode == 401) {
85 // RFC 6750 section 3: a rejected Bearer request must say what it wanted.
86 header('WWW-Authenticate: ' . $mcpAuth->getWwwAuthenticateHeader());
87 }
88
89 http_response_code($mcpAuth->httpcode);
90 echo json_encode([
91 "jsonrpc" => "2.0",
92 "error" => ["code" => -32000, "message" => $mcpAuth->error]
93 ]);
94 exit;
95}
96
97
98// Determine the user the request runs as. An individual API key names its own
99// owner, already loaded and checked by McpAuth; the shared server key falls
100// back to the AI_MCP_USER_ID service user.
101$serviceUser = $mcpAuth->user;
102
103if ($serviceUser === null) {
104 $userId = getDolGlobalInt('AI_MCP_USER_ID');
105
106 if ($userId <= 0) {
107 http_response_code(503);
108 echo json_encode([
109 "jsonrpc" => "2.0",
110 "error" => ["code" => -32000, "message" => "MCP Server Misconfigured: authenticate with a user API key, or set AI_MCP_USER_ID for the shared server key"]
111 ]);
112 exit;
113 }
114
115 $serviceUser = new User($db);
116 if ($serviceUser->fetch($userId) <= 0) {
117 http_response_code(500);
118 echo json_encode([
119 "jsonrpc" => "2.0",
120 "error" => ["code" => -32000, "message" => "MCP Service User not found"]
121 ]);
122 exit;
123 }
124 $serviceUser->loadRights();
125}
126
127// Promote the user to the global $user so MCP tools that legitimately rely on
128// the `global $user` pattern (Dolibarr core convention) see an authenticated
129// user. Without this, there is no PHP web session in HTTP MCP context and any
130// tool reading `global $user` would treat the request as unauthenticated even
131// though authentication succeeded above.
132global $user;
133$user = $serviceUser;
134
135// Load the AI request log helper so we can persist tools/call invocations to
136// llx_ai_request_log (same table the AI Assistant web UI logs to). This gives
137// administrators a single place to audit external MCP client activity.
138require_once DOL_DOCUMENT_ROOT . '/ai/lib/ai.lib.php';
139
152function mcp_log_request(array $req, $resp, float $tStart, string $rawInput): void
153{
154 global $db, $serviceUser;
155
156 $method = isset($req['method']) ? (string) $req['method'] : '';
157 if ($method !== 'tools/call' || !function_exists('ai_log_request')) {
158 return;
159 }
160
161 $params = isset($req['params']) && is_array($req['params']) ? $req['params'] : [];
162 $toolName = isset($params['name']) ? (string) $params['name'] : '';
163 $toolArgs = isset($params['arguments']) ? $params['arguments'] : [];
164
165 $argsJson = is_string($toolArgs) ? $toolArgs : (string) json_encode($toolArgs);
166 $query = '[MCP] ' . $toolName . ' ' . dol_substr($argsJson, 0, 1000);
167
168 $responseShape = ['tool' => $toolName, 'arguments' => $toolArgs];
169
170 $status = 'Success';
171 $errorMsg = '';
172 if (is_array($resp) && isset($resp['error'])) {
173 $status = 'Error';
174 $errorMsg = is_array($resp['error']) && isset($resp['error']['message'])
175 ? (string) $resp['error']['message']
176 : (string) json_encode($resp['error']);
177 } elseif (is_array($resp) && isset($resp['result']['isError']) && $resp['result']['isError']) {
178 $status = 'Error';
179 $errorMsg = is_array($resp['result']['content'] ?? null) ? (string) json_encode($resp['result']['content']) : '';
180 }
181
182 $rawResStr = is_string($resp) ? $resp : (string) json_encode($resp);
183
185 $db,
186 $serviceUser,
187 $query,
188 $responseShape,
189 'mcp',
190 microtime(true) - $tStart,
191 1.0,
192 $status,
193 $errorMsg,
194 $rawInput,
195 $rawResStr
196 );
197}
198
199// Request handling
200try {
201 $tStart = microtime(true);
202
203 // Basic payload size limit
204 $rawInput = file_get_contents('php://input');
205 if ($rawInput === false || strlen($rawInput) > 1024 * 1024) {
206 throw new Exception("Invalid or too large request");
207 }
208
209 $request = json_decode($rawInput, true);
210
211 if (json_last_error() !== JSON_ERROR_NONE) {
212 throw new Exception("Parse Error");
213 }
214
215 $server = new MCPServer($db, $conf, $serviceUser);
216
217 // Batch request handling
218 if (is_array($request) && array_keys($request) === range(0, count($request) - 1)) {
219 // Limit batch size
220 if (count($request) > 20) {
221 http_response_code(413);
222 echo json_encode([
223 "jsonrpc" => "2.0",
224 "error" => ["code" => -32000, "message" => "Batch too large"]
225 ]);
226 exit;
227 }
228
229 $responses = [];
230
231 // Transport headers describe the HTTP request, not individual batch
232 // items: if a Mcp-* / MCP-Protocol-Version header disagrees with ANY
233 // item, the header lies about the request and the WHOLE batch fails
234 // with a single error and HTTP 400 (review finding on #40356). This
235 // also keeps the rate-limiting contract simple: a proxy trusting the
236 // headers never lets a mismatching batch through as 200.
237 foreach ($request as $precheck) {
238 if (!is_array($precheck)) {
239 continue;
240 }
241 $headerError = $server->validateTransportHeaders($headers, $precheck);
242 if ($headerError !== null || $server->getHttpStatus() !== 200) {
243 http_response_code($server->getHttpStatus());
244 if ($headerError === null) {
245 // The offending item was a notification (no id): the error
246 // response was suppressed per JSON-RPC, but the transport
247 // status must still tell the truth.
248 $headerError = ["jsonrpc" => "2.0", "id" => null, "error" => ["code" => -32020, "message" => "Transport header does not match a batch item"]];
249 }
250 echo json_encode($headerError);
251 exit;
252 }
253 }
254
255 // Answer to all MCP requests following the MCP protocol
256 foreach ($request as $req) {
257 if (!is_array($req)) {
258 continue;
259 }
260
261 $reqStart = microtime(true);
262 $res = $server->handleRequest($req);
263
264 if ($res !== null) {
265 $responses[] = $res;
266 }
267
268 // Log each tools/call separately so the admin log viewer shows them individually.
269 mcp_log_request($req, $res, $reqStart, (string) json_encode($req));
270 }
271
272 echo json_encode($responses);
273 } else {
274 // Single request
275 if (!is_array($request)) {
276 throw new Exception("Invalid request format");
277 }
278
279 $response = $server->validateTransportHeaders($headers, $request);
280 if ($response === null && $server->getHttpStatus() === 200) {
281 $response = $server->handleRequest($request);
282 }
283
284 if ($server->getHttpStatus() !== 200) {
285 http_response_code($server->getHttpStatus());
286 if ($response === null) {
287 // The offending request was a notification (no id): the error
288 // response body was suppressed per JSON-RPC, but a 400 must
289 // not go out empty - same handling as the batch path.
290 $response = ["jsonrpc" => "2.0", "id" => null, "error" => ["code" => -32020, "message" => "Transport header does not match the request"]];
291 }
292 }
293
294 if ($response !== null) {
295 echo json_encode($response);
296 }
297
298 // Log this tools/call to llx_ai_request_log (no-op unless AI_LOG_REQUESTS is enabled
299 // and the method is tools/call).
300 mcp_log_request($request, $response, $tStart, $rawInput);
301 }
302} catch (Exception $e) {
304 '[MCP Server] Fatal error: ' . $e->getMessage(),
305 LOG_ERR
306 );
307
308 echo json_encode([
309 "jsonrpc" => "2.0",
310 "id" => null,
311 "error" => [
312 "code" => -32700,
313 "message" => "Parse error"
314 ]
315 ]);
316}
ai_log_request($db, $user, $query, array $response, $provider, float $time, float $confidence, $status, $error='', $rawReq='', $rawRes='', array $context=array(), &$logId=null)
Log AI Request with Raw Payloads.
Definition ai.lib.php:416
MCPServer Class.
McpAuth 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:
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.
mcp_log_request(array $req, $resp, float $tStart, string $rawInput)
Persist an MCP tools/call invocation to the llx_ai_request_log table.