What an AI agent finds, and does not find, in a technical catalog
An AI agent does not know that a fastener catalog carries diameters, lengths and standards. It writes a sentence and passes on what it was told. Here is what our MCP server returns to it, field by field, what it gets on 12 natural-language requests, with and without a rule pack, and what it cannot discover on its own.
On September 9, Meilisearch published a guide to building a search tool for agents. This article starts from a tool that already exists, heurix-mcp-server, and measures what it returns on a technical catalog. The measurements were made with version 0.1.0. Version 0.1.1, the one PyPI serves today, changes only the descriptions and instructions the agent reads, not the tools' code: the texts discussed below are those of 0.1.1. Setting it up in Claude Desktop and Cursor is covered in the MCP server guide and is not repeated here.
Measurement conditions. Heurix engine running locally, at the version served in production on September 15, 2026. A 10,000-reference hardware catalog produced by the engine's generator, with its deliberate disorder: four spellings of the same dimension (M8x30, M8 x 30, M8-30, M8*30), trade abbreviations, product names in English, missing fields. The catalog is indexed twice, identically: once with the outillage (hardware) pack, once with no pack. Calls go through the MCP server's tool functions, unmodified; the MCP transport itself plays no part in the results. Product names and queries are in French, as in the catalog.
What the agent receives
The server exposes three tools. Each one relays one or two REST API calls, with no logic of its own.
| Tool | Parameters | Relayed call | Search quota |
|---|---|---|---|
heurix_catalog_stats | catalog (optional) | No argument: GET /v1/index/catalogs. With a catalog: GET /v1/index/{catalog}/stats then /browse-categories | 0 with no argument, 1 request with a catalog |
heurix_search | catalog, query, filters, limit (1 to 100, default 10) | POST /v1/index/{catalog}/search | 1 request |
heurix_browse | catalog, category, sort, limit | GET /v1/browse/{catalog}/{category} | 0: counted on the Browse quota |
The quota column is measured on the key's counter, before and after each call.
Discovering the catalog
The description of heurix_catalog_stats says to call it first. Given a catalog name, the agent receives:
{
"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
}
]
}
The active pack's name, counts, and the categories with their product counts. annotations: 271 is a number: the list of those 271 annotations is not there. Neither are the names of the product fields (famille, matiere, diametre, norme). The server's instructions say so, and send the agent to the results of heurix_search to read them.
On a key whose plan does not include Browse, the same response carries "browse_categories": []. The tool's description warns that this happens, but the response itself does not tell "no access" from "no categories": the agent only learns it by calling heurix_browse, which then returns a 403 error. And a category name with the wrong case, Visserie instead of visserie, returns total: 0 with no error.
Searching
heurix_search passes four things: the catalog, the text, the filters and the limit. The search API accepts more, which the tool does not pass: offset, facets, in_stock_only, exclude_description, group_by, lang. An agent therefore cannot paginate, request facets, or leave out-of-stock products aside. Here is one result, as the agent receives it:
{
"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
}
| Field | What the agent can do with it |
|---|---|
total | The number of products found. It counts what contains a query term, not what answers the request: 6,582 for "vis inox M8" out of 10,000 references. |
tokens | The query as the engine split it. |
hits[].product | The product as it was indexed, description included. The ten results for "vis inox M8" weigh about 6 KB. |
hits[].matched | Why this product is there: the terms found and the annotations shared with the query. This is the field that lets an agent explain a result rather than paraphrase it. |
hits[].scorein_stockpinnedburied | Relevance, availability, and the priorities set by the merchant. |
fallback | true when the results are featured products shown for lack of a match. |
price_filtersuggested_categoryfilters_unknown | Present only when they have something to say. See below. |
What the agent types, and what it gets
Twelve requests, written the way an agent passes them on. For each, we count the products that answer the whole request, from the product's structured fields (family, material, diameter, standard, price) and the length written in its name. The table gives how many of those products appear in the first ten results, and the rank of the first one.
| Query | Matching products | With the outillage pack | Without a pack |
|---|---|---|---|
| vis inox M8 de 30 mm de long stainless M8 screw, 30 mm long | 4 | 0 in the top 10, first at rank 36 | 0 in the top 10, first at rank 63 |
| vis de 8 par 30 en inox 8 by 30 stainless screw | 4 | none in the top 100 | none in the top 100 |
| vis inox M8x30 | 4 | 2 in the top 10, rank 1 | none in the top 100 |
| vis M8 x 30 inox | 4 | 3 in the top 10, rank 1 | 0 in the top 10, rank 63 |
| cheville nylon de 8 nylon anchor, size 8 | 5 | 0 in the top 10, rank 19 | 0 in the top 10, rank 19 |
| cheville nylon M8 | 5 | 3 in the top 10, rank 1 | 2 in the top 10, rank 1 |
| stainless steel hex bolt M8 | 11 | 5 in the top 10, rank 1 | 0 in the top 10, rank 11 |
| vis à tête fraisée M5 en inox stainless M5 countersunk screw | 20 | 7 in the top 10, rank 1 | 3 in the top 10, rank 1 |
| vis DIN 912 M12x40 | 1 | rank 2 | none in the top 100 |
| écrou M6 inox A4 M6 A4 stainless nut | 34 | 10 in the top 10 | 10 in the top 10 |
| tige filetée M16 laiton M16 brass threaded rod | 22 | 8 in the top 10 | 8 in the top 10 |
| vis inox moins de 1 euro stainless screws under 1 euro | 168 | 10 in the top 10 | 10 in the top 10 |
For "hex bolt", only bolts are counted. Counting hex-head screws as well, 35 products match: 10 in the top 10 with the pack, 4 without.
A dimension written as a sentence is not read as a dimension
"de 30 mm de long" (30 mm long) carries no weight, with or without a pack. The first result is an 80 mm screw, and here is why it is there:
{
"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
}
Diameter, material, family: everything is recognized except the length. 30 is an isolated term among de, mm and long. The pack reads a dimension the way a catalog writes it, M8x30 or M8 x 30, and the response to "vis inox M8x30" then carries annotation #LONG_30 and annotation #VIS_M8X30. Likewise, "de 8" is not a diameter: "cheville nylon M8" puts the right product first, "cheville nylon de 8" at rank 19.
The tool's description says different formats are tolerated. That is true of the spellings of a reference: with M8x30 as with M8 x 30, the first result answers the request. It is not true of a sentence that describes the reference.
Where the pack changes the result
On queries whose words appear as-is in product names (nut, washer, threaded rod, brass), both catalogs return the same first 10 results. The pack changes the result when the query and the product record do not write the same thing:
- Another language. The catalog does not contain the word "bolt"; the pack puts it in the same family as "boulon" (bolt). Without a pack, "stainless steel hex bolt M8" puts no stainless M8 hex bolt in the top 10.
- An abbreviation. Product records write "TF" for countersunk head. With the pack, 7 of the first 10 results are stainless M5 countersunk screws; without it, 3.
- A family written differently. Some of the catalog's screws are named "Screw" rather than "Vis": 111 of the 225 M8 screws. The pack treats both words as the same family, and the query "vis" returns 2,721 products, against 1,391 without a pack.
- A dimension written without spaces. "vis inox M8x30": 4 products out of 4 in the top hundred with the pack, none without. Without a pack,
m8x30stays a single term, and none of the four products writes it that way.
The price constraint does not depend on the pack: "moins de 1 euro" (under 1 euro) is recognized on both sides, and the response carries "price_filter": {"min": null, "max": 1.0}.
Filters: what the agent knows before trying
The filters parameter narrows the search. Its description gives two forms, field:value for a product field and a bare annotation for a pack label, and tells the agent to search without filters first and read the names in the response. Here is what the filters an agent might try return, on the query "vis":
filters | With pack | Without a 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, no signal | 0, no signal |
Three things can be read in this table.
- An annotation exists only if the pack writes it.
DIAM_M8returns zero on a catalog without a pack, and a bare word such asM8is read as an annotation, which the description says.filters_unknownflags it, provided the agent reads it. - Product fields are filterable, with or without a pack, as
field:value, and the field name is case-sensitive:Diametreis notdiametre. The full syntax is in the search reference. - A wrong value is not flagged.
matiere:inoxnames a field that exists and a value no product carries: the values are "inox A2" and "inox A4". The response istotal: 0, identical to a correct filter that finds nothing. The engine checks the field name, never the value, and that is a decision: a field's set of values is open. The tool's description says so; the response does not.
What an agent lacks today
To filter correctly, an agent needs two lists: the catalog's fields with their values, and the annotations its pack writes. None of the three tools returns them. The server's instructions send the agent to read them in a first search: product carries the field names, matched the annotations. That is partial: a search only shows the values of the products it returns, and matched stops at 8 entries per result, limited to what counted for that result.
The API carries a fuller view, on two routes the MCP server does not call:
GET /v1/index/{catalog}/browse-attributesreturns the filterable fields and their twenty most frequent values. On this catalog:diametre(12 values),famille(8),matiere(6),norme(9). It requires a plan that includes Browse, and returns 403 otherwise.GET /v1/rulepacks/annotationsreturns, for each pack, the annotation templates it can write, such asDIAM_M{1}orMAT_INOX. These are templates, not the annotations present in a given catalog, and the response weighs about 26 KB for the eleven packs.
Exposing these lists to the agent would be one more tool. It does not exist today, and this article does not put a date on it.
What you can do right now
You know what the agent only partly discovers. Put it in its instructions:
- The fields and their exact values, in the form
filtersaccepts:diametre:M8,matiere:inox A2|inox A4. The pipe is an OR. - The grammar of your references: ask it to pass the dimension the way your catalog writes it,
M8x30rather than "8 by 30". - Two signals to read before concluding "nothing found":
filters_unknown, andfallback, which flags a list of featured products rather than results. - The exact category names, case included, if your keys have Browse access.
Going further: setting up the server in the MCP guide, the full filter syntax in Search, and every response field in detail in the headless search article.