Skip to main content

Query Analytics (Market)

mdmcp-market_data_table

Query precomputed market-data analytics in any aggregation shape: snapshot, single-dimension breakdown, multi-dimension cross-tab, numeric histogram, percentile summary, time-series trend, or period-over-period comparison. One tool covers every shape — pick via group_by, percentiles, trend, and compare:

  • Snapshot (one row, no buckets): group_by=[]
  • Breakdown by one dim: group_by=[\{"field": "retailer"}]
  • Cross-tab by two-three dims: group_by=[\{"field":"brand_slug"},\{"field":"market"}]
  • Histogram on a numeric field: group_by=[\{"field":"price","interval":1000}]
  • Percentile (median/quartile/p90): set percentiles
  • Trend (per-period time series, one row per period x group): set trend=true
  • Period comparison (WoW/MoM/vs prior): set compare

Full reference is on demand via the market_data_docs tool (topics: filter_fields, metrics, group_by_fields, dates_and_compare, text_search, composition, product_searches, response_shape, lookup_matching; pass ids=[...] for specific entries, e.g. topic="filter_fields", ids=["gender", "tier"]). This description is intentionally condensed — every filter field, metric, group-by / histogram / percentile field, date / snapping / compare rule, price-unit scale and text-search syntax is retrievable there, one section per call. Call it before building a query whenever you need a field id, metric id, valid value, operator, or price scale you do not already have. Read topic="response_shape" to interpret a result you are already holding — what truncated and total_row_count mean, and what the "others" roll-up does and does not let you say about the hidden tail. Read topic="product_searches" before summing any category breakdown: the product_searches and product_details group-bys return a TREE flattened to mixed depths, where an ancestor row already contains its descendants.

Limits: up to 3 group-by/histogram fields, up to 8 primary metrics, row cap of 200 (default 50).

