dolibarr 25.0.0-alpha
api_bridge.class.php
Go to the documentation of this file.
1<?php
2/* Copyright (C) 2026 Jose Martinez <jose.martinez@pichinov.com>
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; either version 3 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
63{
69 private $defs = null;
70
76 private $routes = [];
77
90 private $endpoints = [
91 'thirdparties' => [
92 'module' => 'societe',
93 'path' => '/societe/class/api_thirdparties.class.php',
94 'class' => 'Thirdparties',
95 'label' => 'third parties (customers, prospects, suppliers)',
96 'methods' => [
97 'index' => [
98 'description' => "Use 'mode' to restrict to a nature of third party instead of filtering on names.",
99 'params' => [
100 'mode' => "Nature filter: 0=all (default), 1=customers/prospects, 2=prospects only, 3=neither customer nor prospect, 4=suppliers.",
101 'category' => "Rowid of a third-party category (tag) to restrict the list to."
102 ]
103 ],
104 'get' => []
105 ]
106 ],
107 'proposals' => [
108 'module' => 'propal',
109 'path' => '/comm/propal/class/api_proposals.class.php',
110 'class' => 'Proposals',
111 'label' => 'commercial proposals (quotes / devis)',
112 'methods' => [
113 'index' => [
114 'params' => [
115 'thirdparty_ids' => "Comma-separated third-party rowids to restrict to (e.g. '1,5')."
116 ]
117 ],
118 'get' => []
119 ]
120 ],
121 'tickets' => [
122 'module' => 'ticket',
123 'path' => '/ticket/class/api_tickets.class.php',
124 'class' => 'Tickets',
125 'label' => 'support tickets',
126 'methods' => ['index' => [], 'get' => []]
127 ],
128 'projects' => [
129 'module' => 'projet',
130 'path' => '/projet/class/api_projects.class.php',
131 'class' => 'Projects',
132 'label' => 'projects (including opportunities/leads)',
133 'methods' => ['index' => [], 'get' => []]
134 ],
135 'tasks' => [
136 'module' => 'projet',
137 'path' => '/projet/class/api_tasks.class.php',
138 'class' => 'Tasks',
139 'label' => 'project tasks',
140 'methods' => ['index' => [], 'get' => []]
141 ],
142 'agendaevents' => [
143 'module' => 'agenda',
144 'path' => '/comm/action/class/api_agendaevents.class.php',
145 'class' => 'AgendaEvents',
146 'label' => 'agenda / calendar events (meetings, calls)',
147 'methods' => ['index' => [], 'get' => []]
148 ],
149 'interventions' => [
150 'module' => 'ficheinter',
151 'path' => '/fichinter/class/api_interventions.class.php',
152 'class' => 'Interventions',
153 'label' => 'field service interventions',
154 'methods' => ['index' => [], 'get' => []]
155 ],
156 'contracts' => [
157 'module' => 'contrat',
158 'path' => '/contrat/class/api_contracts.class.php',
159 'class' => 'Contracts',
160 'label' => 'contracts (recurring services)',
161 'methods' => ['index' => [], 'get' => []]
162 ],
163 'members' => [
164 'module' => 'adherent',
165 'path' => '/adherents/class/api_members.class.php',
166 'class' => 'Members',
167 'label' => 'foundation/association members',
168 'methods' => ['index' => [], 'get' => []]
169 ],
170 'subscriptions' => [
171 'module' => 'adherent',
172 'path' => '/adherents/class/api_subscriptions.class.php',
173 'class' => 'Subscriptions',
174 'label' => 'member subscriptions',
175 'methods' => ['index' => [], 'get' => []]
176 ],
177 'stockmovements' => [
178 'module' => 'stock',
179 'path' => '/product/stock/class/api_stockmovements.class.php',
180 'class' => 'StockMovements',
181 'label' => 'stock movements (in/out/transfer history)',
182 'methods' => [
183 'index' => [
184 'description' => "History of physical stock changes; each movement carries product, warehouse, qty (signed) and date."
185 ],
186 'get' => []
187 ]
188 ],
189 'warehouses' => [
190 'module' => 'stock',
191 'path' => '/product/stock/class/api_warehouses.class.php',
192 'class' => 'Warehouses',
193 'label' => 'warehouses',
194 'methods' => ['index' => [], 'get' => []]
195 ],
196 'expensereports' => [
197 'module' => 'expensereport',
198 'path' => '/expensereport/class/api_expensereports.class.php',
199 'class' => 'ExpenseReports',
200 'label' => 'employee expense reports (notes de frais)',
201 'methods' => ['index' => [], 'get' => []]
202 ],
203 'products' => [
204 'module' => 'product',
205 'path' => '/product/class/api_products.class.php',
206 'class' => 'Products',
207 'label' => 'products and services catalog',
208 'methods' => [
209 'index' => [],
210 'get' => [],
211 'getAttributes' => [
212 'suffix' => 'attributes_list',
213 'description' => "Variant attributes (e.g. Size, Color) defined in the catalog."
214 ],
215 'getVariants' => [
216 'suffix' => 'variants_list',
217 'description' => "Variants of one parent product.",
218 'params' => ['id' => 'Rowid of the PARENT product.']
219 ]
220 ]
221 ],
222 // NB: stock inventories have no REST API class in core yet (no api_inventories) —
223 // they cannot be bridged until one exists.
224 ];
225
234 private $commonParamDocs = [
235 'sortfield' => "Field to sort on, prefixed with 't.' (e.g. 't.rowid', 't.ref', 't.datec').",
236 'sortorder' => "Sort direction: 'ASC' or 'DESC'.",
237 'limit' => "Maximum number of records to return.",
238 'page' => "Zero-based page index for pagination.",
239 'sqlfilters' => "Universal search filter. Example: \"(t.ref:like:'PR%') and (t.datec:>=:'2026-01-01')\". Field names are prefixed with 't.'; operators: =, !=, <, <=, >, >=, like, is; combine clauses with 'and'/'or' and parentheses.",
240 'properties' => "Comma-separated list of properties to include in the response, to reduce its size (e.g. 'id,ref,label').",
241 'id' => "Rowid (numeric technical id) of the record."
242 ];
243
251 public function __construct($db, $user = null, $conf = null)
252 {
253 $this->db = $db;
254 $this->user = $user;
255 if ($conf !== null) {
256 $this->conf = $conf;
257 }
258 }
259
268 private function loadApiRuntime()
269 {
270 require_once DOL_DOCUMENT_ROOT . '/includes/restler/framework/Luracast/Restler/AutoLoader.php';
271 $loader = Luracast\Restler\AutoLoader::instance();
272 spl_autoload_register($loader);
273 require_once DOL_DOCUMENT_ROOT . '/api/class/api.class.php';
274 require_once DOL_DOCUMENT_ROOT . '/api/class/api_access.class.php';
275 }
276
282 public function getDefinitions(): array
283 {
284 if (!getDolGlobalInt('AI_MCP_API_BRIDGE')) {
285 return []; // Feature flag off: bridge exposes nothing.
286 }
287 if ($this->defs !== null) {
288 return $this->defs;
289 }
290 $this->loadApiRuntime();
291
292 $this->defs = [];
293 $this->routes = [];
294
295 foreach ($this->endpoints as $key => $ep) {
296 if (!isModEnabled($ep['module'])) {
297 continue; // Dynamic part: a disabled module exposes no tools.
298 }
299 $file = DOL_DOCUMENT_ROOT . $ep['path'];
300 if (!is_readable($file)) {
301 continue;
302 }
303 require_once $file;
304 if (!class_exists($ep['class'])) {
305 continue;
306 }
307
308 // Explicit whitelist: only the methods listed in the endpoint's
309 // 'methods' entry are exposed — nothing else, whatever reflection
310 // could find on the API class.
311 foreach ($ep['methods'] as $method => $meta) {
312 if (!method_exists($ep['class'], $method)) {
313 continue; // whitelisted method absent in this Dolibarr version
314 }
315 $suffix = $meta['suffix'] ?? ($method === 'index' ? 'list' : strtolower($method));
316 $toolname = 'api_' . $key . '_' . $suffix;
317 $def = $this->buildToolDefinition($ep, $key, $method, $toolname, $meta);
318 if ($def) {
319 $this->defs[] = $def;
320 $this->routes[$toolname] = [$key, $method];
321 }
322 }
323 }
324
325 return $this->defs;
326 }
327
341 private function buildToolDefinition(array $ep, string $key, string $method, string $toolname, array $meta = [])
342 {
343 try {
344 $rm = new ReflectionMethod($ep['class'], $method);
345 } catch (ReflectionException $e) {
346 return null;
347 }
348
349 $doc = (string) $rm->getDocComment();
350
351 // First docblock line = human description of the endpoint. The leading
352 // asterisk of the line is excluded so "/**\n * Foo" yields "Foo", not "* Foo".
353 $summary = '';
354 if (preg_match('/\*\s+([^@\s\/*][^\n]*)/', $doc, $m)) {
355 $summary = trim($m[1]);
356 }
357
358 // @param <type> $<name> <description>
359 $paramDocs = [];
360 if (preg_match_all('/@param\s+(\S+)\s+\$(\w+)\s+([^\n]*)/', $doc, $mm, PREG_SET_ORDER)) {
361 foreach ($mm as $pm) {
362 $paramDocs[$pm[2]] = ['type' => $pm[1], 'desc' => trim($pm[3])];
363 }
364 }
365
366 $properties = [];
367 $required = [];
368 foreach ($rm->getParameters() as $p) {
369 $pname = $p->getName();
370 $ptype = isset($paramDocs[$pname]) ? $this->docTypeToJson($paramDocs[$pname]['type']) : 'string';
371 // Parameter doc priority: hand-written per-method enrichment, then the
372 // description guessed from the docblock, then the shared common docs.
373 if (isset($meta['params'][$pname])) {
374 $pdesc = $meta['params'][$pname];
375 } elseif (!empty($paramDocs[$pname]['desc'])) {
376 $pdesc = $paramDocs[$pname]['desc'];
377 } else {
378 $pdesc = $this->commonParamDocs[$pname] ?? '';
379 }
380 $prop = [
381 'type' => $ptype,
382 'description' => $pdesc
383 ];
384 if ($p->isOptional()) {
385 try {
386 $prop['default'] = $p->getDefaultValue();
387 } catch (ReflectionException $e) {
388 // keep without default
389 }
390 } else {
391 $required[] = $pname;
392 }
393 $properties[$pname] = $prop;
394 }
395
396 if ($method === 'index') {
397 $verb = 'List / search';
398 } elseif ($method === 'get') {
399 $verb = 'Get one record of';
400 } else {
401 $verb = 'Read from'; // other whitelisted read helpers (e.g. product variants)
402 }
403 $schema = ['type' => 'object', 'properties' => $properties];
404 if ($required) {
405 $schema['required'] = $required;
406 }
407
408 $description = $verb . ' ' . $ep['label'] . ' through the Dolibarr REST API (auto-generated tool). ' . $summary;
409 if (!empty($meta['description'])) {
410 $description = rtrim($description) . ' ' . $meta['description'];
411 }
412
413 return [
414 'name' => $toolname,
415 'description' => $description,
416 'inputSchema' => $schema
417 ];
418 }
419
426 private function docTypeToJson(string $type): string
427 {
428 $t = strtolower(trim(explode('|', $type)[0]));
429 if (in_array($t, ['int', 'integer'], true)) {
430 return 'integer';
431 }
432 if (in_array($t, ['float', 'double'], true)) {
433 return 'number';
434 }
435 if ($t === 'bool' || $t === 'boolean') {
436 return 'boolean';
437 }
438 return 'string';
439 }
440
446 public function getCategories(): array
447 {
448 return ['thirdparty', 'commercial', 'billing', 'stock', 'reporting'];
449 }
450
459 public function execute(string $name, array $args)
460 {
461 if (!getDolGlobalInt('AI_MCP_API_BRIDGE')) {
462 return ["error" => "API bridge is disabled (AI_MCP_API_BRIDGE not set)."];
463 }
464
465 $this->getDefinitions(); // ensure routes are built
466 if (empty($this->routes[$name])) {
467 return ["error" => "Tool function '$name' not found."];
468 }
469 list($key, $method) = $this->routes[$name];
470 $ep = $this->endpoints[$key];
471
472 // --- Authentication bridge (in-process replacement of DolibarrApiAccess::__isAllowed) ---
473 // The API endpoint methods read the authenticated user from DolibarrApiAccess::$user
474 // and their permission checks (hasRight) run against it. TODO: replicate entity
475 // switching for multicompany setups.
476 $this->loadApiRuntime();
477 DolibarrApiAccess::$user = $this->user;
478 $GLOBALS['user'] = $this->user;
479
480 require_once DOL_DOCUMENT_ROOT . $ep['path'];
481 $api = new $ep['class']();
482
483 // Map named MCP args onto the method's positional signature.
484 $rm = new ReflectionMethod($ep['class'], $method);
485 $callArgs = [];
486 foreach ($rm->getParameters() as $p) {
487 $pname = $p->getName();
488 if (array_key_exists($pname, $args)) {
489 $callArgs[] = $args[$pname];
490 } elseif ($p->isOptional()) {
491 $callArgs[] = $p->getDefaultValue();
492 } else {
493 return ["error" => "Missing required parameter '$pname'."];
494 }
495 }
496
497 try {
498 $result = call_user_func_array([$api, $method], $callArgs);
499 // Serialize API return (cleaned objects) into plain arrays for the MCP client.
500 return json_decode(json_encode($result), true);
501 } catch (Throwable $e) {
502 $code = (int) $e->getCode();
503 return [
504 "error" => $e->getMessage(),
505 "http_status" => ($code > 0 ? $code : 500)
506 ];
507 }
508 }
509}
Abstract base class for all MCP (Model Context Protocol) tools.
Class ToolApiBridge.
buildToolDefinition(array $ep, string $key, string $method, string $toolname, array $meta=[])
Build one MCP tool definition from an API class method: reflection + docblock give the skeleton,...
execute(string $name, array $args)
Execute a bridged tool: authenticate the service user, call the API method in-process with positional...
getCategories()
Return categories this tool belongs to.
getDefinitions()
Returns tool definitions derived from the enabled REST API endpoints.
loadApiRuntime()
Load the REST API runtime (Restler autoloader + DolibarrApi base classes), mirroring the bootstrap se...
docTypeToJson(string $type)
Convert a docblock type to a JSON Schema type.
__construct($db, $user=null, $conf=null)
Constructor.
getDolGlobalInt($key, $default=0)
Return a Dolibarr global constant int value.
isModEnabled($module)
Is Dolibarr module enabled.
conf($dolibarr_main_document_root, $realpathconf=null)
Load conf file (file must exists)
Definition inc.php:431
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
$conf db user
Active Directory does not allow anonymous connections.
Definition repair.php:134