Unlike Facture::addline() which takes ($desc, $pu, $qty, $txtva, ...),
FactureFournisseur::addline() uses a different parameter order:
($desc, $pu, $txtva, $txlocaltax1, $txlocaltax2, $qty,
$fk_product, $remise_percent, ...)
i.e. $qty is in position 6, not 3.
ToolCrudObjects::processAddLine() was calling
FactureFournisseur::addline() with the Facture order, which produced:
- our $qty (1) passed as $txtva -> effective VAT rate 1%
- our $vat (20) passed as $txlocaltax1 -> wrong local tax
- a hardcoded 0 passed as $qty -> the line was inserted with
qty=0 and silently dropped from the visible totals
The result: every supplier invoice created via create_other_document
ended up with the header correctly created but a 0.00 EUR total,
with no error returned (lines_added was reported as success).
This patch reorders the parameters to match the actual
FactureFournisseur::addline() signature and adds an inline comment
to warn future maintainers about this Dolibarr API quirk.
Other addline calls in the same switch (invoice/order/proposal,
supplier_order/supplier_proposal) use their respective correct
signatures already and are unaffected.
Tested with: create_other_document object_type=supplier_invoice
with one line (qty=1, unit_price=10, vat_rate=20) -> line now
appears with Total HT 10.00 EUR.
ToolCrudObjects::processAddLine() uses the line's 'description' as a
fallback product identifier when 'product' is not explicitly provided.
It then calls findProduct() to look up an existing Dolibarr product
by ref/barcode/label.
When no product matches (the common case for AI-generated lines that
describe a one-off service or a custom item), findProduct() returns
an error array and the function aborts WITHOUT ever calling addline().
The resulting document gets its header created but ends up with zero
lines and a 0.00 total.
This is contrary to standard Dolibarr behavior: free-text line items
(no product link, just a description and a price) are a first-class
feature of invoices, orders, and proposals.
This patch distinguishes between the two cases:
- Caller explicitly passed 'product': they wanted a specific product
-> still abort with the original error if not found (unchanged).
- Caller only passed 'description': it's a free-text line ->
silently fall through with $prod = null. Dolibarr then creates a
free-text line with the user-provided description, qty, unit_price
and vat_rate.
Tested with: create_other_document object_type=proposal containing
11 line items, none of which match existing product references ->
all 11 lines are now created with their correct totals (previously
the document was created with 0 lines and a 0.00 total).
Two bugs combined to make UniversalLLMAdapter::__construct() crash with:
TypeError: Argument #4 ($model) must be of type string, array given
at parse_intent.php line 301 when the user-configured model wasn't
properly read.
(1) Wrong shape assumption -- getListOfAIServices() declares model
defaults as a NESTED array of the form:
$servicesList[$key]['textgeneration'] = ['default' => 'model-name']
The previous code read $servicesList[$key]['textgeneration'] (without
['default']) and got the inner array back. That array was then passed
as the $defModel fallback to a (string)-typed constructor argument.
(2) Wrong constant -- the admin UI ("Prompt and custom AI models" tab
in setup.php) stores the per-function model under
AI_API_<SERVICE>_MODEL_TEXT, matching the convention already used by
Ai::generateContent() for the same data. The previous code read
AI_API_<SERVICE>_MODEL (no _TEXT suffix), which is never written by
that form. So the user-configured model was silently ignored and we
fell back to the (array) default from bug #1.
Together these two bugs reliably reproduced the TypeError on any
default install of the AI module where the user had set a custom
text model via the admin UI.
This patch:
- Walks $servicesList[$key]['textgeneration'] correctly (extracting
['default'] when it's an array, accepting strings for backward
compat).
- Tries AI_API_<SERVICE>_MODEL_TEXT first (the constant the admin
UI writes), and falls back to the legacy AI_API_<SERVICE>_MODEL
for compatibility with anyone who might have set it manually.
- Defensively coerces the final value to a string and falls back to
the default if for any reason it's still not a string.
Tested with google (gemini-2.5-flash) and chatgpt configured via
the admin UI -> UniversalLLMAdapter receives a string and the
AI request goes through.
create_customer_invoice and create_other_document declare a 'header'
object whose schema has 'required: [socid]' but no 'properties'
entry. This is technically allowed by JSON Schema but stricter
validators flag it as suspicious: the 'required' array references a
property that is not declared in 'properties'.
Beyond compliance, the omission means LLMs (and human reviewers) have
no machine-readable description of which fields are actually accepted
in the 'header' object. The Dolibarr backend already accepts socid,
date, duree_validite, note_public, note_private and others -- this
patch documents the most useful ones explicitly so consumer tools
can produce richer payloads.
Tested with: create_other_document object_type=proposal accepting
a 'header' that contains all of socid + date + duree_validite +
note_public -> all four fields are correctly mapped to the Propal
object (Dolibarr already accepts unknown properties via dynamic
property assignment, so this is purely a schema-documentation change).
cleanToolSchemaForLLM() truncates each tool description to 3 words
whenever the schema has more than 20 tools. This heuristic likely
made sense for GPT-3.5 (4K context window) but is harmful with the
current crop of LLMs:
- Gemini 2.5 Flash: 1M token context
- GPT-4o: 128K
- Claude Sonnet: 1M
Three words per description is far too aggressive and actively
breaks tool selection. For example:
- create_other_document -> "Create documents other"
- create_sales_order -> "Create a CUSTOMER"
- add_line_item -> "Add a single"
The LLM then has no way to know that create_other_document is what
should be used for supplier_invoice, or that add_line_item modifies
an EXISTING draft. We've observed Gemini concluding that
"supplier_invoice creation is not available" because the truncated
description didn't mention it.
Two complementary changes:
1. Raise the threshold from 20 to 100 -- this effectively disables
compression for normal installs (~30 tools) while leaving a
safety net for very large custom installs that register dozens
of additional tools via the addMcpTools hook.
2. Even when compression IS active, never touch the tool-level
description. Parameter-level compression (stripping defaults,
descriptions of optional fields, collapsing complex objects)
still applies and continues to save tokens. The right answer
for truly oversized schemas is to filter the toolset upstream
(which filterToolsProfessional() already does), not to mutilate
each tool's description.
Tested with: a 30-tool schema sent in full now allows the LLM to
correctly select create_other_document with object_type
'supplier_invoice', 'proposal' and 'supplier_proposal' --
previously it returned a "feature not available" message.
The AI Assistant web UI (parse_intent.php) persists every request to
llx_ai_request_log via ai_log_request() in ai.lib.php. Administrators
can browse the resulting log via the existing Log Viewer
(htdocs/ai/admin/log_viewer.php).
The MCP HTTP server (mcp_server.php) does NOT call this helper. As a
result, external MCP client activity (Claude Desktop, Claude Code CLI,
MCP Inspector, custom scripts, etc.) is invisible to administrators
-- only dol_syslog() entries land in documents/dolibarr.log, which
is much harder to filter and audit.
This patch adds an mcp_log_request() helper alongside the existing
auth/dispatch logic in mcp_server.php. The helper:
- Persists every tools/call invocation to llx_ai_request_log
- Filters out lifecycle methods (initialize, ping,
notifications/initialized, tools/list, ...) to avoid log spam
- Logs with provider='mcp' so administrators can filter MCP traffic
in the Log Viewer
- Captures the tool name + arguments, success/error status, raw
request payload and raw response payload
- Charges the elapsed time per-call (also per-call inside batches)
The helper is gated by the same AI_LOG_REQUESTS toggle that already
controls the web UI logging -- no new admin setting introduced.
Tested with: search_products, create_other_document, search_invoice,
get_sales_report from Claude Desktop and Claude Code CLI -- each
call appears as a new row in the Log Viewer with the right tool name,
status, payloads, and elapsed time.
The get_sales_report tool declares a group_by parameter with enum
values [thirdparty, product, month] in its inputSchema, but the
implementation ignored $args['group_by'] entirely and always
returned a flat list of invoices.
Meanwhile getPurchaseReport() in the same class already implemented
the correct grouped pattern (s.nom or DATE_FORMAT date) -- this
patch ports that pattern to getSalesReport(), with three modes:
- With a socid: detailed invoice list for that customer (unchanged
behavior, kept for backward compatibility).
- Without socid, group_by=thirdparty (default): aggregate by
customer with COUNT and SUM(total_ttc) -> Top-customers report.
- Without socid, group_by=month: aggregate by DATE_FORMAT(datef,
'%Y-%m') -> monthly revenue report.
- Without socid, group_by=product: aggregate by product reference
via INNER JOIN on facturedet, with COALESCE(p.ref, fd.description)
so free-text lines without a product link still appear under
their description. SUM(fd.total_ttc) gives per-product revenue,
COUNT(DISTINCT f.rowid) gives the number of invoices that
contained the product.
Tested:
- group_by=thirdparty: top customers correctly ranked by total.
- group_by=month: 2026-04 and 2026-05 buckets correctly populated.
- group_by=product: top product (SAM-BIK3-CAS-1430-24-N) correctly
ranked at 1 276 EUR over 5 invoices.
- With socid: unchanged detailed listing.
Note: the 'product' grouping is also missing from getPurchaseReport()
in the current code. That can be addressed in a follow-up patch on
the same pattern.
The local document parser in ai_assistant.js calls
getDocument({ data: arrayBuffer }) without cMapUrl and
standardFontDataUrl. As a consequence, PDFs that ship with custom
CID fonts referencing ToUnicode CMaps (which is the norm for files
produced by TCPDF, wkhtmltopdf, iText and most server-side
generators) cannot be decoded properly: PDF.js getTextContent()
returns raw glyph codes and the resulting "text" looks like garbled
base64.
This patch passes the two URLs to getDocument(), pointing them at
the jsdelivr CDN package (cdnjs, which is used elsewhere for the
pdf.js bundle itself, does not distribute the cmaps/ and
standard_fonts/ resource directories).
Tested with a TCPDF-generated invoice that previously returned
unreadable base64-like output. Text now extracts cleanly, including
French accented characters.
The MCP server currently accepts the API key via X-API-Key or
Authorization: Bearer headers only. This works for most MCP clients
(Claude Code CLI, MCP Inspector, mcp-proxy, custom scripts) which let
users configure arbitrary headers.
However, Claude Desktop's "Custom Connectors" feature (currently in
public beta) only exposes OAuth credential fields in its UI. There is
no way for end users to pass a custom X-API-Key or Authorization
header from that client today.
Without a fallback, Claude Desktop users cannot connect to a Dolibarr
MCP server at all (HTTP 401 on every request), even though all other
prerequisites are met.
This patch adds a query-string fallback: ?api_key=XXX or ?key=XXX.
The header-based methods remain preferred and are tried first; the
query string is checked only when no valid header was provided.
Security considerations:
- Query-string credentials appear in webserver access logs and
potentially in Referer headers. This is documented in an inline
comment alongside the code.
- Administrators relying on the fallback should restrict access at
the webserver level (IP allowlist, mTLS, etc.) and/or rotate
AI_MCP_API_KEY regularly.
- Header-based auth remains the recommended path and is unchanged.
Tested with:
- Claude Desktop Custom Connectors (beta): connection now succeeds
- Claude Code CLI with --header: existing header path unchanged
- curl with both header and query-string: both work
Several tool schemas in ToolThirdParty::getDefinitions() declared
'required' => true inside individual properties. This is not valid
JSON Schema draft 2020-12, which requires 'required' to be an array
of property names at the parent object level.
Strict validators (such as Anthropic's Claude API used by Claude
Desktop Custom Connectors and Claude Code) reject these schemas with
HTTP 400 'input_schema is invalid', causing the entire MCP connection
to fail before any tool can be called.
This patch:
- Moves 'required: true' flags from individual properties to a proper
'required: [...]' array at the parent level
- Adds the missing parent-level required array to get_thirdparty_details
(which previously had no way to mark id as required in a compliant manner)
- Fixes 5 tools in total: get_thirdparty_details, create_thirdparty,
update_thirdparty, list_thirdparty_contacts, add_thirdparty_contact
Tested with:
- Claude Desktop Custom Connectors (BETA): tools/list now succeeds
- Claude Code CLI: 32 MCP tools load without 400 errors
PR #37840 added NC legal forms by adapting the French INSEE Niveau II
codes (prefixed with '165' to fit the country id) and inserting GDPL
entries on arbitrary 165XX slots. That approach worked but bypassed the
actual NC reference: the ISEE-NC (Institut de la Statistique et des
Études Économiques de Nouvelle-Calédonie) maintains its own published
"Nomenclature des formes juridiques" (July 2020 edition), which is what
the RIDET business registry actually uses to classify NC entities.
This commit replaces the 46 ad-hoc 165XX codes with the 34 INSEE Niveau
II codes from the ISEE-NC nomenclature (same convention used for France
in the same file), prefixed with '165' to satisfy the table's UNIQUE
(code) constraint, plus one Niveau IV entry for the GDPL (code '6901'
becomes '1656901'), which has no N2 equivalent.
Stripping the '165' prefix from any NC code yields the official
ISEE-NC code.
Source: https://www.isee.nc/definitions-et-methodes/nomenclatures
("Nomenclature des formes juridiques", July 2020)
The GDPL (Groupement de Droit Particulier Local) is a NC-specific legal
form established by French law 88-1028 of 9 November 1988, decree
89-570 of 16 August 1989, and reinforced by organic law 99-209 of
19 March 1999, art. 18.
This realignment lands before any Dolibarr release ships the 165XX
codes from PR #37840 (which sits only in develop, no tag contains it
yet), so no upgrade migration is needed on the upstream side.
Fixes#38203
Co-authored-by: Laurent Destailleur <eldy@destailleur.fr>