Drilldown: every aggregate this tool returns is backed by individual product options. When the user asks to see the products behind a stat or row, follow up with market_data_options_search: reuse the same filters (plus a filter pinning the row's group value, e.g. the retailer or brand) and set date to a day inside the aggregate window — typically its end_date. One exception: a synthetic row — "Unmapped" (the include_missing bucket) or the "others" roll-up — has no filter value behind its label, so it cannot be drilled into; say so instead of issuing a filter that silently returns nothing. The underlying products are always queryable; never direct the user to check retailer websites or other manual alternatives.

Inline result & truncation:

  • Results are returned inline, capped at limit rows. truncated says exactly whether the cap hid rows. total_row_count is exact-or-null: the exact count when the result is complete, and null whenever it is truncated (either include_others setting) — the table backend reports no population total, so the true count is unknown once rows are hidden. It is never a lower bound: read truncated for the "more rows exist" signal, and raise limit or set include_others=true for coverage (the rolled-up tail then sits in a trailing "others" row).
  • What the "others" row is: ONE row standing for every group not listed individually. It is not a count of those groups and carries no information about how many there are, so never report a group count, a "showing X of Y rows", or a per-group average off a truncated result. Whether its value can be combined with the visible rows is a property of the METRIC, not of this row — the same question as adding any two rows together.
  • Under trend total_row_count is always null, truncated or not: the result is a per-period fan-out with no single population to count, and the roll-up happens within each period. truncated is still exact.
  • include_others defaults to true for queries grouped by a categorical dimension: rows past limit roll up into one trailing "others" row so the tail is represented rather than dropped — best for share / mix / distribution questions. Under trend the roll-up happens within each period (per-period top-N + others). Set false for a strict top-N (combine with sort). The "others" tail is itself bounded by the aggregation cap (<= 50,000 / 10,000 buckets), so on an extreme cross-tab even the rolled-up tail can under-count.

Not available inline: trend combined with multiple group_by dimensions (it exceeds the response-time budget) — use a single group-by dimension, a shorter date span, or drop trend. Discouraged (slow or silently partial): long-span trend on high-cardinality dims; multi-dim cross-tabs on high-cardinality dims (prefer include_others=true or fewer / coarser dims); percentiles across high-cardinality group-bys; composition crossed with a high-cardinality dim over a long range (each material bucket multiplies the bucket budget, so the backend may reject the query with a too-many-buckets error — narrow the range or the other dimension).

There is no SKU-level search or SKU-level pricing on this server, in any vertical. The sku_* metrics (sku_count, sku_count_retailer_avg, sku_availability, sku_sellout_pct, pct_mix_sku_count) are option-level AGGREGATES — counts and rates over an option's SKUs — not a way to address one SKU. size_options DOES filter to options offered in a size, but it selects whole options: it does not restrict the row or its metrics to that size, and there is no size/variant dimension to group or sort on. So "which dresses come in a size 16" is answerable, while "what did just the queen size sell for" is not — prices, counts and rates always describe the whole option. Answer at the option level and say the per-SKU breakdown is not available.

Resolve filter values BEFORE querying: most filters take canonical EDITED ids/slugs, not free text (the brand filter is the exception — see below). Get them from the lookup tools and copy the returned values verbatim — search_retailers -> retailer (retailers[].slug), search_brands -> brand_slug (slug), search_markets -> market (string code, e.g. "UK"), search_product_searches -> product_searches (integer id), search_size_group -> size_options (as "group-\{id}" strings), search_size_options -> size_options (integer ids; keep group strings and option ids in separate filters). When a user names a company ("Nike products"), default to resolving it as a retailer (search_retailers -> retailer); use brand_slug only for a brand within a retailer ("Nike at Foot Locker"). The brand filter takes a brand NAME as text — never a slug or id, and it never requires a lookup: for a confident search_brands match use that match's name (the canonical spelling, which also catches variant listings), otherwise use the user's string verbatim. A search_brands miss is never a reason to skip the filter — it means the brand is unmapped, which is exactly what brand is for (including a hit whose slug is null: its name is the value to use). brand and a POSITIVE (eq/in) brand_slug are OR-combined when both are supplied; a negated brand_slug (neq/not_in) stays an AND exclusion that always holds. When you take a brand_slug from search_brands, first confirm the returned name really matches the user's brand (ignore case, punctuation, ®/accents, hyphens vs spaces — that leniency is for judging the NAME, never for rewriting the slug, which is case-sensitive): the tool returns a best-effort list for almost any input, so a returned slug is NOT proof of a match — if none matches, treat the brand as unmapped and use the brand filter. At least one filter is required.

Brand strategy (this tool favours PRECISION): for a confident search_brands match, filter on brand_slug ALONE and group by brand_slug for normalised, deduped counts. For a brand with no slug, filter on brand and group by brand (raw) — which buckets every product, so no include_missing is needed (and it is not supported there) — and tell the user the counts come from raw brand text and may include variants/sub-brands. Keep the group-by dimension consistent with the resolved brand filter (mapped -> brand_slug; raw/unmapped -> brand).

Results assume healthy data coverage: a clean aggregate can be silently wrong if a retailer had a data gap or stale stretch in the window. Before trusting retailer-scoped figures, check get_retailer_coverage for the same retailers and date range, and caveat or narrow the analysis when it reports gap episodes overlapping the window.

vertical (apparel | beauty | homeware) selects the top-level data segment; omit to use the account's default vertical (see market_data_entitlements). This is NOT a product category — categories like dresses or mascara are a within-vertical filter found via search_product_searches. All three verticals are supported and return the SAME row grain: one row per product option. Homeware options bundle more SKUs per option (e.g. bed sizes) than apparel or beauty, so sku_count runs higher there — but no vertical returns per-SKU rows. Available verticals vary by account; requesting one you're not entitled to returns an error — call market_data_entitlements to see yours.

Date range snapping: start_date / end_date are snapped OUTWARD to whole calendar periods (weeks or months), so metric values describe the snapped window and not the literal dates you passed — the range is never a precise cutoff. The result reports the window actually measured as evaluated_window (start:end, end capped at today, so a final period can still be forming): read it and report figures against it. For the snapping rules, the weekly-vs-monthly boundary and partial-period caveats, call market_data_docs with topic="dates_and_compare".

Sort order: asc (lowest first) or desc (highest first). Sort by any field id (group-by, histogram, metric, or percentile id like price_p50); limit is a strict cap — combine with sort for top-N. A lone histogram group-by defaults to its band field ascending (a distribution is read in axis order, not by population), overriding the backend's metric-descending default; pass an explicit sort to change it. Note this interacts with limit: a histogram with more populated bands than limit is truncated from the TOP of the range (the high-value tail) under the ascending default — raise limit to keep the whole distribution. Empty bands are always omitted, so a distribution can still have gaps between the bands that are returned.

Retail-term routing — map common asks to the right shape before picking metrics:

  • "Discount", "markdown", "on sale", "full price" are ADVERTISED-discount concepts unless the user explicitly asks about raw price movement: depth -> avg_advertised_discount_pct; penetration / "% on sale" -> advertised_discounted_product_pct; "time to markdown" -> avg_days_to_first_discount; "deepest discount" is a histogram (group_by=[\{"field":"deepest_advertised_discount_percentage"}]), not a metric. Depth averages ONLY the discounted products, so it is not a market rate — request penetration with it and report both; no metric returns a blended all-products discount.
  • Entry / median / exit price points are PERCENTILES — set percentiles on price (current) or full_price (ticket/RRP), e.g. p10/p50/p90. They are NOT avg_min_price/avg_max_price, which average each product's own markdown-inclusive observed extremes (a clearance floor / per-product ceiling, not the assortment's price architecture).
  • "Full-price / ticket / RRP architecture" is about the full_price FIELD (percentiles or histogram) across the whole assortment — full_price is already pre-markdown, so do NOT add a discount filter. "Products currently selling at full price" is instead the POPULATION filter advertised_discounted=false.
  • "How many products / SKUs / options" -> a count metric (product_count, sku_count, or distinct_product_count_aggregate for a deduplicated distinct count — it requires a group_by), never the number of returned rows (rows are capped by limit).
  • "Bestsellers / top sellers / fastest selling" (individual products) -> market_data_options_search sorted by sellout_percentage desc; aggregate sell-out rate / speed -> first_majority_sellout_pct / avg_days_to_first_majority_sku_sellout.
  • "New in / new arrivals / launches / newness" -> new_arriving_products_count (add pct_mix_new_arriving_products_count for mix). New In methodology covers in + out of stock — omit the in_stock filter unless the user explicitly wants currently-available arrivals. To scope a population by launch timing instead ("launched in the last 90 days"), use a date_found filter with distinct_product_count_aggregate (deduplicated across the range; requires a group_by — with a single-retailer filter, grouping by retailer is a no-op) — over a multi-period range product_count would give the per-period average, not the population. See the Date filters section — in this tool's description, or market_data_docs topic="dates_and_compare" when it is not.

Default filters: is_second_hand=false and outlet=false are appended automatically when those fields are absent from filters. To include or target second-hand or outlet assortments, pass the field explicitly — an explicit filter always wins over the default.

Filter field table

