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 * Copyright (C) 2026 Nick Fragoulis
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
69{
70
80 const ENDPOINT_CATEGORIES = array(
81 'thirdparties' => array('thirdparty', 'billing', 'commercial'),
82 'categories' => array('thirdparty', 'stock'),
83 'invoices' => array('billing', 'thirdparty'),
84 'proposals' => array('commercial', 'thirdparty'),
85 'orders' => array('commercial', 'thirdparty'),
86 'products' => array('stock', 'commercial'),
87 'stockmovements' => array('stock'),
88 'warehouses' => array('stock'),
89 'projects' => array('project'),
90 'tasks' => array('project'),
91 'agendaevents' => array('thirdparty', 'project'),
92 'interventions' => array('project', 'commercial'),
93 'contracts' => array('commercial', 'billing'),
94 'members' => array('thirdparty', 'billing'),
95 'subscriptions' => array('thirdparty', 'billing'),
96 'expensereports' => array('billing'),
97 'tickets' => array('thirdparty', 'project'),
98 'shipments' => array('stock', 'commercial', 'thirdparty'),
99 'receptions' => array('stock', 'thirdparty')
100 );
101
108 const BRIDGE_MAX_LIMIT = 100;
109
115 private $defs = null;
116
122 private $routes = [];
123
130 private $endpoints = [];
131
150 private $enrichments = [
151 'thirdparties' => [
152 'label' => 'third parties (customers, prospects, suppliers)',
153 'methods' => [
154 'index' => [
155 'default_properties' => 'id,name,code_client,code_fournisseur,email,town,client,fournisseur,status',
156 'description' => "Use 'mode' to restrict to a nature of third party instead of filtering on names. To find one company by name use sqlfilters on t.nom (e.g. \"(t.nom:like:'%acme%')\"); other useful fields: t.name_alias, t.code_client, t.code_fournisseur, t.email, t.town, t.zip, t.fk_pays (country rowid), t.status (1=open, 0=closed). Prefer a small 'limit' and 'properties' (e.g. 'id,name,code_client,code_fournisseur,email,town,client,fournisseur,status') to keep answers short.",
157 'params' => [
158 'mode' => "Nature filter: 0=all (default), 1=customers/prospects, 2=prospects only, 3=neither customer nor prospect, 4=suppliers.",
159 'category' => "Rowid of a third-party category (tag) to restrict the list to.",
160 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}} instead of a bare list; use it to know the total count."
161 ]
162 ],
163 'get' => [
164 'description' => "Returns the full record: address, contact channels, customer/supplier codes, VAT number, default payment terms/modes, outstanding limit. In the result 'client' is 1=customer, 2=prospect, 3=both; 'fournisseur' is 1 when supplier."
165 ],
166 'getByEmail' => [
167 'suffix' => 'get_by_email',
168 'description' => "Find one third party by its exact company email address (not contact emails).",
169 'params' => ['email' => "Exact email address of the company."]
170 ],
171 'getOutStandingInvoices' => [
172 'suffix' => 'outstanding_invoices',
173 'description' => "Total amount still due on validated, unpaid invoices of one third party. Returns {opened: amount} in the company currency.",
174 'params' => ['mode' => "'customer' (default) for customer invoices, 'supplier' for supplier invoices."]
175 ],
176 'getOutStandingOrder' => [
177 'suffix' => 'outstanding_orders',
178 'description' => "Total amount of open (not yet invoiced) orders of one third party. Returns {opened: amount}.",
179 'params' => ['mode' => "'customer' (default) for sales orders, 'supplier' for purchase orders."]
180 ],
181 'getOutStandingProposals' => [
182 'suffix' => 'outstanding_proposals',
183 'description' => "Total amount of open commercial proposals of one third party. Returns {opened: amount}.",
184 'params' => ['mode' => "'customer' (default) or 'supplier'."]
185 ]
186 ]
187 ],
188 'categories' => [
189 'label' => 'categories / tags',
190 'methods' => [
191 'index' => [
192 'description' => "Categories form a tree per type (fk_parent = parent rowid, 0 for root). Always pass 'type' to restrict to one kind of object. Search by name with sqlfilters on t.label.",
193 'params' => [
194 'type' => "Kind of object the category applies to: 'product', 'customer', 'supplier', 'contact', 'member', 'project', 'user', 'bank_account', 'warehouse', 'actioncomm', 'website_page', 'ticket', 'knowledgemanagement'."
195 ]
196 ],
197 'get' => [
198 'params' => ['include_childs' => "Set to true to also return the sub-categories (children). The parameter name 'include_childs' is fixed by the REST API."]
199 ],
200 'getObjects' => [
201 'suffix' => 'objects_list',
202 'description' => "Objects tagged with one category (e.g. all products in category 12, all customers tagged 'VIP').",
203 'params' => [
204 'id' => "Rowid of the category.",
205 'type' => "Object type to list: 'product', 'customer', 'supplier', 'contact', 'member', 'project', 'user', 'warehouse', 'actioncomm', 'ticket'.",
206 'onlyids' => "1 to return only rowids (faster, use it for counting), 0 (default) for full objects."
207 ]
208 ]
209 ]
210 ],
211 'invoices' => [
212 'label' => 'customer invoices',
213 'methods' => [
214 'index' => [
215 'default_properties' => 'id,ref,socid,date,date_lim_reglement,total_ht,total_ttc,paye,remaintopay',
216 'description' => "Use 'status' for the usual questions (unpaid, paid, drafts). Oldest first: sortfield 't.datef' with sortorder 'ASC' (due-date order: 't.date_lim_reglement'). Set withLines=false for lists: lines are large and rarely needed. Amounts: total_ht (excl. tax), total_tva, total_ttc (incl. tax), paye (1=paid). Dates are unix timestamps: date (invoice date), date_lim_reglement (due date). Overdue unpaid invoices: status='unpaid' plus sqlfilters \"(t.date_lim_reglement:<:'YYYY-MM-DD')\". Useful sqlfilters fields: t.ref, t.datef, t.total_ttc, t.fk_soc, t.type (0=standard, 1=replacement, 2=credit note, 3=deposit, 4=proforma), t.fk_statut (0=draft, 1=validated, 2=paid, 3=abandoned).",
217 'params' => [
218 'thirdparty_ids' => "Comma-separated third-party rowids to restrict to (e.g. '1,5'). Look the rowid up with api_thirdparties_list first when only a name is known.",
219 'status' => "One of 'draft', 'unpaid' (validated and not fully paid), 'paid', 'cancelled'. Empty = all.",
220 'withLines' => "false to omit invoice lines from each record (recommended for lists).",
221 'loadlinkedobjects' => "1 to include linked objects (orders, proposals, shipments) — slower, default 0.",
222 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}; use it to know how many invoices match."
223 ]
224 ],
225 'get' => [
226 'description' => "One invoice with its lines (product, qty, unit price, VAT rate, line totals), status, remaining amount to pay and linked contacts. Look the rowid up with api_invoices_list (sqlfilters on t.ref) when only the reference is known.",
227 'params' => [
228 'contact_list' => "0 = no contacts, 1 (default) = contact rowids, 2 = full contact records.",
229 'withLines' => "false to omit the lines."
230 ]
231 ],
232 'getByRef' => [
233 'suffix' => 'get_by_ref',
234 'description' => "One invoice by its exact reference (e.g. 'FA2401-0001').",
235 'params' => ['ref' => "Exact invoice reference.", 'contact_list' => "0 = no contacts, 1 (default) = contact rowids, 2 = full contact records."]
236 ],
237 'getPayments' => [
238 'suffix' => 'payments_list',
239 'description' => "Payments already recorded on one invoice: amount, date, payment mode, bank reference.",
240 'params' => ['id' => "Rowid of the invoice."]
241 ]
242 ]
243 ],
244 'proposals' => [
245 'label' => 'commercial proposals (quotes / devis)',
246 'methods' => [
247 'index' => [
248 'default_properties' => 'id,ref,socid,datep,fin_validite,total_ht,total_ttc,fk_statut',
249 'description' => "Statuses (t.fk_statut): 0=draft, 1=validated (open, awaiting answer), 2=signed/accepted, 3=not signed/refused, 4=billed. Dates are unix timestamps: datep (proposal date), fin_validite (validity end — expired open proposals: fk_statut=1 plus sqlfilters \"(t.fin_validite:<:'YYYY-MM-DD')\"). Useful sqlfilters fields: t.ref, t.datep, t.total_ht, t.total_ttc, t.fk_soc, t.fk_statut.",
250 'params' => [
251 'thirdparty_ids' => "Comma-separated third-party rowids to restrict to (e.g. '1,5'). Look the rowid up with api_thirdparties_list first when only a name is known.",
252 'loadlinkedobjects' => "1 to include linked objects (orders, invoices) — slower, default 0.",
253 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}; use it to know how many proposals match."
254 ]
255 ],
256 'get' => [
257 'description' => "One proposal with its lines (product, qty, unit price, discount, line totals), status and validity date.",
258 'params' => ['contact_list' => "0 = no contacts, 1 (default) = contact rowids, 2 = full contact records."]
259 ],
260 'getByRef' => [
261 'suffix' => 'get_by_ref',
262 'description' => "One proposal by its exact reference (e.g. 'PR2401-0001').",
263 'params' => ['ref' => "Exact proposal reference.", 'contact_list' => "0 = no contacts, 1 (default) = contact rowids, 2 = full contact records."]
264 ]
265 ]
266 ],
267 'tickets' => [
268 'label' => 'support tickets',
269 'methods' => [
270 'index' => [
271 'default_properties' => 'id,ref,track_id,subject,fk_soc,fk_statut,severity_code,type_code,datec',
272 'description' => "Statuses (t.fk_statut): 0=not read, 1=read, 2=assigned, 3=in progress, 5=needs more info, 7=waiting, 8=closed, 9=canceled. Open tickets = fk_statut < 8. Severity in severity_code (LOW, NORMAL, HIGH, BLOCKING), nature in type_code (COM=commercial, ISSUE=incident, ...). Useful sqlfilters fields: t.subject, t.fk_statut, t.severity_code, t.type_code, t.datec (creation), t.fk_user_assign (assigned user rowid).",
273 'params' => [
274 'socid' => "Third-party rowid to restrict tickets to one customer (0 = all).",
275 'loadcontacts' => "1 to include linked contacts per ticket, 0 (default, faster) without.",
276 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
277 ]
278 ],
279 'get' => [
280 'description' => "One ticket with its subject, full message, status, severity, assigned user and linked third party. The public tracking id is in track_id."
281 ],
282 'getByTrackId' => [
283 'suffix' => 'get_by_track_id',
284 'description' => "One ticket by its public tracking id (the hash customers receive by email).",
285 'params' => ['track_id' => "Public tracking id of the ticket."]
286 ],
287 'getByRef' => [
288 'suffix' => 'get_by_ref',
289 'description' => "One ticket by its exact internal reference (e.g. 'TS2401-0001').",
290 'params' => ['ref' => "Exact ticket reference."]
291 ]
292 ]
293 ],
294 'projects' => [
295 'label' => 'projects (including opportunities/leads)',
296 'methods' => [
297 'index' => [
298 'default_properties' => 'id,ref,title,fk_soc,public,date_start,date_end,fk_statut,opp_status,opp_amount',
299 'description' => "Statuses (t.fk_statut): 0=draft, 1=open, 2=closed. A project used as a sales opportunity carries opp_status (pipeline step rowid), opp_percent (probability) and opp_amount. Dates are unix timestamps: date_start (dateo), date_end (datee). Useful sqlfilters fields: t.ref, t.title, t.fk_soc, t.fk_statut, t.dateo, t.datee, t.public (1=visible to everyone). Tasks of a project: use api_tasks_list with sqlfilters \"(t.fk_projet:=:'ID')\".",
300 'params' => [
301 'thirdparty_ids' => "Comma-separated third-party rowids to restrict to (e.g. '1,5').",
302 'category' => "Rowid of a project category (tag) to restrict the list to.",
303 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
304 ]
305 ],
306 'get' => [
307 'description' => "One project with its dates, status, budget, opportunity data and linked third party."
308 ],
309 'getByRef' => [
310 'suffix' => 'get_by_ref',
311 'description' => "One project by its exact reference (e.g. 'PJ2401-0001').",
312 'params' => ['ref' => "Exact project reference."]
313 ]
314 ]
315 ],
316 'tasks' => [
317 'label' => 'project tasks',
318 'methods' => [
319 'index' => [
320 'default_properties' => 'id,ref,label,fk_projet,progress,planned_workload,duration_effective,date_start,date_end',
321 'description' => "Tasks belong to a project (fk_projet — filter one project with sqlfilters \"(t.fk_projet:=:'ID')\"). progress is a percentage (0-100); planned_workload and duration_effective (time already spent) are in SECONDS — divide by 3600 for hours. fk_task_parent > 0 for subtasks. Useful sqlfilters fields: t.label, t.fk_projet, t.progress, t.dateo (start), t.datee (end).",
322 'params' => [
323 'includetimespent' => "1 to also load the time-spent summary per task (slower).",
324 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
325 ]
326 ],
327 'get' => [
328 'description' => "One task with its project, planned workload, progress and dates (durations in seconds).",
329 'params' => ['includetimespent' => "1 to include the detail of time spent records."]
330 ],
331 'getTimespent' => [
332 'suffix' => 'timespent_list',
333 'description' => "Time records logged on one task: date, duration in seconds, user and note. Sum task_duration for the total.",
334 'params' => ['id' => "Rowid of the task."]
335 ]
336 ]
337 ],
338 'agendaevents' => [
339 'label' => 'agenda / calendar events (meetings, calls)',
340 'methods' => [
341 'index' => [
342 'default_properties' => 'id,ref,label,type_code,datep,datef,fulldayevent,percentage,fk_soc,userownerid',
343 'description' => "Default sortfield is t.id — pass sortfield 't.datep' with sortorder 'ASC' for chronological order. Nature in type_code (AC_RDV=meeting, AC_TEL=phone call, AC_EMAIL=email, AC_OTH=other, AC_OTH_AUTO=automatic log). percentage: -1 = plain event, 0-99 = to-do in progress, 100 = done. Dates are unix timestamps: datep (start), datef (end). Upcoming events: sqlfilters \"(t.datep:>=:'YYYY-MM-DD')\". Other useful fields: t.label, t.fk_soc, t.fk_element/t.elementtype (linked business object).",
344 'params' => [
345 'user_ids' => "Comma-separated user rowids to restrict to events owned by these users (e.g. '1,3').",
346 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
347 ]
348 ],
349 'get' => [
350 'description' => "One event with its type, start/end dates, owner, linked third party/contact and note."
351 ]
352 ]
353 ],
354 'interventions' => [
355 'label' => 'field service interventions',
356 'methods' => [
357 'index' => [
358 'default_properties' => 'id,ref,socid,fk_statut,description,datec,duration',
359 'description' => "Statuses (t.fk_statut): 0=draft, 1=validated, 2=billed, 3=done/closed. duration is in SECONDS (divide by 3600 for hours). Useful sqlfilters fields: t.ref, t.fk_soc, t.fk_statut, t.datec, t.description.",
360 'params' => [
361 'thirdparty_ids' => "Comma-separated third-party rowids to restrict to (e.g. '1,5').",
362 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
363 ]
364 ],
365 'get' => [
366 'description' => "One intervention with its lines (each line = one on-site work entry with date, duration in seconds and description)."
367 ]
368 ]
369 ],
370 'contracts' => [
371 'label' => 'contracts (recurring services)',
372 'methods' => [
373 'index' => [
374 'default_properties' => 'id,ref,socid,date_contrat,statut',
375 'description' => "Contract statuses (t.statut): 0=draft, 1=validated. What matters is usually the LINE status (each line is one service): 0=inactive/draft, 4=active/running, 5=closed. Set withLines=false for lists (lines are large), then read one contract with api_contracts_get or its lines with api_contracts_lines_list. Useful sqlfilters fields: t.ref, t.fk_soc, t.statut, t.date_contrat.",
376 'params' => [
377 'thirdparty_ids' => "Comma-separated third-party rowids to restrict to (e.g. '1,5').",
378 'withLines' => "false to omit the service lines from each record (recommended for lists).",
379 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
380 ]
381 ],
382 'get' => [
383 'description' => "One contract with its service lines: per line fk_product, description, date_start/date_end (unix timestamps) and statut (0=inactive, 4=active, 5=closed).",
384 'params' => ['withLines' => "false to omit the lines."]
385 ],
386 'getLines' => [
387 'suffix' => 'lines_list',
388 'description' => "Service lines of one contract, with their own pagination and sqlfilters (fields prefixed 'd.', e.g. \"(d.statut:=:'4')\" for active services).",
389 'params' => ['id' => "Rowid of the contract."]
390 ]
391 ]
392 ],
393 'members' => [
394 'label' => 'foundation/association members',
395 'methods' => [
396 'index' => [
397 'default_properties' => 'id,ref,firstname,lastname,societe,email,typeid,statut,datefin',
398 'description' => "Statuses (t.statut): -1=draft, 1=validated (member), 0=membership terminated (resiliated), -2=excluded. Whether the subscription is up to date is in datefin (unix timestamp of the paid-up end date): late members = statut 1 plus sqlfilters \"(t.datefin:<:'YYYY-MM-DD')\" (or datefin null). Useful sqlfilters fields: t.firstname, t.lastname, t.societe, t.email, t.statut, t.datefin.",
399 'params' => [
400 'typeid' => "Rowid of a member type to restrict the list to.",
401 'category' => "Rowid of a member category (tag) to restrict the list to.",
402 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
403 ]
404 ],
405 'get' => [
406 'description' => "One member with identity, member type, status, linked third party and paid-up end date (datefin)."
407 ],
408 'getSubscriptions' => [
409 'suffix' => 'subscriptions_list',
410 'description' => "Subscription (membership fee) history of one member: period start/end and amount per payment.",
411 'params' => ['id' => "Rowid of the member."]
412 ]
413 ]
414 ],
415 'subscriptions' => [
416 'label' => 'member subscriptions',
417 'methods' => [
418 'index' => [
419 'description' => "All membership fee payments across members: fk_adherent (member rowid), dateh (period start), datef (period end), amount. NB: the default sortfield here is 'dateadh' WITHOUT the 't.' prefix (API quirk). To get the fees of one member prefer api_members_subscriptions_list.",
420 'params' => [
421 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
422 ]
423 ],
424 'get' => [
425 'description' => "One subscription payment with its member, period and amount."
426 ]
427 ]
428 ],
429 'stockmovements' => [
430 'label' => 'stock movements (in/out/transfer history)',
431 'methods' => [
432 'index' => [
433 'default_properties' => 'id,product_id,warehouse_id,qty,date,type,label,inventorycode',
434 'description' => "History of physical stock changes; each movement carries product_id, warehouse_id, qty (SIGNED: positive=in, negative=out), date and label. Movements of one product: sqlfilters \"(t.fk_product:=:'ID')\"; of one warehouse: \"(t.fk_entrepot:=:'ID')\"; over a period: \"(t.datem:>=:'YYYY-MM-DD')\". The source document (reception, shipment, inventory...) is in origintype/fk_origin when set.",
435 'params' => [
436 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
437 ]
438 ],
439 'get' => []
440 ]
441 ],
442 'warehouses' => [
443 'label' => 'warehouses',
444 'methods' => [
445 'index' => [
446 'description' => "Warehouses/locations: ref (name), lieu (short location), statut (1=open, 0=closed). Search by name with sqlfilters on t.ref. Per-product stock by warehouse is NOT here — use api_products_get with includestockdata=1.",
447 'params' => [
448 'category' => "Rowid of a warehouse category (tag) to restrict the list to.",
449 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
450 ]
451 ],
452 'get' => [
453 'description' => "One warehouse with its address, status and description."
454 ]
455 ]
456 ],
457 'orders' => [
458 'label' => 'customer orders (commandes)',
459 'methods' => [
460 'index' => [
461 'default_properties' => 'id,ref,socid,date_commande,delivery_date,total_ht,total_ttc,statut',
462 'description' => "Statuses (t.fk_statut): -1=cancelled, 0=draft, 1=validated (open), 2=shipment in progress, 3=closed (delivered / billed). Dates are unix timestamps: date_commande (order date), delivery_date (planned delivery). Useful sqlfilters fields: t.ref, t.date_commande, t.total_ht, t.total_ttc, t.fk_soc, t.fk_statut.",
463 'params' => [
464 'thirdparty_ids' => "Comma-separated third-party rowids to restrict to (e.g. '1,5'). Look the rowid up with api_thirdparties_list first when only a name is known.",
465 'loadlinkedobjects' => "1 to include linked objects (proposals, shipments, invoices) — slower, default 0.",
466 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}; use it to know how many orders match."
467 ]
468 ],
469 'get' => [
470 'description' => "One order with its lines (product, qty, unit price, discount, line totals), status and dates.",
471 'params' => ['contact_list' => "0 = no contacts, 1 (default) = contact rowids, 2 = full contact records."]
472 ],
473 'getByRef' => [
474 'suffix' => 'get_by_ref',
475 'description' => "One order by its exact reference (e.g. 'CO2401-0001').",
476 'params' => ['ref' => "Exact order reference.", 'contact_list' => "0 = no contacts, 1 (default) = contact rowids, 2 = full contact records."]
477 ]
478 ]
479 ],
480 'shipments' => [
481 'label' => 'customer shipments (expéditions, goods sent)',
482 'methods' => [
483 'index' => [
484 'default_properties' => 'id,ref,socid,ref_customer,date_delivery,date_shipping,statut',
485 'description' => "Statuses (t.fk_statut): 0=draft (reference '(PROVnn)' until validated), 1=validated (goods left), 2=closed (billed / processed). Dates are unix timestamps: date_shipping (sent), date_delivery (planned delivery). Useful sqlfilters fields: t.ref, t.ref_customer, t.fk_soc, t.fk_statut, t.date_delivery.",
486 'params' => [
487 'thirdparty_ids' => "Comma-separated third-party rowids to restrict to (e.g. '1,5'). Look the rowid up with api_thirdparties_list first when only a name is known.",
488 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}; use it to know how many shipments match."
489 ]
490 ],
491 'get' => [
492 'description' => "One shipment: customer, source order, dates, status, tracking number; the lines are in api_shipments_get_lines."
493 ],
494 'getLines' => [
495 'suffix' => 'get_lines',
496 'description' => "Lines of a shipment: product, quantity shipped, batch/lot when the product is tracked.",
497 'params' => ['id' => "Rowid of the shipment."]
498 ]
499 ]
500 ],
501 'receptions' => [
502 'label' => 'supplier receptions (réceptions, goods received)',
503 'methods' => [
504 'index' => [
505 'default_properties' => 'id,ref,socid,ref_supplier,date_reception,date_delivery,statut',
506 'description' => "Statuses (t.fk_statut): 0=draft (reference '(PROVnn)' until validated), 1=validated (goods received into stock), 2=closed / processed. Dates are unix timestamps: date_reception (received), date_delivery (planned). Useful sqlfilters fields: t.ref, t.ref_supplier, t.fk_soc, t.fk_statut, t.date_delivery. A draft created by the chat keeps its '(PROVnn)' reference: search it by id or with sqlfilters on t.ref_supplier.",
507 'params' => [
508 'thirdparty_ids' => "Comma-separated supplier rowids to restrict to (e.g. '1,5'). Look the rowid up with api_thirdparties_list first when only a name is known.",
509 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}; use it to know how many receptions match."
510 ]
511 ],
512 'get' => [
513 'description' => "One reception: supplier, supplier reference, dates, status; the lines are in api_receptions_get_lines."
514 ],
515 'getLines' => [
516 'suffix' => 'get_lines',
517 'description' => "Lines of a reception: product, quantity received, batch/lot and warehouse when tracked.",
518 'params' => ['id' => "Rowid of the reception."]
519 ]
520 ]
521 ],
522 'expensereports' => [
523 'label' => 'employee expense reports (notes de frais)',
524 'methods' => [
525 'index' => [
526 'default_properties' => 'id,ref,fk_user_author,date_debut,date_fin,total_ht,total_ttc,fk_statut',
527 'description' => "Statuses (t.fk_statut): 0=draft, 2=validated (waiting approval), 4=canceled, 5=approved, 6=paid, 99=refused. The employee is fk_user_author (a USER rowid, not a third party). Period: date_debut/date_fin (unix timestamps). Useful sqlfilters fields: t.ref, t.fk_user_author, t.fk_statut, t.date_debut, t.total_ttc.",
528 'params' => [
529 'user_ids' => "Comma-separated user rowids to restrict to the reports of these employees (e.g. '1,3').",
530 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
531 ]
532 ],
533 'get' => [
534 'description' => "One expense report with its lines: per line the date, expense type code (type_fees_code: TRA_TRIP=transport, TRA_MEAL=meal, ...), VAT and amounts."
535 ]
536 ]
537 ],
538 'products' => [
539 'label' => 'products and services catalog',
540 'methods' => [
541 'index' => [
542 'default_properties' => 'id,ref,label,type,price,price_ttc,tva_tx,status,status_buy',
543 'description' => "Search by name with sqlfilters on t.label, by reference on t.ref (e.g. \"(t.label:like:'%screw%')\"). Result fields: type (0=product, 1=service), price (sale price excl. tax), price_ttc, tva_tx (VAT rate), status (1=for sale), status_buy (1=for purchase), stock_reel (only with includestockdata=1). Prefer 'properties' (e.g. 'id,ref,label,type,price,price_ttc,tva_tx,status,status_buy') and a small 'limit'.",
544 'params' => [
545 'mode' => "0=all (default), 1=products only, 2=services only.",
546 'category' => "Rowid of a product category to restrict the list to.",
547 'variant_filter' => "0=all (default), 1=products without variants, 2=parents of variants only, 3=variants only.",
548 'ids_only' => "true to return only rowids (fast, use for counting).",
549 'includestockdata' => "1 to add stock_reel / stock_theorique per product (slower; requires the Stock module).",
550 'pagination_data' => "Set to true to get {data, pagination:{total,page,page_count,limit}}."
551 ]
552 ],
553 'get' => [
554 'description' => "Full product record: description, prices, VAT, barcode, weight/dimensions, accounting codes, optional stock and sub-products.",
555 'params' => [
556 'includestockdata' => "1 to load stock_reel, stock_theorique and per-warehouse stock (requires the Stock module).",
557 'includesubproducts' => "true to load the kit/BOM components (sub-products).",
558 'includeparentid' => "true to add fk_product_parent for a variant.",
559 'includetrans' => "true to load multilingual labels/descriptions."
560 ]
561 ],
562 'getByRef' => [
563 'suffix' => 'get_by_ref',
564 'description' => "One product by its exact reference (e.g. 'PROD-001'); same options as get.",
565 'params' => ['ref' => "Exact product reference."]
566 ],
567 'getByBarcode' => [
568 'suffix' => 'get_by_barcode',
569 'description' => "One product by its barcode (EAN/UPC); same options as get.",
570 'params' => ['barcode' => "Barcode value as printed."]
571 ],
572 'getPurchasePrices' => [
573 'suffix' => 'purchase_prices_list',
574 'description' => "Supplier prices of one product: for each supplier, the supplier reference, minimum quantity, unit purchase price and VAT. Identify the product by id, ref or barcode.",
575 'params' => ['id' => "Rowid of the product (use 0 when identifying by ref or barcode).", 'ref' => "Product reference, alternative to id.", 'barcode' => "Product barcode, alternative to id."]
576 ],
577 'getAttributes' => [
578 'suffix' => 'attributes_list',
579 'module' => 'variants',
580 'description' => "Variant attributes (e.g. Size, Color) defined in the catalog."
581 ],
582 'getVariants' => [
583 'suffix' => 'variants_list',
584 'module' => 'variants',
585 'description' => "Variants of one parent product.",
586 'params' => ['id' => 'Rowid of the PARENT product.', 'includestock' => "1 to add stock data on each variant."]
587 ]
588 ]
589 ],
590 // NB: stock inventories have no REST API class in core yet (no api_inventories) —
591 // they cannot be bridged until one exists.
592 ];
593
602 private $commonParamDocs = [
603 'sortfield' => "Field to sort on, prefixed with 't.' (e.g. 't.rowid', 't.ref', 't.datec'). Use the SQL column names: creation date is 't.datec' (NEVER 'date_creation') and last modification 't.tms' (never 'date_modification').",
604 'sortorder' => "Sort direction: 'ASC' or 'DESC'.",
605 'limit' => "Maximum number of records to return.",
606 'page' => "Zero-based page index for pagination.",
607 'sqlfilters' => "Universal search filter. Syntax: (t.field:operator:'value'); operators: =, !=, <, <=, >, >=, like, is; combine clauses with 'and'/'or' and parentheses. Example: \"(t.ref:like:'PR%') and (t.datec:>=:'2026-01-01')\". 'like' is case-insensitive; the IN operator is NOT supported (use 'or'); dates as 'YYYY-MM-DD'.",
608 'properties' => "Comma-separated list of properties to include in the response, to reduce its size (e.g. 'id,ref,label').",
609 'id' => "Rowid (numeric technical id) of the record."
610 ];
611
619 public function __construct($db, $user = null, $conf = null)
620 {
621 $this->db = $db;
622 $this->user = $user;
623 if ($conf !== null) {
624 $this->conf = $conf;
625 }
626 }
627
636 private function loadApiRuntime()
637 {
638 require_once DOL_DOCUMENT_ROOT . '/core/lib/functions2.lib.php'; // dolGetModulesDirs(), getModuleDirForApiClass() — main.inc.php loads this only conditionally; api/index.php requires it explicitly for the same reason
639 require_once DOL_DOCUMENT_ROOT . '/includes/restler/framework/Luracast/Restler/AutoLoader.php';
640 $loader = Luracast\Restler\AutoLoader::instance();
641 spl_autoload_register($loader);
642 require_once DOL_DOCUMENT_ROOT . '/api/class/api.class.php';
643 require_once DOL_DOCUMENT_ROOT . '/api/class/api_access.class.php';
644 }
645
667 private function discoverEndpoints(): array
668 {
669 $endpoints = [];
670
671 foreach (dolGetModulesDirs() as $dir) {
672 $handle = @opendir(dol_osencode($dir));
673 if (!is_resource($handle)) {
674 continue;
675 }
676
677 while (($file = readdir($handle)) !== false) {
678 $regmod = [];
679 if (!is_readable($dir.$file) || !preg_match("/^mod(.*)\\.class\\.php$/i", $file, $regmod)) {
680 continue;
681 }
682
683 $module = strtolower($regmod[1]);
684 $moduledirforclass = getModuleDirForApiClass($module);
685
686 // Same descriptor-name to module-name exceptions as api/index.php.
687 $modulenameforenabled = $module;
688 if ($module == 'propale') {
689 $modulenameforenabled = 'propal';
690 } elseif ($module == 'supplierproposal') {
691 $modulenameforenabled = 'supplier_proposal';
692 } elseif ($module == 'ficheinter') {
693 $modulenameforenabled = 'intervention';
694 } elseif ($module == 'product' && !isModEnabled('product') && isModEnabled('service')) {
695 $modulenameforenabled = 'service';
696 }
697
698 if (!isModEnabled($modulenameforenabled)) {
699 continue; // A disabled module exposes no tools.
700 }
701
702 $dir_part = dol_buildpath('/'.$moduledirforclass.'/class/');
703 $handle_part = @opendir(dol_osencode($dir_part));
704 if (!is_resource($handle_part)) {
705 continue;
706 }
707
708 while (($file_searched = readdir($handle_part)) !== false) {
709 if (in_array($file_searched, ['api_access.class.php', 'api_setup.class.php', 'api_documents.class.php', 'api_login.class.php', 'api_status.class.php'], true)) {
710 continue; // Framework plumbing, not business endpoints (setup/documents even require main.inc.php, fatal outside a web page).
711 }
712 $regapi = [];
713 if (!is_readable($dir_part.$file_searched) || !preg_match("/^api_(.*)\\.class\\.php$/i", $file_searched, $regapi)) {
714 continue;
715 }
716
717 $key = strtolower($regapi[1]);
718 if (isset($endpoints[$key])) {
719 continue; // First module wins, as in the REST layer.
720 }
721
722 $endpoints[$key] = [
723 'module' => $modulenameforenabled,
724 'path' => $dir_part.$file_searched,
725 'candidate' => str_replace('_', '', ucwords($regapi[1])),
726 'class' => '', // resolved lazily by resolveEndpointClass()
727 'label' => $this->enrichments[$key]['label'] ?? $key,
728 ];
729 }
730 closedir($handle_part);
731 }
732 closedir($handle);
733 }
734
735 ksort($endpoints);
736
737 return $endpoints;
738 }
739
749 private function resolveEndpointClass(string $key): bool
750 {
751 if ($this->endpoints[$key]['class'] !== '') {
752 return true;
753 }
754 require_once $this->endpoints[$key]['path'];
755 $candidate = $this->endpoints[$key]['candidate'];
756 $classname = '';
757 if (class_exists($candidate.'Api')) {
758 $classname = $candidate.'Api';
759 } elseif (class_exists($candidate)) {
760 $classname = $candidate;
761 }
762 if ($classname === '') {
763 return false; // api_xxx file without the matching class.
764 }
765 // $classname passed class_exists() above, so the constructor cannot throw.
766 $reflection = new ReflectionClass($classname);
767 $this->endpoints[$key]['class'] = $reflection->getName();
768
769 // A key-only label ("paiements") is poor guidance for the model; take
770 // the first line of the class docblock when no enrichment names it.
771 if ($this->endpoints[$key]['label'] === $key) {
772 $classdoc = (string) $reflection->getDocComment();
773 if (preg_match('/\*\s+([^@\s\/*][^\n]*)/', $classdoc, $mlabel)) {
774 // "API class for contacts" -> "contacts": keep the object, drop the boilerplate.
775 $this->endpoints[$key]['label'] = trim(preg_replace('/^API class (for|of)\s+(the\s+)?/i', '', trim($mlabel[1])));
776 }
777 }
778
779 return true;
780 }
781
792 private function defaultMethods(): array
793 {
794 return ['index' => [], 'get' => []];
795 }
796
806 private function applyMethodRestriction(array $methods): array
807 {
808 $configured = getDolGlobalString('AI_MCP_API_BRIDGE_METHODS');
809 if ($configured === '') {
810 return $methods;
811 }
812 $allowed = array_map('trim', explode(',', $configured));
813 return array_intersect_key($methods, array_flip($allowed));
814 }
815
821 public function getDefinitions(): array
822 {
823 if (!getDolGlobalInt('AI_MCP_API_BRIDGE')) {
824 return []; // Feature flag off: bridge exposes nothing.
825 }
826 if ($this->defs !== null) {
827 return $this->defs;
828 }
829
830 // Cross-request cache of the generated definitions: the directory scans
831 // and the per-method reflection/docblock work below produce the same
832 // result for a given set of enabled modules, so it is generated once
833 // and reread from a JSON file until something relevant changes. The
834 // routes and the endpoint map ride along because execute() needs them
835 // (the endpoint class itself is still required lazily at call time).
836 $cachettl = getDolGlobalInt('AI_MCP_BRIDGE_DEFS_CACHE_TTL', 86400);
837 $cachefile = ($cachettl > 0) ? $this->defsCacheFile() : '';
838 if ($cachefile !== '' && is_readable($cachefile) && (dol_now() - (int) filemtime($cachefile)) < $cachettl) {
839 $payload = json_decode((string) file_get_contents($cachefile), true);
840 if (is_array($payload) && isset($payload['defs'], $payload['routes'], $payload['endpoints'])
841 && is_array($payload['defs']) && is_array($payload['routes']) && is_array($payload['endpoints'])) {
842 $this->defs = $payload['defs'];
843 $this->routes = $payload['routes'];
844 $this->endpoints = $payload['endpoints'];
845
846 return $this->defs;
847 }
848 }
849
850 $this->loadApiRuntime();
851
852 $this->defs = [];
853 $this->routes = [];
854 $this->endpoints = $this->discoverEndpoints();
855
856 foreach ($this->endpoints as $key => $ep) {
857 // Endpoint whitelist, per the review that merged the bridge in
858 // #39856 ("we should add a whitelist of api we think it is enable
859 // for ai"): a discovered endpoint is exposed only when it carries a
860 // hand-written $enrichments entry. Discovery decides what is
861 // reachable; the enrichment entry is the conscious line that makes
862 // it exposed. Removing this guard means auto-exposing every enabled
863 // module's endpoints — an explicit decision to make, not a default.
864 if (!isset($this->enrichments[$key])) {
865 continue;
866 }
867 // Method whitelist: only the listed methods are exposed — nothing
868 // else, whatever reflection could find on the API class. Enriched
869 // endpoints without a 'methods' key fall back to the read-only pair.
870 $methods = $this->applyMethodRestriction($this->enrichments[$key]['methods'] ?? $this->defaultMethods());
871 if (empty($methods) || !$this->resolveEndpointClass($key)) {
872 continue; // nothing left to expose, or api file without its class
873 }
874 $ep = $this->endpoints[$key]; // re-read: 'class' is now resolved
875
876 foreach ($methods as $method => $meta) {
877 // A method may need an optional module beyond its endpoint's own
878 // (e.g. products getVariants needs Variants): skip when off, so
879 // the tool does not exist instead of failing opaquely.
880 if (!empty($meta['module']) && !isModEnabled($meta['module'])) {
881 continue;
882 }
883 if (!method_exists($ep['class'], $method)) {
884 continue; // whitelisted method absent in this Dolibarr version
885 }
886 $suffix = $meta['suffix'] ?? ($method === 'index' ? 'list' : strtolower($method));
887 $toolname = 'api_' . $key . '_' . $suffix;
888 $def = $this->buildToolDefinition($ep, $key, $method, $toolname, $meta);
889 if ($def) {
890 $def['categories'] = self::ENDPOINT_CATEGORIES[$key] ?? array('billing', 'commercial', 'thirdparty', 'stock', 'project', 'reporting');
891 $this->defs[] = $def;
892 $this->routes[$toolname] = [$key, $method];
893 }
894 }
895 }
896
897 if ($cachefile !== '') {
898 $this->writeDefsCache($cachefile);
899 }
900
901 return $this->defs;
902 }
903
918 private function defsCacheFile()
919 {
920 global $conf;
921
922 $dir = '';
923 if (!empty($conf->ai->multidir_temp[$conf->entity])) {
924 $dir = $conf->ai->multidir_temp[$conf->entity];
925 } elseif (!empty($conf->ai->dir_temp)) {
926 $dir = $conf->ai->dir_temp;
927 }
928 if (empty($dir) || dol_mkdir($dir) < 0) {
929 return '';
930 }
931
932 $modules = array_map('strval', array_values((array) $conf->modules));
933 sort($modules);
934 $signature = implode(',', $modules).'|'.DOL_VERSION.'|'.((int) $conf->entity).'|'.((int) @filemtime(__FILE__)).'|'.DOL_DOCUMENT_ROOT;
935 // Security-relevant runtime restrictions live in the DATABASE, not in
936 // this file: they must be part of the signature too, or restricting
937 // them would silently keep serving the wider cached toolset for up to
938 // a full TTL (review sonikf on the initial version).
939 $signature .= '|'.getDolGlobalInt('AI_MCP_API_BRIDGE').'|'.getDolGlobalString('AI_MCP_API_BRIDGE_METHODS');
940
941 return rtrim($dir, '/').'/bridge_tooldefs_'.md5($signature).'.json';
942 }
943
953 private function writeDefsCache(string $cachefile)
954 {
955 $payload = json_encode(array('defs' => $this->defs, 'routes' => $this->routes, 'endpoints' => $this->endpoints));
956 if (!is_string($payload)) {
957 return;
958 }
959 foreach ((array) glob(dirname($cachefile).'/bridge_tooldefs_*.json') as $old) {
960 if (is_string($old) && $old !== $cachefile) {
961 @unlink($old);
962 }
963 }
964 $tmpfile = $cachefile.'.tmp.'.getmypid();
965 if (file_put_contents($tmpfile, $payload) !== false) {
966 @rename($tmpfile, $cachefile);
967 }
968 }
969
983 private function buildToolDefinition(array $ep, string $key, string $method, string $toolname, array $meta = [])
984 {
985 try {
986 $rm = new ReflectionMethod($ep['class'], $method);
987 } catch (ReflectionException $e) {
988 return null;
989 }
990
991 $doc = (string) $rm->getDocComment();
992
993 // First docblock line = human description of the endpoint. The leading
994 // asterisk of the line is excluded so "/**\n * Foo" yields "Foo", not "* Foo".
995 $summary = '';
996 if (preg_match('/\*\s+([^@\s\/*][^\n]*)/', $doc, $m)) {
997 $summary = trim($m[1]);
998 }
999
1000 // @param <type> $<name> <description>
1001 $paramDocs = [];
1002 if (preg_match_all('/@param\s+(\S+)\s+\$(\w+)\s+([^\n]*)/', $doc, $mm, PREG_SET_ORDER)) {
1003 foreach ($mm as $pm) {
1004 $paramDocs[$pm[2]] = ['type' => $pm[1], 'desc' => trim($pm[3])];
1005 }
1006 }
1007
1008 $properties = [];
1009 $required = [];
1010 foreach ($rm->getParameters() as $p) {
1011 $pname = $p->getName();
1012 $ptype = isset($paramDocs[$pname]) ? $this->docTypeToJson($paramDocs[$pname]['type']) : 'string';
1013 // Parameter doc priority: hand-written per-method enrichment, then the
1014 // description guessed from the docblock, then the shared common docs.
1015 // Exception for the two syntax-bearing params (sqlfilters, sortfield):
1016 // their API docblocks carry a thin per-endpoint example that would win
1017 // over — and hide — the full syntax contract (operators, and/or, the
1018 // unsupported IN, the datec/tms column names), so there the common doc
1019 // is APPENDED to the docblock description instead of being shadowed.
1020 if (isset($meta['params'][$pname])) {
1021 $pdesc = $meta['params'][$pname];
1022 } elseif (!empty($paramDocs[$pname]['desc'])) {
1023 $pdesc = $paramDocs[$pname]['desc'];
1024 if (in_array($pname, ['sqlfilters', 'sortfield'], true) && !empty($this->commonParamDocs[$pname])) {
1025 $pdesc = rtrim($pdesc, '. ').'. '.$this->commonParamDocs[$pname];
1026 }
1027 } else {
1028 $pdesc = $this->commonParamDocs[$pname] ?? '';
1029 }
1030 // The API docblocks carry Restler's inline validation tags. They are
1031 // markup, not prose: left in place they reach the model as noise
1032 // ("... (example '1' or '1,2,3') {@pattern /^[0-9,]*$/i}"). Lift the
1033 // ones JSON Schema can express into real constraints, and drop the
1034 // rest from the text.
1035 $constraints = [];
1036 $pdesc = $this->liftInlineTags($pdesc, $ptype, $constraints);
1037
1038 $prop = [
1039 'type' => $ptype,
1040 'description' => $pdesc
1041 ];
1042 $prop += $constraints;
1043 if ($p->isOptional()) {
1044 try {
1045 $prop['default'] = ($pname == 'limit') ? self::BRIDGE_DEFAULT_LIMIT : $p->getDefaultValue();
1046 } catch (ReflectionException $e) {
1047 // keep without default
1048 }
1049 } else {
1050 $required[] = $pname;
1051 }
1052 $properties[$pname] = $prop;
1053 }
1054
1055 if ($method === 'index') {
1056 $verb = 'List / search';
1057 } elseif ($method === 'get') {
1058 $verb = 'Get one record of';
1059 } else {
1060 $verb = 'Read from'; // other whitelisted read helpers (e.g. product variants)
1061 }
1062 $schema = ['type' => 'object', 'properties' => $properties];
1063 if ($required) {
1064 $schema['required'] = $required;
1065 }
1066
1067 $description = $verb . ' ' . $ep['label'] . ' through the Dolibarr REST API (auto-generated tool). ' . $summary;
1068 if (!empty($meta['description'])) {
1069 $description = rtrim($description) . ' ' . $meta['description'];
1070 }
1071
1072 return [
1073 'name' => $toolname,
1074 'description' => $description,
1075 'inputSchema' => $schema
1076 ];
1077 }
1078
1095 private function liftInlineTags($desc, string $ptype, array &$constraints): string
1096 {
1097 $desc = (string) $desc;
1098
1099 if (strpos($desc, '{@') === false) {
1100 return $desc;
1101 }
1102
1103 $matches = [];
1104 if (preg_match_all('/\{@(\w[\w-]*)\s*([^}]*)\}/', $desc, $matches, PREG_SET_ORDER)) {
1105 foreach ($matches as $tag) {
1106 $name = strtolower($tag[1]);
1107 $value = trim($tag[2]);
1108
1109 if ($name === 'min' && is_numeric($value)) {
1110 $constraints['minimum'] = $this->tagValueToNumber($value);
1111 } elseif ($name === 'max' && is_numeric($value)) {
1112 $constraints['maximum'] = $this->tagValueToNumber($value);
1113 } elseif ($name === 'choice' && $value !== '') {
1114 $choices = array_map('trim', explode(',', $value));
1115 if ($ptype === 'integer' || $ptype === 'number') {
1116 foreach ($choices as $i => $choice) {
1117 if (is_numeric($choice)) {
1118 $choices[$i] = $this->tagValueToNumber($choice);
1119 }
1120 }
1121 }
1122 $constraints['enum'] = array_values($choices);
1123 } elseif ($name === 'pattern' && $value !== '') {
1124 $pattern = $this->restlerPatternToJsonSchema($value);
1125 if ($pattern !== '') {
1126 $constraints['pattern'] = $pattern;
1127 }
1128 }
1129 }
1130 }
1131
1132 // Remove every tag, including the ones left untranslated, then tidy the
1133 // whitespace the removal leaves behind.
1134 $desc = preg_replace('/\s*\{@\w[\w-]*[^}]*\}/', '', $desc);
1135
1136 return trim(preg_replace('/\s{2,}/', ' ', (string) $desc));
1137 }
1138
1148 private function tagValueToNumber(string $value)
1149 {
1150 return (strpos($value, '.') === false) ? (int) $value : (float) $value;
1151 }
1152
1166 private function restlerPatternToJsonSchema(string $value): string
1167 {
1168 $reg = [];
1169 if (!preg_match('/^(.)(.*)\1([a-zA-Z]*)$/s', $value, $reg)) {
1170 return ''; // not delimited: not a PCRE literal, leave it in the description
1171 }
1172
1173 $expression = $reg[2];
1174 $flags = $reg[3];
1175
1176 if ($flags !== '' && !($flags === 'i' && !preg_match('/[a-zA-Z]/', $expression))) {
1177 return '';
1178 }
1179
1180 return $expression;
1181 }
1182
1189 private function docTypeToJson(string $type): string
1190 {
1191 $t = strtolower(trim(explode('|', $type)[0]));
1192 if (in_array($t, ['int', 'integer'], true)) {
1193 return 'integer';
1194 }
1195 if (in_array($t, ['float', 'double'], true)) {
1196 return 'number';
1197 }
1198 if ($t === 'bool' || $t === 'boolean') {
1199 return 'boolean';
1200 }
1201 return 'string';
1202 }
1203
1210 public function getRequiredRights(string $toolName)
1211 {
1212 return self::RIGHTS_ENFORCED_DOWNSTREAM;
1213 }
1214
1220 public function getCategories(): array
1221 {
1222 // Union of the classifier categories of the whitelisted endpoints —
1223 // derived so it cannot drift, expressed in the classifier vocabulary
1224 // so query filtering keeps working (see ENDPOINT_CATEGORIES).
1225 $all = array();
1226 foreach (array_keys($this->enrichments) as $key) {
1227 $all = array_merge($all, self::ENDPOINT_CATEGORIES[$key] ?? array());
1228 }
1229
1230 return array_values(array_unique($all));
1231 }
1238 private function extrafieldsElementForEndpoint($key)
1239 {
1240 $map = array(
1241 'thirdparties' => 'societe',
1242 'contacts' => 'socpeople',
1243 'invoices' => 'facture',
1244 'supplierinvoices' => 'facture_fourn',
1245 'orders' => 'commande',
1246 'supplierorders' => 'commande_fournisseur',
1247 'proposals' => 'propal',
1248 'supplierproposals' => 'supplier_proposal',
1249 'products' => 'product',
1250 'contracts' => 'contrat',
1251 'interventions' => 'fichinter',
1252 'tickets' => 'ticket',
1253 'projects' => 'projet',
1254 'tasks' => 'project_task',
1255 'members' => 'adherent',
1256 'expensereports' => 'expensereport',
1257 'shipments' => 'expedition',
1258 'receptions' => 'reception',
1259 'agendaevents' => 'actioncomm',
1260 'warehouses' => 'stock',
1261 'categories' => 'categorie',
1262 'bankaccounts' => 'bank_account'
1263 );
1264
1265 return $map[$key] ?? '';
1266 }
1267
1276 public function execute(string $name, array $args)
1277 {
1278 if (!getDolGlobalInt('AI_MCP_API_BRIDGE')) {
1279 return ["error" => "API bridge is disabled (AI_MCP_API_BRIDGE not set)."];
1280 }
1281
1282 $this->getDefinitions(); // ensure routes are built
1283 if (empty($this->routes[$name])) {
1284 return ["error" => "Tool function '$name' not found."];
1285 }
1286 list($key, $method) = $this->routes[$name];
1287 $ep = $this->endpoints[$key];
1288
1289 // Server-side guarantee of compact list results: doc-strings recommend
1290 // 'properties', but a model that ignores them would otherwise pull full
1291 // ~130-column objects — unreadable in the chat table and in the PDF
1292 // report. When the whitelist entry declares default_properties and the
1293 // caller did not choose, the default applies; an explicit 'properties'
1294 // from the model always wins.
1295 $methodmeta = isset($this->enrichments[$key]['methods'][$method]) ? $this->enrichments[$key]['methods'][$method] : array();
1296 if (!empty($methodmeta['default_properties']) && !array_key_exists('properties', $args)) {
1297 $args['properties'] = $methodmeta['default_properties'];
1298 }
1299
1300 // --- Authentication bridge (in-process replacement of DolibarrApiAccess::__isAllowed) ---
1301 // The API endpoint methods read the authenticated user from DolibarrApiAccess::$user
1302 // and their permission checks (hasRight) run against it. TODO: replicate entity
1303 // switching for multicompany setups.
1304 $this->loadApiRuntime();
1305 // Establish the caller's permission context, the same way the REST entry
1306 // point does in api_access.class.php ("Set also the global variable $user
1307 // to the $user of API"): the API layer authenticates via
1308 // DolibarrApiAccess::$user, and API/business code reads the global.
1309 // Unlike a REST request, this runs in-process mid-request, so both are
1310 // restored at the single exit point below — nothing after a tool call
1311 // (hooks, triggers, log attribution, another handler) may inherit the
1312 // tool's user.
1313 $saveduserapi = DolibarrApiAccess::$user;
1314 $saveduserglobal = empty($GLOBALS['user']) ? null : $GLOBALS['user'];
1315 DolibarrApiAccess::$user = $this->user;
1316 $GLOBALS['user'] = $this->user;
1317
1318 require_once $ep['path'];
1319 $api = new $ep['class']();
1320
1321 // Map named MCP args onto the method's positional signature.
1322 $rm = new ReflectionMethod($ep['class'], $method);
1323 $callArgs = [];
1324 $output = null;
1325 foreach ($rm->getParameters() as $p) {
1326 $pname = $p->getName();
1327 if (array_key_exists($pname, $args)) {
1328 // Cap an explicit 'limit': one call must not pull thousands of full objects.
1329 $callArgs[] = ($pname == 'limit') ? min((int) $args[$pname], self::BRIDGE_MAX_LIMIT) : $args[$pname];
1330 } elseif ($p->isOptional()) {
1331 // Bridge default for an omitted 'limit' is smaller than the API's 100.
1332 $callArgs[] = ($pname == 'limit') ? self::BRIDGE_DEFAULT_LIMIT : $p->getDefaultValue();
1333 } else {
1334 $output = ["error" => "Missing required parameter '$pname'."];
1335 break;
1336 }
1337 }
1338
1339 if ($output === null) {
1340 try {
1341 $result = call_user_func_array([$api, $method], $callArgs);
1342 // Serialize API return (cleaned objects) into plain arrays for the MCP client.
1343 $output = json_decode(json_encode($result), true);
1344 } catch (Throwable $e) {
1345 $code = (int) $e->getCode();
1346 $message = $e->getMessage();
1347 if ($message === '') {
1348 // Core throws bare RestException(403) in places: give the model
1349 // something to reason on instead of an empty string.
1350 $message = 'Access denied or resource error (HTTP '.($code > 0 ? $code : 500).').';
1351 }
1352 $output = [
1353 "error" => $message,
1354 "http_status" => ($code > 0 ? $code : 500)
1355 ];
1356 }
1357 }
1358
1359 // Extrafields flagged as personal data (GDPR) must not reach an AI provider.
1360 $elementForExtrafields = $this->extrafieldsElementForEndpoint($key);
1361 if ($elementForExtrafields !== '') {
1362 require_once DOL_DOCUMENT_ROOT.'/ai/lib/ai.lib.php';
1363 $output = aiStripPersonalExtrafields($this->db, $output, $elementForExtrafields);
1364 }
1365
1366 // Restore the caller's context (single exit point).
1367 DolibarrApiAccess::$user = $saveduserapi;
1368 if ($saveduserglobal !== null) {
1369 $GLOBALS['user'] = $saveduserglobal;
1370 }
1371
1372 return $output;
1373 }
1374}
aiStripPersonalExtrafields($db, $payload, $elementtype)
Remove extrafields flagged as personal data from an API-shaped payload.
Definition ai.lib.php:1122
Abstract base class for all MCP (Model Context Protocol) tools.
Class ToolApiBridge.
resolveEndpointClass(string $key)
Load an endpoint's api file and resolve its real class name (lazy, cached in $this->endpoints).
extrafieldsElementForEndpoint($key)
Map a bridge endpoint key to the element type ExtraFields uses.
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,...
getRequiredRights(string $toolName)
Rights are enforced by the REST API classes themselves.
execute(string $name, array $args)
Execute a bridged tool: authenticate the acting user, call the API method in-process with positional ...
defsCacheFile()
Path of the definitions cache file for the CURRENT state, or '' when no writable temp directory exist...
applyMethodRestriction(array $methods)
Apply the AI_MCP_API_BRIDGE_METHODS restriction.
defaultMethods()
Methods exposed for an endpoint carrying no hand-written entry.
restlerPatternToJsonSchema(string $value)
Convert a Restler {@pattern} value to a JSON Schema pattern.
getCategories()
Return categories this tool belongs to.
discoverEndpoints()
Discover the REST API endpoints of every enabled module.
const ENDPOINT_CATEGORIES
Endpoint key -> intent categories of the assistant's query classifier (classifyIntentUniversal() in p...
getDefinitions()
Returns tool definitions derived from the enabled REST API endpoints.
writeDefsCache(string $cachefile)
Persist the generated definitions/routes/endpoints, pruning cache files of previous states so stale s...
loadApiRuntime()
Load the REST API runtime (Restler autoloader + DolibarrApi base classes), mirroring the bootstrap se...
tagValueToNumber(string $value)
Convert a numeric tag value to the PHP number JSON encodes as a number.
docTypeToJson(string $type)
Convert a docblock type to a JSON Schema type.
liftInlineTags($desc, string $ptype, array &$constraints)
Lift Restler's inline validation tags out of a parameter description.
__construct($db, $user=null, $conf=null)
Constructor.
const BRIDGE_DEFAULT_LIMIT
Default and ceiling applied to list 'limit' parameters when called through the bridge.
if(! $sortfield) if(! $sortorder) $module
Definition list.php:193
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $conf
The main.inc.php has been included so the following variable are now defined:
getModuleDirForApiClass($moduleobject)
Get name of directory where the api_...class.php file is stored.
dolGetModulesDirs($subdir='')
Return list of directories that contain modules.
dol_now($mode='gmt')
Return date for now.
dol_osencode($str)
Return a string encoded into OS filesystem encoding.
getDolGlobalInt($key, $default=0)
Return a Dolibarr global constant int value.
dol_buildpath($path, $type=0, $returnemptyifnotfound=0)
Return path of url or filesystem.
getDolGlobalString($key, $default='')
Return a Dolibarr global constant string value.
isModEnabled($module)
Is Dolibarr module enabled.
dol_mkdir($dir, $dataroot='', $newmask='')
Creation of a directory (this can create recursive subdir)
conf($dolibarr_main_document_root, $realpathconf=null)
Load conf file (file must exists)
Definition inc.php:431
$conf db user
Active Directory does not allow anonymous connections.
Definition repair.php:141