← All articles
Analysis · September 2026

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.

ToolParametersRelayed callSearch quota
heurix_catalog_statscatalog (optional)No argument: GET /v1/index/catalogs. With a catalog: GET /v1/index/{catalog}/stats then /browse-categories0 with no argument, 1 request with a catalog
heurix_searchcatalog, query, filters, limit (1 to 100, default 10)POST /v1/index/{catalog}/search1 request
heurix_browsecatalog, category, sort, limitGET /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
}
FieldWhat the agent can do with it
totalThe 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.
tokensThe query as the engine split it.
hits[].productThe product as it was indexed, description included. The ten results for "vis inox M8" weigh about 6 KB.
hits[].matchedWhy 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[].score
in_stock
pinned
buried
Relevance, availability, and the priorities set by the merchant.
fallbacktrue when the results are featured products shown for lack of a match.
price_filter
suggested_category
filters_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.

QueryMatching productsWith the outillage packWithout a pack
vis inox M8 de 30 mm de long
stainless M8 screw, 30 mm long
40 in the top 10, first at rank 360 in the top 10, first at rank 63
vis de 8 par 30 en inox
8 by 30 stainless screw
4none in the top 100none in the top 100
vis inox M8x3042 in the top 10, rank 1none in the top 100
vis M8 x 30 inox43 in the top 10, rank 10 in the top 10, rank 63
cheville nylon de 8
nylon anchor, size 8
50 in the top 10, rank 190 in the top 10, rank 19
cheville nylon M853 in the top 10, rank 12 in the top 10, rank 1
stainless steel hex bolt M8115 in the top 10, rank 10 in the top 10, rank 11
vis à tête fraisée M5 en inox
stainless M5 countersunk screw
207 in the top 10, rank 13 in the top 10, rank 1
vis DIN 912 M12x401rank 2none in the top 100
écrou M6 inox A4
M6 A4 stainless nut
3410 in the top 1010 in the top 10
tige filetée M16 laiton
M16 brass threaded rod
228 in the top 108 in the top 10
vis inox moins de 1 euro
stainless screws under 1 euro
16810 in the top 1010 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, m8x30 stays 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":

filtersWith packWithout a pack
["M8"]0, filters_unknown: ["M8"]0, filters_unknown: ["M8"]
["inox"]0, filters_unknown: ["inox"]0, filters_unknown: ["inox"]
["DIAM_M8"]2250, filters_unknown: ["DIAM_M8"]
["diametre:M8"]225114
["Diametre:M8"]0, filters_unknown: ["Diametre"]0, filters_unknown: ["Diametre"]
["matiere:inox A2"]588305
["matiere:inox"]0, no signal0, no signal

Three things can be read in this table.

  • An annotation exists only if the pack writes it. DIAM_M8 returns zero on a catalog without a pack, and a bare word such as M8 is read as an annotation, which the description says. filters_unknown flags 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: Diametre is not diametre. The full syntax is in the search reference.
  • A wrong value is not flagged. matiere:inox names a field that exists and a value no product carries: the values are "inox A2" and "inox A4". The response is total: 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-attributes returns 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/annotations returns, for each pack, the annotation templates it can write, such as DIAM_M{1} or MAT_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 filters accepts: 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, M8x30 rather than "8 by 30".
  • Two signals to read before concluding "nothing found": filters_unknown, and fallback, 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.

14-day free trial

Try Heurix on your catalog, no credit card.

Start free trial