fieldtypedefinition
retailerstringRegion-specific retailer slug (e.g. levi-uk, zara-us). Use values returned by search_retailers. This is a DIFFERENT id space from brand_slug: a retailer slug is not a brand slug. Both search_brands and search_retailers surface entries for a name like 'Levi's', but they return distinct values — retailer levi-uk vs. brand levis — that are not interchangeable between the two fields. There is no bare levis retailer slug. Default to retailer when a user names a company ('Nike products', 'the Adidas range'). CASE-SENSITIVE: pass the slug exactly as search_retailers returned it; a re-spelled value matches nothing. Display-name values (spaces or punctuation) are rejected with a pointer to search_retailers.
brand_slugstringNormalised brand slug — the label/manufacturer (e.g. nike, h-m, levis). Use values returned by search_brands (the entries that carry a slug). This is a DIFFERENT id space from retailer: a brand slug is not a retailer slug. Both search_brands and search_retailers surface entries for a name like 'Levi's', but they return distinct values — brand levis vs. retailer levi-uk — that are not interchangeable between the two fields. Use brand_slug for a brand within a retailer ('Nike at Foot Locker'); a bare company name defaults to retailer. CASE-SENSITIVE: pass the slug exactly as search_brands returned it — some carry capitals (LC-Waikiki, EA7) and a re-spelled value matches a different key, not the same brand (Dakine and dakine are separate keys). Display-name values (spaces or punctuation) are rejected with a pointer to search_brands — if the brand has no slug, use the free-text brand field instead. If you also supply a brand filter, a POSITIVE (eq/in) brand_slug is combined with it using OR (see the brand field); a negated one (neq/not_in) stays an AND exclusion that always holds.
brandtextFree-text brand match — the 'manual search' path for brands that have no normalised slug (only ~half of products carry a brand_slug). Matches the brand name as a case-insensitive phrase (e.g. bila77, Hugo Boss); a list matches ANY of the given names. Supports eq (one name) and in (a list) only, at most 50 names per request — each name is matched separately, so for a longer list resolve slugs and use brand_slug in instead. IMPORTANT: when brand and a POSITIVE (eq/in) brand_slug are both supplied they are combined with OR — a product matching EITHER field is returned (a deliberate exception to the otherwise-AND filter list). A negated brand_slug (neq/not_in) is NOT part of that OR: it stays an AND exclusion that always holds, so it still removes its brands from a brand match. A brand filter alongside a positive brand_slug CANNOT be broken down by brand_slug (rejected) — the backend would restrict the buckets to the filtered slug(s) and drop the volume brand added; group by brand (raw) for that breakdown. Tell the user you are matching either brand field rather than requiring both. The value is a brand NAME, never a slug — see the lookup workflow for which name to use. Prefer brand_slug when search_brands returns the brand with a slug; use brand for a name it has no slug for (or when the user asks for a literal brand name).
genderstringGender category. Valid values: women, men, unisex-adults, girls, boys, unisex-kids. Shorthand adult__all__ and child__all__ expand to all adult or all child genders. Case-insensitive (Unisex Kids normalises to unisex-kids); unknown values are rejected with an error.
marketstringRetailer market code — a 2-letter code like UK or US. Use the id values returned by search_markets. Case-insensitive; ISO GB is accepted for the UK. Country names (e.g. United Kingdom) are rejected with an error.
product_searchesinteger[]EDITED product-search category IDs. Use the INTEGER id returned by search_product_searches — this field takes no slug, unlike retailer and brand_slug. That row's curation_value (sub_cat_jeans_bottoms) is an internal keyword, and name / name_full are labels; none of the three is a filter value. Pass 1302, not "1302". The categories form a TREE (top-level category > subcategory > style), and an id selects that category AND everything beneath it — filtering Tops returns every T-Shirt too. So OR-ing an ancestor with its own descendant is a no-op that reads like a widening: in [Tops, T-Shirts] returns exactly the Tops count. Sibling categories do not overlap, so OR-ing across branches adds up — with ONE exception: Apparel (excl footwear & accessories) is a roll-up spanning most apparel categories despite looking like an ordinary top-level id, so OR-ing it with any other category returns its own figure unchanged. Never OR it with anything. Category names repeat across branches (three different categories are called Shorts), so match on the whole path, not the leaf name.
predominant_colourstringEDITED predominant colour classification. Valid values: black, grey, maroon, red, pink, fuchsia, purple, blue, navy, teal, aqua, green, lime, yellow, orange, copper, brown, gold, neutral, silver, white. The values unassigned and multicolour also appear in the data; when this field is used as a group_by, they merge into the others row only under a top-N roll-up; otherwise they appear as their own rows. Case-insensitive; unknown values are rejected with an error.
predominant_patternstringEDITED predominant pattern classification. Valid values: plain, abstract, animal, aztec, camouflage, checks, conversational, floral, geometric, graphics, lace, paisley, spots, stripes, tile. Shorthand pattern__all__ expands to every pattern except plain (i.e. patterned products only). Case-insensitive; unknown values are rejected with an error.
compositionstringFabric composition material — PRESENCE only. Supports eq and in only. Valid values (exact, lowercase): cotton, organic cotton, wool, silk, linen, cashmere, leather, calf leather, suede, viscose, acetate, rayon, polyester, recycled polyester, elastane, polyamide, nylon, spandex, polyurethane, acrylic, rubber. Matches any product CONTAINING the material — products usually have several, so material populations overlap. For HOW MUCH of a fibre a garment contains, use composition_percentage, not this field.
composition_percentagenumberHow much of a named fibre a garment contains, on a 0-100 scale (20 = 20%, NOT 0.2). Requires material (same fabric vocabulary as composition); value carries the percentage, and value2 the upper bound on between only — it is rejected on every other op, which ignores it. Ops: gt, gte, lt, lte, between, eq. Express a BAND as one between filter with value <= value2 — two filters on the same fibre that between them pin both ends (gte + lte, eq + lte, two betweens) are rejected, because they match on different mentions and admit garments outside the band. Distinct from composition, which is presence-only, and from a composition group-by, whose pct_mix is a fibre's share of PRESENCES, not garment weight. Percentages come from parsed care text and are per mention, not per garment component, so bands are NOT additive: one garment can match several disjoint bands, and garments whose percentage failed to parse match none. Call market_data_docs(topic="composition") before banding or comparing retailers.
in_stockbooleanProduct has at least one available SKU.
is_second_handbooleanWhether the product is second hand.
outletbooleanWhether the product comes from an outlet assortment.
advertised_discountedbooleanWhether the product is currently advertised as discounted.
pricenumberCurrent selling price in the requested currency. Filter thresholds use the value x 100 — a fixed scale the backend applies regardless of ISO 4217 exponent (GBP £49.99 = 4999; JPY ¥5,000 = 500000). Integer values only: a fractional value is rejected as un-multiplied major units. Returned prices are in major units. See market_data_docs topic="metrics" for the price-units note.
full_pricenumberHighest observed selling price in the requested currency. Filter thresholds use the value x 100 (fixed; GBP 4999 = £49.99). Integer values only: a fractional value is rejected as un-multiplied major units. Returned prices are in major units. See market_data_docs topic="metrics" for the price-units note.
advertised_discount_percentagenumberCurrent advertised discount percentage, on a 0-100 scale: 50 means 50% off — do NOT pass 0.5 for 50%.
deepest_advertised_discount_percentagenumberDeepest advertised discount percentage ever recorded on the option, on a 0-100 scale (50 = 50% off).
first_advertised_discount_percentagenumberFirst advertised discount percentage recorded on the option, on a 0-100 scale (50 = 50% off).
has_had_advertised_discountbooleanWhether the option has ever had an advertised discount.
sellout_percentagenumberPercentage of SKUs linked to the option that have sold out, on a 0-100 scale: 50 means 50% sold out — do NOT pass 0.5 for 50%.
tierstringRetailer market segment. Valid values: value, mass, premium, luxury. Case-insensitive; unknown values are rejected with an error.
sku_countintegerTotal number of SKUs linked to the product option.
option_idstringEDITED option identifier.
size_optionsinteger[] | string[]Size option IDs from search_size_options, or size group IDs as group-\{id} strings from search_size_group.
nametextFull-text search on product name. Elasticsearch query_string: default AND; UPPERCASE OR/NOT; trailing wildcards only. Wrap each multi-word term in escaped double quotes or it splits into independent AND-ed words instead of matching the phrase; inside an OR list a broad word then dominates and silently broadens the match — e.g. windbreaker OR \"shower jacket\" OR \"track jacket\", not windbreaker OR shower jacket OR track jacket (which collapses to a bare jacket match). See market_data_docs topic text_search.
descriptiontextFull-text search on product description. Elasticsearch query_string: default AND; UPPERCASE OR/NOT; trailing wildcards only. Wrap each multi-word term in escaped double quotes or it splits into independent AND-ed words instead of matching the phrase; inside an OR list a broad word then dominates and silently broadens the match — e.g. waterproof OR \"shower resistant\" OR \"wind resistant\", not waterproof OR shower resistant OR wind resistant (which collapses to a bare resistant match). See market_data_docs topic text_search.
date_founddateProduct launch date. Accepts literal dates (YYYY-MM-DD) and relative expressions — see market_data_docs topic dates_and_compare for the date-filter rules.
date_first_selloutdateDate all SKUs first went out of stock. Accepts literal and relative dates — see market_data_docs topic dates_and_compare for the date-filter rules.
date_first_majority_sku_selloutdateDate >=51% of SKUs first went out of stock (requires 2+ SKUs). Accepts literal and relative dates — see market_data_docs topic dates_and_compare for the date-filter rules.
normalised_average_ratingnumberAverage user review rating on a 5-point star scale (1.0 to 5.0). Despite the name it is NOT normalised to 0-1 — gte 4 means 4+ stars.
number_of_reviewsintegerNumber of user reviews on the product.
activewear_categorykeywordActivewear classification. Valid values: performance (sports/training activewear), athleisure (fashion-led activewear), none (not activewear). Supports eq and neq only.
is_licensed_activewearbooleanWhether the product is officially licensed activewear (e.g. NFL, NBA branded gear). Supports eq only.
sport_typekeywordSport the product is associated with. Valid values: american_football, baseball, basketball, boxing, cycling, football, golf, handball, hockey, hiking_and_outdoors, lacrosse, rugby, running, skateboarding, snowsports, softball, surfing, tennis, training, volleyball, yoga, other. Supports eq and in only.

