Ce qu'un agent IA trouve, et ne trouve pas, dans un catalogue technique
Un agent IA ne sait pas qu'un catalogue de visserie porte des diamètres, des longueurs et des normes. Il écrit une phrase, et il transmet ce qu'on lui a dit. Voici ce que notre serveur MCP lui rend, champ par champ, ce qu'il obtient sur 12 demandes en langage naturel, avec et sans pack de règles, et ce qu'il ne peut pas découvrir seul.
Meilisearch a publié le 9 septembre un guide pour construire un outil de recherche destiné aux agents. Cet article part d'un outil qui existe déjà, heurix-mcp-server, et mesure ce qu'il rend sur un catalogue technique. Les mesures ont été faites avec la version 0.1.0. La 0.1.1, celle que PyPI sert aujourd'hui, n'en change que les descriptions et les instructions que l'agent lit, pas le code des outils : c'est de ces textes-là, en 0.1.1, qu'il est question plus bas. L'installation dans Claude Desktop et Cursor est décrite dans le guide du serveur MCP ; elle n'est pas reprise ici.
Conditions de la mesure. Moteur Heurix en local, à la version servie en production le 15 septembre 2026. Catalogue de 10 000 références de quincaillerie produit par le générateur du moteur, avec son désordre voulu : quatre graphies de la même dimension (M8x30, M8 x 30, M8-30, M8*30), des abréviations métier, des fiches en anglais, des champs manquants. Ce catalogue est indexé deux fois, à l'identique : une fois avec le pack outillage, une fois sans pack. Les appels passent par les fonctions d'outil du serveur MCP, sans modification ; le transport MCP lui-même n'est pas en jeu dans les résultats.
Ce que l'agent reçoit
Le serveur expose trois outils. Chacun relaie un ou deux appels de l'API REST, sans logique propre.
| Outil | Paramètres | Appel relayé | Quota de recherche |
|---|---|---|---|
heurix_catalog_stats | catalog (facultatif) | Sans argument : GET /v1/index/catalogs. Avec un catalogue : GET /v1/index/{catalog}/stats puis /browse-categories | 0 sans argument, 1 requête avec un catalogue |
heurix_search | catalog, query, filters, limit (1 à 100, 10 par défaut) | POST /v1/index/{catalog}/search | 1 requête |
heurix_browse | catalog, category, sort, limit | GET /v1/browse/{catalog}/{category} | 0 : compté sur le quota Browse |
La colonne quota est mesurée sur le compteur de la clé, avant et après chaque appel.
Découvrir le catalogue
La description de heurix_catalog_stats demande de l'appeler en premier. Avec un nom de catalogue, l'agent reçoit :
{
"catalog": "quincaillerie-outillage",
"products": 10000,
"terms": 5621,
"annotations": 271,
"rulepack": "outillage",
"rulepacks": [
"outillage"
],
"synonym_groups": 0,
"sandbox": false,
"browse_categories": [
{
"category": "fixation",
"products": 9239
},
{
"category": "boulonnerie",
"products": 4700
},
{
"category": "visserie",
"products": 4516
},
{
"category": "accessoires",
"products": 1080
},
{
"category": "maconnerie",
"products": 465
}
]
}
Le nom du pack actif, des comptes, et les catégories avec leur nombre de produits. annotations: 271 est un nombre : la liste des 271 annotations n'y est pas. Les noms des champs du produit (famille, matiere, diametre, norme) n'y sont pas non plus. Les instructions du serveur le disent, et renvoient l'agent aux résultats de heurix_search pour les lire.
Sur une clé dont l'offre n'inclut pas Browse, la même réponse porte "browse_categories": []. La description de l'outil prévient que c'est le cas, mais la réponse elle-même ne distingue pas « pas d'accès » de « pas de catégorie » : l'agent ne le sait qu'en appelant heurix_browse, qui rend alors une erreur 403. Et un nom de catégorie mal cassé, Visserie au lieu de visserie, rend total: 0 sans erreur.
Chercher
heurix_search transmet quatre choses : le catalogue, le texte, les filtres et la limite. L'API de recherche en accepte davantage, que l'outil ne transmet pas : offset, facets, in_stock_only, exclude_description, group_by, lang. Un agent ne peut donc ni paginer, ni demander des facettes, ni écarter les ruptures. Voici un résultat, tel qu'il le reçoit :
{
"query": "vis M8 x 30 inox",
"tokens": [
"vis",
"m8",
"x",
"30",
"inox"
],
"total": 6733,
"offset": 0,
"limit": 1,
"hits": [
{
"product": {
"id": "REF-000004",
"name": "Vis hexagonale M8 x 30 inox A4",
"price": 1.79,
"categories": [
"visserie",
"fixation"
],
"famille": "Vis",
"matiere": "inox A4",
"diametre": "M8",
"description": "ISO 4762 filetage métrique M8",
"norme": "ISO 4762",
"stock": 3643
},
"score": 77.4,
"in_stock": true,
"matched": [
"terme 'vis' (depuis 'vis', x1.00)",
"terme 'm8' (depuis 'm8', x1.00)",
"terme '30' (depuis '30', x1.00)",
"terme 'inox' (depuis 'inox', x1.00)",
"annotation #DIAM_M8",
"annotation #DIAM_M8_INOX",
"annotation #FAM_VIS",
"annotation #LONG_30"
],
"pinned": false,
"buried": false
}
],
"fallback": false
}
| Champ | Ce que l'agent peut en faire |
|---|---|
total | Le nombre de produits trouvés. Il compte ce qui contient un terme de la requête, pas ce qui répond à la demande : 6 582 pour « vis inox M8 » sur 10 000 références. |
tokens | La requête telle que le moteur l'a découpée. |
hits[].product | Le produit tel qu'il a été indexé, description comprise. Les dix résultats de « vis inox M8 » pèsent environ 6 Ko. |
hits[].matched | Pourquoi ce produit est là : les termes trouvés et les annotations partagées avec la requête. C'est le champ qui permet à un agent d'expliquer un résultat plutôt que de le paraphraser. |
hits[].scorein_stockpinnedburied | La pertinence, la disponibilité, les priorités posées par le marchand. |
fallback | true quand les résultats sont des produits mis en avant faute de correspondance. |
price_filtersuggested_categoryfilters_unknown | Présents seulement quand ils ont quelque chose à dire. Voir plus bas. |
Ce que l'agent tape, et ce qu'il obtient
Douze demandes, écrites comme un agent les transmet. Pour chacune, on compte les produits qui répondent à toute la demande, d'après les champs structurés du produit (famille, matière, diamètre, norme, prix) et la longueur écrite dans son nom. Le tableau donne le nombre de ces produits dans les dix premiers résultats, et le rang du premier.
| Requête | Produits qui répondent | Avec pack outillage | Sans pack |
|---|---|---|---|
| vis inox M8 de 30 mm de long | 4 | 0 dans les 10, premier au rang 36 | 0 dans les 10, premier au rang 63 |
| vis de 8 par 30 en inox | 4 | aucun dans les 100 premiers | aucun dans les 100 premiers |
| vis inox M8x30 | 4 | 2 dans les 10, rang 1 | aucun dans les 100 premiers |
| vis M8 x 30 inox | 4 | 3 dans les 10, rang 1 | 0 dans les 10, rang 63 |
| cheville nylon de 8 | 5 | 0 dans les 10, rang 19 | 0 dans les 10, rang 19 |
| cheville nylon M8 | 5 | 3 dans les 10, rang 1 | 2 dans les 10, rang 1 |
| stainless steel hex bolt M8 | 11 | 5 dans les 10, rang 1 | 0 dans les 10, rang 11 |
| vis à tête fraisée M5 en inox | 20 | 7 dans les 10, rang 1 | 3 dans les 10, rang 1 |
| vis DIN 912 M12x40 | 1 | rang 2 | aucun dans les 100 premiers |
| écrou M6 inox A4 | 34 | 10 dans les 10 | 10 dans les 10 |
| tige filetée M16 laiton | 22 | 8 dans les 10 | 8 dans les 10 |
| vis inox moins de 1 euro | 168 | 10 dans les 10 | 10 dans les 10 |
Pour « hex bolt », seuls les boulons sont comptés. En comptant aussi les vis à tête hexagonale, 35 produits répondent : 10 parmi les 10 premiers avec le pack, 4 sans.
Une dimension écrite en phrase n'est pas lue comme une dimension
« de 30 mm de long » ne pèse rien, avec ou sans pack. Le premier résultat est une vis de 80 mm, et voici pourquoi il est là :
{
"product": {
"id": "REF-000148",
"name": "Vis TF M8 x 80 inox A2",
"price": 1.96,
"categories": [
"visserie",
"fixation"
],
"famille": "Vis",
"matiere": "inox A2",
"diametre": "M8",
"ref": "VIS-8-80-INOX",
"description": "ISO 4762 filetage métrique M8",
"norme": "ISO 4762",
"stock": 3978
},
"score": 57.0,
"in_stock": true,
"matched": [
"terme 'vis' (depuis 'vis', x1.00)",
"terme 'inox' (depuis 'inox', x1.00)",
"terme 'm8' (depuis 'm8', x1.00)",
"annotation #DIAM_M8",
"annotation #DIAM_M8_INOX",
"annotation #FAM_VIS",
"annotation #MAT_INOX"
],
"pinned": false,
"buried": false
}
Diamètre, matière, famille : tout est reconnu, sauf la longueur. 30 est un terme isolé parmi de, mm et long. Le pack lit une dimension comme un catalogue l'écrit, M8x30 ou M8 x 30, et la réponse à « vis inox M8x30 » porte alors annotation #LONG_30 et annotation #VIS_M8X30. De même, « de 8 » n'est pas un diamètre : « cheville nylon M8 » place le bon produit en tête, « cheville nylon de 8 » au rang 19.
La description de l'outil dit que les formats différents sont tolérés. C'est exact pour les graphies d'une référence : avec M8x30 comme avec M8 x 30, le premier résultat répond à la demande. Ce ne l'est pas pour une phrase qui décrit la référence.
Là où le pack change le résultat
Sur les requêtes dont les mots figurent tels quels dans les noms de produits (écrou, rondelle, tige filetée, laiton), les deux catalogues rendent les mêmes 10 premiers résultats. Le pack change le résultat quand la requête et la fiche n'écrivent pas la même chose :
- Une autre langue. Le catalogue ne contient pas le mot « bolt » ; le pack le range dans la même famille que « boulon ». Sans pack, « stainless steel hex bolt M8 » ne place aucun boulon inox M8 à tête hexagonale parmi les 10 premiers.
- Une abréviation. Les fiches écrivent « TF » pour tête fraisée. Avec le pack, 7 des 10 premiers résultats sont des vis fraisées M5 inox ; sans, 3.
- Une famille écrite autrement. Une partie des vis du catalogue s'appelle « Screw » : 111 des 225 vis M8. Le pack reconnaît les deux mots comme la même famille, et la requête « vis » rend 2 721 produits, contre 1 391 sans pack.
- Une dimension collée. « vis inox M8x30 » : 4 produits sur 4 dans les cent premiers avec le pack, aucun sans. Sans pack,
m8x30reste un seul terme, et aucun des quatre produits ne l'écrit ainsi.
La contrainte de prix, elle, ne dépend pas du pack : « moins de 1 euro » est reconnu des deux côtés et la réponse porte "price_filter": {"min": null, "max": 1.0}.
Les filtres : ce que l'agent sait avant d'essayer
Le paramètre filters restreint la recherche. Sa description en donne deux formes, champ:valeur pour un champ du produit et une annotation seule pour une étiquette du pack, et demande à l'agent de chercher d'abord sans filtre pour lire les noms dans la réponse. Voici ce que rendent les filtres qu'un agent peut essayer, sur la requête « vis » :
filters | Avec pack | Sans pack |
|---|---|---|
["M8"] | 0, filters_unknown: ["M8"] | 0, filters_unknown: ["M8"] |
["inox"] | 0, filters_unknown: ["inox"] | 0, filters_unknown: ["inox"] |
["DIAM_M8"] | 225 | 0, filters_unknown: ["DIAM_M8"] |
["diametre:M8"] | 225 | 114 |
["Diametre:M8"] | 0, filters_unknown: ["Diametre"] | 0, filters_unknown: ["Diametre"] |
["matiere:inox A2"] | 588 | 305 |
["matiere:inox"] | 0, sans signal | 0, sans signal |
Trois choses se lisent dans ce tableau.
- Une annotation n'existe que si le pack l'écrit.
DIAM_M8rend zéro sur un catalogue sans pack, et un mot seul commeM8est lu comme une annotation, ce que la description dit.filters_unknownle signale, à condition que l'agent le lise. - Les champs du produit sont filtrables, avec ou sans pack, sous la forme
champ:valeur, et le nom du champ est sensible à la casse :Diametren'est pasdiametre. La syntaxe complète est dans la référence de la recherche. - Une valeur fausse ne se signale pas.
matiere:inoxdésigne un champ qui existe et une valeur qu'aucun produit ne porte : les valeurs sont « inox A2 » et « inox A4 ». La réponse esttotal: 0, identique à celle d'un filtre juste qui ne trouve rien. Le moteur vérifie le nom du champ, jamais la valeur, et c'est une décision : l'ensemble des valeurs d'un champ est ouvert. La description de l'outil le dit ; la réponse, elle, ne le dit pas.
Ce qui manque à un agent aujourd'hui
Pour filtrer juste, un agent doit connaître deux listes : les champs du catalogue avec leurs valeurs, et les annotations que son pack écrit. Aucun des trois outils ne les rend. Les instructions du serveur envoient l'agent les lire dans une première recherche : product porte les noms des champs, matched les annotations. C'est partiel : une recherche ne montre que les valeurs des produits qu'elle rend, et matched s'arrête à 8 entrées par résultat, limitées à ce qui a compté pour ce résultat.
L'API en porte une vue plus complète, sur deux routes que le serveur MCP n'appelle pas :
GET /v1/index/{catalog}/browse-attributesrend les champs filtrables et leurs vingt valeurs les plus fréquentes. Sur ce catalogue :diametre(12 valeurs),famille(8),matiere(6),norme(9). Elle exige une offre qui inclut Browse, et refuse en 403 sinon.GET /v1/rulepacks/annotationsrend, pour chaque pack, les modèles d'annotation qu'il peut écrire, commeDIAM_M{1}ouMAT_INOX. Ce sont des modèles, pas les annotations présentes dans un catalogue donné, et la réponse pèse environ 26 Ko pour les onze packs.
Exposer ces listes à l'agent serait un outil de plus. Il n'existe pas aujourd'hui, et cet article ne le date pas.
Ce que vous pouvez faire dès maintenant
Vous connaissez ce que l'agent ne découvre qu'en partie. Donnez-le-lui dans ses instructions :
- Les champs et leurs valeurs exactes, sous la forme que
filtersaccepte :diametre:M8,matiere:inox A2|inox A4. Le tuyau est un OU. - La grammaire de vos références : demandez-lui de transmettre la dimension comme votre catalogue l'écrit,
M8x30plutôt que « de 8 par 30 ». - Deux signaux à lire avant de conclure « rien trouvé » :
filters_unknown, etfallback, qui signale une liste de produits mis en avant et non des résultats. - Les noms exacts de catégories, casse comprise, si vos clés ont accès à Browse.
Pour aller plus loin : l'installation du serveur dans le guide MCP, la syntaxe complète des filtres dans Recherche, et le détail de chaque champ de la réponse dans l'article sur la recherche headless.