Valid operators by field type

field typevalid operators
stringeq, neq, in, not_in
keywordeq, neq, in, not_in
text (name, description)eq only (query_string full-text)
text (brand)eq (one name), in (any of a list) — phrase match, not query_string
integer[] / integer[] | string[] (id lists)eq, neq, in, not_in
number / integereq, neq, gt, gte, lt, lte, between, in, not_in
dateeq, neq, gt, gte, lt, lte, between
booleaneq, neq

Parameters​

NameTypeRequiredConstraintsDescription
namestringyes—Concise name describing the type of data that you are trying to request.
metricsstring[]yes—Metric IDs to request. See the metric table: market_data_docs topic="metrics".
start_datestringyes—Inclusive start date for the analysis period. Must be within the past two years (730 days).
end_datestringyes—Inclusive end date for the analysis period. Must be within the past two years (730 days).
filtersobject[]yes—AND-combined filters. Nesting and OR groups are not supported, with one exception: brand and a POSITIVE (eq/in) brand_slug are OR-combined when both are supplied (a product matching either brand field is returned). A negated brand_slug (neq/not_in) stays an AND exclusion — it always holds, even alongside brand. At least one filter is required.
group_byobject[]no—0-3 group-by dimensions. Empty list = global snapshot (one row of metric values). One entry = breakdown by that dim. Two-three entries = cross-tab. Use TableHistogram for numeric bucketing (price bands, discount bands).
percentilesobject[]no—Optional percentile metrics on numeric fields. Each entry adds one column (e.g. price_p50, full_price_p90) to the response. Use for median / quartile / p90-p99 stats alongside metrics.
compareobjectno—Optional period-over-period comparison. When set, each metric gets a sibling \<metric>_compare column carrying the formatted comparison value (percent_change by default). Use mode='period' with explicit dates, or mode='relative' with an offset (previous_period, year_over_year). Use for WoW / MoM / vs-prior-period asks.
trendbooleannodefault: falseWhen True, splits the date range into sub-periods at the auto-selected granularity (W/M based on range length) and adds a dates column carrying each period as a start:end string. Output is long-format: one row per (period x group), re-ranked per period (top-N membership can vary period to period). Use for time-series questions. Compatible with compare using mode='relative'. Combining trend with multi-dimensional group_by is not available inline. The result's total_row_count is always null here (truncated or not): a fan-out has no single population to count — read truncated.
sortobject[]no—Optional sort order. Each entry sorts by a group-by/histogram field id, a metric id, or a percentile id (e.g. price_p50). Applied in order; later entries break ties from earlier ones.
limitintegernodefault: 50 · 1–200Maximum number of rows in the response. Defaults to 50; combine with sort to get the top-N. Caps the group buckets, plus one trailing "others" row when include_others=true and one "Unmapped" row when a group-by sets include_missing. The "Unmapped" row rides outside limit on a strict top-N (include_others=false); with include_others=true it can occupy one of the limit slots, so a truncated result may show limit - 1 real buckets.
include_othersbooleanno—Roll rows past limit into a single trailing row labelled "others" representing every group not listed individually, so the tail is represented rather than dropped. That row is not a count of those groups and says nothing about how many there are — a truncated result reports total_row_count=null and no group count can be recovered from it. When omitted, defaults to True for a categorical group_by (the roll-up is how truncation is signalled) and False otherwise. Set True for share / mix / distribution / long-tail questions where the tail matters. Compatible with trend=true — the roll-up is computed within each period (per-period top-N + others). With multi-dim group_by only the first column is labelled "others"; the rest go blank.
verticalstringno—Optional. The market vertical (top-level data segment) to query: apparel | beauty | homeware. This is NOT a product category — product categories (e.g. dresses, mascara) are a within-vertical filter found via search_product_searches. All three verticals are supported and return the SAME row grain: one row per product option. Homeware options bundle more SKUs per option (e.g. bed sizes) than apparel or beauty, so sku_count runs higher there — but no vertical returns per-SKU rows, or exposes a size/variant dimension to group or sort on. When omitted it resolves to the account's default vertical (reported as default_vertical by market_data_entitlements). Available verticals vary by account — only pass this when the user explicitly asks about a different vertical; otherwise omit it and let the account default apply. Requesting a vertical you're not entitled to returns an error; call market_data_entitlements to see yours.
currencystringno—Optional ISO 4217 currency code (e.g. USD, EUR, GBP), case-insensitive. Converts every price-denominated value in the request to this currency. Price INPUTS — any price filter (price / full_price) threshold and, in market_data_table, the histogram interval — use a FIXED 'value x 100' scale, applied regardless of the currency's ISO 4217 exponent (GBP £49.99 = 4999, £1,600 = 160000, interval 1000 = a £10 band; JPY ¥5,000 = 500000, interval 1000 = a ¥10 band). All returned prices — market_data_table price metrics / percentiles and market_data_options_search per-option prices — are in whole (major) currency units, so price inputs and outputs differ by 100x. See the price-units note — in this tool's description, or market_data_docs topic="metrics" when it is not — incl. the ISO deviation for zero-/3-decimal currencies. When omitted it is resolved from the caller's config (else USD). The backend validates the code; an unknown code is rejected upstream.

Try it​

Loading interactive widget…

Code examples​

curl -s https://mcp.edited.com/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-06-18" \
-H "x-api-key: $MCP_API_KEY" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "mdmcp-market_data_table",
"arguments": {
"name": "example",
"start_date": "example",
"end_date": "example",
"trend": false,
"limit": 50,
"include_others": false,
"vertical": "example",
"currency": "example"
}
}
}'

Input schema​

{
"type": "object",
"properties": {
"name": {
"description": "Concise name describing the type of data that you are trying to request.",
"examples": [
"average price across the UK",
"product count by retailer in Q1 2026",
"price distribution for Zara dresses",
"average price trend monthly for Zara",
"median price for Zara dresses",
"Zara average price WoW change"
],
"type": "string"
},
"metrics": {
"description": "Metric IDs to request. See the metric table: `market_data_docs` `topic=\"metrics\"`.",
"examples": [
[
"avg_price",
"product_count"
]
],
"items": {
"enum": [
"avg_days_to_first_discount",
"avg_days_to_first_majority_sku_sellout",
"avg_advertised_discount_pct",
"avg_first_advertised_discount_pct",
"avg_first_price",
"avg_full_price",
"avg_max_price",
"avg_min_price",
"avg_normalised_average_rating",
"avg_number_of_reviews",
"avg_price",
"avg_price_increase_pct",
"advertised_discounted_product_count",
"advertised_discounted_product_pct",
"distinct_product_count_aggregate",
"first_majority_sellout_pct",
"new_arriving_products_count",
"new_arriving_products_count_retailer_avg",
"pct_mix",
"pct_mix_new_arriving_products_count",
"pct_mix_sku_count",
"price_increased_product_count",
"price_increased_product_pct",
"product_count",
"product_count_retailer_avg",
"replenished_products_count",
"replenished_products_pct",
"sku_availability",
"sku_count",
"sku_count_retailer_avg",
"sku_sellout_pct"
],
"type": "string"
},
"type": "array"
},
"start_date": {
"description": "Inclusive start date for the analysis period. Must be within the past two years (730 days).",
"examples": [
"2026-01-01"
],
"format": "date",
"type": "string"
},
"end_date": {
"description": "Inclusive end date for the analysis period. Must be within the past two years (730 days).",
"examples": [
"2026-01-31"
],
"format": "date",
"type": "string"
},
"filters": {
"description": "AND-combined filters. Nesting and OR groups are not supported, with one exception: `brand` and a POSITIVE (`eq`/`in`) `brand_slug` are OR-combined when both are supplied (a product matching either brand field is returned). A negated `brand_slug` (`neq`/`not_in`) stays an AND exclusion — it always holds, even alongside `brand`. At least one filter is required.",
"examples": [
[
{
"field": "brand_slug",
"op": "in",
"value": [
"nike",
"adidas"
]
},
{
"field": "market",
"op": "in",
"value": [
"DE",
"IT"
]
}
]
],
"items": {
"properties": {
"field": {
"description": "Field to filter on. See the filter field table — in this tool's description, or `market_data_docs` `topic=\"filter_fields\"` when it is not.",
"enum": [
"retailer",
"brand_slug",
"brand",
"gender",
"market",
"product_searches",
"predominant_colour",
"predominant_pattern",
"composition",
"composition_percentage",
"in_stock",
"is_second_hand",
"outlet",
"advertised_discounted",
"price",
"full_price",
"advertised_discount_percentage",
"deepest_advertised_discount_percentage",
"first_advertised_discount_percentage",
"has_had_advertised_discount",
"sellout_percentage",
"tier",
"sku_count",
"option_id",
"size_options",
"name",
"description",
"date_found",
"date_first_sellout",
"date_first_majority_sku_sellout",
"normalised_average_rating",
"number_of_reviews",
"activewear_category",
"is_licensed_activewear",
"sport_type"
],
"examples": [
"retailer"
],
"type": "string"
},
"op": {
"description": "Comparison operator. Use 'between' for numeric or date range bounds. Use 'eq' for text-search fields (name, description). 'brand' supports 'eq' (one name) and 'in' (a list) only.",
"enum": [
"eq",
"neq",
"in",
"not_in",
"gt",
"gte",
"lt",
"lte",
"between"
],
"examples": [
"eq",
"in"
],
"type": "string"
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"items": {
"type": "string"
},
"type": "array"
},
{
"items": {
"type": "integer"
},
"type": "array"
},
{
"items": {
"type": "number"
},
"type": "array"
}
],
"description": "Primary filter value. Use a list for 'in' and 'not_in'.",
"examples": [
"zara",
[
"zara",
"hm"
],
true
]
},
"value2": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
},
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"description": "Upper bound used only with 'between'.",
"examples": [
100
]
},
"material": {
"description": "Fibre name, REQUIRED for `composition_percentage` and rejected on every other field. Names which fibre the percentage in `value` applies to — `material: \"spandex\", op: \"gte\", value: 20` is 'at least 20% spandex'. Takes the same closed fabric vocabulary as the `composition` filter.",
"examples": [
"spandex",
"recycled polyester"
],
"type": "string"
}
},
"required": [
"field",
"op",
"value"
],
"type": "object"
},
"minItems": 1,
"type": "array"
},
"group_by": {
"description": "0-3 group-by dimensions. Empty list = global snapshot (one row of metric values). One entry = breakdown by that dim. Two-three entries = cross-tab. Use TableHistogram for numeric bucketing (price bands, discount bands).",
"examples": [
[],
[
{
"field": "retailer"
}
],
[
{
"field": "brand_slug"
},
{
"field": "market"
}
],
[
{
"field": "price",
"interval": 1000
}
]
],
"items": {
"type": "object",
"properties": {
"field": {
"enum": [
"brand",
"brand_slug",
"composition",
"cs_subcategory",
"gender",
"market",
"predominant_colour",
"predominant_pattern",
"product_details",
"product_searches",
"retailer",
"advertised_discount_percentage",
"days_to_first_majority_sku_sellout",
"days_to_first_sellout",
"deepest_advertised_discount_percentage",
"deepest_discount_percentage",
"discount_percentage",
"first_advertised_discount_percentage",
"first_discount_percentage",
"first_price",
"full_price",
"inferred_full_price",
"normalised_average_rating",
"number_of_reviews",
"performance_score",
"price",
"price_increase_percentage",
"sellout_percentage"
],
"description": "Group-by field id. See the group-by field table: `market_data_docs` `topic=\"group_by_fields\"`. NEVER SUM the rows of a `product_searches` or `product_details` breakdown: both are hierarchies returned as one flat list of MIXED depths, where `>` separates levels and an ancestor row's value already contains every descendant row's. One population can occupy several rows at once — filtering to T-Shirts and grouping by `product_searches` returns `Tops`, `Tops > T-Shirts` AND `Apparel (excl footwear & accessories)`, all with the SAME value. Read `market_data_docs` `topic=\"product_searches\"` before doing any arithmetic across these rows. / Histogram field id. See the histogram field table: `market_data_docs` `topic=\"group_by_fields\"`.",
"examples": [
"retailer",
"brand_slug",
"price",
"discount_percentage"
],
"type": "string"
},
"include_missing": {
"default": false,
"description": "When True, products with no value for this field are surfaced as a single \"Unmapped\" bucket instead of being dropped from the results. ONLY supported on `brand_slug` (only ~half of products carry one), so a brand breakdown does not silently exclude unmapped brands; every other group-by field rejects it. Also requires a SINGLE group-by entry — it cannot be combined with a cross-tab. Off by default. Note the bucket is labelled \"Unmapped\" here, whereas `market_data_options_search` labels the same unmapped population \"Unknown\" — the two are not joinable on the label. The bucket is never rolled into the `\"others\"` row, so it cannot be truncated away. It rides ON TOP OF `limit` on a strict top-N (`include_others=false`); with `include_others=true` it can take one of the `limit` slots instead, leaving `limit - 1` real buckets. That label is synthetic, not a filter value: no filter can select products with no `brand_slug`, so an \"Unmapped\" row CANNOT be drilled into. If the user may want the products behind it, group by `brand` (raw) instead — it buckets every product and every label it returns is a valid `brand` filter value.",
"type": "boolean"
},
"interval": {
"description": "Bucket width, in the field's own units — for price fields the value x 100, a fixed scale the backend applies regardless of ISO 4217 exponent, matching price filters: GBP 1000 = a £10 band, JPY 1000 = a ¥10 band. Leave unset for the BE default (price 1000; discount_percentage 10%); the default is a fixed count, not rescaled per currency. Returned bucket LABELS are in major units (a 1000 interval → 0-10, 10-20 bands), so the interval you send (x100) and the labels you read back (major) differ by 100x — see the price-units note, in this tool's description or `market_data_docs` `topic=\"metrics\"` when it is not.",
"exclusiveMinimum": 0,
"type": "number"
}
},
"required": [
"field"
],
"description": "Categorical group-by — buckets rows by exact term value. / Numeric histogram group-by — buckets rows by interval on a continuous field."
},
"type": "array"
},
"percentiles": {
"description": "Optional percentile metrics on numeric fields. Each entry adds one column (e.g. `price_p50`, `full_price_p90`) to the response. Use for median / quartile / p90-p99 stats alongside `metrics`.",
"examples": [
[
{
"field": "price",
"percentile": 50
}
],
[
{
"field": "price",
"percentile": 25
},
{
"field": "price",
"percentile": 50
},
{
"field": "price",
"percentile": 75
}
]
],
"items": {
"description": "Percentile of a numeric field — `p50` of price, `p90` of full_price, etc.\n\nUse percentiles when the user wants a pinpoint summary statistic of a\ndistribution (median, quartiles, p90/p95) alongside regular metrics.\nUse a histogram (via ``group_by``) when they want the full distribution\nshape as bucketed rows.",
"properties": {
"field": {
"description": "Numeric field id. See the percentile field table: `market_data_docs` `topic=\"group_by_fields\"`.",
"enum": [
"first_price",
"full_price",
"price"
],
"examples": [
"price",
"full_price"
],
"type": "string"
},
"percentile": {
"description": "Integer 0-100. Common picks: 25 (lower quartile), 50 (median), 75 (upper quartile), 90.",
"examples": [
50,
75,
90
],
"maximum": 100,
"minimum": 0,
"type": "integer"
}
},
"required": [
"field",
"percentile"
],
"type": "object"
},
"type": "array"
},
"compare": {
"description": "Optional period-over-period comparison. When set, each metric gets a sibling `<metric>_compare` column carrying the formatted comparison value (percent_change by default). Use `mode='period'` with explicit dates, or `mode='relative'` with an offset (`previous_period`, `year_over_year`). Use for WoW / MoM / vs-prior-period asks.",
"examples": [
{
"compare_end_date": "2026-04-21",
"compare_start_date": "2026-04-15",
"format": "percent_change",
"mode": "period"
},
{
"mode": "relative",
"offset": "previous_period"
}
],
"type": "object",
"properties": {
"mode": {
"enum": [
"period",
"relative"
],
"description": "Comparison shape. `period` compares the primary range to the explicit comparison range — same filters across two date ranges. / Comparison shape. `relative` derives the comparison range from the primary range and the chosen offset.",
"type": "string"
},
"format": {
"default": "percent_change",
"description": "How to express the difference: `percent_change` (default — % change from the comparison period) or `absolute_change` (raw delta in metric units).",
"enum": [
"percent_change",
"absolute_change"
],
"type": "string"
},
"compare_start_date": {
"description": "Inclusive start date for the comparison range.",
"examples": [
"2026-04-15"
],
"format": "date",
"type": "string"
},
"compare_end_date": {
"description": "Inclusive end date for the comparison range.",
"examples": [
"2026-04-21"
],
"format": "date",
"type": "string"
},
"offset": {
"description": "`previous_period` — the whole-period window immediately before the primary (non-trend: the snapped primary window shifted back by its own span; trend: one interval). `year_over_year` — shift back by one calendar year.",
"enum": [
"previous_period",
"year_over_year"
],
"type": "string"
}
}
},
"trend": {
"default": false,
"description": "When True, splits the date range into sub-periods at the auto-selected granularity (W/M based on range length) and adds a `dates` column carrying each period as a `start:end` string. Output is long-format: one row per (period x group), re-ranked per period (top-N membership can vary period to period). Use for time-series questions. Compatible with `compare` using `mode='relative'`. Combining trend with multi-dimensional `group_by` is not available inline. The result's `total_row_count` is always `null` here (truncated or not): a fan-out has no single population to count — read `truncated`.",
"type": "boolean"
},
"sort": {
"description": "Optional sort order. Each entry sorts by a group-by/histogram field id, a metric id, or a percentile id (e.g. `price_p50`). Applied in order; later entries break ties from earlier ones.",
"items": {
"description": "Single sort directive applied to the table response.",
"properties": {
"field": {
"description": "Field id to sort by — either a `group_by` field id, a histogram field id, a `metric` id, or a percentile id (e.g. `price_p50`).",
"type": "string"
},
"order": {
"default": "desc",
"description": "Sort direction. Defaults to descending.",
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
"required": [
"field"
],
"type": "object"
},
"type": "array"
},
"limit": {
"default": 50,
"description": "Maximum number of rows in the response. Defaults to 50; combine with `sort` to get the top-N. Caps the group buckets, plus one trailing `\"others\"` row when `include_others=true` and one `\"Unmapped\"` row when a group-by sets `include_missing`. The `\"Unmapped\"` row rides outside `limit` on a strict top-N (`include_others=false`); with `include_others=true` it can occupy one of the `limit` slots, so a truncated result may show `limit - 1` real buckets.",
"maximum": 200,
"minimum": 1,
"type": "integer"
},
"include_others": {
"description": "Roll rows past `limit` into a single trailing row labelled `\"others\"` representing every group not listed individually, so the tail is represented rather than dropped. That row is not a count of those groups and says nothing about how many there are — a truncated result reports `total_row_count=null` and no group count can be recovered from it. When omitted, defaults to True for a categorical `group_by` (the roll-up is how truncation is signalled) and False otherwise. Set True for share / mix / distribution / long-tail questions where the tail matters. Compatible with `trend=true` — the roll-up is computed within each period (per-period top-N + others). With multi-dim `group_by` only the first column is labelled `\"others\"`; the rest go blank.",
"type": "boolean"
},
"vertical": {
"description": "Optional. The market vertical (top-level data segment) to query: `apparel` | `beauty` | `homeware`. This is NOT a product category — product categories (e.g. dresses, mascara) are a within-vertical filter found via `search_product_searches`. All three verticals are supported and return the SAME row grain: one row per product option. Homeware options bundle more SKUs per option (e.g. bed sizes) than apparel or beauty, so `sku_count` runs higher there — but no vertical returns per-SKU rows, or exposes a size/variant dimension to group or sort on. When omitted it resolves to the account's default vertical (reported as `default_vertical` by `market_data_entitlements`). Available verticals vary by account — only pass this when the user explicitly asks about a different vertical; otherwise omit it and let the account default apply. Requesting a vertical you're not entitled to returns an error; call `market_data_entitlements` to see yours.",
"enum": [
"apparel",
"beauty",
"homeware"
],
"type": "string"
},
"currency": {
"description": "Optional ISO 4217 currency code (e.g. `USD`, `EUR`, `GBP`), case-insensitive. Converts every price-denominated value in the request to this currency. Price INPUTS — any price filter (`price` / `full_price`) threshold and, in `market_data_table`, the histogram `interval` — use a FIXED 'value x 100' scale, applied regardless of the currency's ISO 4217 exponent (GBP £49.99 = `4999`, £1,600 = `160000`, interval `1000` = a £10 band; JPY ¥5,000 = `500000`, interval `1000` = a ¥10 band). All returned prices — `market_data_table` price metrics / percentiles and `market_data_options_search` per-option prices — are in whole (major) currency units, so price inputs and outputs differ by 100x. See the price-units note — in this tool's description, or `market_data_docs` `topic=\"metrics\"` when it is not — incl. the ISO deviation for zero-/3-decimal currencies. When omitted it is resolved from the caller's config (else `USD`). The backend validates the code; an unknown code is rejected upstream.",
"type": "string"
}
},
"required": [
"name",
"metrics",
"start_date",
"end_date",
"filters"
]
}

Output schema​

Describes both branches of the envelope — result on success, error on a refusal. Validate against this rather than pattern-matching the prose above; see Concepts → Response shape.

{
"type": "object",
"properties": {
"result": {
"description": "Envelope returned by the market-data query tools.\n\n``rows`` are the inline rows, hard-capped (``size`` for options, ``limit``\nfor table). ``truncated`` says, exactly, whether the cap hid matching rows.\n``total_row_count`` is EXACT-OR-NONE — the exact matching count when the tool\ncan know it, ``None`` when it cannot; it is never a lower bound or estimate:\n\n- options: the true matching population (backend ``meta.result_count``);\n- table, not truncated: the exact returned row count;\n- table, truncated (either ``include_others`` setting): ``None`` — the\n ``/table`` backend returns no population metadata, so once the cap hides\n rows the true count is genuinely unknown, and a fabricated integer (a former\n ``limit + 1`` \"floor\") reads like an exact small count. Read ``truncated``\n for the \"more rows exist\" signal; raise ``limit`` or use\n ``include_others=true`` for coverage. The trailing ``\"others\"`` row carries\n the rolled-up tail on the ``include_others=true`` path.\n- trend: ALWAYS ``None``, truncated or not — a trend is a per-period fan-out\n (one backend call per bucket), so there is no single population to count.\n ``truncated`` is still exact, and the roll-up is per period.\n\nUnder ``include_others=true`` (table path only) the rows past ``limit`` are\nrolled into ONE trailing ``\"others\"`` row standing for every group not listed\nindividually. That row is not a count of them and carries no information\nabout how many there are, so a group count, a \"showing X of Y rows\", or a\nper-group average cannot be derived from a truncated result. Whether its\nvalue combines with the visible rows is a property of the METRIC and the\ngroup-by, not of this row.\n\nPeriod provenance (table path) makes the multi-period aggregation legible:\n\n- ``granularity`` — the auto-selected bucket size (``W``/``M``); ``W`` for the\n single-week options path.\n- ``period_count`` — how many W/M buckets the range spans.\n- ``evaluated_window`` — ``\"start:end\"`` (the same shape as the trend ``dates``\n column) for the span of dates the values ACTUALLY describe. Requested dates\n are snapped OUTWARD to bucket bounds, so this is usually wider than what was\n asked for: request June (``2026-06-01`` / ``2026-06-30``) and the values\n describe ``2026-05-31:2026-07-04``. The ``start`` is the covering bucket's\n start — the backward snap genuinely sweeps that pre-request data into the\n aggregate. The ``end`` is capped at today: an in-progress trailing bucket\n rounds outward to a nominal bucket-end that holds no data past today, so the\n end reports the latest date that can contribute rather than the boundary.\n An ``end`` that is NOT on the granularity's natural boundary (a non\n month-end for ``M``, a non-Saturday for ``W``) therefore signals an\n incomplete final period. Report figures against this window, not against the\n requested dates. On the options path it is the covering weekly bucket for\n the requested ``date``.\n- ``compare_evaluated_window`` — the same, for a non-trend ``compare``: the\n comparison range snaps independently to whole buckets. The\n ``<metric>_compare`` column headers name this same evaluated window, so\n header and field agree on the window the figures describe. ``None`` without\n ``compare`` and under ``trend`` (each interval resolves its own\n comparison window there).\n- ``compare_period_count`` / ``compare_combined_over_periods`` — the same\n period provenance for that compare window, which spans and combines\n independently of the primary range: the ``<metric>_compare`` change\n figures are computed against a baseline first combined over this many\n periods (``combined_over_periods`` / ``period_count`` describe only the\n primary window). ``None`` without a non-trend ``compare``.\n- ``compare_windows_overlap`` — ``True`` when the primary and comparison\n evaluated windows share at least one bucket. A relative ``previous_period``\n compare is derived to abut without overlap, so this fires only on a\n hand-supplied absolute ``compare`` whose snapped window collides with the\n primary's — in which case a near-zero ``<metric>_compare`` delta is a\n self-contamination artefact, not a real change. ``None`` without a\n non-trend ``compare``.\n- ``combined_over_periods`` — ``True`` when the range spans >1 period on the\n non-trend path, i.e. the value went through the cross-period combine.\n ``False`` for a single period, for ``trend`` (each row is already one\n period), and for options. Date filters do not suppress it: they are\n evaluated per period, so they can empty periods out of an averaged\n metric's divisor — the combined value may describe fewer populated\n periods than ``period_count``, down to a single period's raw value —\n but they never switch the path away from per-period combining.\n- ``metric_combine`` — ``metric_id -> combine method`` (see\n ``METRIC_COMBINE_BY_ID``) so the single value is machine-self-describing;\n ``None`` on the options path (no aggregate metrics). When\n ``combined_over_periods`` is ``True`` the matching metric columns are also\n tagged in ``columns`` (e.g. ``Product Count [avg/period]``).",
"properties": {
"name": {
"type": "string"
},
"columns": {
"items": {
"type": "string"
},
"type": "array"
},
"rows": {
"items": {
"items": {},
"type": "array"
},
"type": "array"
},
"total_row_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
]
},
"truncated": {
"type": "boolean"
},
"currency": {
"type": "string"
},
"vertical": {
"type": "string"
},
"granularity": {
"anyOf": [
{
"enum": [
"W",
"M"
],
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"period_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null
},
"evaluated_window": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"compare_evaluated_window": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null
},
"compare_period_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"default": null
},
"compare_combined_over_periods": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null
},
"compare_windows_overlap": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"default": null
},
"combined_over_periods": {
"default": false,
"type": "boolean"
},
"metric_combine": {
"anyOf": [
{
"additionalProperties": {
"type": "string"
},
"type": "object"
},
{
"type": "null"
}
],
"default": null
}
},
"required": [
"name",
"columns",
"rows",
"total_row_count",
"truncated",
"currency",
"vertical"
],
"type": "object"
},
"error": {
"type": "object",
"description": "Present instead of `result` when the call was rejected. Branch on `code`; never string-match `message`.",
"properties": {
"code": {
"type": "string",
"enum": [
"invalid_input",
"not_entitled",
"upstream_unavailable",
"internal"
]
},
"message": {
"type": "string"
},
"field": {
"type": "string",
"description": "Offending parameter, when the failure is attributable to one."
},
"details": {
"type": "object",
"additionalProperties": true
}
},
"required": [
"code",
"message"
]
}
},
"oneOf": [
{
"required": [
"result"
]
},
{
"required": [
"error"
]
}
],
"x-fastmcp-wrap-result": true
}