# EDITED MCP > Market data, on tap for AI agents. --- ## EDITED MCP Source: https://build.edited.com/ EDITED · MCP Server # Market data, on tap for AI agents Query EDITED's retail market intelligence directly from your AI agent — pricing, assortment, trends, promotions, and research, across thousands of retailers and brands worldwide. Get startedBook a demo Competitive Market Data “Zara's discount depth vs H&M this quarter” Trend & Research “What is EDITED flagging for SS26 knitwear” Messaging & Promotions “Which UK retailers ran promotions last week” ## What it does The EDITED MCP gateway gives AI agents direct access to structured retail data across three domains: Competitive Market Data — live pricing, stock, discounts, and new arrivals at the product level, across retailers, categories, and markets. Query individual products or run aggregated analytics. Trend & Research — EDITED's editorial library: trend reports, runway analysis, and seasonal commentary, searchable at the article and paragraph level with image retrieval. Messaging & Promotions — retailer homepage and marketing-email captures (homepages and emails only, not category landing pages), with structured promotion data extracted from each. For more on what each domain covers, go to The data. For every tool and its schema, see the Reference. ## At a glance | Property | Value | | --- | --- | | Transport | Streamable HTTP | | Endpoint | https://mcp.edited.com/mcp (no trailing slash) | | Protocol version | Negotiated — 2025-06-18, 2025-03-26 or 2024-11-05 | | Mode | Stateless — no session to manage | | Auth | API key — send it in the x-api-key header on every call | ## Where to go next | If you want to… | Read | | --- | --- | | Decide which surface fits your job (MCP or API) | MCP or the API? | | Get a key and make your first call | Quickstart | | Understand what data is available | The data | | Search, filter, and pick the right tool | How to query | | Get access and authenticate | Operations → Authentication | | Check the privacy & security shape | Operations → Data privacy & security | | Understand how the transport works | Concepts → Transports | | Browse every tool and its schema | Reference | | See an end-to-end agentic flow | Recipes → Example workflow | | Wire the server into Claude Desktop | Recipes → Connect Claude Desktop | | Wire the server into Claude Code | Recipes → Connect Claude Code | | Wire it into a custom Python or TypeScript agent | Recipes → Connect a custom client | | Try tools live, in the browser | Recipes → Using the Reference | For AI agents reading these docs: /llms.txt is a Markdown index of every page. --- ## MCP or the API? Source: https://build.edited.com/mcp-or-api # MCP or the API? EDITED's retail data is reachable two ways: the EDITED MCP server and the REST API. They're different ways in, built for different jobs — and they don't cover quite the same ground (more on that below). It's not a ranking, and not a choice you're locked into. This page helps you pick the right one for what you're doing. Looking for the protocol-level explainer — what MCP is as an open standard? That's Concepts → What is MCP?. This page is about which EDITED surface to reach for. ## Start with what you're doing The fastest way to choose is to start from the job, not the technology: - Exploring, asking questions, prototyping, or letting an agent work → reach for MCP. - Integrating EDITED data into your own systems, automating a pipeline, or pulling data at scale → reach for the REST API. That's the 80% answer. It isn't a hard rule — you can run production agents on MCP, and serious builders use both — but if you remember one thing, start from the verb. ## The surfaces Three programmatic surfaces — and they don't all reach the same data: EDITED MCP — an interactive, self-describing interface built for exploration and agent/LLM workflows. Structured tool calls, normalized results, discovery built in. It reaches all three EDITED data domains — Competitive Market Data, Trend & Research, and Messaging & Promotions. Start at the Quickstart and How to query. Query API (REST) — synchronous request/response for programmatic stats and option-level detail, over Competitive Market Data. The right choice when you want an exact aggregation, or to pull specific records into your own code. Reference docs are being brought onto the portal — in progress. Jobs API (REST) — asynchronous bulk extraction over Competitive Market Data: submit a job, poll for it, download the results. The right tool for large historical, over-time pulls. Reference docs are being brought onto the portal — in progress. MCP is the only programmatic route to Research and Promotions The REST API — Query and Jobs — covers Competitive Market Data. EDITED's Trend & Research and Messaging & Promotions are available programmatically only through MCP; there's no REST API for them. So if your work touches research or promotions data, MCP isn't the convenient choice — it's the only one. ## Which surface do I choose? | Your job | Surface | Why | | --- | --- | --- | | Explore, ask questions, prototype, agent/chat | MCP | interactive, normalized, self-documenting | | Work with trend & research reports | MCP | the only programmatic surface for Research | | Work with messaging & promotions data | MCP | the only programmatic surface for Promotions | | Reproduce an exact EDITED Market stat or chart | Query API | mirrors the app's aggregations | | Pull individual option-level records in your code | Query API | synchronous REST, full option detail | | Bulk or historical over-time extraction | Jobs API | async NDJSON, optimized for the job | The difference you'll feel most is normalized vs raw: - MCP gives you normalized, comparable data — a clean vocabulary shared across retailers, so you can discover and compare fast. - The API gives you the raw, complete record — the full option-level detail, including the messy richness a normalized vocabulary can't hold (brand-named fibers, free-text descriptions, per-panel composition). More to work with, but you handle the parsing. Same data at different resolutions, for different jobs — neither is "better." ## What makes MCP feel different Beyond the protocol, MCP changes the experience of working with the data in one way worth calling out: it documents itself at the point of use. You — or your agent — can ask the tools what a field means, how it's derived, and where its edges are, mid-task, without leaving for a separate reference. That's a different feel from reading reference docs, but it isn't that MCP is documented and the API isn't. Both surfaces are fully documented: the API's reference lives here on the portal, MCP's lives inline in the tools. Same knowledge, delivered where each surface's users are working. ## Where to go next | If you want to… | Read | | --- | --- | | Make your first MCP call | Quickstart | | Understand what data is available | The data | | Search, filter, and pick the right tool | How to query | | Understand MCP as a protocol | Concepts → What is MCP? | | Browse every MCP tool and its schema | Reference | | Get access and authenticate | Operations → Authentication | The REST API quickstart and reference are being brought onto the portal — in progress. --- ## Quickstart Source: https://build.edited.com/quickstart # Quickstart Four steps from zero to a working call. ## 1. Get an API key Every request needs an x-api-key header. Keys are issued by EDITED — contact your account manager or customer success representative, or email support@edited.com. Details in Authentication & access. Already have a key? Carry on. ## 2. Verify the handshake initialize is the standard MCP opening call — and the quickest check that your key works: ``` 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: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "curl", "version": "0" } } }' ``` Mind the URL: /mcp with no trailing slash — /mcp/ gets a 307 redirect instead of an answer (see Concepts → Transports). A JSON-RPC body back means you're in. A 401 means the key is missing or not yet enabled for MCP — see Errors. The server is stateless, so there's no session to track between calls; see Concepts → Transports for the full flow. ## 3. Call your first tool Resolve a fuzzy brand query into stable identifiers: ``` 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: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "mdmcp-search_brands", "arguments": { "query": "Nike", "search_limit": 3 } } }' ``` The result is a list of brand matches, each with a stable slug you can feed into downstream queries. Every tool, with its schema and a live "Try it" widget, is in the Reference. Prefer a UI? The official MCP Inspector gives you an interactive explorer (add your x-api-key in its Headers panel): ``` npx @modelcontextprotocol/inspector \ --transport http \ --url "https://mcp.edited.com/mcp" ``` ## 4. Connect your client curl proves the plumbing; agents are the point. Wire EDITED MCP into: | Client | Recipe | | --- | --- | | Claude Desktop | Connect Claude Desktop | | Claude Code | Connect Claude Code | | Your own agent (TS / Python) | Connect a custom client | | Just your browser | Using the Reference | Then see Recipes → Example workflow for an end-to-end agentic flow — a natural-language question in, structured EDITED data back. --- ## The data Source: https://build.edited.com/the-data # The data EDITED has spent 12+ years building one of the most comprehensive structured datasets in retail — 90,000 brands and 5bn+ SKUs across three verticals: apparel (including footwear and accessories), beauty, and home. The MCP gateway exposes EDITED's data across three domains, each a separate backend namespace. Alongside the query tools, a set of identifier-resolution and data-quality helpers in the mdmcp namespace supports market-data queries; the Trend & Research and Messaging & Promotions domains resolve their own identifiers with their own tools. This page describes what each domain contains and what you can do with it. For the complete tool catalogue with parameters and schemas, see the Reference. For a worked end-to-end example, see Recipes → Example workflow. ## Competitive Market Data Namespace prefix: mdmcp Live structured data on fashion retail products across thousands of retailers and brands worldwide. Updated continuously. Covers pricing, stock levels, discounts, new arrivals, colours, patterns, and sizing — at both the individual product level and in aggregate. Available via MCP for the last two years, kept on fast infrastructure for real-time queries; EDITED's complete historical archive, reaching back the full 12+ years, remains accessible via the API. Two query tools cover this domain. They serve different query shapes and are complementary, not interchangeable. ### market_data_options_search — row-level product data Returns individual product options: specific items with their current price, stock status, discount depth, colour, sizing, and other attributes. Use this tool when the question is about specific products: "show me", "list", "what are the cheapest", "find items where". Supports filtering by retailer, brand, category, colour, pattern, price range, discount depth, stock status, and more. Date-specific; covers up to two years back. ### market_data_table — aggregated analytics Returns computed aggregates: snapshots, breakdowns by dimension, cross-tabs, price histograms, period-over-period comparisons, and trends. Use this tool when the question is about the market: "how many", "average price", "share by retailer", "how has this changed week on week". Supports grouping by retailer, brand, category, market, colour, and pattern. Up to three group-by dimensions in a single call. Choosing between the two A useful rule: if the user would want to see a table of individual products, use market_data_options_search. If they would want to see a chart or a summary statistic, use market_data_table. For the full decision guide, see How to query → Picking the right tool. ### Verticals Market data spans three verticals, and the API identifies them as apparel, beauty, and homeware — note homeware is the token the tools accept, though the vertical is described as "home" elsewhere. A vertical is a top-level segment of the data, not a product category: categories like dresses or mascara sit within a vertical and are resolved with search_product_searches. The vertical parameter is optional on market_data_options_search, market_data_table, search_product_searches, and get_retailer_coverage. Omit it and it resolves to your account's default vertical; pass it only when you specifically want a different one — and pass the same vertical across the tools in a workflow, so the categories and slugs you resolve match the data you query. Access varies by account. All three verticals are visible to everyone in the tool schemas, but requesting one your account isn't entitled to returns an error. market_data_entitlements reports what you actually have: - market_data_entitlements — returns the verticals this account can access, its default_vertical, and its default currency. Call it before offering vertical-specific analysis rather than discovering the limit through an error. All three verticals return the same row grain — one row per product option. Homeware options bundle more SKUs per option (bed sizes, for example), so sku_count runs higher there; no vertical exposes per-SKU rows. ### Field and metric reference - market_data_docs — on-demand reference for the market-data query and lookup tools, one topic per call (field ids, metrics, filters, matching behaviour, and more). Call it when you need an exact field id, metric id, valid value, operator, or price scale rather than guessing — it reports the topics it covers, so you don't have to track them here. ## Trend & Research Namespace prefix: researchmcp EDITED's editorial content: trend reports, runway analysis, seasonal commentary, and market research articles. Semantic and paragraph-level search over the full library, with image retrieval. The research tools follow a discovery → drill-down pattern. Start broad to find relevant reports, then go deeper into the specific passages or images you need. ### Discovery - research_article_search — semantic search returning one summary hit per report. The right starting point for any topic query. Returns report_id values you carry into the drill-down tools. - research_list_reports — browse reports newest-first by publish date. Use when you want to know what exists in a date window rather than searching by topic. - research_analyze_query — analyses a research query into retrieval intent (topics, article types, season, and so on) and, for a genuinely temporal phrase ("last 30 days", "Q3 2025"), an ISO date window. Optional and composable: the search tools accept those dates and intent axes directly, so reach for it when you want the parse done for you rather than as a mandatory first step. A bare year or season comes back as intent, not a date window — the search tools turn those into a range themselves. ### Drill-down — text - research_chunk_search — paragraph-level semantic search. Use after research_article_search, passing report_ids to search within specific reports. Returns granular passages with a session-scoped chunk_id. - research_get_chunk — fetch a single text chunk by chunk_id. Cheap direct lookup; use within the same session as the search that returned the id. - research_read_report — returns the full article body for a report_id. Use when the user wants to read or summarise a specific report. Prefer research_chunk_search when you only need specific passages — the full body is a large payload. - research_get_report — lightweight metadata and excerpt for a known report_id. Cheaper than a full read; use when you need context without the full content. - research_report_links — resolves report_id values to public EDITED Research URLs. Note: the public article page requires a user login to open. ### Drill-down — images - research_image_search — semantic search for images within articles. Good for visual and trend queries: "SS25 colour charts", "runway looks", "store window displays". Returns image URLs, captions, and session-scoped image_id values. - research_match_images_to_text — given a block of text (typically an already-generated answer), finds images that visually support it. No date filter; use report_ids to constrain scope. - research_get_image — fetch a single image by image_id. Pass include_bytes: true to receive base64-encoded bytes for multimodal model input. ## Messaging & Promotions Namespace prefix: messagingmcp EDITED captures retailer homepages and marketing emails at regular intervals. This domain covers homepages and marketing emails only — not category landing pages or other messaging surfaces EDITED captures elsewhere. It exposes two indexes designed to be queried together: a visual and semantic index of the creative itself, and a structured index of promotions extracted from those captures. ### Visual & messaging search - messaging_search — semantic search over captured homepage and email screenshots. Returns OCR'd on-page text, a model-generated visual description, and image URLs. Use for creative context queries: "what is Zara saying this season", "which retailers are running summer colour stories", "what does H&M's homepage look like this week". By default returns only distinct creative (one result per chain head). Set include_repeats: true for date-precise queries where you need the capture nearest a specific day. ### Promotions — structured queries The four promo tools share a common filter vocabulary: retailers, regions, date range, promotion types, discount depth, whether a code is required, and whether inferred promotions are included. - count_promos — count distinct promotions matching a filter. Accepts group_by (retailer, promo type, region, shoot) and interval (day, week, month) for bucketed results. - list_promos — return individual promotions as examples, most-recent first. Use after count_promos to inspect what the aggregates contain. Returns up to 50 records with full promotion detail. - aggregate_promos — compute mean, median, min, or max over promotion depth. Use for questions like "what is the average discount depth for UK fast-fashion retailers this month". - search_promo_text — BM25 text search over free-text promotion fields (description, conditions, categories, promo codes). Use for fuzzy intent queries — searching for a specific campaign name or code string. ### Cross-modal enrichment - enrich_with_vm — given image_id values from promotion or messaging results, fetches the matching capture's full visual context: OCR text, visual description, and image URL. Use to ground structured promo results in the creative they came from. ## Helpers Namespace prefix: mdmcp "Helpers" is a functional grouping — the identifier-resolution and data-quality tools that support a market-data query — not a separate backend. They are all Competitive Market Data tools, so their names carry the same mdmcp- prefix as the query tools (mdmcp-search_retailers, mdmcp-get_retailer_coverage) — there's no bare search_retailers on the wire. The table and prose below drop the prefix for readability, but the name you pass to tools/call always carries it. Call them before or alongside the domain query tools, not instead of them. The Trend & Research and Messaging & Promotions domains have their own resolution tools in their own namespaces. ### Why identifiers matter EDITED's query tools use stable internal identifiers — slugs and integer IDs — rather than free-text strings. Passing a free-text retailer name to a query tool will either fail or return unexpected results. For retailers, always resolve the market first. Retailer slugs are not globally unique — the slug for Zara in the UK may be zara rather than zara-uk, depending on when the retailer was added to the system. Resolving the market first and then passing country_code to search_retailers is the reliable pattern. An agent that skips the market step and searches for a retailer by name alone may match the wrong regional storefront. The typical resolution sequence for a retailer query: - Call search_markets to resolve the market ("UK", "US", "Germany") - Call search_retailers with the market's country_code to get the correct regional slug - Use that slug in downstream query tools Identifiers are stable: resolve a concept once and reuse the result within a session rather than searching again. The identifier resolution tools support batch queries — pass a list of up to ten names in a single call. For a full walkthrough of the resolution pattern, see How to query → Identifier resolution. ### Data quality - get_retailer_coverage — assess data completeness and freshness for one or more retailers over a date range. Returns gap summaries classified as no_data, low_volume, or high_staleness, along with healthy vs. total period counts. Scoped to Competitive Market Data (mdmcp) only — it does not report coverage gaps in Trend & Research or Messaging & Promotions. Call this before running analytics over any date window where data gaps would silently distort results. A clean aggregate from market_data_table can be wrong if the retailer had a coverage gap in that range — and the aggregate will not tell you this on its own. ### Identifier resolution | Tool | Resolves | Returns | | --- | --- | --- | | search_retailers | Retailer names to slugs | slug (e.g. zara-uk) | | search_brands | Brand names to slugs | slug (e.g. nike) | | search_markets | Market names to codes | id (e.g. UK) | | search_product_searches | Product category names to IDs | id (e.g. 138) | | search_size_group | Sizing system names to IDs | id (e.g. 43) | | search_size_options | Individual size names to IDs | id (e.g. 901) | Crossing into the promotions domain. The market-data domain and the messaging/promotions domain use different retailer identifiers — a slug from mdmcp-search_retailers is not a valid promo filter. When you move to the promotions tools, resolve the retailer again in that domain with messagingmcp-list_retailers: match the name against the returned retailer_name, then pass that row's own retailer slug and a region it reports. Don't carry a market-data slug across the boundary. --- ## How to query Source: https://build.edited.com/how-to-query # How to query This page covers the patterns that apply across EDITED's MCP tools — how to resolve identifiers reliably, how to check data quality before running analytics, and how to pick the right tool for a given question. For the complete tool catalogue with parameters and schemas, see the Reference. ## Identifier resolution Most EDITED query tools expect stable internal identifiers — slugs and integer IDs — rather than free-text names. Resolving these identifiers is a required step before calling any domain query tool, not optional pre-processing. ### Resolving retailers: market first Retailer slugs are not globally unique. The slug for a retailer in one region may not follow a predictable pattern, and passing a retailer name directly to a query tool without first establishing market context risks matching the wrong regional storefront. The reliable sequence for any retailer-scoped query: - Call search_markets to resolve the market the user cares about - Call search_retailers with country_code set to the market's code - Use the returned slug in downstream query tools Every tool returns exactly one of result or error, so the samples below share one unwrapper. A refusal arrives with isError: false, which is why it checks the payload and not just the flag — see Concepts → Response shape. ``` function unwrap(result) { if (result.isError) { // never completed — the text is prose, not JSON throw new Error(result.content?.[0]?.text ?? "call failed"); } const body = result.structuredContent; if (body.error) throw new Error(`${body.error.code}: ${body.error.message}`); return body.result; } ``` ``` // Step 1 — resolve the market. Search the full country name: "UK" with // search_limit 1 comes back with Ukraine. const marketResult = await client.callTool({ name: "mdmcp-search_markets", arguments: { query: "United Kingdom", search_limit: 1 }, }); const market = unwrap(marketResult)[0]; // → { id: "UK", name: "United Kingdom (UK)" } // Step 2 — resolve the retailer within that market. // search_retailers wraps its hits, so read `.retailers` before indexing. const retailerResult = await client.callTool({ name: "mdmcp-search_retailers", arguments: { query: "Zara", search_limit: 3, country_code: market.id }, }); const retailer = unwrap(retailerResult).retailers[0]; // → { name: "Zara (UK)", slug: "zara", region: { iso_code: "UK", ... }, ... } // Step 3 — use the slug in your query tool ``` search_retailers is the only resolver that wraps its results: it returns { retailers, search_limit, at_search_limit }, while search_brands, search_markets and search_product_searches return a bare list. And don't build a slug by hand — Zara in the UK is zara, not zara-uk. Retailer hits carry match too, and it's the sharpest case: a bare Zara is exact on every country's Zara (…), so choose on region/country_code, not rank. And an exact retailer hit says only that EDITED knows the retailer — run get_retailer_coverage on the slug you pick before trusting it holds data over your window. ### Resolving other entities Brands, product categories, markets, and sizing entities follow a simpler pattern — search, take the best hit, extract the identifier. Read that hit's match value before you trust it, though: rank alone isn't confirmation (see below). | What you have | Tool to call | Identifier to extract | | --- | --- | --- | | Brand name ("Nike") | search_brands | slug | | Product category ("dresses") | search_product_searches | id | | Market name ("Germany") | search_markets | id | | Sizing system ("EU Women's Apparel") | search_size_group | id | | Individual size ("M", "42") | search_size_options | id | Brands are global — no market scoping required. But a top hit isn't automatically your brand: when nothing matches the query strongly, the vector search fills the remaining slots with nearest-neighbour suggestions, so a plausible-looking name can rank first without being a real match. Every hit carries a match value — treat a semantic_only result as a candidate to verify, not a confirmed answer, and check it rather than trusting rank. The Reference lists the full set of match values. ### Resolving retailers for the promotions domain The market-data domain and the messaging/promotions domain use different retailer identifiers — a slug from mdmcp-search_retailers is not a valid promo filter. Resolve the retailer again in the messaging domain with messagingmcp-list_retailers: match the request against the returned retailer_name, then pass that row's own retailer slug and a region it reports to the promo tools. Don't carry a market-data slug across the boundary. ### Batching All identifier resolution tools accept either a single string or a list of up to ten strings. When you need to resolve multiple entities for the same query, batch them in a single call — it runs concurrently and saves round-trips. ``` // Resolve two retailers at once const result = await client.callTool({ name: "mdmcp-search_retailers", arguments: { query: ["Zara", "H&M"], search_limit: 3, country_code: "UK", }, }); // One result object per query, in the order you asked — and each one is a // `retailers` wrapper, not a bare list. const [zaraResult, hmResult] = unwrap(result); const zara = zaraResult.retailers[0]; // → { name: "Zara (UK)", slug: "zara", ... } const hm = hmResult.retailers[0]; // → { name: "H&M (UK)", slug: "hm", ... } ``` Once resolved, reuse identifiers within a session rather than calling the resolution tools again for the same entity. ### Scoping resolution to a vertical search_product_searches resolves categories within a vertical, so it takes the same optional vertical parameter as the market-data query tools. Resolve categories under the same vertical you intend to query — otherwise the ids you get back won't match the data you ask for. See Choosing a vertical below. ## Choosing a vertical Market data covers three verticals — apparel, beauty, and homeware. The vertical parameter is optional on market_data_options_search, market_data_table, search_product_searches, and get_retailer_coverage. Omit it unless the user is explicitly asking about another vertical. When omitted it resolves to the account's default vertical, which is the right behaviour for most queries. When you do set it, set it consistently across every tool in the workflow — resolving a category under one vertical and querying another returns mismatched results. Entitlements vary by account. All three values appear in every tool's schema, but requesting a vertical the account can't access returns an error rather than an empty result. Check before offering vertical-specific analysis: ``` const entitlements = await client.callTool({ name: "mdmcp-market_data_entitlements", arguments: {}, }); // → { verticals: ["apparel", "beauty", "homeware"], // default_vertical: "apparel", currency: "USD" } ``` Note the token is homeware, not home. A vertical is a top-level data segment, not a product category — categories such as dresses or mascara live within a vertical and are resolved with search_product_searches. ## Coverage checking Before running analytics over a date window, call get_retailer_coverage to verify that EDITED's data for the retailers in scope is complete and fresh enough to support the question. A clean aggregate from market_data_table can be silently wrong if a retailer had a coverage gap in the date range. The aggregate will not signal this on its own — it simply returns fewer products without explanation. get_retailer_coverage answers the question directly. ``` const coverage = await client.callTool({ name: "mdmcp-get_retailer_coverage", arguments: { retailers: ["zara", "hm"], start_date: "2026-01-01", end_date: "2026-03-31", granularity: ["weekly"], }, }); ``` Results are classified as no_data, low_volume, or high_staleness for each period. If gaps exist, either narrow the date range, exclude the affected retailer, or surface the caveat to the user before presenting results. When coverage checking matters most: - Any time-windowed trend or comparison query - Period-over-period comparisons where a gap in one period would skew the delta - Queries scoped to a single retailer, where a gap has nowhere to average out For a simple snapshot query ("what is Zara's current assortment"), coverage checking adds less value — the query is point-in-time and the user can see whether results look thin. ## Picking the right tool ### Market data: options vs table The two Competitive Market Data query tools serve different question shapes. | Question type | Examples | Use | | --- | --- | --- | | Specific products | "Show me Zara's discounted dresses", "What are the cheapest in-stock trainers at H&M" | market_data_options_search | | Aggregates and analytics | "Average price by retailer", "Share of discounted products this month", "How has product count changed week on week" | market_data_table | A useful shortcut: if the user would want to see a list of products, use market_data_options_search. If they would want to see a number, a percentage, or a chart, use market_data_table. market_data_table is the more powerful tool for analysis — it supports breakdowns by up to three dimensions, period-over-period comparisons, histograms, and percentiles. Use group_by: [] for a global snapshot with no bucketing. ### Research: discovery before drill-down The Trend & Research tools are designed to be used in sequence. Going straight to a paragraph-level search or a full article read without a discovery step wastes context and often returns less relevant results. The recommended sequence: - research_analyze_query (optional) — if the user named a relative window ("last quarter") or a topic you'd rather have parsed for you, run it to get back ISO dates and retrieval intent, and pass those to the search tools. It's composable, not required: the search tools accept explicit dates and intent axes directly, so skip it when you already know them. - research_article_search — find which reports are relevant. Returns one summary hit per report with a report_id. This is almost always the right first step for a topic query. - research_chunk_search with report_ids — drill into specific reports to find the passages that answer the question. More precise than searching the full corpus. - research_read_report — only when the user wants to read or summarise a full report. It returns the complete article body, which is a large payload; prefer chunk search when you only need specific passages. For image retrieval, research_image_search follows the same pattern: use after research_article_search and pass report_ids to search within relevant reports rather than the full corpus. ### Messaging and promotions: structured and visual together The messaging and promotions tools are designed to be used together. The structured promo tools (count_promos, list_promos, aggregate_promos) give you numbers and records; messaging_search gives you the creative context those numbers came from; enrich_with_vm bridges them by fetching the visual context for a known image_id. A typical pattern: count or aggregate promotions to answer the analytical question, then use list_promos to surface representative examples, then enrich_with_vm to show the creative alongside the data. ### Calling a tool doesn't require a model It's easy to assume that because MCP is how AI agents reach EDITED's data, every MCP call has a model deciding which tool to use. It doesn't. MCP is a protocol — a server exposes tools, a client calls them — and nothing about that exchange requires an LLM to be the one choosing the tool or its arguments. Your own code can do that directly, deterministically, every time. That gives you two ways to call a tool, not one: - Call it directly, when you already know which tool and which arguments you need. No model involved — same tool, same result, every run. - Let a model choose, when the right tool or the right arguments genuinely depend on something you only learn at runtime, and it's not practical to write out every case in advance. Default to calling the tool directly when you can. Reserve model-driven tool selection for the cases that actually need judgment — it costs more, runs slower, and won't behave identically twice. ``` // Known ahead of time -> call the tool directly, no model involved: const result = await client.callTool({ name: "mdmcp-market_data_table", arguments: { retailers: ["zara"], product_search_id: 138, metric: "avg_price", }, }); ``` ``` // Only knowable at runtime -> let a model choose: const flaggedCategory = "dresses"; // would come from an earlier workflow step const flaggedRetailer = "zara"; // would come from an earlier workflow step const response = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "your-model-of-choice", // any MCP-compatible model works here max_tokens: 1000, messages: [{ role: "user", content: `An inventory anomaly was flagged in ${flaggedCategory} for ${flaggedRetailer}. Check competitive pricing context for it.`, }], mcp_servers: [{ type: "url", url: EDITED_MCP_URL, name: "edited-mcp" }], }), }); ``` Same server, same tool available either way — the difference is who's holding the steering wheel. ## Using the server-defined prompts EDITED ships three server-defined prompts — one per domain — that encode recommended routing and orchestration logic. These are the fastest way to build a well-behaved agent — rather than re-implementing the routing rules in your own system prompt, compose these in and extend from them. Fetch them at runtime with prompts/get: ``` const prompt = await client.getPrompt({ name: "mdmcp-market_data_query_guide", arguments: {}, }); // prompt.messages — prepend to your conversation context ``` ### mdmcp-market_data_query_guide Covers the full workflow for Competitive Market Data queries: how to resolve filter values using the identifier resolution tools, how to route between market_data_options_search and market_data_table, and how to read the inline result envelope including the truncation signal. Use this as the foundation for any agent that will answer market data questions. ### researchmcp-research_orchestration Covers routing, stop conditions, source attribution, and image-description rules for the three research search tools (research_article_search, research_chunk_search, research_image_search). Encodes the discovery-before-drill-down pattern and the rules for when to stop searching and synthesise. Use this as the foundation for any agent that will answer trend or research questions. ### messagingmcp-messaging_orchestration Covers routing, tool budget, lean-query, and provenance-citation rules for the Messaging & Promotions tools — how to move between messaging_search and the promo tools (count_promos, list_promos, aggregate_promos, search_promo_text) and bridge them with enrich_with_vm. Consumer-specific concerns — persona, output format, citation style — stay your agent's responsibility. Use this as the foundation for any agent that will answer messaging or promotions questions. All three prompts are maintained server-side — when EDITED updates the guidance, your agent picks it up on the next listPrompts call without a redeploy. Browse the current prompt content in the Reference. --- ## Before you build Source: https://build.edited.com/before-you-build # Before you build This page is the pre-build companion to Troubleshooting. It isn't what the data is — see The data — or how to call it — see How to query. It's the short list of places where the data's shape or vocabulary behaves differently than a new builder reasonably expects, drawn from real use. Two things make this cheaper than it looks: - It's learn-once. The facts underneath these gotchas are stable — the vertical tokens, the way a metric is computed, and the slug a given retailer resolves to don't shift under you. New retailers and verticals get added, but a slug is never quietly reassigned to a different retailer. So the cost is a single orientation, after which you cache what you resolved and reuse it across the session. - The answer is already in the tools. Everything below is verifiable with market_data_docs, market_data_entitlements, and the search_* resolution tools. These trip builders up not because the information is missing, but because it's reasonable — for a person or an agent — to shortcut past it. The fix each time is the same one move: check the tool that knows, then trust your cached answer. ## An average may describe a subset, not the whole assortment What you'd expect. An "average" spans everything in scope — average price across the assortment, average discount across the range. What actually happens. Some aggregates are computed over a conditional population — only the rows that meet a filter — rather than the full set. Average discount depth, for instance, is computed across only the products currently on discount, not the whole assortment. Read as a market-wide figure, it overstates. Nothing errors; the number is just answering a narrower question than you asked. How to design around it. Before you surface an aggregate, confirm the population it's computed over — check the metric's definition in market_data_docs (the metrics topic) rather than assuming it spans everything. For any time-windowed or period-over-period figure, pair it with get_retailer_coverage first: a clean-looking aggregate can still be built on a range with data gaps, and it won't tell you that on its own. ## EDITED's terms may not mean what the same word means in your system What you'd expect. Words like market, category, vertical, and brand carry the definitions they have in your own product or schema. What actually happens. They're EDITED's terms, with specific meanings that often differ from a builder's: - Market is a country or region context, not a market segment or a total-addressable-market figure. - Vertical is a top-level segment of the data (apparel, beauty, home) — not your industry vertical — and the token a tool accepts isn't always the word you'd guess, so resolve it rather than typing it in. - Category is a resolved product-search concept (via search_product_searches), not a free-text taxonomy label you supply. Mapping your word onto EDITED's without checking returns the wrong concept, not an error — the most expensive kind of mismatch to catch, because everything keeps working. How to design around it. Map each EDITED term to your own vocabulary deliberately before you build on it, and confirm any definition you're unsure of in market_data_docs. Each term is defined in full on the page that owns it — verticals in The data, for instance — so this entry only has to flag that the words collide, with a single source to keep current. ## A retailer identifier isn't portable — resolve it in context What you'd expect. Once you've got a retailer's slug, it's that retailer's ID everywhere — reuse it across markets and across domains. What actually happens. Retailer identifiers are contextual: - Slugs aren't globally unique. The same retailer can slug differently by market — Zara in the UK may be zara, not zara-uk — so a name on its own can match the wrong regional storefront. Resolve the market first. - IDs don't cross domains. A slug from the market-data tools isn't a valid filter in the messaging & promotions tools; the two domains identify retailers differently. Either one returns some other retailer's data, not an error — the same silent kind of wrong as the term collisions above. How to design around it. Resolve the retailer in the context you're querying: market first for market data (search_markets, then search_retailers), and re-resolve with messagingmcp-list_retailers when you move to promotions. The full sequence is in How to query — this entry is just the heads-up that one slug won't carry everywhere. ## How this page grows This page is the reported-friction surface: each friction that comes back from real use is one more entry in the same shape — what you'd expect, what actually happens, how to design around it. Each names a class of surprise and how to verify it, never the specific values — so adding a retailer or a vertical never dates the page, and a class that stops being true simply comes off. The goal is a page a builder can trust because it's short and current, not a comprehensive list of caveats. --- ## API Reference Source: https://build.edited.com/reference # API Reference Every tool, resource, and prompt EDITED MCP exposes — auto-generated from the live server, last refreshed September 30, 2026. Tools here are search-focused: each one resolves a fuzzy concept (a brand name, a product type, a country) into the stable slug or id that downstream filters expect. They're how an agent bridges between natural language and EDITED's structured catalogue. Try anything live Every tool page below has a pre-filled "Try it" widget. Click Send to call the server directly from these docs — no SDK setup required. ## Tools (32) - Check Retailer Coverage (Market) (mdmcp-get_retailer_coverage) — Assess data coverage — completeness AND freshness — for one or more retailers over a date range. - Reference Docs (Market) (mdmcp-market_data_docs) — Reference documentation for the query and lookup tools, one topic per call: filter_fields (every filter field with type, definition, valid values, and the operator validity table), metrics (metric ids and definitions, price-unit scales, retail-term routing), group_by_fields (group-by, histogram and percentile fields), dates_and_compare (date snapping, trend rules, multi-period combining, compare semantics), text_search (name/description query syntax), composition (how fabric percentages are captured, why percentage bands are not additive, and per-retailer coverage caveats — read this before banding fibre content or comparing retailers on it), product_searches (the category/product_details taxonomies are TREES returned as one flat mixed-depth list, so their rows must never be summed — read this before doing any arithmetic across a category breakdown), response_shape (how every tool's result is structured, what an error payload means, and why an empty result is not a failure), lookup_matching (how to read match on the search_* tools: what each tool compares for exact, why there is no score, and why a name match is not evidence of data). - Vertical Access (Market) (mdmcp-market_data_entitlements) — Report which market-data verticals this account can access. - 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. - Search Product Options (Market) (mdmcp-market_data_options_search) — Search individual product options from the market-data service for a given date. - Search Brands (Market) (mdmcp-search_brands) — Find brands for the brand_slug and brand filters. - Search Size Groups (Market) (mdmcp-search_size_group) — Find size groups to use in filter queries. - Search Sizes (Market) (mdmcp-search_size_options) — Find specific sizes to use in filter queries. - Search Markets (Market) (mdmcp-search_markets) — Find markets to use in filter queries. - Search Retailers (Market) (mdmcp-search_retailers) — Find retailers to use in filter queries. - Search Product Categories (Market) (mdmcp-search_product_searches) — Find product categories to use in filter queries. - Analyze Query (Research) (researchmcp-research_analyze_query) — Analyse a research query into retrieval intent and (optionally) a date window. - Search Articles (Research) (researchmcp-research_article_search) — Search EDITED Research articles by meaning. - Search Passages (Research) (researchmcp-research_chunk_search) — Search EDITED Research articles for granular text passages. - Get Passage (Research) (researchmcp-research_get_chunk) — Fetch a single text chunk by its stable id. - Get Image (Research) (researchmcp-research_get_image) — Fetch a single image by its stable id. - Get Report Metadata (Research) (researchmcp-research_get_report) — Fetch full metadata for a known report_id. - Search Images (Research) (researchmcp-research_image_search) — Search EDITED Research articles for images by meaning. - List Reports (Research) (researchmcp-research_list_reports) — Browse what has been published lately, newest-first by publish date. - Match Images to Text (Research) (researchmcp-research_match_images_to_text) — Find images that match arbitrary text by meaning — "find me more of this". - Read Full Report (Research) (researchmcp-research_read_report) — Read the full article text for a known report_id. - Get Report Links (Research) (researchmcp-research_report_links) — Convert between report_ids and public EDITED Research article URLs. - Aggregate Promo Discounts (Messaging) (messagingmcp-aggregate_promos) — Compute a statistic over promo discount depth, deduped by promo_id. - Count Promotions (Messaging) (messagingmcp-count_promos) — Count distinct promotions matching the filters, deduped by promo_id. - Enrich Image Context (Messaging) (messagingmcp-enrich_with_vm) — Fetch VM (visual + page-text) context for a list of image_ids. - Explain Promo Methodology (Messaging) (messagingmcp-explain_promo_data) — Explain how the promo dataset was captured and what the tool fields mean. - Get Promotion Details (Messaging) (messagingmcp-get_promo) — Fetch a single promotion by promo_id — the campaign detail / drill-down view. - List Promotions (Messaging) (messagingmcp-list_promos) — List distinct promotions (one per promo_id = one campaign), newest first by start date. - List Retailers (Messaging) (messagingmcp-list_retailers) — List the retailers (or regions) the messaging corpus actually covers. - Search Visuals (Messaging) (messagingmcp-messaging_search) — Search messaging channel visuals (homepage and newsletter screenshots) by meaning. - Resolve Time Period (Messaging) (messagingmcp-resolve_time_period) — Resolve a natural-language time expression to {start_date, end_date}. - Search Promo Text (Messaging) (messagingmcp-search_promo_text) — Relevance-scored text search across promo description / conditions / categories / codes. ## Resources (1) - Session ID (session_id) ## Prompts (3) - Market data query guide (mdmcp-market_data_query_guide) — Workflow guide: resolve filter values with search_* tools, route table vs options, and read the inline result envelope incl. - Research orchestration guide (researchmcp-research_orchestration) — Orchestration policy for the three research_*_search tools. - Messaging orchestration guide (messagingmcp-messaging_orchestration) — Orchestration policy for the messaging MCP tools. --- ## Market data query guide Source: https://build.edited.com/reference/prompts/mdmcp-market_data_query_guide # Market data query guide mdmcp-market_data_query_guide Workflow guide: resolve filter values with search_* tools, route table vs options, and read the inline result envelope incl. the truncation signal. ## Arguments None. --- ## Messaging orchestration guide Source: https://build.edited.com/reference/prompts/messagingmcp-messaging_orchestration # Messaging orchestration guide messagingmcp-messaging_orchestration Orchestration policy for the messaging MCP tools. Layer-3 routing, tool budget, lean-query, and provenance-citation rules. Compose into your agent's system prompt. Consumer-specific concerns (persona, output format, citation style) remain your agent's responsibility. ## Arguments None. --- ## Research orchestration guide Source: https://build.edited.com/reference/prompts/researchmcp-research_orchestration # Research orchestration guide researchmcp-research_orchestration Orchestration policy for the three research_*_search tools. Returns the shared routing, stop-condition, source-attribution, and image-description rules every consumer of research_article_search, research_chunk_search, and research_image_search should follow. Compose into your agent's own system prompt rather than re-implementing the rules locally. ## Arguments None. --- ## Session ID Source: https://build.edited.com/reference/resources/session_id # Session ID URI: mdmcp+data://session_id MIME type: text/plain No description. ## Read this resource ``` const result = await client.readResource({ uri: "mdmcp+data://session_id" }); ``` --- ## Check Retailer Coverage (Market) Source: https://build.edited.com/reference/tools/mdmcp-get_retailer_coverage # Check Retailer Coverage (Market) mdmcp-get_retailer_coverage Assess data coverage — completeness AND freshness — for one or more retailers over a date range. The "can I trust the market data for these retailers over this window?" tool. Run it after resolving slugs with search_retailers and before trusting a market_data_table / market_data_options_search aggregate over the same window: a clean average can be silently wrong if the retailer had a no_data gap or a stretch of low_recency in the range. Input: retailers (slugs from search_retailers, 1..N), start_date / end_date (YYYY-MM-DD), granularities (one or more of daily, weekly, monthly; default ["daily"]), and an optional vertical. start_date must be on or before end_date; supported range is the past two years (730 days), inclusive. retailers takes region-specific retailer slugs from search_retailers (e.g. levi-uk, zara-us) — NOT brand slugs from search_brands. These are separate id spaces: a name like "Levi's" surfaces under both searches, but its brand slug (levis) is not a valid retailer here and will match no coverage. Pass the search_retailers slug, not the search_brands one. vertical (apparel | beauty | homeware) is optional. When omitted it resolves to the account's default vertical (reported as default_vertical by market_data_entitlements) and scopes the access contract. Coverage is reported on the same data stream the query tools read, in every vertical — including homeware — so a coverage verdict and a market_data_table / market_data_options_search result always describe the same dataset. Available verticals vary by account; requesting one you are not entitled to returns an error — call market_data_entitlements to see yours. Verdicts come from md-quality UNCHANGED — the tool only summarizes them into gaps. Per retailer, each requested granularity returns a block keyed daily / weekly / monthly with: - periods_assessed, healthy_periods, health_pct — counts over md-quality's per-period verdicts at that granularity; - gap_episodes — contiguous runs of non-healthy periods, each \{start_date, end_date, period_count, reasons, min_volume_score, min_recency_score}. reasons is the union of md-quality's non-healthy reasons across the run; min_* are its worst scores in the run (min_recency_score is null when no documents); - verdict — a one-line string for direct inclusion in an answer; it appends the worst relevant score to each episode (e.g. min recency 0.83) so severity is visible at a glance. Disallowed / unknown / deprecated retailers come back inline as a per-retailer error (no_access / unknown_retailer / deprecated_retailer), never a hard failure for the whole call. Reasons (from md-quality): no_data = zero documents that day; low_volume = anomalous drop vs. the retailer's own baseline; low_recency = documents exist but are going stale; healthy = none of the above. no_data states an absence, not a cause: zero documents can mean EDITED did not track that retailer/market/vertical combination during the requested window (tracking may have started after, or stopped before, it), or collection was genuinely interrupted — this tool cannot tell those apart. Never present no_data as the crawler being down (even if the user assumes so); report it neutrally — "EDITED has no data for this retailer over this window" — and, if asked why, offer the range of possible causes. Scores (from md-quality): volume_score ∈ [0,1] = fraction of the retailer's historical norm of documents seen (1.0 normal, 0.3 ≈ 30% of expected, 0 none); recency_score ∈ [0,1] | null = freshness (1.0 seen today, 0.0 ≥ 6 days old, null = no documents that day). How to read a verdict: a low health_pct, or any no_data / low_recency gap episode overlapping an analytics window, is a signal to caveat or narrow that analysis — exclude the affected retailer, narrow the date range to the healthy stretch, or surface the gap in the answer. Do NOT flag on health_pct alone: the percentage collapses severity, so a retailer with two isolated barely-stale weeks (low_recency, min recency ≈ 0.83) can score the same as one with sustained no_data gaps. Judge each episode by its reasons and min_volume_score / min_recency_score before calling coverage a problem. Weigh severity before alarming: at monthly a single flagged period is 0% healthy by construction, so a low_recency month with a high min recency (near 1.0) is only mildly stale, NOT a hard gap — say so rather than reporting it as unusable. Request daily to pinpoint exact gap dates and monthly for an at-a-glance trend; both come back from a single call. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | retailers | string[] | yes | — | Retailer slugs (from search_retailers), 1..N. | | start_date | string | yes | — | Inclusive start of the window (YYYY-MM-DD). Must be on or before end_date and within the past two years (730 days). | | end_date | string | yes | — | Inclusive end of the window (YYYY-MM-DD). Within the past two years (730 days). | | granularities | string[] | no | default: ["daily"] | One or more of daily, weekly, monthly — a gap summary is returned per requested granularity from a single backend call. | | vertical | string | no | — | Optional. apparel \| beauty \| homeware. When omitted it resolves to the account's default vertical (see market_data_entitlements). | ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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-get_retailer_coverage", "arguments": { "start_date": "example", "end_date": "example", "granularities": [ "daily" ], "vertical": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "mdmcp-get_retailer_coverage", arguments: { "start_date": "example", "end_date": "example", "granularities": [ "daily" ], "vertical": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "mdmcp-get_retailer_coverage", {"start_date": "example", "end_date": "example", "granularities": ["daily"], "vertical": "example"}, ) ``` ## Input schema ``` { "type": "object", "properties": { "retailers": { "description": "Retailer slugs (from `search_retailers`), 1..N.", "examples": [ [ "zara", "h-and-m" ] ], "items": { "type": "string" }, "minItems": 1, "type": "array" }, "start_date": { "description": "Inclusive start of the window (YYYY-MM-DD). Must be on or before `end_date` and within the past two years (730 days).", "examples": [ "2026-01-01" ], "format": "date", "type": "string" }, "end_date": { "description": "Inclusive end of the window (YYYY-MM-DD). Within the past two years (730 days).", "examples": [ "2026-03-31" ], "format": "date", "type": "string" }, "granularities": { "default": [ "daily" ], "description": "One or more of `daily`, `weekly`, `monthly` — a gap summary is returned per requested granularity from a single backend call.", "examples": [ [ "daily" ], [ "daily", "monthly" ] ], "items": { "enum": [ "daily", "weekly", "monthly" ], "type": "string" }, "type": "array" }, "vertical": { "description": "Optional. `apparel` | `beauty` | `homeware`. When omitted it resolves to the account's default vertical (see `market_data_entitlements`).", "enum": [ "apparel", "beauty", "homeware" ], "type": "string" } }, "required": [ "retailers", "start_date", "end_date" ] } ``` ## 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": "Top-level tool result. ``retailers`` carries one entry per requested slug\n(ok or error), keyed by slug.", "properties": { "vertical": { "type": "string" }, "granularities": { "items": { "enum": [ "daily", "weekly", "monthly" ], "type": "string" }, "type": "array" }, "start_date": { "format": "date", "type": "string" }, "end_date": { "format": "date", "type": "string" }, "retailers": { "additionalProperties": { "description": "Per-retailer entry. ``status=\"ok\"`` carries one summary block per\nrequested granularity (the others stay ``None``); ``status=\"error\"`` carries\na ``reason`` and no summaries.", "properties": { "status": { "enum": [ "ok", "error" ], "type": "string" }, "reason": { "anyOf": [ { "enum": [ "unknown_retailer", "deprecated_retailer", "no_access" ], "type": "string" }, { "type": "null" } ], "default": null }, "daily": { "anyOf": [ { "description": "Gap summary for one retailer at one granularity.\n\n``periods_assessed`` / ``healthy_periods`` / ``health_pct`` are counts over\nmd-quality's per-period verdicts. ``verdict`` is a one-line string suitable\nfor direct inclusion in an answer.", "properties": { "periods_assessed": { "type": "integer" }, "healthy_periods": { "type": "integer" }, "health_pct": { "type": "number" }, "gap_episodes": { "items": { "description": "A maximal contiguous run of non-healthy periods (run-length encoded).\n\n``reasons`` is the union of md-quality's non-healthy reasons across the run;\n``min_*`` are md-quality's worst (minimum) scores within it. ``daily``\nepisodes have ``start_date == end_date`` per period; weekly/monthly episodes\nspan the run's first/last period bounds.", "properties": { "start_date": { "format": "date", "type": "string" }, "end_date": { "format": "date", "type": "string" }, "period_count": { "type": "integer" }, "reasons": { "items": { "enum": [ "no_data", "low_volume", "low_recency" ], "type": "string" }, "type": "array" }, "min_volume_score": { "type": "number" }, "min_recency_score": { "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "start_date", "end_date", "period_count", "reasons", "min_volume_score", "min_recency_score" ], "type": "object" }, "type": "array" }, "verdict": { "type": "string" } }, "required": [ "periods_assessed", "healthy_periods", "health_pct", "gap_episodes", "verdict" ], "type": "object" }, { "type": "null" } ], "default": null }, "weekly": { "anyOf": [ { "description": "Gap summary for one retailer at one granularity.\n\n``periods_assessed`` / ``healthy_periods`` / ``health_pct`` are counts over\nmd-quality's per-period verdicts. ``verdict`` is a one-line string suitable\nfor direct inclusion in an answer.", "properties": { "periods_assessed": { "type": "integer" }, "healthy_periods": { "type": "integer" }, "health_pct": { "type": "number" }, "gap_episodes": { "items": { "description": "A maximal contiguous run of non-healthy periods (run-length encoded).\n\n``reasons`` is the union of md-quality's non-healthy reasons across the run;\n``min_*`` are md-quality's worst (minimum) scores within it. ``daily``\nepisodes have ``start_date == end_date`` per period; weekly/monthly episodes\nspan the run's first/last period bounds.", "properties": { "start_date": { "format": "date", "type": "string" }, "end_date": { "format": "date", "type": "string" }, "period_count": { "type": "integer" }, "reasons": { "items": { "enum": [ "no_data", "low_volume", "low_recency" ], "type": "string" }, "type": "array" }, "min_volume_score": { "type": "number" }, "min_recency_score": { "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "start_date", "end_date", "period_count", "reasons", "min_volume_score", "min_recency_score" ], "type": "object" }, "type": "array" }, "verdict": { "type": "string" } }, "required": [ "periods_assessed", "healthy_periods", "health_pct", "gap_episodes", "verdict" ], "type": "object" }, { "type": "null" } ], "default": null }, "monthly": { "anyOf": [ { "description": "Gap summary for one retailer at one granularity.\n\n``periods_assessed`` / ``healthy_periods`` / ``health_pct`` are counts over\nmd-quality's per-period verdicts. ``verdict`` is a one-line string suitable\nfor direct inclusion in an answer.", "properties": { "periods_assessed": { "type": "integer" }, "healthy_periods": { "type": "integer" }, "health_pct": { "type": "number" }, "gap_episodes": { "items": { "description": "A maximal contiguous run of non-healthy periods (run-length encoded).\n\n``reasons`` is the union of md-quality's non-healthy reasons across the run;\n``min_*`` are md-quality's worst (minimum) scores within it. ``daily``\nepisodes have ``start_date == end_date`` per period; weekly/monthly episodes\nspan the run's first/last period bounds.", "properties": { "start_date": { "format": "date", "type": "string" }, "end_date": { "format": "date", "type": "string" }, "period_count": { "type": "integer" }, "reasons": { "items": { "enum": [ "no_data", "low_volume", "low_recency" ], "type": "string" }, "type": "array" }, "min_volume_score": { "type": "number" }, "min_recency_score": { "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "start_date", "end_date", "period_count", "reasons", "min_volume_score", "min_recency_score" ], "type": "object" }, "type": "array" }, "verdict": { "type": "string" } }, "required": [ "periods_assessed", "healthy_periods", "health_pct", "gap_episodes", "verdict" ], "type": "object" }, { "type": "null" } ], "default": null } }, "required": [ "status" ], "type": "object" }, "type": "object" } }, "required": [ "vertical", "granularities", "start_date", "end_date", "retailers" ], "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 } ``` --- ## Reference Docs (Market) Source: https://build.edited.com/reference/tools/mdmcp-market_data_docs # Reference Docs (Market) mdmcp-market_data_docs Reference documentation for the query and lookup tools, one topic per call: filter_fields (every filter field with type, definition, valid values, and the operator validity table), metrics (metric ids and definitions, price-unit scales, retail-term routing), group_by_fields (group-by, histogram and percentile fields), dates_and_compare (date snapping, trend rules, multi-period combining, compare semantics), text_search (name/description query syntax), composition (how fabric percentages are captured, why percentage bands are not additive, and per-retailer coverage caveats — read this before banding fibre content or comparing retailers on it), product_searches (the category/product_details taxonomies are TREES returned as one flat mixed-depth list, so their rows must never be summed — read this before doing any arithmetic across a category breakdown), response_shape (how every tool's result is structured, what an error payload means, and why an empty result is not a failure), lookup_matching (how to read match on the search_* tools: what each tool compares for exact, why there is no score, and why a name match is not evidence of data). Pass ids to get only specific entries (supported for filter_fields, metrics, group_by_fields) — e.g. topic="filter_fields", ids=["gender", "tier"]. The query tool descriptions may not carry all of this inline — call this whenever you need a field id, metric id, valid value, operator, price scale or date rule you do not already have, and to re-read a section later. The long-form composition and product_searches guidance is only available here. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | topic | string | yes | — | Documentation section to retrieve. | | ids | string[] | no | — | Optional: return only these field/metric ids. Supported for filter_fields, metrics and group_by_fields. | ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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_docs", "arguments": { "topic": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "mdmcp-market_data_docs", arguments: { "topic": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "mdmcp-market_data_docs", {"topic": "example"}, ) ``` ## Input schema ``` { "type": "object", "properties": { "topic": { "description": "Documentation section to retrieve.", "enum": [ "filter_fields", "metrics", "group_by_fields", "dates_and_compare", "text_search", "composition", "product_searches", "response_shape", "lookup_matching" ], "type": "string" }, "ids": { "description": "Optional: return only these field/metric ids. Supported for filter_fields, metrics and group_by_fields.", "items": { "type": "string" }, "type": "array" } }, "required": [ "topic" ] } ``` ## 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": { "type": "string" }, "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" ] } }, "x-fastmcp-wrap-result": true, "oneOf": [ { "required": [ "result" ] }, { "required": [ "error" ] } ] } ``` --- ## Vertical Access (Market) Source: https://build.edited.com/reference/tools/mdmcp-market_data_entitlements # Vertical Access (Market) mdmcp-market_data_entitlements Report which market-data verticals this account can access. Call this before offering vertical-specific analysis: the other tools list all verticals to everyone, but access varies by account. Requesting a vertical the account isn't entitled to returns an error. When vertical is omitted, default_vertical is what will be used. ## Parameters No parameters. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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_entitlements", "arguments": {} } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "mdmcp-market_data_entitlements", arguments: {}, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "mdmcp-market_data_entitlements", {}, ) ``` ## Input schema ``` { "type": "object", "properties": {} } ``` ## 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": { "properties": { "verticals": { "description": "Verticals this account may query (`apparel` / `beauty` / `homeware`).", "items": { "type": "string" }, "type": "array" }, "default_vertical": { "description": "The vertical used when a tool's `vertical` parameter is omitted.", "type": "string" }, "currency": { "description": "Currency market-data figures are returned in.", "type": "string" } }, "required": [ "verticals", "default_vertical", "currency" ], "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 } ``` --- ## Search Product Options (Market) Source: https://build.edited.com/reference/tools/mdmcp-market_data_options_search # Search Product Options (Market) mdmcp-market_data_options_search Search individual product options from the market-data service for a given date. Each option row is the weekly summary for the week that contains that date — there is no daily resolution, so two dates falling in the same week return the same snapshot. The result reports that week as evaluated_window (start:end), with the end capped at today: a date in the current week reports up to today, not the future Saturday, so an end that is not a Saturday marks a still-forming week. Returns all matching rows up to size (default 10, max 50) inline. total_row_count is the true matching population; when it exceeds the returned rows the result is truncated=true — narrow the query (tighter filters / smaller scope) or sort to surface the rows you need within the cap. 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. Use this tool for specific products ("show me", "list", "the cheapest"). For "bestsellers / top sellers / fastest selling", sort by sellout_percentage desc. It is also the expected follow-up when the user wants to drill down from an aggregate market_data_table result to the individual products behind it ("show me the products behind that", stat-to-evidence): 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. For aggregated analytics (snapshots, breakdowns, histograms, trends), use market_data_table instead. 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 RECALL): for a confident search_brands match, filter on BOTH brand_slug (that match's slug) AND brand (that match's name) so unmapped and variant listings are also returned; for no confident match, use brand alone. Tell the user you are matching on both the normalised and raw brand, so results include unmapped/variant listings. 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. 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 | field | type | definition | | --- | --- | --- | | retailer | string | Region-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_slug | string | Normalised 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. | | brand | text | Free-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). | | gender | string | Gender 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. | | market | string | Retailer 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_searches | integer[] | 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_colour | string | EDITED 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_pattern | string | EDITED 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. | | composition | string | Fabric 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_percentage | number | How 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_stock | boolean | Product has at least one available SKU. | | is_second_hand | boolean | Whether the product is second hand. | | outlet | boolean | Whether the product comes from an outlet assortment. | | advertised_discounted | boolean | Whether the product is currently advertised as discounted. | | price | number | Current 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_price | number | Highest 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_percentage | number | Current advertised discount percentage, on a 0-100 scale: 50 means 50% off — do NOT pass 0.5 for 50%. | | deepest_advertised_discount_percentage | number | Deepest advertised discount percentage ever recorded on the option, on a 0-100 scale (50 = 50% off). | | first_advertised_discount_percentage | number | First advertised discount percentage recorded on the option, on a 0-100 scale (50 = 50% off). | | has_had_advertised_discount | boolean | Whether the option has ever had an advertised discount. | | sellout_percentage | number | Percentage 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%. | | tier | string | Retailer market segment. Valid values: value, mass, premium, luxury. Case-insensitive; unknown values are rejected with an error. | | sku_count | integer | Total number of SKUs linked to the product option. | | option_id | string | EDITED option identifier. | | size_options | integer[] \| string[] | Size option IDs from search_size_options, or size group IDs as group-\{id} strings from search_size_group. | | name | text | Full-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. | | description | text | Full-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_found | date | Product 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_sellout | date | Date 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_sellout | date | Date >=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_rating | number | Average 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_reviews | integer | Number of user reviews on the product. | | activewear_category | keyword | Activewear classification. Valid values: performance (sports/training activewear), athleisure (fashion-led activewear), none (not activewear). Supports eq and neq only. | | is_licensed_activewear | boolean | Whether the product is officially licensed activewear (e.g. NFL, NBA branded gear). Supports eq only. | | sport_type | keyword | Sport 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 type | valid operators | | --- | --- | | string | eq, neq, in, not_in | | keyword | eq, 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 / integer | eq, neq, gt, gte, lt, lte, between, in, not_in | | date | eq, neq, gt, gte, lt, lte, between | | boolean | eq, neq | Available fields | field | | --- | | brand | | care | | composition | | cs_grp | | days_in_stock | | days_to_first_majority_sku_sellout | | advertised_discount_percentage | | advertised_discounted | | deepest_advertised_discount_percentage | | description | | first_advertised_discount_percentage | | first_price | | full_price | | gender | | has_had_advertised_discount | | image_urls | | in_stock | | is_second_hand | | market | | name | | normalised_average_rating | | number_of_reviews | | option_id | | outlet | | predominant_colour | | predominant_pattern | | price | | product_hash | | product_searches | | restock_count | | retailer | | sellout_percentage | | sku_count | | url | | activewear_category | | is_licensed_activewear | | sport_type | Returned brand field: brand.name/brand.slug are the canonical EDITED brand and its brand_slug when the product is mapped; for unmapped products (~half have no brand_slug) they fall back to the retailer's raw brand name and slug. So a row's brand.slug is NOT guaranteed to be a valid brand_slug filter value, nor to match a brand_slug group-by bucket in market_data_table — do not feed it straight back into a brand_slug filter; resolve the brand via search_brands first, or filter on the raw brand name instead. composition returns each fibre with its percentage(s), e.g. cotton 96%, elastane 4%. A fibre can carry several percentages (polyester 88%/100%) because they are parsed per mention in the care text, not per garment part — all are shown; do not read one as the garment's figure. ? means the fibre was detected but no percentage was parsed — it is NOT 0%. care is the raw source text the percentages came from, returned unjoined; request both together to check a garment against its source. See market_data_docs(topic="composition"). Sortable fields | sort field | | --- | | advertised_discount_percentage | | deepest_advertised_discount_percentage | | first_advertised_discount_percentage | | first_price | | full_price | | normalised_average_rating | | number_of_reviews | | price | | sellout_percentage | | sku_count | Sort order: asc (lowest first) or desc (highest first). ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | name | string | yes | — | concise name describing the type of data that you are trying to request | | date | string | yes | — | Date to search, as YYYY-MM-DD. Resolves to the weekly summary covering the week that contains this date (there is no daily resolution), so two dates in the same week return the same snapshot. Supported range: the past two years (730 days), inclusive. | | filters | object[] | 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. | | size | integer | no | default: 10 · 1–50 | Number of results to return. Default 10, max 50. | | fields | string[] | no | — | Fields to return for each option. If omitted, a compact default set covering the most commonly useful fields is returned. | | sort | object | no | — | Optional sort. If omitted the service returns results in its default order. | | vertical | string | no | — | 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. | | currency | string | no | — | 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 - TypeScript - Python ``` 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_options_search", "arguments": { "name": "example", "date": "example", "size": 10, "vertical": "example", "currency": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "mdmcp-market_data_options_search", arguments: { "name": "example", "date": "example", "size": 10, "vertical": "example", "currency": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "mdmcp-market_data_options_search", {"name": "example", "date": "example", "size": 10, "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": [ "cheapest discounted Zara dresses", "in-stock Nike sneakers in the UK", "new arriving luxury handbags on Farfetch" ], "type": "string" }, "date": { "description": "Date to search, as YYYY-MM-DD. Resolves to the weekly summary covering the week that contains this date (there is no daily resolution), so two dates in the same week return the same snapshot. Supported range: the past two years (730 days), inclusive.", "examples": [ "2026-03-15" ], "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": "retailer", "op": "in", "value": [ "zara" ] } ] ], "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" }, "size": { "default": 10, "description": "Number of results to return. Default 10, max 50.", "examples": [ 10 ], "maximum": 50, "minimum": 1, "type": "integer" }, "fields": { "description": "Fields to return for each option. If omitted, a compact default set covering the most commonly useful fields is returned.", "examples": [ [ "option_id", "name", "retailer", "price" ] ], "items": { "enum": [ "brand", "care", "composition", "cs_grp", "days_in_stock", "days_to_first_majority_sku_sellout", "advertised_discount_percentage", "advertised_discounted", "deepest_advertised_discount_percentage", "description", "first_advertised_discount_percentage", "first_price", "full_price", "gender", "has_had_advertised_discount", "image_urls", "in_stock", "is_second_hand", "market", "name", "normalised_average_rating", "number_of_reviews", "option_id", "outlet", "predominant_colour", "predominant_pattern", "price", "product_hash", "product_searches", "restock_count", "retailer", "sellout_percentage", "sku_count", "url", "activewear_category", "is_licensed_activewear", "sport_type" ], "type": "string" }, "type": "array" }, "sort": { "description": "Optional sort. If omitted the service returns results in its default order.", "examples": [ { "field": "price", "order": "asc" } ], "properties": { "field": { "description": "Field to sort on. Must be one of the documented sort fields.", "enum": [ "advertised_discount_percentage", "deepest_advertised_discount_percentage", "first_advertised_discount_percentage", "first_price", "full_price", "normalised_average_rating", "number_of_reviews", "price", "sellout_percentage", "sku_count" ], "examples": [ "price" ], "type": "string" }, "order": { "description": "Sort order. 'asc' for lowest first, 'desc' for highest first.", "enum": [ "asc", "desc" ], "examples": [ "asc" ], "type": "string" } }, "required": [ "field", "order" ], "type": "object" }, "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", "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 ``_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 ``_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 ``_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 } ``` --- ## Query Analytics (Market) Source: https://build.edited.com/reference/tools/mdmcp-market_data_table # 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 | field | type | definition | | --- | --- | --- | | retailer | string | Region-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_slug | string | Normalised 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. | | brand | text | Free-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). | | gender | string | Gender 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. | | market | string | Retailer 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_searches | integer[] | 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_colour | string | EDITED 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_pattern | string | EDITED 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. | | composition | string | Fabric 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_percentage | number | How 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_stock | boolean | Product has at least one available SKU. | | is_second_hand | boolean | Whether the product is second hand. | | outlet | boolean | Whether the product comes from an outlet assortment. | | advertised_discounted | boolean | Whether the product is currently advertised as discounted. | | price | number | Current 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_price | number | Highest 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_percentage | number | Current advertised discount percentage, on a 0-100 scale: 50 means 50% off — do NOT pass 0.5 for 50%. | | deepest_advertised_discount_percentage | number | Deepest advertised discount percentage ever recorded on the option, on a 0-100 scale (50 = 50% off). | | first_advertised_discount_percentage | number | First advertised discount percentage recorded on the option, on a 0-100 scale (50 = 50% off). | | has_had_advertised_discount | boolean | Whether the option has ever had an advertised discount. | | sellout_percentage | number | Percentage 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%. | | tier | string | Retailer market segment. Valid values: value, mass, premium, luxury. Case-insensitive; unknown values are rejected with an error. | | sku_count | integer | Total number of SKUs linked to the product option. | | option_id | string | EDITED option identifier. | | size_options | integer[] \| string[] | Size option IDs from search_size_options, or size group IDs as group-\{id} strings from search_size_group. | | name | text | Full-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. | | description | text | Full-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_found | date | Product 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_sellout | date | Date 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_sellout | date | Date >=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_rating | number | Average 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_reviews | integer | Number of user reviews on the product. | | activewear_category | keyword | Activewear classification. Valid values: performance (sports/training activewear), athleisure (fashion-led activewear), none (not activewear). Supports eq and neq only. | | is_licensed_activewear | boolean | Whether the product is officially licensed activewear (e.g. NFL, NBA branded gear). Supports eq only. | | sport_type | keyword | Sport 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 type | valid operators | | --- | --- | | string | eq, neq, in, not_in | | keyword | eq, 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 / integer | eq, neq, gt, gte, lt, lte, between, in, not_in | | date | eq, neq, gt, gte, lt, lte, between | | boolean | eq, neq | ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | name | string | yes | — | Concise name describing the type of data that you are trying to request. | | metrics | string[] | yes | — | Metric IDs to request. See the metric table: market_data_docs topic="metrics". | | start_date | string | yes | — | Inclusive start date for the analysis period. Must be within the past two years (730 days). | | end_date | string | yes | — | Inclusive end date for the analysis period. Must be within the past two years (730 days). | | filters | object[] | 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_by | object[] | 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). | | percentiles | object[] | 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. | | compare | object | no | — | Optional period-over-period comparison. When set, each metric gets a sibling \_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. | | trend | boolean | no | default: false | 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. | | sort | object[] | 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. | | limit | integer | no | default: 50 · 1–200 | 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. | | include_others | boolean | no | — | 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. | | vertical | string | no | — | 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. | | currency | string | no | — | 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 - TypeScript - Python ``` 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" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ 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" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "mdmcp-market_data_table", {"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 `_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 ``_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 ``_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 ``_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 } ``` --- ## Search Brands (Market) Source: https://build.edited.com/reference/tools/mdmcp-search_brands # Search Brands (Market) mdmcp-search_brands Find brands for the brand_slug and brand filters. Search by name, slug, or description. A slug you already hold is a valid query and returns that brand as exact, which is how you re-check a slug you were given (fcuk is French Connection UK). Use the returned slug value for the brand_slug filter, COPIED EXACTLY: that filter is case-sensitive and some slugs carry capitals (LC-Waikiki, EA7), so re-casing one matches zero products with no error. Use the returned name when you filter on the free-text brand field — that canonical spelling matches variant listings the user's phrasing may miss. Never put a slug in a brand filter. Some brands have slug: null (no normalised slug); for those the name is all you get, and it is exactly what brand wants. Every hit carries match: exact (query equals its name or slug, ignoring case, accents, punctuation and spacing; a separator between two digits is kept, so 3-6 months is not 36 months), lexical (text search ranked it: a typo, a partial, or the name plus or minus a word), or semantic_only (only the vector arm returned it: a nearest neighbour, not a match — verify or discard). Nothing beats semantic_only = not found. No hit is evidence of data behind the name. Rules: market_data_docs(topic="lookup_matching"). An unmapped brand (nothing beats semantic_only) is filtered on brand with the user's own string — never an unrelated slug or name. The leniency behind exact judges the NAME; it never licenses rewriting a slug. Try queries like "Nike", "luxury brands", "Gucci", "sportswear", "fcuk". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string \| string[] | yes | — | Single query string or list of query strings for batch search. | | search_limit | integer | no | default: 10 · 1–50 | Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10. | ## Returns - When query is a string: list[BrandHit] - When query is a list: list[list[BrandHit]] with results in same order as queries. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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-search_brands", "arguments": { "query": "Nike", "search_limit": 10 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "mdmcp-search_brands", arguments: { "query": "Nike", "search_limit": 10 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "mdmcp-search_brands", {"query": "Nike", "search_limit": 10}, ) ``` ## Example response The payload — what you read from result.structuredContent.result. A refusal arrives on the sibling error key instead, with isError still false — see Concepts → Response shape. ``` [ { "name": "Nike", "slug": "nike", "match": "exact" }, { "name": "Nike SB", "slug": "nike-sb", "match": "lexical" }, { "name": "Nike Golf", "slug": "nike-golf", "match": "lexical" } ] ``` ## Input schema ``` { "type": "object", "properties": { "query": { "anyOf": [ { "type": "string" }, { "items": { "type": "string" }, "maxItems": 10, "type": "array" } ] }, "search_limit": { "default": 10, "description": "Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10.", "maximum": 50, "minimum": 1, "type": "integer" } }, "required": [ "query" ] } ``` ## 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": { "anyOf": [ { "items": { "properties": { "name": { "type": "string" }, "slug": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "name", "slug", "match" ], "type": "object" }, "type": "array" }, { "items": { "items": { "properties": { "name": { "type": "string" }, "slug": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "name", "slug", "match" ], "type": "object" }, "type": "array" }, "type": "array" } ] }, "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" ] } }, "x-fastmcp-wrap-result": true, "oneOf": [ { "required": [ "result" ] }, { "required": [ "error" ] } ] } ``` --- ## Search Markets (Market) Source: https://build.edited.com/reference/tools/mdmcp-search_markets # Search Markets (Market) mdmcp-search_markets Find markets to use in filter queries. Search by market name. Markets represent geographic or regional market segments. The id is the code (US, UK, EU) and the name carries it as a suffix — United States (US), Europe (EU). The code, the full name and the country on its own all name the same market, so any of US, United States (US) or United States is exact. Use the returned id value when building market filters. Every hit carries match: exact (query equals its id or name, ignoring case, accents, punctuation and spacing; a separator between two digits is kept, so 3-6 months is not 36 months; a region/code suffix on the name is ignored), lexical (text search ranked it: a typo, a partial, or the name plus or minus a word), or semantic_only (only the vector arm returned it: a nearest neighbour, not a match — verify or discard). Nothing beats semantic_only = not found. No hit is evidence of data behind the name. Rules: market_data_docs(topic="lookup_matching"). Try queries like "United States", "US", "UK market", "Europe". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string \| string[] | yes | — | Single query string or list of query strings for batch search. | | search_limit | integer | no | default: 10 · 1–50 | Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10. | ## Returns - When query is a string: list[MarketHit] - When query is a list: list[list[MarketHit]] with results in same order as queries. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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-search_markets", "arguments": { "query": "Nike", "search_limit": 10 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "mdmcp-search_markets", arguments: { "query": "Nike", "search_limit": 10 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "mdmcp-search_markets", {"query": "Nike", "search_limit": 10}, ) ``` ## Example response The payload — what you read from result.structuredContent.result. A refusal arrives on the sibling error key instead, with isError still false — see Concepts → Response shape. ``` [ { "id": "UK", "name": "United Kingdom (UK)", "match": "exact" }, { "id": "US", "name": "United States (US)", "match": "exact" } ] ``` ## Input schema ``` { "type": "object", "properties": { "query": { "anyOf": [ { "type": "string" }, { "items": { "type": "string" }, "maxItems": 10, "type": "array" } ] }, "search_limit": { "default": 10, "description": "Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10.", "maximum": 50, "minimum": 1, "type": "integer" } }, "required": [ "query" ] } ``` ## 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": { "anyOf": [ { "items": { "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "id", "name", "match" ], "type": "object" }, "type": "array" }, { "items": { "items": { "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "id", "name", "match" ], "type": "object" }, "type": "array" }, "type": "array" } ] }, "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" ] } }, "x-fastmcp-wrap-result": true, "oneOf": [ { "required": [ "result" ] }, { "required": [ "error" ] } ] } ``` --- ## Search Product Categories (Market) Source: https://build.edited.com/reference/tools/mdmcp-search_product_searches # Search Product Categories (Market) mdmcp-search_product_searches Find product categories to use in filter queries. Search by category name or type. Use the returned id value — an INTEGER — when building category/product search filters. Filtering on an id selects that category AND its whole subtree. Unlike retailer and brand_slug, product_searches takes no slug: curation_value (sub_cat_jeans_bottoms) is the taxonomy's internal keyword and is NOT a filter value, nor is name or the name_full path. Pass 1302, not "1302". The categories form a tree, up to three levels deep: type is top_level_category, subcategory or style, and parent_id / parent_name give the level above (name_full is the whole path, and the value a product_searches group-by returns as its row label; it is null, never partial, for the rare record whose ancestor chain is incomplete). Names are NOT unique — three separate categories are called Shorts — so disambiguate on name_full, never on name. Beware Apparel (excl footwear & accessories): it has no parent and no children, so it reads as an ordinary top-level category, but it is a roll-up spanning most of the apparel assortment. Before summing anything across a category breakdown, see market_data_docs(topic="product_searches"). Every hit carries match: exact (query equals its name or name_full, ignoring case, accents, punctuation and spacing; a separator between two digits is kept, so 3-6 months is not 36 months; a pasted id is never compared), lexical (text search ranked it: a typo, a partial, or the name plus or minus a word), or semantic_only (only the vector arm returned it: a nearest neighbour, not a match — verify or discard). Nothing beats semantic_only = not found. No hit is evidence of data behind the name. Rules: market_data_docs(topic="lookup_matching"). Try queries like "dresses", "mens shoes", "accessories", "outerwear", "tops". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string \| string[] | yes | — | Single query string or list of query strings for batch search. | | search_limit | integer | no | default: 10 · 1–50 | Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10. | | vertical | string | no | — | Optional market vertical (apparel \| beauty \| homeware); resolves from config when omitted. Scopes results to that vertical's categories. | ## Returns - When query is a string: list[ProductSearchHit] - When query is a list: list[list[ProductSearchHit]] with results in same order as queries. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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-search_product_searches", "arguments": { "query": "Nike", "search_limit": 10, "vertical": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "mdmcp-search_product_searches", arguments: { "query": "Nike", "search_limit": 10, "vertical": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "mdmcp-search_product_searches", {"query": "Nike", "search_limit": 10, "vertical": "example"}, ) ``` ## Example response The payload — what you read from result.structuredContent.result. A refusal arrives on the sibling error key instead, with isError still false — see Concepts → Response shape. ``` [ { "id": 138, "name": "Dresses", "vertical": "apparel", "type": "top_level_category", "category": "dresses", "parent_id": null, "curation_value": "dresses", "match": "exact" }, { "id": 2956, "name": "Bodycon", "vertical": "apparel", "type": "subcategory", "category": "dresses", "parent_id": 138, "curation_value": "sub_cat_bodycon_dress", "match": "lexical" } ] ``` ## Input schema ``` { "type": "object", "properties": { "query": { "anyOf": [ { "type": "string" }, { "items": { "type": "string" }, "maxItems": 10, "type": "array" } ] }, "search_limit": { "default": 10, "description": "Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10.", "maximum": 50, "minimum": 1, "type": "integer" }, "vertical": { "description": "Optional. The market vertical (top-level data segment) to search within: `apparel` | `beauty` | `homeware`. NOT a product category. Pass the same vertical you use on the analytics/options tools so discovered slugs/categories match the data you query. 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" } }, "required": [ "query" ] } ``` ## 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": { "anyOf": [ { "items": { "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "vertical": { "type": "string" }, "type": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "category": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "parent_id": { "anyOf": [ { "type": "integer" }, { "type": "null" } ] }, "curation_value": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "parent_name": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "name_full": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "id", "name", "vertical", "type", "category", "parent_id", "curation_value", "match" ], "type": "object" }, "type": "array" }, { "items": { "items": { "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "vertical": { "type": "string" }, "type": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "category": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "parent_id": { "anyOf": [ { "type": "integer" }, { "type": "null" } ] }, "curation_value": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "parent_name": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "name_full": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "id", "name", "vertical", "type", "category", "parent_id", "curation_value", "match" ], "type": "object" }, "type": "array" }, "type": "array" } ] }, "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" ] } }, "x-fastmcp-wrap-result": true, "oneOf": [ { "required": [ "result" ] }, { "required": [ "error" ] } ] } ``` --- ## Search Retailers (Market) Source: https://build.edited.com/reference/tools/mdmcp-search_retailers # Search Retailers (Market) mdmcp-search_retailers Find retailers to use in filter queries. Search by name, slug, region, or description. A slug you already hold is a valid query and returns that retailer as exact — worth knowing because slugs are not always the name: dvf is Diane Von Furstenberg (US), groceriesatasda-uk is Asda (UK). Use the returned retailers[].slug value when building retailer filters, copied exactly — the filter matches it verbatim, case included. One retailer per country: Zara (ES) and Zara (UK) are separate retailers with separate data, so a bare Zara is exact on EVERY country it trades in. Picking one is your job — choose on region, pass country_code, or name the country in the query (Zara (ES)). Every hit carries match: exact (query equals its name or slug, ignoring case, accents, punctuation and spacing; a separator between two digits is kept, so 3-6 months is not 36 months; a region/code suffix on the name is ignored), lexical (text search ranked it: a typo, a partial, or the name plus or minus a word), or semantic_only (only the vector arm returned it: a nearest neighbour, not a match — verify or discard). Nothing beats semantic_only = not found. No hit is evidence of data behind the name. Rules: market_data_docs(topic="lookup_matching"). Run get_retailer_coverage on the slugs you pick before reporting numbers from the query tools — that, not match, answers whether data exists. Try queries like "Zara", "UK retailers", "fast fashion", "Amazon", "dvf". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string \| string[] | yes | — | Single query string or list of query strings for batch search. | | search_limit | integer | no | default: 10 · 1–50 | Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10. | | country_code | string | no | — | Optional country code to filter by region (e.g., "UK", "US", "ES"). EDITED codes are ISO 3166-1 alpha-2 with two exceptions: the United Kingdom is "UK" (not ISO "GB"; "GB" is accepted and mapped to "UK") and "EU" covers EU-wide retailers. Case-insensitive. | ## Returns - A RetailerSearchResult — retailers plus search_limit / - at_search_limit. A batch request returns one result per query, in the - same order as the queries. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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-search_retailers", "arguments": { "query": "Nike", "search_limit": 10, "country_code": "UK" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "mdmcp-search_retailers", arguments: { "query": "Nike", "search_limit": 10, "country_code": "UK" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "mdmcp-search_retailers", {"query": "Nike", "search_limit": 10, "country_code": "UK"}, ) ``` ## Example response The payload — what you read from result.structuredContent.result. A refusal arrives on the sibling error key instead, with isError still false — see Concepts → Response shape. ``` { "retailers": [ { "name": "Zara (UK)", "slug": "zara", "region": { "id": 561, "iso_code": "UK", "name": "United Kingdom" }, "is_deprecated": false, "visible": true, "tier": { "id": 6, "name": "Mass", "slug": "mass" }, "match": "exact" }, { "name": "Zara Home (UK)", "slug": "zarahome-uk", "region": { "id": 561, "iso_code": "UK", "name": "United Kingdom" }, "is_deprecated": false, "visible": true, "tier": { "id": 6, "name": "Mass", "slug": "mass" }, "match": "lexical" }, { "name": "La Senza (UK)", "slug": "lasenza-uk", "region": { "id": 561, "iso_code": "UK", "name": "United Kingdom" }, "is_deprecated": false, "visible": true, "tier": { "id": 6, "name": "Mass", "slug": "mass" }, "match": "semantic_only" } ], "search_limit": 3, "at_search_limit": true } ``` ## Input schema ``` { "type": "object", "properties": { "query": { "anyOf": [ { "type": "string" }, { "items": { "type": "string" }, "maxItems": 10, "type": "array" } ] }, "search_limit": { "default": 10, "description": "Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10.", "maximum": 50, "minimum": 1, "type": "integer" }, "country_code": { "type": "string" } }, "required": [ "query" ] } ``` ## 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": { "anyOf": [ { "description": "Retailer hits, the applied ``search_limit``, and whether the search filled it.", "properties": { "retailers": { "items": { "properties": { "name": { "type": "string" }, "slug": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "region": { "anyOf": [ { "properties": { "id": { "type": "integer" }, "iso_code": { "type": "string" }, "name": { "type": "string" } }, "required": [ "id", "iso_code", "name" ], "type": "object" }, { "type": "null" } ], "default": null }, "is_deprecated": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ], "default": null, "description": "Constant (`false` or absent; ingest filters on it), not an availability signal — use `get_retailer_coverage` for that." }, "visible": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ], "default": null, "description": "Constant (`true`; ingest filters on it), not an availability signal." }, "tier": { "anyOf": [ { "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "slug": { "type": "string" } }, "required": [ "id", "name", "slug" ], "type": "object" }, { "type": "null" } ], "default": null }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "name", "slug", "match" ], "type": "object" }, "type": "array" }, "search_limit": { "minimum": 1, "type": "integer" }, "at_search_limit": { "description": "True if the search filled search_limit. Retailer search is hybrid (lexical + semantic) and the vector arm has no similarity floor, so a full list does NOT mean more matches exist — low-relevance neighbours pad it out, and they are the rows reporting `match: semantic_only`. Raise search_limit (max 50) only if a retailer you expected is missing. `retailers` can be shorter than search_limit while this is true, if a matched record could not be returned (momentarily unavailable, or malformed).", "type": "boolean" } }, "required": [ "retailers", "search_limit", "at_search_limit" ], "type": "object" }, { "items": { "description": "Retailer hits, the applied ``search_limit``, and whether the search filled it.", "properties": { "retailers": { "items": { "properties": { "name": { "type": "string" }, "slug": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "region": { "anyOf": [ { "properties": { "id": { "type": "integer" }, "iso_code": { "type": "string" }, "name": { "type": "string" } }, "required": [ "id", "iso_code", "name" ], "type": "object" }, { "type": "null" } ], "default": null }, "is_deprecated": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ], "default": null, "description": "Constant (`false` or absent; ingest filters on it), not an availability signal — use `get_retailer_coverage` for that." }, "visible": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ], "default": null, "description": "Constant (`true`; ingest filters on it), not an availability signal." }, "tier": { "anyOf": [ { "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "slug": { "type": "string" } }, "required": [ "id", "name", "slug" ], "type": "object" }, { "type": "null" } ], "default": null }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "name", "slug", "match" ], "type": "object" }, "type": "array" }, "search_limit": { "minimum": 1, "type": "integer" }, "at_search_limit": { "description": "True if the search filled search_limit. Retailer search is hybrid (lexical + semantic) and the vector arm has no similarity floor, so a full list does NOT mean more matches exist — low-relevance neighbours pad it out, and they are the rows reporting `match: semantic_only`. Raise search_limit (max 50) only if a retailer you expected is missing. `retailers` can be shorter than search_limit while this is true, if a matched record could not be returned (momentarily unavailable, or malformed).", "type": "boolean" } }, "required": [ "retailers", "search_limit", "at_search_limit" ], "type": "object" }, "type": "array" } ] }, "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" ] } }, "x-fastmcp-wrap-result": true, "oneOf": [ { "required": [ "result" ] }, { "required": [ "error" ] } ] } ``` --- ## Search Size Groups (Market) Source: https://build.edited.com/reference/tools/mdmcp-search_size_group # Search Size Groups (Market) mdmcp-search_size_group Find size groups to use in filter queries. Search by name, category, or gender. Size groups define sizing systems. Names put the garment or audience first and the region second, so they read Womens US, Womens UK, Men's Garments, Bras EU D, Beds UK, Teens — not "US Women's Apparel". Use the returned id value when building size group filters. Every hit carries match: exact (query equals its name, ignoring case, accents, punctuation and spacing; a separator between two digits is kept, so 3-6 months is not 36 months; a pasted id is never compared), lexical (text search ranked it: a typo, a partial, or the name plus or minus a word), or semantic_only (only the vector arm returned it: a nearest neighbour, not a match — verify or discard). Nothing beats semantic_only = not found. No hit is evidence of data behind the name. Rules: market_data_docs(topic="lookup_matching"). Try queries like "women's sizes", "mens shoes", "kids clothing", "UK sizing". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string \| string[] | yes | — | Single query string or list of query strings for batch search. | | search_limit | integer | no | default: 10 · 1–50 | Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10. | ## Returns - When query is a string: list[SizeGroupHit] - When query is a list: list[list[SizeGroupHit]] with results in same order as queries. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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-search_size_group", "arguments": { "query": "Nike", "search_limit": 10 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "mdmcp-search_size_group", arguments: { "query": "Nike", "search_limit": 10 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "mdmcp-search_size_group", {"query": "Nike", "search_limit": 10}, ) ``` ## Example response The payload — what you read from result.structuredContent.result. A refusal arrives on the sibling error key instead, with isError still false — see Concepts → Response shape. ``` [ { "id": 43, "name": "Womens UK", "category": "womens_garments", "gender": "adults", "subtitle": null, "unit": "womens_uk", "visible": true, "match": "exact" } ] ``` ## Input schema ``` { "type": "object", "properties": { "query": { "anyOf": [ { "type": "string" }, { "items": { "type": "string" }, "maxItems": 10, "type": "array" } ] }, "search_limit": { "default": 10, "description": "Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10.", "maximum": 50, "minimum": 1, "type": "integer" } }, "required": [ "query" ] } ``` ## 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": { "anyOf": [ { "items": { "properties": { "category": { "type": "string" }, "gender": { "type": "string" }, "id": { "type": "integer" }, "name": { "type": "string" }, "subtitle": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "unit": { "type": "string" }, "visible": { "description": "Constant (`true`; ingest filters on it), not an availability signal.", "type": "boolean" }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "category", "gender", "id", "name", "subtitle", "unit", "visible", "match" ], "type": "object" }, "type": "array" }, { "items": { "items": { "properties": { "category": { "type": "string" }, "gender": { "type": "string" }, "id": { "type": "integer" }, "name": { "type": "string" }, "subtitle": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "unit": { "type": "string" }, "visible": { "description": "Constant (`true`; ingest filters on it), not an availability signal.", "type": "boolean" }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "category", "gender", "id", "name", "subtitle", "unit", "visible", "match" ], "type": "object" }, "type": "array" }, "type": "array" } ] }, "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" ] } }, "x-fastmcp-wrap-result": true, "oneOf": [ { "required": [ "result" ] }, { "required": [ "error" ] } ] } ``` --- ## Search Sizes (Market) Source: https://build.edited.com/reference/tools/mdmcp-search_size_options # Search Sizes (Market) mdmcp-search_size_options Find specific sizes to use in filter queries. Search by size name or category. Size options are individual sizes within a size group. Names are as the group writes them and are often not the bare size you would type: XL & larger, 3XL, One Size, 8-8.5 adult, 2-3 years. Sizes are stored the way a label prints them, so a spelled-out word finds nothing — NO size is named "medium" or "large"; search M, XL. Search the size you mean and read the name you get back. Use the returned id value when building size filters. Every hit carries match: exact (query equals its name, ignoring case, accents, punctuation and spacing; a separator between two digits is kept, so 3-6 months is not 36 months; a pasted id is never compared), lexical (text search ranked it: a typo, a partial, or the name plus or minus a word), or semantic_only (only the vector arm returned it: a nearest neighbour, not a match — verify or discard). Nothing beats semantic_only = not found. No hit is evidence of data behind the name. Rules: market_data_docs(topic="lookup_matching"). Try queries like "M", "XL", "One Size", "32 waist", "2-3 years". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string \| string[] | yes | — | Single query string or list of query strings for batch search. | | search_limit | integer | no | default: 10 · 1–50 | Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10. | ## Returns - When query is a string: list[SizeOptionHit] - When query is a list: list[list[SizeOptionHit]] with results in same order as queries. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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-search_size_options", "arguments": { "query": "Nike", "search_limit": 10 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "mdmcp-search_size_options", arguments: { "query": "Nike", "search_limit": 10 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "mdmcp-search_size_options", {"query": "Nike", "search_limit": 10}, ) ``` ## Example response The payload — what you read from result.structuredContent.result. A refusal arrives on the sibling error key instead, with isError still false — see Concepts → Response shape. ``` [ { "id": 901, "group_id": 43, "name": "12", "subtitle": null, "tags": "uk_size_12_equivalent", "tags_women": null, "visible": true, "group": { "id": 43, "name": "Womens UK", "category": "womens_garments", "gender": "adults", "subtitle": null, "unit": "womens_uk", "visible": true }, "match": "exact" } ] ``` ## Input schema ``` { "type": "object", "properties": { "query": { "anyOf": [ { "type": "string" }, { "items": { "type": "string" }, "maxItems": 10, "type": "array" } ] }, "search_limit": { "default": 10, "description": "Maximum number of results to return (default 10, max 50). Raise it when validating or comparing many entities in one call so the list is not clipped at the default 10.", "maximum": 50, "minimum": 1, "type": "integer" } }, "required": [ "query" ] } ``` ## 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": { "anyOf": [ { "items": { "properties": { "group": { "properties": { "category": { "type": "string" }, "gender": { "type": "string" }, "id": { "type": "integer" }, "name": { "type": "string" }, "subtitle": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "unit": { "type": "string" }, "visible": { "description": "Constant (`true`; ingest filters on it), not an availability signal.", "type": "boolean" } }, "required": [ "category", "gender", "id", "name", "subtitle", "unit", "visible" ], "type": "object" }, "group_id": { "type": "integer" }, "id": { "type": "integer" }, "name": { "type": "string" }, "subtitle": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "tags": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "tags_women": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "visible": { "description": "Constant (`true`; ingest filters on it), not an availability signal.", "type": "boolean" }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "group", "group_id", "id", "name", "subtitle", "tags", "tags_women", "visible", "match" ], "type": "object" }, "type": "array" }, { "items": { "items": { "properties": { "group": { "properties": { "category": { "type": "string" }, "gender": { "type": "string" }, "id": { "type": "integer" }, "name": { "type": "string" }, "subtitle": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "unit": { "type": "string" }, "visible": { "description": "Constant (`true`; ingest filters on it), not an availability signal.", "type": "boolean" } }, "required": [ "category", "gender", "id", "name", "subtitle", "unit", "visible" ], "type": "object" }, "group_id": { "type": "integer" }, "id": { "type": "integer" }, "name": { "type": "string" }, "subtitle": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "tags": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "tags_women": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "visible": { "description": "Constant (`true`; ingest filters on it), not an availability signal.", "type": "boolean" }, "match": { "description": "What matched, not how well. Not a score. See the tool description.", "enum": [ "exact", "lexical", "semantic_only" ], "type": "string" } }, "required": [ "group", "group_id", "id", "name", "subtitle", "tags", "tags_women", "visible", "match" ], "type": "object" }, "type": "array" }, "type": "array" } ] }, "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" ] } }, "x-fastmcp-wrap-result": true, "oneOf": [ { "required": [ "result" ] }, { "required": [ "error" ] } ] } ``` --- ## Aggregate Promo Discounts (Messaging) Source: https://build.edited.com/reference/tools/messagingmcp-aggregate_promos # Aggregate Promo Discounts (Messaging) messagingmcp-aggregate_promos Compute a statistic over promo discount depth, deduped by promo_id. metric defaults to 'promo_depth' — the only aggregatable field — so it can be omitted. promo_depth_type is required (depths are unitless integers — without unit pinning the tool would mix percentages with currency amounts). Multi-buy promotions are EXCLUDED from every aggregate: their depth carries a third meaning (qualifying-item %, null for pure BOGO), so 'percentage' aggregates percentage_discount promos only and 'flat' aggregates flat_discount only. Returns a scalar value plus n_promos and n_observed (how many distinct promos contributed, and how many of those have a non-inferred row). With group_by or interval, returns a buckets array of those same shapes. group_by returns AT MOST 50 buckets — the groups with the most rows behind them — and the response does not say what it dropped: that is every group for 'promo_type', but a fraction of them for 'retailer' (hundreds even inside one region) and 'shoot' (many thousands). To ask about a particular retailer, filter to it rather than expecting it among the buckets. Use for "average % off", "deepest discount", "median flat amount". For counts or row-level retrieval use count_promos / list_promos. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | statistic | string | yes | — | Statistic to compute over distinct promotions' depth. | | promo_depth_type | string | yes | — | REQUIRED. Pins the unit. 'percentage' for % off, 'flat' for currency-amount promos. Without it, percentages and currency amounts would be averaged together into a meaningless number. | | metric | string | no | default: "promo_depth" | Field to aggregate. Only 'promo_depth' (numeric discount value) exists, so the param can be omitted. | | promo_currency | string | no | — | ISO 4217 code (e.g. GBP, USD). REQUIRED when promo_depth_type='flat' — different currencies must not be averaged. Must be omitted for 'percentage' (rejected if supplied — percentages have no currency; use regions to scope a market). | | retailers | string[] | no | — | Retailer slugs to filter by, e.g. ['zara', 'h-m']. Omit for all; matches any listed retailer. Slugs match exactly and are case-sensitive, so take them from list_retailers: match the request against retailer_name, then pass that row's retailer slug and one of the regions it reports. | | regions | string[] | no | — | Regions to filter by, e.g. ['UK', 'US']. Omit for all; matches any listed region. Exact match, so use the values list_retailers reports for the retailer. | | start_date | string | no | — | Inclusive start of the active-date window (YYYY-MM-DD). | | end_date | string | no | — | Inclusive end of the active-date window (YYYY-MM-DD). | | min_depth | integer | no | — | Minimum promo_depth. Depths are unitless — a percentage or a currency amount depending on the promo type — so constrain the type to avoid mixing units. | | max_depth | integer | no | — | Maximum promo_depth. Same unit caveat as min_depth. | | has_code | boolean | no | — | True = only promos with a code, False = only without; omit for no filter. | | include_inferred | boolean | no | default: true | Include inferred promotions (default True). False = observed only. | | group_by | string | no | — | Optional categorical bucketing. Mutually exclusive with interval. 'shoot' groups by source image to surface co-promoted bundles. Returns at most 50 buckets — the groups with the most rows behind them — and does not report what it omitted; 'retailer' and 'shoot' have far more groups than that, so treat the result as a top-50, not a complete set. | | interval | string | no | — | Optional temporal bucketing on promotion_date. Exclusive with group_by. Rows are limited to [start_date, end_date], but bucket keys are calendar-aligned (week/month starts), so an edge bucket may be keyed before start_date and cover only the in-window part of its period. | ## Returns - On success: {"result": {"metric", "statistic", "promo_depth_type", - "promo_currency", "value", "n_promos", "n_observed", "filter_echo"}} — - the first four pin the unit the statistic is in, without which a depth is - meaningless. - With group_by or interval: {"result": {"metric", "statistic", - "promo_depth_type", "promo_currency", "buckets": [{group, value, - n_promos, n_observed}], "filter_echo"}}. - On failure: {"error": {code, message}} - code is invalid_input, upstream_unavailable, not_entitled - (authorization refused), or internal when the filtered set spans too - many distinct depths to describe in one pass — retry narrower, since - min_depth/max_depth, regions, retailers and promo_currency - each shrink that spread. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "messagingmcp-aggregate_promos", "arguments": { "statistic": "example", "promo_depth_type": "example", "metric": "promo_depth", "promo_currency": "example", "start_date": "example", "end_date": "example", "min_depth": 1, "max_depth": 1, "has_code": false, "include_inferred": true, "group_by": "example", "interval": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "messagingmcp-aggregate_promos", arguments: { "statistic": "example", "promo_depth_type": "example", "metric": "promo_depth", "promo_currency": "example", "start_date": "example", "end_date": "example", "min_depth": 1, "max_depth": 1, "has_code": false, "include_inferred": true, "group_by": "example", "interval": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "messagingmcp-aggregate_promos", {"statistic": "example", "promo_depth_type": "example", "metric": "promo_depth", "promo_currency": "example", "start_date": "example", "end_date": "example", "min_depth": 1, "max_depth": 1, "has_code": False, "include_inferred": True, "group_by": "example", "interval": "example"}, ) ``` ## Input schema ``` { "type": "object", "properties": { "statistic": { "description": "Statistic to compute over distinct promotions' depth.", "enum": [ "mean", "median", "min", "max" ], "type": "string" }, "promo_depth_type": { "description": "REQUIRED. Pins the unit. 'percentage' for % off, 'flat' for currency-amount promos. Without it, percentages and currency amounts would be averaged together into a meaningless number.", "enum": [ "percentage", "flat" ], "type": "string" }, "metric": { "const": "promo_depth", "default": "promo_depth", "description": "Field to aggregate. Only 'promo_depth' (numeric discount value) exists, so the param can be omitted.", "type": "string" }, "promo_currency": { "description": "ISO 4217 code (e.g. GBP, USD). REQUIRED when promo_depth_type='flat' — different currencies must not be averaged. Must be omitted for 'percentage' (rejected if supplied — percentages have no currency; use `regions` to scope a market).", "type": "string" }, "retailers": { "description": "Retailer slugs to filter by, e.g. ['zara', 'h-m']. Omit for all; matches any listed retailer. Slugs match exactly and are case-sensitive, so take them from `list_retailers`: match the request against `retailer_name`, then pass that row's `retailer` slug and one of the regions it reports.", "items": { "type": "string" }, "type": "array" }, "regions": { "description": "Regions to filter by, e.g. ['UK', 'US']. Omit for all; matches any listed region. Exact match, so use the values `list_retailers` reports for the retailer.", "items": { "type": "string" }, "type": "array" }, "start_date": { "description": "Inclusive start of the active-date window (YYYY-MM-DD).", "format": "date", "type": "string" }, "end_date": { "description": "Inclusive end of the active-date window (YYYY-MM-DD).", "format": "date", "type": "string" }, "min_depth": { "description": "Minimum promo_depth. Depths are unitless — a percentage or a currency amount depending on the promo type — so constrain the type to avoid mixing units.", "type": "integer" }, "max_depth": { "description": "Maximum promo_depth. Same unit caveat as min_depth.", "type": "integer" }, "has_code": { "description": "True = only promos with a code, False = only without; omit for no filter.", "type": "boolean" }, "include_inferred": { "default": true, "description": "Include inferred promotions (default True). False = observed only.", "type": "boolean" }, "group_by": { "description": "Optional categorical bucketing. Mutually exclusive with `interval`. 'shoot' groups by source image to surface co-promoted bundles. Returns at most 50 buckets — the groups with the most rows behind them — and does not report what it omitted; 'retailer' and 'shoot' have far more groups than that, so treat the result as a top-50, not a complete set.", "enum": [ "retailer", "promo_type", "region", "shoot" ], "type": "string" }, "interval": { "description": "Optional temporal bucketing on promotion_date. Exclusive with group_by. Rows are limited to [start_date, end_date], but bucket keys are calendar-aligned (week/month starts), so an edge bucket may be keyed before start_date and cover only the in-window part of its period.", "enum": [ "day", "week", "month" ], "type": "string" } }, "required": [ "statistic", "promo_depth_type" ] } ``` ## 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": { "type": "object", "properties": { "value": { "anyOf": [ { "type": "number" }, { "type": "null" } ], "description": "Null when no matching promotion carried a depth." }, "n_promos": { "description": "Distinct promotions, counted exactly to 40,000 and estimated above that (within roughly half a percent). Matched promotions carrying no depth are counted here but contribute nothing, so this sits above the population behind `value` by that share plus the estimate's error.", "type": "integer" }, "n_observed": { "description": "Distinct promotions, counted exactly to 40,000 and estimated above that (within roughly half a percent). The subset with a directly-observed row.", "type": "integer" }, "metric": { "const": "promo_depth", "type": "string" }, "statistic": { "type": "string" }, "promo_depth_type": { "type": "string" }, "promo_currency": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "filter_echo": { "properties": { "retailers": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "regions": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "start_date": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "end_date": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "include_inferred": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ], "default": null }, "min_depth": { "anyOf": [ { "type": "integer" }, { "type": "null" } ], "default": null }, "max_depth": { "anyOf": [ { "type": "integer" }, { "type": "null" } ], "default": null }, "has_code": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ], "default": null }, "statistic": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_depth_type": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_currency": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "group_by": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "interval": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null } }, "type": "object" }, "buckets": { "description": "Under group_by this is AT MOST 50 buckets — the groups with the most rows behind them, not the deepest — with no report of what was dropped; 50 buckets is a top-50, not a complete set.", "items": { "properties": { "value": { "anyOf": [ { "type": "number" }, { "type": "null" } ], "description": "Null when no matching promotion carried a depth." }, "n_promos": { "description": "Distinct promotions, counted exactly to 40,000 and estimated above that (within roughly half a percent). Matched promotions carrying no depth are counted here but contribute nothing, so this sits above the population behind `value` by that share plus the estimate's error.", "type": "integer" }, "n_observed": { "description": "Distinct promotions, counted exactly to 40,000 and estimated above that (within roughly half a percent). The subset with a directly-observed row.", "type": "integer" }, "group": { "type": "string" } }, "required": [ "value", "n_promos", "n_observed", "group" ], "type": "object" }, "type": "array" } }, "required": [ "filter_echo", "metric", "promo_currency", "promo_depth_type", "statistic" ], "allOf": [ { "anyOf": [ { "required": [ "filter_echo", "metric", "n_observed", "n_promos", "promo_currency", "promo_depth_type", "statistic", "value" ] }, { "required": [ "buckets", "filter_echo", "metric", "promo_currency", "promo_depth_type", "statistic" ] } ] } ] }, "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" ] } }, "x-fastmcp-wrap-result": true, "oneOf": [ { "required": [ "result" ] }, { "required": [ "error" ] } ] } ``` --- ## Count Promotions (Messaging) Source: https://build.edited.com/reference/tools/messagingmcp-count_promos # Count Promotions (Messaging) messagingmcp-count_promos Count distinct promotions matching the filters, deduped by promo_id. A "distinct promotion" is a campaign: captures of the same offer are linked to one promo_id across time (see explain_promo_data('identity')), and each is counted once regardless of how many captures back it. observed_count is the subset with at least one directly-observed (non-inferred) row. With group_by or interval, returns a buckets array of \{group, count, observed_count}. With interval, a promotion is counted in EVERY bucket it was active in, so bucket counts can sum to more than the unbucketed count. With group_by='promo_type', promos with a null type (rare) fall in no bucket, so buckets sum to slightly less than the total. group_by returns AT MOST 50 buckets — the groups with the most rows behind them — and the response does not say what it dropped: that is every group for 'promo_type', but a fraction of them for 'retailer' (hundreds even inside one region) and 'shoot' (many thousands). To ask about a particular retailer, filter to it rather than expecting it among the buckets. filter_echo reflects what was actually applied. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | retailers | string[] | no | — | Retailer slugs to filter by, e.g. ['zara', 'h-m']. Omit for all; matches any listed retailer. Slugs match exactly and are case-sensitive, so take them from list_retailers: match the request against retailer_name, then pass that row's retailer slug and one of the regions it reports. | | regions | string[] | no | — | Regions to filter by, e.g. ['UK', 'US']. Omit for all; matches any listed region. Exact match, so use the values list_retailers reports for the retailer. | | start_date | string | no | — | Start of date range (inclusive, YYYY-MM-DD). Filters to promos active at any point in [start_date, end_date]. | | end_date | string | no | — | End of date range (inclusive, YYYY-MM-DD). | | promo_types | string[] | no | — | Promo types to include: 'percentage_discount', 'flat_discount', 'multi-buy'. Omit for all. | | min_depth | integer | no | — | Minimum promo_depth. Depths are unitless — a percentage or a currency amount depending on the promo type — so constrain the type to avoid mixing units. | | max_depth | integer | no | — | Maximum promo_depth. Same unit caveat as min_depth. | | has_code | boolean | no | — | True = only promos with a code, False = only without; omit for no filter. | | include_inferred | boolean | no | default: true | Include inferred promotions (default True). False = observed only. | | group_by | string | no | — | Optional categorical bucketing. Mutually exclusive with interval. 'shoot' groups by source image to surface co-promoted bundles. Returns at most 50 buckets — the groups with the most rows behind them — and does not report what it omitted; 'retailer' and 'shoot' have far more groups than that, so treat the result as a top-50, not a complete set. | | interval | string | no | — | Optional temporal bucketing on promotion_date. Exclusive with group_by. Rows are limited to [start_date, end_date], but bucket keys are calendar-aligned (week/month starts), so an edge bucket may be keyed before start_date and cover only the in-window part of its period. | ## Returns - On success: {"result": {"count", "observed_count", "filter_echo"}} or - {"result": {"buckets": [...], "filter_echo"}}. - On failure: {"error": {code, message}} - code is invalid_input, upstream_unavailable, or - not_entitled (authorization refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "messagingmcp-count_promos", "arguments": { "start_date": "example", "end_date": "example", "min_depth": 1, "max_depth": 1, "has_code": false, "include_inferred": true, "group_by": "example", "interval": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "messagingmcp-count_promos", arguments: { "start_date": "example", "end_date": "example", "min_depth": 1, "max_depth": 1, "has_code": false, "include_inferred": true, "group_by": "example", "interval": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "messagingmcp-count_promos", {"start_date": "example", "end_date": "example", "min_depth": 1, "max_depth": 1, "has_code": False, "include_inferred": True, "group_by": "example", "interval": "example"}, ) ``` ## Input schema ``` { "type": "object", "properties": { "retailers": { "description": "Retailer slugs to filter by, e.g. ['zara', 'h-m']. Omit for all; matches any listed retailer. Slugs match exactly and are case-sensitive, so take them from `list_retailers`: match the request against `retailer_name`, then pass that row's `retailer` slug and one of the regions it reports.", "items": { "type": "string" }, "type": "array" }, "regions": { "description": "Regions to filter by, e.g. ['UK', 'US']. Omit for all; matches any listed region. Exact match, so use the values `list_retailers` reports for the retailer.", "items": { "type": "string" }, "type": "array" }, "start_date": { "description": "Start of date range (inclusive, YYYY-MM-DD). Filters to promos active at any point in [start_date, end_date].", "format": "date", "type": "string" }, "end_date": { "description": "End of date range (inclusive, YYYY-MM-DD).", "format": "date", "type": "string" }, "promo_types": { "description": "Promo types to include: 'percentage_discount', 'flat_discount', 'multi-buy'. Omit for all.", "items": { "type": "string" }, "type": "array" }, "min_depth": { "description": "Minimum promo_depth. Depths are unitless — a percentage or a currency amount depending on the promo type — so constrain the type to avoid mixing units.", "type": "integer" }, "max_depth": { "description": "Maximum promo_depth. Same unit caveat as min_depth.", "type": "integer" }, "has_code": { "description": "True = only promos with a code, False = only without; omit for no filter.", "type": "boolean" }, "include_inferred": { "default": true, "description": "Include inferred promotions (default True). False = observed only.", "type": "boolean" }, "group_by": { "description": "Optional categorical bucketing. Mutually exclusive with `interval`. 'shoot' groups by source image to surface co-promoted bundles. Returns at most 50 buckets — the groups with the most rows behind them — and does not report what it omitted; 'retailer' and 'shoot' have far more groups than that, so treat the result as a top-50, not a complete set.", "enum": [ "retailer", "promo_type", "region", "shoot" ], "type": "string" }, "interval": { "description": "Optional temporal bucketing on promotion_date. Exclusive with group_by. Rows are limited to [start_date, end_date], but bucket keys are calendar-aligned (week/month starts), so an edge bucket may be keyed before start_date and cover only the in-window part of its period.", "enum": [ "day", "week", "month" ], "type": "string" } } } ``` ## 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": { "type": "object", "properties": { "count": { "description": "Distinct promotions, counted exactly to 40,000 and estimated above that (within roughly half a percent).", "type": "integer" }, "observed_count": { "description": "Distinct promotions, counted exactly to 40,000 and estimated above that (within roughly half a percent). The subset with at least one directly-observed (non-inferred) row.", "type": "integer" }, "filter_echo": { "properties": { "retailers": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "regions": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "start_date": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "end_date": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "include_inferred": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ], "default": null }, "min_depth": { "anyOf": [ { "type": "integer" }, { "type": "null" } ], "default": null }, "max_depth": { "anyOf": [ { "type": "integer" }, { "type": "null" } ], "default": null }, "has_code": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ], "default": null }, "promo_types": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "group_by": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "interval": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null } }, "type": "object" }, "buckets": { "description": "Under interval a promotion is counted in every bucket it was active in, so the buckets can sum to more than the unbucketed count. Under group_by this is AT MOST 50 buckets — the groups with the most rows behind them — with no report of what was dropped; 50 buckets is a top-50, not a complete set.", "items": { "description": "One bucket of a grouped or intervalled count.", "properties": { "group": { "description": "The term value under group_by, or the bucket's start date (YYYY-MM-DD) under interval.", "type": "string" }, "count": { "description": "Distinct promotions, counted exactly to 40,000 and estimated above that (within roughly half a percent).", "type": "integer" }, "observed_count": { "description": "Distinct promotions, counted exactly to 40,000 and estimated above that (within roughly half a percent). The subset with a directly-observed row.", "type": "integer" } }, "required": [ "group", "count", "observed_count" ], "type": "object" }, "type": "array" } }, "required": [ "filter_echo" ], "allOf": [ { "anyOf": [ { "required": [ "count", "filter_echo", "observed_count" ] }, { "required": [ "buckets", "filter_echo" ] } ] } ] }, "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" ] } }, "x-fastmcp-wrap-result": true, "oneOf": [ { "required": [ "result" ] }, { "required": [ "error" ] } ] } ``` --- ## Enrich Image Context (Messaging) Source: https://build.edited.com/reference/tools/messagingmcp-enrich_with_vm # Enrich Image Context (Messaging) messagingmcp-enrich_with_vm Fetch VM (visual + page-text) context for a list of image_ids. Use after a promo tool or messaging_search to pull the caption (and optionally OCR) plus channel/provenance metadata for specific shots. full_text=True returns untruncated OCR (default is a 500-char snippet). Each entry's provenance: channel_name names the captured channel/page, channel_target describes how it was shot, date is the capture date. image_description is the VLM caption (may be empty when captioning failed); ocr_text is whole-page OCR text, not scoped to one promotion. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | image_ids | string[] | yes | — | image_id values to look up — typically harvested from a prior promo tool (list_promos / count_promos / search_promo_text) or messaging_search. | | include | string[] | no | — | Which content fields to return per image: subset of ['description', 'ocr']. Omitted defaults to ['description'] — visual context is VM enrichment's unique value; OCR is opt-in. | | full_text | boolean | no | default: false | When True, forces OCR inclusion and returns it untruncated (overrides the default 500-char snippet). Default False. | ## Returns - On success: {"result": {"image_ids_found": [...], "image_ids_missing": [...], - "vm_context": {image_id: {...}}}}. Missing ids are surfaced, never dropped. - On failure: {"error": {code, message}} - code is invalid_input, upstream_unavailable, or - not_entitled (authorization refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "messagingmcp-enrich_with_vm", "arguments": { "full_text": false } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "messagingmcp-enrich_with_vm", arguments: { "full_text": false }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "messagingmcp-enrich_with_vm", {"full_text": False}, ) ``` ## Input schema ``` { "type": "object", "properties": { "image_ids": { "description": "image_id values to look up — typically harvested from a prior promo tool (list_promos / count_promos / search_promo_text) or messaging_search.", "items": { "type": "string" }, "type": "array" }, "include": { "description": "Which content fields to return per image: subset of ['description', 'ocr']. Omitted defaults to ['description'] — visual context is VM enrichment's unique value; OCR is opt-in.", "items": { "type": "string" }, "type": "array" }, "full_text": { "default": false, "description": "When True, forces OCR inclusion and returns it untruncated (overrides the default 500-char snippet). Default False.", "type": "boolean" } }, "required": [ "image_ids" ] } ``` ## 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": { "properties": { "image_ids_found": { "items": { "type": "string" }, "type": "array" }, "image_ids_missing": { "description": "Requested ids with no VM document; a miss lands here, never an error.", "items": { "type": "string" }, "type": "array" }, "vm_context": { "description": "Object keyed by image_id. Each value carries image_id, retailer (the filterable slug), retailer_name, date, channel_name, channel_target, channel_type, region, image_url and verticals (human-readable vertical names, [] if none) — all present, null where the document is silent. image_description and ocr_text are present only when `include` asked for them; ocr_text is truncated unless full_text was requested.", "title": "Vm Context" } }, "required": [ "image_ids_found", "image_ids_missing", "vm_context" ], "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 } ``` --- ## Explain Promo Methodology (Messaging) Source: https://build.edited.com/reference/tools/messagingmcp-explain_promo_data # Explain Promo Methodology (Messaging) messagingmcp-explain_promo_data Explain how the promo dataset was captured and what the tool fields mean. Documentation lookup, no data access. Call when unsure how to interpret a promo field (e.g. promo_depth units, inferred flags, active_window vs promo_active_dates) or why an expected offer type (e.g. free shipping) has few or unreliable results. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | topic | string | no | — | Which section of the data guide to return. 'overview' = capture model + what counts as a promotion (and what is excluded); 'fields' = per-field dictionary with derivations; 'identity' = what promo_id means (grouping vs persistence); 'dates' = date fields, active_window vs promo_active_dates, coverage; 'inferred' = observed vs forward-projected rows and provenance flags; 'quality' = caveats. Omit for overview. | ## Returns - On success: {"result": {"topic": str, "content": markdown str, - "topics": all topic names}}. - On failure: {"error": {code, message}} - code is not_entitled (authorization refused) or - upstream_unavailable — both from resolving the caller's config, - since the guide is served from a file and this tool reaches no data - source at all. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "messagingmcp-explain_promo_data", "arguments": { "topic": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "messagingmcp-explain_promo_data", arguments: { "topic": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "messagingmcp-explain_promo_data", {"topic": "example"}, ) ``` ## Input schema ``` { "type": "object", "properties": { "topic": { "description": "Which section of the data guide to return. 'overview' = capture model + what counts as a promotion (and what is excluded); 'fields' = per-field dictionary with derivations; 'identity' = what promo_id means (grouping vs persistence); 'dates' = date fields, active_window vs promo_active_dates, coverage; 'inferred' = observed vs forward-projected rows and provenance flags; 'quality' = caveats. Omit for overview.", "enum": [ "overview", "fields", "identity", "dates", "inferred", "quality" ], "type": "string" } } } ``` ## 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": { "properties": { "topic": { "enum": [ "overview", "fields", "identity", "dates", "inferred", "quality" ], "type": "string" }, "content": { "type": "string" }, "topics": { "items": { "enum": [ "overview", "fields", "identity", "dates", "inferred", "quality" ], "type": "string" }, "type": "array" } }, "required": [ "topic", "content", "topics" ], "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 } ``` --- ## Get Promotion Details (Messaging) Source: https://build.edited.com/reference/tools/messagingmcp-get_promo # Get Promotion Details (Messaging) messagingmcp-get_promo Fetch a single promotion by promo_id — the campaign detail / drill-down view. The representative capture is the most recent one (or the most recent at or before as_of). Provenance comes at two levels: any_inferred (campaign — does any backing row come from forward-projection?) and representative_inferred (row — does this specific view rest on a projected capture?); n_observed counts the distinct observed captures (same-day re-shoots count separately). All are scoped to <= as_of when given. Unlike list/search, the response includes promo_active_dates — the literal active sub-ranges (half-open {gte, lt}), gaps visible — so use this tool to answer "has this promotion run continuously?". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | promo_id | string | yes | — | The promo_id to fetch (as returned by list_promos / search_promo_text). | | as_of | string | no | — | Optional point-in-time view (YYYY-MM-DD). Returns the promotion as it stood on or before this date — the latest capture whose date is <= as_of. | ## Returns - On success: {"result": {"promo": PromoHit}}. - When the promo_id is unknown (or has no capture at/before as_of): - {"result": {"promo": null}}. - On failure: {"error": {code, message}} - code is upstream_unavailable, internal (our own bad - data; a retry returns the same error), or not_entitled (authorization - refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "messagingmcp-get_promo", "arguments": { "promo_id": "example", "as_of": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "messagingmcp-get_promo", arguments: { "promo_id": "example", "as_of": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "messagingmcp-get_promo", {"promo_id": "example", "as_of": "example"}, ) ``` ## Input schema ``` { "type": "object", "properties": { "promo_id": { "description": "The promo_id to fetch (as returned by list_promos / search_promo_text).", "type": "string" }, "as_of": { "description": "Optional point-in-time view (YYYY-MM-DD). Returns the promotion as it stood on or before this date — the latest capture whose date is <= as_of.", "format": "date", "type": "string" } }, "required": [ "promo_id" ] } ``` ## 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": { "properties": { "promo": { "anyOf": [ { "description": "One promotion, as the representative capture describes it.", "properties": { "promo_id": { "type": "string" }, "image_id": { "type": "string" }, "retailer": { "description": "The retailer slug — the value retailer filters accept.", "type": "string" }, "retailer_name": { "description": "Display name, not filterable.", "type": "string" }, "region": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "channel_type": { "type": "string" }, "date": { "type": "string" }, "promotion_date": { "type": "string" }, "promo_description": { "type": "string" }, "promo_type": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "promo_depth": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "promo_currency": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "promo_categories": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ] }, "promo_conditions": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ] }, "promo_codes": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "promo_gender": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "inferred": { "description": "Whether the representative row is forward-projected.", "type": "boolean" }, "promo_active_dates": { "items": { "additionalProperties": true, "description": "One active sub-range of a promo, half-open: `lt` is the last active day + 1.", "properties": { "gte": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null, "description": "First active day, inclusive." }, "lt": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null, "description": "Last active day + 1, exclusive." } }, "type": "object" }, "type": "array" }, "n_observed": { "description": "Distinct observed (non-inferred) captures for this promo_id — cardinality over capture timestamps, so same-day re-shoots count separately (scoped to <= as_of when a point-in-time view was requested).", "type": "integer" }, "any_inferred": { "description": "Whether ANY backing row (within the as_of scope) is forward-projected — campaign-level provenance, mirroring list_promos' field of the same name.", "type": "boolean" }, "representative_date": { "description": "The promotion_date of the representative row (latest, or latest <= as_of).", "type": "string" }, "representative_inferred": { "description": "The inferred flag on the representative row specifically.", "type": "boolean" } }, "required": [ "promo_id", "image_id", "retailer", "retailer_name", "region", "channel_type", "date", "promotion_date", "promo_description", "promo_type", "promo_depth", "promo_currency", "promo_categories", "promo_conditions", "promo_codes", "promo_gender", "inferred", "promo_active_dates", "n_observed", "any_inferred", "representative_date", "representative_inferred" ], "type": "object" }, { "type": "null" } ], "description": "Null when no promo carries that id — a miss, not an error." } }, "required": [ "promo" ], "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 } ``` --- ## List Promotions (Messaging) Source: https://build.edited.com/reference/tools/messagingmcp-list_promos # List Promotions (Messaging) messagingmcp-list_promos List distinct promotions (one per promo_id = one campaign), newest first by start date. Entries are campaign-level. Same-offer banners within one screenshot are folded into a single record (their extras appear flat in all_observed_* — there is no nested related-promos structure); active_window ({start, end, duration_days}) is that record's lifetime across captures — the outer calendar span, which may contain gaps (duration_days counts the span, not active days; for the literal gap-visible sub-ranges call get_promo). A long window is the expected signature of an ongoing campaign. any_inferred marks campaigns with at least one forward-projected backing row. Field meanings (promo_depth units per type, "sitewide" categories, single promo_codes, English-only text, exclusions like free shipping): call explain_promo_data. total_matched is the full distinct-promo count (the returned page may be smaller); coverage is the dataset's observed date frontier — an active_window.end at or beyond coverage.end means "ongoing at the frontier" (projected rows can run up to 5 days past it, so window ends may exceed coverage.end and even today). ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | retailers | string[] | no | — | Retailer slugs to filter by, e.g. ['zara', 'h-m']. Omit for all; matches any listed retailer. Slugs match exactly and are case-sensitive, so take them from list_retailers: match the request against retailer_name, then pass that row's retailer slug and one of the regions it reports. | | regions | string[] | no | — | Regions to filter by, e.g. ['UK', 'US']. Omit for all; matches any listed region. Exact match, so use the values list_retailers reports for the retailer. | | start_date | string | no | — | Start of the date window (inclusive, YYYY-MM-DD). Matches promotions active at ANY point in [start_date, end_date], not ones that started there. | | end_date | string | no | — | End of the date window (inclusive, YYYY-MM-DD). | | promo_types | string[] | no | — | Promo types to include: 'percentage_discount', 'flat_discount', 'multi-buy'. Omit for all. | | min_depth | integer | no | — | Minimum promo_depth. Depths are unitless — a percentage or a currency amount depending on the promo type — so constrain the type to avoid mixing units. | | max_depth | integer | no | — | Maximum promo_depth. Same unit caveat as min_depth. | | has_code | boolean | no | — | True = only promos with a code, False = only without; omit for no filter. | | include_inferred | boolean | no | default: true | Include inferred promotions (default True). False = observed only. | | limit | integer | no | default: 10 · 1–50 | Max distinct promotions. | ## Returns - On success: {"result": {"total_matched", "coverage", "returned", - "promos": [...], "filter_echo"}}. - On failure: {"error": {code, message}} - code is invalid_input, upstream_unavailable, or - not_entitled (authorization refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "messagingmcp-list_promos", "arguments": { "start_date": "example", "end_date": "example", "min_depth": 1, "max_depth": 1, "has_code": false, "include_inferred": true, "limit": 10 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "messagingmcp-list_promos", arguments: { "start_date": "example", "end_date": "example", "min_depth": 1, "max_depth": 1, "has_code": false, "include_inferred": true, "limit": 10 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "messagingmcp-list_promos", {"start_date": "example", "end_date": "example", "min_depth": 1, "max_depth": 1, "has_code": False, "include_inferred": True, "limit": 10}, ) ``` ## Input schema ``` { "type": "object", "properties": { "retailers": { "description": "Retailer slugs to filter by, e.g. ['zara', 'h-m']. Omit for all; matches any listed retailer. Slugs match exactly and are case-sensitive, so take them from `list_retailers`: match the request against `retailer_name`, then pass that row's `retailer` slug and one of the regions it reports.", "items": { "type": "string" }, "type": "array" }, "regions": { "description": "Regions to filter by, e.g. ['UK', 'US']. Omit for all; matches any listed region. Exact match, so use the values `list_retailers` reports for the retailer.", "items": { "type": "string" }, "type": "array" }, "start_date": { "description": "Start of the date window (inclusive, YYYY-MM-DD). Matches promotions active at ANY point in [start_date, end_date], not ones that started there.", "format": "date", "type": "string" }, "end_date": { "description": "End of the date window (inclusive, YYYY-MM-DD).", "format": "date", "type": "string" }, "promo_types": { "description": "Promo types to include: 'percentage_discount', 'flat_discount', 'multi-buy'. Omit for all.", "items": { "type": "string" }, "type": "array" }, "min_depth": { "description": "Minimum promo_depth. Depths are unitless — a percentage or a currency amount depending on the promo type — so constrain the type to avoid mixing units.", "type": "integer" }, "max_depth": { "description": "Maximum promo_depth. Same unit caveat as min_depth.", "type": "integer" }, "has_code": { "description": "True = only promos with a code, False = only without; omit for no filter.", "type": "boolean" }, "include_inferred": { "default": true, "description": "Include inferred promotions (default True). False = observed only.", "type": "boolean" }, "limit": { "default": 10, "description": "Max distinct promotions.", "maximum": 50, "minimum": 1, "type": "integer" } } } ``` ## 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": { "properties": { "total_matched": { "description": "Distinct promotions, counted exactly to 40,000 and estimated above that (within roughly half a percent). The full count; the page in promos may be smaller.", "type": "integer" }, "coverage": { "description": "The dataset frontier, ignoring the query's own filters.\n\nend is the latest observed (non-inferred) promotion date, so a promo still\nrunning at the frontier can be told from one that genuinely closed. Either\nbound is null on an empty index.", "properties": { "start": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "end": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "start", "end" ], "type": "object" }, "returned": { "type": "integer" }, "promos": { "items": { "properties": { "promo_id": { "type": "string" }, "image_id": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "retailer": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null, "description": "The retailer slug — this is the value the retailers filter accepts." }, "retailer_name": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null, "description": "Display name, not filterable." }, "region": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "channel_type": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_description": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_type": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_depth": { "anyOf": [ { "type": "number" }, { "type": "null" } ], "default": null }, "promo_currency": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_codes": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_categories": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "promo_conditions": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "all_observed_codes": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null, "description": "Union across every banner folded into this record; the singular field of the same name is unaffected by it." }, "all_observed_categories": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null, "description": "Union across every banner folded into this record; the singular field of the same name is unaffected by it." }, "all_observed_conditions": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null, "description": "Union across every banner folded into this record; the singular field of the same name is unaffected by it." }, "promo_gender": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_start_date": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "active_window": { "anyOf": [ { "description": "A promo's outer first-to-last span, computed per query.\n\nduration_days is the inclusive calendar span start..end, not a count of\nactive days — the window may contain gaps.", "properties": { "start": { "type": "string" }, "end": { "type": "string" }, "duration_days": { "type": "integer" } }, "required": [ "start", "end", "duration_days" ], "type": "object" }, { "type": "null" } ], "description": "Null when either span bound is missing." }, "any_inferred": { "description": "Whether ANY backing row is forward-projected — campaign-level provenance.", "type": "boolean" } }, "required": [ "promo_id", "active_window", "any_inferred" ], "type": "object" }, "type": "array" }, "filter_echo": { "properties": { "retailers": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "regions": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "start_date": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "end_date": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "include_inferred": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ], "default": null }, "min_depth": { "anyOf": [ { "type": "integer" }, { "type": "null" } ], "default": null }, "max_depth": { "anyOf": [ { "type": "integer" }, { "type": "null" } ], "default": null }, "has_code": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ], "default": null }, "promo_types": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "limit": { "anyOf": [ { "type": "integer" }, { "type": "null" } ], "default": null } }, "type": "object" } }, "required": [ "total_matched", "coverage", "returned", "promos", "filter_echo" ], "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 } ``` --- ## List Retailers (Messaging) Source: https://build.edited.com/reference/tools/messagingmcp-list_retailers # List Retailers (Messaging) messagingmcp-list_retailers List the retailers (or regions) the messaging corpus actually covers. Use this before a detailed query to confirm a retailer or region is present and how fresh the data is — and before any durability or year-on-year claim, where a gap in capture (or a stale latest_date) otherwise reads as a change in the retailer's behaviour. Never suggest a retailer from memory — only reference retailers returned here. To resolve one retailer's slug, pass its name (or a fragment) as query rather than paging the whole catalogue. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | by | string | no | default: "retailer" | Grouping. 'retailer' (default) lists each retailer with its regions and latest capture date. 'region' lists each region with the retailers present. | | query | string | no | — | Case-insensitive substring to narrow the groups: matched against the retailer slug and display name (by='retailer') or the region label (by='region'). Use this to look up one retailer's slug without fetching the whole catalogue, e.g. query='zara'. | | limit | integer | no | default: 100 · 1–1000 | Max groups returned (default 100). The full corpus is ~2,800 retailers; total_groups reports the pre-limit match count so truncation is visible. | ## Returns - On success: {"result": {"by", "total_groups", "returned", "groups": [...]}} — - total_groups counts matches before limit; if returned < total_groups, - narrow with query rather than raising the limit. - For by="retailer": each group is {retailer, retailer_name, regions, - latest_date, count} — retailer is the slug to pass to the promo/visual - filters, retailer_name the display name to match a request against. - count is a raw document count across the promo + visual indices (a - volume signal — NOT a promotion count; use count_promos for that); - latest_date is the latest capture date. - For by="region": each group is {region, retailers, latest_date}. - On failure: {"error": {code, message}} - code is upstream_unavailable or not_entitled - (authorization refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "messagingmcp-list_retailers", "arguments": { "by": "retailer", "query": "Nike", "limit": 100 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "messagingmcp-list_retailers", arguments: { "by": "retailer", "query": "Nike", "limit": 100 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "messagingmcp-list_retailers", {"by": "retailer", "query": "Nike", "limit": 100}, ) ``` ## Input schema ``` { "type": "object", "properties": { "by": { "default": "retailer", "description": "Grouping. 'retailer' (default) lists each retailer with its regions and latest capture date. 'region' lists each region with the retailers present.", "enum": [ "retailer", "region" ], "type": "string" }, "query": { "description": "Case-insensitive substring to narrow the groups: matched against the retailer slug and display name (by='retailer') or the region label (by='region'). Use this to look up one retailer's slug without fetching the whole catalogue, e.g. query='zara'.", "type": "string" }, "limit": { "default": 100, "description": "Max groups returned (default 100). The full corpus is ~2,800 retailers; `total_groups` reports the pre-limit match count so truncation is visible.", "maximum": 1000, "minimum": 1, "type": "integer" } } } ``` ## 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": { "properties": { "by": { "enum": [ "retailer", "region" ], "type": "string" }, "total_groups": { "description": "The count before limit.", "type": "integer" }, "returned": { "description": "The count after limit.", "type": "integer" }, "groups": { "description": "Retailer rows or region rows, according to by.", "items": { "type": "object", "properties": { "retailer": { "description": "The slug to filter on.", "type": "string" }, "retailer_name": { "description": "Display name to match a request against; falls back to the slug when none is recorded.", "type": "string" }, "regions": { "items": { "type": "string" }, "type": "array" }, "latest_date": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "count": { "type": "integer" }, "region": { "type": "string" }, "retailers": { "items": { "type": "string" }, "type": "array" } }, "required": [ "latest_date" ], "allOf": [ { "anyOf": [ { "required": [ "count", "latest_date", "regions", "retailer", "retailer_name" ] }, { "required": [ "latest_date", "region", "retailers" ] } ] } ], "description": "One list_retailers(by=\"retailer\") row. / One list_retailers(by=\"region\") row: the inverted map." }, "type": "array" } }, "required": [ "by", "total_groups", "returned", "groups" ], "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 } ``` --- ## Search Visuals (Messaging) Source: https://build.edited.com/reference/tools/messagingmcp-messaging_search # Search Visuals (Messaging) messagingmcp-messaging_search Search messaging channel visuals (homepage and newsletter screenshots) by meaning. Ranks whole-page captures against an embedding of the page's OCR text plus a VLM-written caption, so the query can match either what the page says or what it shows. Each hit's image_description is that caption (may be empty when captioning failed) and ocr_text is a whole-page OCR snippet — page-level text, not scoped to one promotion. Use this tool to find promotional visuals matching a topic or creative brief; pair with enrich_with_vm for full OCR. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string | yes | — | Natural-language query describing the promotional content to find. | | start_date | string | no | — | Inclusive start date (YYYY-MM-DD) on the shot date. | | end_date | string | no | — | Inclusive end date (YYYY-MM-DD) on the shot date. | | regions | string[] | no | — | Regions to filter by, e.g. ['UK', 'US']. Omit for all; matches any listed region. Exact match, so use the values list_retailers reports for the retailer. | | retailers | string[] | no | — | Retailer slugs to filter by, e.g. ['zara', 'h-m']. Omit for all; matches any listed retailer. Slugs match exactly and are case-sensitive, so take them from list_retailers: match the request against retailer_name, then pass that row's retailer slug and one of the regions it reports. | | verticals | string[] | no | — | Verticals (industries) to filter by: subset of ['Apparel', 'Homeware', 'Beauty']. Omit for all; matches any listed vertical. Values match exactly (case-sensitive). | | channel_type | string[] | no | — | Filter to channel types: subset of ['homepage', 'newsletter']. Omit for all channels. | | k | integer | no | default: 5 · 1–50 | Number of results to return. | | include_repeats | boolean | no | default: false | When False (default), return chain-heads only — one result per unique shoot, excluding re-captures of unchanged pages. Set True to include all captures. | ## Returns - On success: {"result": {"total": int, "hits": list[MessagingHit]}}. - total is the number of hits RETURNED (capped by k) — not a corpus-wide - match count; ranked retrieval has no meaningful total, so do not report - it as "N matching pages". - Each hit: image_id, retailer, retailer_name, channel_type, region, - date, image_url, image_description, ocr_text, channel_name, - channel_target, verticals, score. - On failure: {"error": {code, message}} - code is invalid_input, upstream_unavailable, - internal (our own bad data; a retry returns the same error), or - not_entitled (authorization refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "messagingmcp-messaging_search", "arguments": { "query": "Nike", "start_date": "example", "end_date": "example", "k": 5, "include_repeats": false } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "messagingmcp-messaging_search", arguments: { "query": "Nike", "start_date": "example", "end_date": "example", "k": 5, "include_repeats": false }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "messagingmcp-messaging_search", {"query": "Nike", "start_date": "example", "end_date": "example", "k": 5, "include_repeats": False}, ) ``` ## Input schema ``` { "type": "object", "properties": { "query": { "description": "Natural-language query describing the promotional content to find.", "type": "string" }, "start_date": { "description": "Inclusive start date (YYYY-MM-DD) on the shot date.", "format": "date", "type": "string" }, "end_date": { "description": "Inclusive end date (YYYY-MM-DD) on the shot date.", "format": "date", "type": "string" }, "regions": { "description": "Regions to filter by, e.g. ['UK', 'US']. Omit for all; matches any listed region. Exact match, so use the values `list_retailers` reports for the retailer.", "items": { "type": "string" }, "type": "array" }, "retailers": { "description": "Retailer slugs to filter by, e.g. ['zara', 'h-m']. Omit for all; matches any listed retailer. Slugs match exactly and are case-sensitive, so take them from `list_retailers`: match the request against `retailer_name`, then pass that row's `retailer` slug and one of the regions it reports.", "items": { "type": "string" }, "type": "array" }, "verticals": { "description": "Verticals (industries) to filter by: subset of ['Apparel', 'Homeware', 'Beauty']. Omit for all; matches any listed vertical. Values match exactly (case-sensitive).", "items": { "enum": [ "Apparel", "Homeware", "Beauty" ], "type": "string" }, "type": "array" }, "channel_type": { "description": "Filter to channel types: subset of ['homepage', 'newsletter']. Omit for all channels.", "items": { "enum": [ "homepage", "newsletter" ], "type": "string" }, "type": "array" }, "k": { "default": 5, "description": "Number of results to return.", "maximum": 50, "minimum": 1, "type": "integer" }, "include_repeats": { "default": false, "description": "When False (default), return chain-heads only — one result per unique shoot, excluding re-captures of unchanged pages. Set True to include all captures.", "type": "boolean" } }, "required": [ "query" ] } ``` ## 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": { "properties": { "total": { "type": "integer" }, "hits": { "items": { "description": "One matching visual, with its provenance and relevance score.", "properties": { "image_id": { "type": "string" }, "retailer": { "description": "The retailer slug — the value retailer filters accept.", "type": "string" }, "retailer_name": { "description": "Display name, not filterable.", "type": "string" }, "channel_type": { "type": "string" }, "region": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "date": { "type": "string" }, "image_url": { "type": "string" }, "image_description": { "description": "Full VLM caption, untruncated.", "type": "string" }, "ocr_text": { "description": "OCR page text, truncated to a snippet.", "type": "string" }, "channel_name": { "type": "string" }, "channel_target": { "type": "string" }, "verticals": { "description": "Human-readable vertical names; [] if none.", "items": { "type": "string" }, "type": "array" }, "score": { "type": "number" } }, "required": [ "image_id", "retailer", "retailer_name", "channel_type", "region", "date", "image_url", "image_description", "ocr_text", "channel_name", "channel_target", "verticals", "score" ], "type": "object" }, "type": "array" } }, "required": [ "total", "hits" ], "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 } ``` --- ## Resolve Time Period (Messaging) Source: https://build.edited.com/reference/tools/messagingmcp-resolve_time_period # Resolve Time Period (Messaging) messagingmcp-resolve_time_period Resolve a natural-language time expression to {start_date, end_date}. Uses messaging's own season calendar: SS = Mar–Aug; AW/FW = Sep–following Feb; Resort/Cruise = Nov–following Jan. Pass the returned date_range to the promo and search tools rather than embedding the expression in a query string. Relative expressions ("last week") resolve against now when supplied, so backtesting with a past anchor produces windows relative to that anchor. Future-date handling: a start in the future is unset (open-ended start); an end in the future is clamped to now. Unusual expressions (named holidays like "Eid"/"Diwali", non-English phrases) that no season/deterministic rule matches fall through to a best-effort LLM parse, flagged is_heuristic=True — surface it for confirmation rather than treating it as an exact filter. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | expression | string | yes | — | Natural-language time expression, e.g. 'SS25', 'Resort 25', 'last 3 months', 'Black Friday 2024'. | | now | string | no | — | Anchor date for relative expressions; defaults to today. | ## Returns - On success: {"result": {date_range: {start_date, end_date}, - interpretation, is_heuristic}}. - is_heuristic is True when the heuristic / LLM fallback was used or a - future-date rewrite was applied. - An expression with no temporal content at all does not fail — the model - answers it confidently, so the window comes back with is_heuristic=True - and an interpretation saying it is a guess. Branch on is_heuristic; do - not read the absence of an error as a successful parse. - On failure: {"error": {code, message, field}} - code is invalid_input with field expression; internal with no - field when the resolved window itself is unusable (a retry returns the - same error); or, from resolving the caller's config rather than from the - parse and with no field, not_entitled (authorization refused) or - upstream_unavailable. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "messagingmcp-resolve_time_period", "arguments": { "expression": "example", "now": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "messagingmcp-resolve_time_period", arguments: { "expression": "example", "now": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "messagingmcp-resolve_time_period", {"expression": "example", "now": "example"}, ) ``` ## Input schema ``` { "type": "object", "properties": { "expression": { "description": "Natural-language time expression, e.g. 'SS25', 'Resort 25', 'last 3 months', 'Black Friday 2024'.", "type": "string" }, "now": { "description": "Anchor date for relative expressions; defaults to today.", "format": "date", "type": "string" } }, "required": [ "expression" ] } ``` ## 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": { "additionalProperties": true, "properties": { "date_range": { "description": "Always present — an expression that cannot be parsed is a refusal on `expression`, not a null window.", "properties": { "start_date": { "anyOf": [ { "format": "date", "type": "string" }, { "type": "null" } ] }, "end_date": { "anyOf": [ { "format": "date", "type": "string" }, { "type": "null" } ] } }, "required": [ "start_date", "end_date" ], "type": "object" }, "interpretation": { "description": "How the window was arrived at.", "type": "string" }, "is_heuristic": { "description": "True when the window was guessed rather than parsed — no date rule resolved the expression, or a future bound was rewritten. Read `interpretation` before treating the window as exact.", "type": "boolean" } }, "required": [ "date_range", "interpretation", "is_heuristic" ], "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 } ``` --- ## Search Promo Text (Messaging) Source: https://build.edited.com/reference/tools/messagingmcp-search_promo_text # Search Promo Text (Messaging) messagingmcp-search_promo_text Relevance-scored text search across promo description / conditions / categories / codes. The searched text is plain English (extraction translates all source languages), and the corpus targets quantified-discount promotions — free shipping, loyalty perks, and single-product sales are excluded by policy, so searching for them is unreliable (compound offers like "20% off plus free shipping" do match; a few stray pure records exist); see explain_promo_data. Returns one entry per distinct promotion (highest-scoring row per promo), each with the list_promos field set plus active_window, any_inferred, and a raw BM25 score (ordering only — not a probability). total_matched and coverage mirror list_promos. Double-quote a span to match it as an exact phrase ("40% off"); quotes can be mixed with loose terms and repeated, but everything is OR-combined — a hit needs only one of the phrases/terms, so a mixed query does NOT require the phrase. To require a phrase, send it alone. Phrasing matches adjacent words, not literal punctuation. categories is a keyword field — it only matches a whole value exactly, not individual words. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string | yes | — | Keyword or short phrase to match across promo text. Lean, specific terms work best — the corpus is already filtered to promos so generic words match everything. Wrap a span in double quotes to match it as an exact phrase, e.g. "40% off" — quotes can be mixed with loose terms and repeated, e.g. "buy one get one" student. Phrasing matches adjacent words, not literal punctuation (a % is not indexed). | | fields | string[] | no | — | Which text fields to search. Subset of ['description', 'conditions', 'categories', 'codes']. Omit for all four (description and codes boosted). | | retailers | string[] | no | — | Retailer slugs to filter by, e.g. ['zara', 'h-m']. Omit for all; matches any listed retailer. Slugs match exactly and are case-sensitive, so take them from list_retailers: match the request against retailer_name, then pass that row's retailer slug and one of the regions it reports. | | regions | string[] | no | — | Regions to filter by, e.g. ['UK', 'US']. Omit for all; matches any listed region. Exact match, so use the values list_retailers reports for the retailer. | | start_date | string | no | — | Start of the active-date window (inclusive, YYYY-MM-DD). | | end_date | string | no | — | End of the active-date window (inclusive, YYYY-MM-DD). | | include_inferred | boolean | no | default: true | Include inferred promotions (default True). False = observed only. | | limit | integer | no | default: 10 · 1–20 | Max distinct promotions. | ## Returns - On success: {"result": {"total_matched", "coverage", "returned", - "promos": [...], "filter_echo"}}. - On failure: {"error": {code, message}} - code is invalid_input, upstream_unavailable, or - not_entitled (authorization refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "messagingmcp-search_promo_text", "arguments": { "query": "Nike", "start_date": "example", "end_date": "example", "include_inferred": true, "limit": 10 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "messagingmcp-search_promo_text", arguments: { "query": "Nike", "start_date": "example", "end_date": "example", "include_inferred": true, "limit": 10 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "messagingmcp-search_promo_text", {"query": "Nike", "start_date": "example", "end_date": "example", "include_inferred": True, "limit": 10}, ) ``` ## Input schema ``` { "type": "object", "properties": { "query": { "description": "Keyword or short phrase to match across promo text. Lean, specific terms work best — the corpus is already filtered to promos so generic words match everything. Wrap a span in double quotes to match it as an exact phrase, e.g. \"40% off\" — quotes can be mixed with loose terms and repeated, e.g. \"buy one get one\" student. Phrasing matches adjacent words, not literal punctuation (a `%` is not indexed).", "type": "string" }, "fields": { "description": "Which text fields to search. Subset of ['description', 'conditions', 'categories', 'codes']. Omit for all four (description and codes boosted).", "items": { "type": "string" }, "type": "array" }, "retailers": { "description": "Retailer slugs to filter by, e.g. ['zara', 'h-m']. Omit for all; matches any listed retailer. Slugs match exactly and are case-sensitive, so take them from `list_retailers`: match the request against `retailer_name`, then pass that row's `retailer` slug and one of the regions it reports.", "items": { "type": "string" }, "type": "array" }, "regions": { "description": "Regions to filter by, e.g. ['UK', 'US']. Omit for all; matches any listed region. Exact match, so use the values `list_retailers` reports for the retailer.", "items": { "type": "string" }, "type": "array" }, "start_date": { "description": "Start of the active-date window (inclusive, YYYY-MM-DD).", "format": "date", "type": "string" }, "end_date": { "description": "End of the active-date window (inclusive, YYYY-MM-DD).", "format": "date", "type": "string" }, "include_inferred": { "default": true, "description": "Include inferred promotions (default True). False = observed only.", "type": "boolean" }, "limit": { "default": 10, "description": "Max distinct promotions.", "maximum": 20, "minimum": 1, "type": "integer" } }, "required": [ "query" ] } ``` ## 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": { "properties": { "total_matched": { "description": "Distinct promotions, counted exactly to 40,000 and estimated above that (within roughly half a percent). The full count; the page in promos may be smaller.", "type": "integer" }, "coverage": { "description": "The dataset frontier, ignoring the query's own filters.\n\nend is the latest observed (non-inferred) promotion date, so a promo still\nrunning at the frontier can be told from one that genuinely closed. Either\nbound is null on an empty index.", "properties": { "start": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "end": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "start", "end" ], "type": "object" }, "returned": { "type": "integer" }, "promos": { "items": { "properties": { "promo_id": { "type": "string" }, "image_id": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "retailer": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null, "description": "The retailer slug — this is the value the retailers filter accepts." }, "retailer_name": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null, "description": "Display name, not filterable." }, "region": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "channel_type": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_description": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_type": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_depth": { "anyOf": [ { "type": "number" }, { "type": "null" } ], "default": null }, "promo_currency": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_codes": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_categories": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "promo_conditions": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "all_observed_codes": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null, "description": "Union across every banner folded into this record; the singular field of the same name is unaffected by it." }, "all_observed_categories": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null, "description": "Union across every banner folded into this record; the singular field of the same name is unaffected by it." }, "all_observed_conditions": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null, "description": "Union across every banner folded into this record; the singular field of the same name is unaffected by it." }, "promo_gender": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "promo_start_date": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "active_window": { "anyOf": [ { "description": "A promo's outer first-to-last span, computed per query.\n\nduration_days is the inclusive calendar span start..end, not a count of\nactive days — the window may contain gaps.", "properties": { "start": { "type": "string" }, "end": { "type": "string" }, "duration_days": { "type": "integer" } }, "required": [ "start", "end", "duration_days" ], "type": "object" }, { "type": "null" } ], "description": "Null when either span bound is missing." }, "any_inferred": { "description": "Whether ANY backing row is forward-projected — campaign-level provenance.", "type": "boolean" }, "score": { "description": "Raw BM25, for ordering only — not a probability, not comparable across tools.", "type": "number" } }, "required": [ "promo_id", "active_window", "any_inferred", "score" ], "type": "object" }, "type": "array" }, "filter_echo": { "properties": { "retailers": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "regions": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "start_date": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "end_date": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "include_inferred": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ], "default": null }, "query": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null }, "fields": { "anyOf": [ { "items": { "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null }, "limit": { "anyOf": [ { "type": "integer" }, { "type": "null" } ], "default": null } }, "type": "object" } }, "required": [ "total_matched", "coverage", "returned", "promos", "filter_echo" ], "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 } ``` --- ## Analyze Query (Research) Source: https://build.edited.com/reference/tools/researchmcp-research_analyze_query # Analyze Query (Research) researchmcp-research_analyze_query Analyse a research query into retrieval intent and (optionally) a date window. The recommended first call for any topic or trend question — anything other than "what has EDITED published lately". It turns a natural-language question into the exact taxonomy slugs the search tools rank on (e.g. "trends in men's SS27 tailoring" → topics=["tailoring"], gender=["men"], season=["spring-summer"], year=["2027"]), which materially improves what comes back from research_article_search / research_chunk_search / research_image_search. Call it once per query and thread the result into each search. The rest of a discovery flow needs, in order: one of those three search tools, then research_report_links to turn the report_ids you cite into public URLs (an expected final step, not an on-request extra), and research_read_report when the user wants the article text rather than a link. Load all of them alongside this one — the link step is not optional, so discovering it only after the search returns costs an avoidable round trip. Still optional and composable — nothing rejects a search without it. The search tools accept the same intent axes and start_date/end_date directly, so you may derive them from your own reasoning, use this tool for just one of intent or dates, or pass explicit dates and intent together (explicit dates set the filter; intent always drives ranking). ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string | yes | — | The full natural-language research query to analyse for topic/format intent and any time window. | | now | string | no | — | Anchor date for relative expressions like 'last 30 days'; defaults to today. Pass for reproducible results. | ## Returns - On success, all four fields below sit under result: - {"result": {intent, date_range, interpretation, is_inferred}}. - intent: the six axes (article_types, topics, gender, season, - year, moments), each a possibly-empty list of known slugs. Thread - these into the search tools' matching params. - date_range: {start_date, end_date} (ISO YYYY-MM-DD, inclusive) for a - genuinely temporal phrase (relative window, quarter, month range, - single day), or null. A year, a season, or a named event is NOT returned as a - date_range — it is surfaced in intent (year/season/moments) - instead, and the search tools turn year/moments into a window - automatically. - interpretation: short human-readable summary of how the query parsed. - is_inferred: true when a heuristic/LLM fallback or a future-date clamp - was used — surface interpretation to the user so they can confirm. - Errors: - {"error": {code, message, field}} - code is invalid_input with field query for malformed or blank - input (retrying it unchanged fails the same way); internal with no - field when the analysis produced a payload we could not publish (a - retry returns the same error); or upstream_unavailable with no - field, worth one retry, when the model returned output we could not - use — unparseable, or a window with a missing or mangled date. - Resolving the caller's config, before any analysis, can also refuse - with upstream_unavailable or not_entitled (authorization refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "researchmcp-research_analyze_query", "arguments": { "query": "Nike", "now": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "researchmcp-research_analyze_query", arguments: { "query": "Nike", "now": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "researchmcp-research_analyze_query", {"query": "Nike", "now": "example"}, ) ``` ## Input schema ``` { "type": "object", "properties": { "query": { "description": "The full natural-language research query to analyse for topic/format intent and any time window.", "type": "string" }, "now": { "description": "Anchor date for relative expressions like 'last 30 days'; defaults to today. Pass for reproducible results.", "format": "date", "type": "string" } }, "required": [ "query" ] } ``` ## 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": { "properties": { "intent": { "description": "Retrieval-intent axes extracted from the query.\n\nValues are slugs to pass to the matching parameter on a search tool, which\nis where each axis's accepted vocabulary is advertised.", "properties": { "article_types": { "items": { "type": "string" }, "type": "array" }, "topics": { "items": { "type": "string" }, "type": "array" }, "gender": { "items": { "type": "string" }, "type": "array" }, "season": { "items": { "type": "string" }, "type": "array" }, "year": { "items": { "type": "string" }, "type": "array" }, "moments": { "items": { "type": "string" }, "type": "array" } }, "required": [ "article_types", "topics", "gender", "season", "year", "moments" ], "type": "object" }, "date_range": { "anyOf": [ { "description": "The window a query resolved to; a null bound is open-ended, never absent.", "properties": { "start_date": { "anyOf": [ { "format": "date", "type": "string" }, { "type": "null" } ] }, "end_date": { "anyOf": [ { "format": "date", "type": "string" }, { "type": "null" } ] } }, "required": [ "start_date", "end_date" ], "type": "object" }, { "type": "null" } ], "description": "Null for a query with no genuinely temporal phrase — a year, a season or a named event is surfaced in intent instead." }, "interpretation": { "type": "string" }, "is_inferred": { "type": "boolean" } }, "required": [ "intent", "date_range", "interpretation", "is_inferred" ], "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 } ``` --- ## Search Articles (Research) Source: https://build.edited.com/reference/tools/researchmcp-research_article_search # Search Articles (Research) researchmcp-research_article_search Search EDITED Research articles by meaning. The front door for topic discovery — trends, categories, a season's direction. Returns one article-level summary per relevant report, ranked on topic/audience/season rather than publish order, so it finds the reports that define a topic. Prefer it over research_list_reports, which only browses what is newest and cannot filter by topic at all. Use this first to identify which reports cover a topic (populating the intent axes from research_analyze_query). Then drill in with research_chunk_search (passing the returned report_id values) for granular passages, or research_image_search for visual content. Before presenting results, resolve the report_id values you cite with research_report_links (one batched call) so the user gets clickable EDITED Research URLs alongside your answer — this follow-up is expected, not optional. The linked pages require login, so when the user wants the content itself, also call research_read_report. Ranking is automatic: results are ordered by a blend of relevance, recency, and — when you supply the optional intent axes (article_types, topics, gender, season, year, moments) — how well each report's tags match them. No intent is required; supplying it biases the order toward matching reports. Populate the axes from research_analyze_query or your own reasoning; unknown slugs are ignored. year/moments also set the time window when you pass no dates: a year narrows to that year — except a current or forward-looking one, which never reaches less publish history than the default — moments alone to the last 12 months, and with neither the window is the last 550 days. That default is a hard filter on publish date, not a recency bias, so older reports are unreachable at any k: for a trend's history or origins, pass an explicit start_date several years back. The score on each hit is this composite value — not a raw relevance number, and not comparable across tools. Try queries like "Black Friday discounting strategy", "Gen Z denim trends", "luxury handbag pricing", "athleisure market growth". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string | yes | — | Natural language search query for finding research articles. | | start_date | string | no | — | Inclusive start date (YYYY-MM-DD). | | end_date | string | no | — | Inclusive end date (YYYY-MM-DD). | | k | integer | no | default: 5 · 1–10 | Number of articles to return. | | article_types | string[] | no | — | Editorial format of the reports to favour; research_analyze_query can supply these. | | topics | string[] | no | — | Subject matter to favour. | | gender | string[] | no | — | Audience to favour. | | season | string[] | no | — | Retail season to favour. A ranking signal only — never converted to a date filter. | | year | string[] | no | — | Four-digit year(s) the query targets, e.g. ['2025']. | | moments | string[] | no | — | Named retail moments the query targets. | ## Returns - On success: {"result": {"total": int, "articles": list[ArticleHit]}}. Each hit carries - report_id, title, date, score, a summary field drawn from the - report's full text, and tags — the report's curated taxonomy tags - (article_types, topics, gender, season, year, moments), with an - empty list for an untagged axis. Read tags.gender / tags.season before - grounding an audience- or season-specific claim in a hit: a title alone - does not say whether a buyers' guide is menswear or womenswear. The tag - values are drawn from the same vocabulary the intent params accept, so you - can thread one back in to narrow a follow-up search — with two caveats: a - year/moments tag also moves the date window, not just the ranking, and - a tag value outside the params' vocabulary is ignored rather than applied. - Follow up with research_report_links to turn the report_ids you cite into - public links; to read the full article text, call research_read_report. - On retrieval failure: {"error": {code, message}} - code is invalid_input, upstream_unavailable, - internal (our own bad data; a retry returns the same error), or - not_entitled (authorization refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "researchmcp-research_article_search", "arguments": { "query": "Nike", "start_date": "example", "end_date": "example", "k": 5 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "researchmcp-research_article_search", arguments: { "query": "Nike", "start_date": "example", "end_date": "example", "k": 5 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "researchmcp-research_article_search", {"query": "Nike", "start_date": "example", "end_date": "example", "k": 5}, ) ``` ## Input schema ``` { "type": "object", "properties": { "query": { "description": "Natural language search query for finding research articles.", "type": "string" }, "start_date": { "description": "Inclusive start date (YYYY-MM-DD).", "format": "date", "type": "string" }, "end_date": { "description": "Inclusive end date (YYYY-MM-DD).", "format": "date", "type": "string" }, "k": { "default": 5, "description": "Number of articles to return.", "maximum": 10, "minimum": 1, "type": "integer" }, "article_types": { "description": "Editorial format of the reports to favour; `research_analyze_query` can supply these.", "items": { "enum": [ "trend-analysis", "trends-by-season", "trends-by-city", "street-style", "retailer-messaging", "buyers-guide", "consumer-moments", "hindsighting", "consumer-strategy", "forecasting", "product-newness", "collections-by-city", "events-opportunities", "messaging-calendars", "assortment-pricing", "site-merchandising-promotion" ], "type": "string" }, "type": "array" }, "topics": { "description": "Subject matter to favour.", "items": { "enum": [ "footwear", "accessories", "pricing-discounting", "denim-fabric", "color", "bottoms", "active-sports", "subcultures", "print-licensing", "intimates-swimwear", "tops", "outerwear", "details-trims", "diversity-inclusion", "dresses", "sustainability", "generation-demographic", "knitwear", "tailoring" ], "type": "string" }, "type": "array" }, "gender": { "description": "Audience to favour.", "items": { "enum": [ "men", "women", "children", "mid-mature" ], "type": "string" }, "type": "array" }, "season": { "description": "Retail season to favour. A ranking signal only — never converted to a date filter.", "items": { "enum": [ "spring-summer", "fall-winter", "pre-spring", "pre-fall" ], "type": "string" }, "type": "array" }, "year": { "description": "Four-digit year(s) the query targets, e.g. ['2025'].", "items": { "pattern": "^20[0-9]{2}$", "type": "string" }, "type": "array" }, "moments": { "description": "Named retail moments the query targets.", "items": { "enum": [ "black-friday", "lunar-new-year", "easter", "halloween", "international-womens-day", "mothers-day-uk", "mothers-day-us", "4th-july-us", "earth-month", "festival-season", "holiday-season", "ramadan", "valentines-day", "back-to-college", "back-to-school", "fathers-day", "occasion-season", "spring-break-us" ], "type": "string" }, "type": "array" } }, "required": [ "query" ] } ``` ## 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": { "properties": { "total": { "type": "integer" }, "articles": { "items": { "properties": { "report_id": { "type": "integer" }, "title": { "type": "string" }, "date": { "format": "date", "type": "string" }, "score": { "type": "number" }, "summary": { "type": "string" }, "tags": { "description": "The parent report's curated taxonomy tags, keyed by the public intent axes.\n\nPopulated on every search hit, browse row and report lookup so a consumer can\nsee a report's audience, season and subject matter *at discovery time* — a\ntitle alone does not say whether a \"SS27 Buyers' Guide\" is menswear or\nwomenswear, and grounding a womenswear claim in a menswear report is the\nfailure this prevents.\n\nThe values are drawn from the vocabulary the search tools' intent params\naccept, so a tag read off a hit can usually be threaded straight back in to\nnarrow a follow-up search. Two limits on that, both worth knowing before\ntreating a threaded tag as a guaranteed narrowing:\n\n- **The vocabularies are not identical.** These are curated report-side\n labels, so a value can exist here that the params do not recognise (prod\n carries ``moments: [\"pride\"]``, which is outside ``MomentsParam``'s enum).\n Threading such a value is ignored by ``normalise_intent`` or rejected by the\n param schema — never a silent narrowing.\n- **``year`` and ``moments`` are not pure ranking axes.** Unlike the other\n four, they also derive the publish-date window when no explicit dates are\n given (see ``retrieval/date_window.py``), so threading one back moves the\n filter as well as the boost.\n\nEvery axis is always present on the model; an untagged axis is an empty list.\nCoverage is uneven and heavily skewed by report class — buyers' guides are\nclose to fully tagged for ``gender``, while a large minority of the corpus\ncarries no taxonomy at all — so an empty axis is common and means \"not\ncurated\", never \"out of scope\". (One near-exception: a topic tag that exists\nonly as an all-encompassing label is dropped rather than surfaced, matching\nwhat the scorer sees — see ``canonicalise_topics``.)\n\nTwo exceptions, both id re-fetches: the chunk and image indices carry no\ntaxonomy tags and ``get_chunk`` / ``get_image`` do not spend a second query\nresolving them, so the field stays at its empty default there — and the\nresearch_get_chunk / research_get_image tools serve ``ChunkRow`` / ``ImageRow``,\nwhich have no ``tags`` field at all, rather than let an empty axis read as\n\"this report is untagged\". So `tags` is absent from those two tools' published\nschemas; wherever it *is* published, an empty axis does mean untagged.", "properties": { "article_types": { "items": { "type": "string" }, "type": "array" }, "topics": { "items": { "type": "string" }, "type": "array" }, "gender": { "items": { "type": "string" }, "type": "array" }, "season": { "items": { "type": "string" }, "type": "array" }, "year": { "items": { "type": "string" }, "type": "array" }, "moments": { "items": { "type": "string" }, "type": "array" } }, "type": "object" } }, "required": [ "report_id", "title", "date", "score", "summary" ], "type": "object" }, "type": "array" } }, "required": [ "total", "articles" ], "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 } ``` --- ## Search Passages (Research) Source: https://build.edited.com/reference/tools/researchmcp-research_chunk_search # Search Passages (Research) researchmcp-research_chunk_search Search EDITED Research articles for granular text passages. Returns chunk-level passages — each hit is a paragraph-sized excerpt with a session-scoped chunk_id. Use after research_article_search to drill into specific reports: pass the returned report_id values via the report_ids parameter, plus a focused query for the passage you want. It also works corpus-wide (omit report_ids) as a topic-discovery search when you want passage-level evidence rather than a report list — either way, prefer it over research_list_reports for anything topic-shaped. Before presenting results, resolve the report_id values you cite with research_report_links (one batched call) so the user gets clickable EDITED Research URLs — this follow-up is expected, not optional. The linked pages require login, so when the user wants the content itself, call research_read_report. Ranking is automatic: a blend of relevance, recency, and — when you supply the optional intent axes (article_types, topics, gender, season, year, moments) — taxonomy match. None is required and unknown slugs are ignored. year/moments also set the time window when you pass no dates: a year narrows to that year — except a current or forward-looking one, which never reaches less publish history than the default — moments alone to the last 12 months, and with neither the window is the last 550 days. With no explicit dates, report_ids suppresses the window entirely (explicit dates still win). The score on each hit is this composite value, not a raw relevance number and not comparable across tools. Try queries like "average discount depth in womenswear", "outerwear sell-through rate", "spring/summer colour palette", "promotional cadence in footwear". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string | yes | — | Natural language search query for finding chunks within reports. | | report_ids | integer[] | no | — | Constrain the search to specific articles — pass report_id values returned by research_article_search. Omit to search the full corpus. | | start_date | string | no | — | Inclusive start date (YYYY-MM-DD). | | end_date | string | no | — | Inclusive end date (YYYY-MM-DD). | | k | integer | no | default: 5 · 1–10 | Number of chunks to return. | | article_types | string[] | no | — | Editorial format of the reports to favour; research_analyze_query can supply these. | | topics | string[] | no | — | Subject matter to favour. | | gender | string[] | no | — | Audience to favour. | | season | string[] | no | — | Retail season to favour. A ranking signal only — never converted to a date filter. | | year | string[] | no | — | Four-digit year(s) the query targets, e.g. ['2025']. | | moments | string[] | no | — | Named retail moments the query targets. | ## Returns - On success: {"result": {"total": int, "chunks": list[ChunkHit]}}. Each hit carries - report_id, title, date, score, text, chunk_id, and tags — - the parent report's curated taxonomy tags (article_types, topics, - gender, season, year, moments), empty list per untagged axis. Check - tags.gender / tags.season before grounding an audience- or - season-specific claim in a passage. - On retrieval failure: {"error": {code, message}} - code is invalid_input, upstream_unavailable, - internal (our own bad data; a retry returns the same error), or - not_entitled (authorization refused). - chunk_id is opaque and session-scoped: redeem it with research_get_chunk - in the same session, and never persist it — re-ingestion reassigns ids. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "researchmcp-research_chunk_search", "arguments": { "query": "Nike", "start_date": "example", "end_date": "example", "k": 5 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "researchmcp-research_chunk_search", arguments: { "query": "Nike", "start_date": "example", "end_date": "example", "k": 5 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "researchmcp-research_chunk_search", {"query": "Nike", "start_date": "example", "end_date": "example", "k": 5}, ) ``` ## Input schema ``` { "type": "object", "properties": { "query": { "description": "Natural language search query for finding chunks within reports.", "type": "string" }, "report_ids": { "description": "Constrain the search to specific articles — pass `report_id` values returned by `research_article_search`. Omit to search the full corpus.", "items": { "type": "integer" }, "type": "array" }, "start_date": { "description": "Inclusive start date (YYYY-MM-DD).", "format": "date", "type": "string" }, "end_date": { "description": "Inclusive end date (YYYY-MM-DD).", "format": "date", "type": "string" }, "k": { "default": 5, "description": "Number of chunks to return.", "maximum": 10, "minimum": 1, "type": "integer" }, "article_types": { "description": "Editorial format of the reports to favour; `research_analyze_query` can supply these.", "items": { "enum": [ "trend-analysis", "trends-by-season", "trends-by-city", "street-style", "retailer-messaging", "buyers-guide", "consumer-moments", "hindsighting", "consumer-strategy", "forecasting", "product-newness", "collections-by-city", "events-opportunities", "messaging-calendars", "assortment-pricing", "site-merchandising-promotion" ], "type": "string" }, "type": "array" }, "topics": { "description": "Subject matter to favour.", "items": { "enum": [ "footwear", "accessories", "pricing-discounting", "denim-fabric", "color", "bottoms", "active-sports", "subcultures", "print-licensing", "intimates-swimwear", "tops", "outerwear", "details-trims", "diversity-inclusion", "dresses", "sustainability", "generation-demographic", "knitwear", "tailoring" ], "type": "string" }, "type": "array" }, "gender": { "description": "Audience to favour.", "items": { "enum": [ "men", "women", "children", "mid-mature" ], "type": "string" }, "type": "array" }, "season": { "description": "Retail season to favour. A ranking signal only — never converted to a date filter.", "items": { "enum": [ "spring-summer", "fall-winter", "pre-spring", "pre-fall" ], "type": "string" }, "type": "array" }, "year": { "description": "Four-digit year(s) the query targets, e.g. ['2025'].", "items": { "pattern": "^20[0-9]{2}$", "type": "string" }, "type": "array" }, "moments": { "description": "Named retail moments the query targets.", "items": { "enum": [ "black-friday", "lunar-new-year", "easter", "halloween", "international-womens-day", "mothers-day-uk", "mothers-day-us", "4th-july-us", "earth-month", "festival-season", "holiday-season", "ramadan", "valentines-day", "back-to-college", "back-to-school", "fathers-day", "occasion-season", "spring-break-us" ], "type": "string" }, "type": "array" } }, "required": [ "query" ] } ``` ## 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": { "properties": { "total": { "type": "integer" }, "chunks": { "items": { "properties": { "report_id": { "type": "integer" }, "title": { "type": "string" }, "date": { "format": "date", "type": "string" }, "score": { "type": "number" }, "text": { "type": "string" }, "chunk_id": { "type": "string" }, "tags": { "description": "The parent report's curated taxonomy tags, keyed by the public intent axes.\n\nPopulated on every search hit, browse row and report lookup so a consumer can\nsee a report's audience, season and subject matter *at discovery time* — a\ntitle alone does not say whether a \"SS27 Buyers' Guide\" is menswear or\nwomenswear, and grounding a womenswear claim in a menswear report is the\nfailure this prevents.\n\nThe values are drawn from the vocabulary the search tools' intent params\naccept, so a tag read off a hit can usually be threaded straight back in to\nnarrow a follow-up search. Two limits on that, both worth knowing before\ntreating a threaded tag as a guaranteed narrowing:\n\n- **The vocabularies are not identical.** These are curated report-side\n labels, so a value can exist here that the params do not recognise (prod\n carries ``moments: [\"pride\"]``, which is outside ``MomentsParam``'s enum).\n Threading such a value is ignored by ``normalise_intent`` or rejected by the\n param schema — never a silent narrowing.\n- **``year`` and ``moments`` are not pure ranking axes.** Unlike the other\n four, they also derive the publish-date window when no explicit dates are\n given (see ``retrieval/date_window.py``), so threading one back moves the\n filter as well as the boost.\n\nEvery axis is always present on the model; an untagged axis is an empty list.\nCoverage is uneven and heavily skewed by report class — buyers' guides are\nclose to fully tagged for ``gender``, while a large minority of the corpus\ncarries no taxonomy at all — so an empty axis is common and means \"not\ncurated\", never \"out of scope\". (One near-exception: a topic tag that exists\nonly as an all-encompassing label is dropped rather than surfaced, matching\nwhat the scorer sees — see ``canonicalise_topics``.)\n\nTwo exceptions, both id re-fetches: the chunk and image indices carry no\ntaxonomy tags and ``get_chunk`` / ``get_image`` do not spend a second query\nresolving them, so the field stays at its empty default there — and the\nresearch_get_chunk / research_get_image tools serve ``ChunkRow`` / ``ImageRow``,\nwhich have no ``tags`` field at all, rather than let an empty axis read as\n\"this report is untagged\". So `tags` is absent from those two tools' published\nschemas; wherever it *is* published, an empty axis does mean untagged.", "properties": { "article_types": { "items": { "type": "string" }, "type": "array" }, "topics": { "items": { "type": "string" }, "type": "array" }, "gender": { "items": { "type": "string" }, "type": "array" }, "season": { "items": { "type": "string" }, "type": "array" }, "year": { "items": { "type": "string" }, "type": "array" }, "moments": { "items": { "type": "string" }, "type": "array" } }, "type": "object" } }, "required": [ "report_id", "title", "date", "score", "text", "chunk_id" ], "type": "object" }, "type": "array" } }, "required": [ "total", "chunks" ], "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 } ``` --- ## Get Passage (Research) Source: https://build.edited.com/reference/tools/researchmcp-research_get_chunk # Get Passage (Research) researchmcp-research_get_chunk Fetch a single text chunk by its stable id. Use after research_chunk_search when you have a chunk_id and want the chunk back without re-running a search. Cheap direct id lookup. chunk_id is an opaque id assigned when the chunk is indexed. It round-trips reliably within a session (search -> get). It is NOT durable across re-ingestion: the hourly cron deletes a report's docs and re-inserts them with new ids when the report is modified, so a chunk_id obtained before reprocessing will miss afterward. Fetch within the same session. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | chunk_id | string | yes | — | The chunk_id returned by a previous research_chunk_search call. | ## Returns - On hit: {"result": {"chunk": {report_id, title, date, score, text, chunk_id}}}. - On miss: {"result": {"chunk": None}}. Not an error. - On retrieval failure: {"error": {code, message}} - code is upstream_unavailable, internal (our own bad - data; a retry returns the same error), or not_entitled (authorization - refused). - The score field is 0.0 for direct id lookups (no relevance ranking applies). - No tags here, unlike a search hit: the chunk search that gave you this - chunk_id already returned the report's taxonomy tags. If you need the - audience/season of the parent report and no longer have them, call - research_get_report(report_id). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "researchmcp-research_get_chunk", "arguments": { "chunk_id": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "researchmcp-research_get_chunk", arguments: { "chunk_id": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "researchmcp-research_get_chunk", {"chunk_id": "example"}, ) ``` ## Input schema ``` { "type": "object", "properties": { "chunk_id": { "description": "The chunk_id returned by a previous research_chunk_search call.", "minLength": 1, "type": "string" } }, "required": [ "chunk_id" ] } ``` ## 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": { "properties": { "chunk": { "anyOf": [ { "properties": { "report_id": { "type": "integer" }, "title": { "type": "string" }, "date": { "format": "date", "type": "string" }, "score": { "type": "number" }, "text": { "type": "string" }, "chunk_id": { "type": "string" } }, "required": [ "report_id", "title", "date", "score", "text", "chunk_id" ], "type": "object" }, { "type": "null" } ] } }, "required": [ "chunk" ], "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 } ``` --- ## Get Image (Research) Source: https://build.edited.com/reference/tools/researchmcp-research_get_image # Get Image (Research) researchmcp-research_get_image Fetch a single image by its stable id. Use after research_image_search or research_match_images_to_text when you have an image_id. The response always includes the image metadata (URL, caption, copyright, format); pass include_bytes=True to also include the base64 image bytes for multimodal LLM input. image_id is an opaque id assigned when the image is indexed. It round-trips reliably within a session (search -> get). It is NOT durable across re-ingestion: the hourly cron deletes a report's docs and re-inserts them with new ids when the report is modified, so an image_id obtained before reprocessing will miss afterward. Fetch within the same session. ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | image_id | string | yes | — | The image_id returned by a previous research_image_search or research_match_images_to_text call. | | include_bytes | boolean | no | default: false | When true, the response carries the base64-encoded image bytes in bytes_base64. Default false to keep payloads small; opt in only when you need the actual image (e.g. for multimodal LLM input). | ## Returns - On hit: {"result": { - "image": {report_id, title, date, score, url, caption, copyright, - format, image_id}, - "bytes_base64": str | None, - "format": str | None, - }} - On miss: {"result": {"image": None, "bytes_base64": None, "format": None}}. - On retrieval failure: {"error": {code, message}} - code is upstream_unavailable, internal (our own bad - data; a retry returns the same error), or not_entitled (authorization - refused). - bytes_base64 is populated only when include_bytes=True and the image is - found. format is a top-level convenience copy of image.format. - No tags here, unlike a search hit: the image search that gave you this - image_id already returned the report's taxonomy tags. If you need the - audience/season of the parent report and no longer have them, call - research_get_report(report_id). - The score field is 0.0 for direct id lookups (no relevance ranking applies). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "researchmcp-research_get_image", "arguments": { "image_id": "example", "include_bytes": false } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "researchmcp-research_get_image", arguments: { "image_id": "example", "include_bytes": false }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "researchmcp-research_get_image", {"image_id": "example", "include_bytes": False}, ) ``` ## Input schema ``` { "type": "object", "properties": { "image_id": { "description": "The image_id returned by a previous research_image_search or research_match_images_to_text call.", "minLength": 1, "type": "string" }, "include_bytes": { "default": false, "description": "When true, the response carries the base64-encoded image bytes in `bytes_base64`. Default false to keep payloads small; opt in only when you need the actual image (e.g. for multimodal LLM input).", "type": "boolean" } }, "required": [ "image_id" ] } ``` ## 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": { "properties": { "image": { "anyOf": [ { "properties": { "report_id": { "type": "integer" }, "title": { "type": "string" }, "date": { "format": "date", "type": "string" }, "score": { "type": "number" }, "url": { "type": "string" }, "caption": { "type": "string" }, "copyright": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "format": { "anyOf": [ { "enum": [ "png", "jpeg", "webp", "jpg" ], "type": "string" }, { "type": "null" } ] }, "image_id": { "type": "string" } }, "required": [ "report_id", "title", "date", "score", "url", "caption", "copyright", "format", "image_id" ], "type": "object" }, { "type": "null" } ] }, "bytes_base64": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "description": "Null unless include_bytes was requested and the image was found." }, "format": { "anyOf": [ { "enum": [ "png", "jpeg", "webp", "jpg" ], "type": "string" }, { "type": "null" } ], "description": "Copy of image.format, for convenience." } }, "required": [ "image", "bytes_base64", "format" ], "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 } ``` --- ## Get Report Metadata (Research) Source: https://build.edited.com/reference/tools/researchmcp-research_get_report # Get Report Metadata (Research) researchmcp-research_get_report Fetch full metadata for a known report_id. Use after one of the search tools (research_article_search, research_chunk_search, research_image_search) when you have a report_id and want more context (full excerpt, modified date) without re-running a search. Cheap direct id lookup. For the full article text use research_read_report; for a public link use research_report_links. Try queries like research_get_report(report_id=152777) on an id from a prior search hit; "when was report 152777 published", "details on report 160001". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | report_id | integer | yes | — | The report_id returned by a previous search tool. | ## Returns - On hit: {"result": {"report": {report_id, title, date, modified, excerpt, - tags}}}. tags is the report's curated taxonomy tags (article_types, - topics, gender, season, year, moments), empty list per - untagged axis — use it to settle the audience or season of a report - you have only seen as an id. - On miss: {"result": {"report": None}}. Not an error. - On retrieval failure: {"error": {code, message}} - code is upstream_unavailable, internal (our own bad - data; a retry returns the same error), or not_entitled (authorization - refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "researchmcp-research_get_report", "arguments": { "report_id": 1 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "researchmcp-research_get_report", arguments: { "report_id": 1 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "researchmcp-research_get_report", {"report_id": 1}, ) ``` ## Input schema ``` { "type": "object", "properties": { "report_id": { "description": "The report_id returned by a previous search tool.", "type": "integer" } }, "required": [ "report_id" ] } ``` ## 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": { "properties": { "report": { "anyOf": [ { "properties": { "report_id": { "type": "integer" }, "title": { "type": "string" }, "date": { "format": "date", "type": "string" }, "modified": { "format": "date-time", "type": "string" }, "excerpt": { "type": "string" }, "tags": { "description": "The parent report's curated taxonomy tags, keyed by the public intent axes.\n\nPopulated on every search hit, browse row and report lookup so a consumer can\nsee a report's audience, season and subject matter *at discovery time* — a\ntitle alone does not say whether a \"SS27 Buyers' Guide\" is menswear or\nwomenswear, and grounding a womenswear claim in a menswear report is the\nfailure this prevents.\n\nThe values are drawn from the vocabulary the search tools' intent params\naccept, so a tag read off a hit can usually be threaded straight back in to\nnarrow a follow-up search. Two limits on that, both worth knowing before\ntreating a threaded tag as a guaranteed narrowing:\n\n- **The vocabularies are not identical.** These are curated report-side\n labels, so a value can exist here that the params do not recognise (prod\n carries ``moments: [\"pride\"]``, which is outside ``MomentsParam``'s enum).\n Threading such a value is ignored by ``normalise_intent`` or rejected by the\n param schema — never a silent narrowing.\n- **``year`` and ``moments`` are not pure ranking axes.** Unlike the other\n four, they also derive the publish-date window when no explicit dates are\n given (see ``retrieval/date_window.py``), so threading one back moves the\n filter as well as the boost.\n\nEvery axis is always present on the model; an untagged axis is an empty list.\nCoverage is uneven and heavily skewed by report class — buyers' guides are\nclose to fully tagged for ``gender``, while a large minority of the corpus\ncarries no taxonomy at all — so an empty axis is common and means \"not\ncurated\", never \"out of scope\". (One near-exception: a topic tag that exists\nonly as an all-encompassing label is dropped rather than surfaced, matching\nwhat the scorer sees — see ``canonicalise_topics``.)\n\nTwo exceptions, both id re-fetches: the chunk and image indices carry no\ntaxonomy tags and ``get_chunk`` / ``get_image`` do not spend a second query\nresolving them, so the field stays at its empty default there — and the\nresearch_get_chunk / research_get_image tools serve ``ChunkRow`` / ``ImageRow``,\nwhich have no ``tags`` field at all, rather than let an empty axis read as\n\"this report is untagged\". So `tags` is absent from those two tools' published\nschemas; wherever it *is* published, an empty axis does mean untagged.", "properties": { "article_types": { "items": { "type": "string" }, "type": "array" }, "topics": { "items": { "type": "string" }, "type": "array" }, "gender": { "items": { "type": "string" }, "type": "array" }, "season": { "items": { "type": "string" }, "type": "array" }, "year": { "items": { "type": "string" }, "type": "array" }, "moments": { "items": { "type": "string" }, "type": "array" } }, "type": "object" } }, "required": [ "report_id", "title", "date", "modified", "excerpt" ], "type": "object" }, { "type": "null" } ] } }, "required": [ "report" ], "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 } ``` --- ## Search Images (Research) Source: https://build.edited.com/reference/tools/researchmcp-research_image_search # Search Images (Research) researchmcp-research_image_search Search EDITED Research articles for images by meaning. Returns image-level hits — each carries a URL, caption, and a session-scoped image_id. Use after research_article_search (passing report_ids to restrict by article) when visual content is needed, or stand-alone for visually-themed queries like "denim runway looks". Before presenting results, resolve the report_id values you cite with research_report_links (one batched call) so the user gets clickable EDITED Research URLs — this follow-up is expected, not optional. The linked pages require login, so when the user wants the content itself, call research_read_report. Ranking is automatic: a blend of relevance, recency, and — when you supply the optional intent axes (article_types, topics, gender, season, year, moments) — taxonomy match. None is required and unknown slugs are ignored. year/moments also set the time window when you pass no dates: a year narrows to that year — except a current or forward-looking one, which never reaches less publish history than the default — moments alone to the last 12 months, and with neither the window is the last 550 days. With no explicit dates, report_ids suppresses the window entirely (explicit dates still win). The score on each hit is this composite value, not a raw relevance number and not comparable across tools. Try queries like "denim runway looks", "store window displays", "sneaker product shots", "SS25 colour charts". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | query | string | yes | — | Natural language search query for finding images within reports. | | report_ids | integer[] | no | — | Constrain the search to specific articles — pass report_id values returned by research_article_search. Omit to search the full corpus. | | start_date | string | no | — | Inclusive start date (YYYY-MM-DD). | | end_date | string | no | — | Inclusive end date (YYYY-MM-DD). | | k | integer | no | default: 5 · 1–10 | Number of images to return. | | article_types | string[] | no | — | Editorial format of the reports to favour; research_analyze_query can supply these. | | topics | string[] | no | — | Subject matter to favour. | | gender | string[] | no | — | Audience to favour. | | season | string[] | no | — | Retail season to favour. A ranking signal only — never converted to a date filter. | | year | string[] | no | — | Four-digit year(s) the query targets, e.g. ['2025']. | | moments | string[] | no | — | Named retail moments the query targets. | ## Returns - On success: {"result": {"total": int, "images": list[ImageHit]}}. Each hit carries - report_id, title, date, score, url, caption, copyright - (image attribution string such as "Launchmetrics/Spotlight", or null), - format (png/jpeg/webp/jpg or null), image_id, and tags — the - parent report's curated taxonomy tags (article_types, topics, gender, - season, year, moments), empty list per untagged axis. Check - tags.gender / tags.season before reading a look as evidence for a - different audience or season. - On retrieval failure: {"error": {code, message}} - code is invalid_input, upstream_unavailable, - internal (our own bad data; a retry returns the same error), or - not_entitled (authorization refused). - image_id is opaque and session-scoped: redeem it with research_get_image - in the same session, and never persist it — re-ingestion reassigns ids. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "researchmcp-research_image_search", "arguments": { "query": "Nike", "start_date": "example", "end_date": "example", "k": 5 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "researchmcp-research_image_search", arguments: { "query": "Nike", "start_date": "example", "end_date": "example", "k": 5 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "researchmcp-research_image_search", {"query": "Nike", "start_date": "example", "end_date": "example", "k": 5}, ) ``` ## Input schema ``` { "type": "object", "properties": { "query": { "description": "Natural language search query for finding images within reports.", "type": "string" }, "report_ids": { "description": "Constrain the search to specific articles — pass `report_id` values returned by `research_article_search`. Omit to search the full corpus.", "items": { "type": "integer" }, "type": "array" }, "start_date": { "description": "Inclusive start date (YYYY-MM-DD).", "format": "date", "type": "string" }, "end_date": { "description": "Inclusive end date (YYYY-MM-DD).", "format": "date", "type": "string" }, "k": { "default": 5, "description": "Number of images to return.", "maximum": 10, "minimum": 1, "type": "integer" }, "article_types": { "description": "Editorial format of the reports to favour; `research_analyze_query` can supply these.", "items": { "enum": [ "trend-analysis", "trends-by-season", "trends-by-city", "street-style", "retailer-messaging", "buyers-guide", "consumer-moments", "hindsighting", "consumer-strategy", "forecasting", "product-newness", "collections-by-city", "events-opportunities", "messaging-calendars", "assortment-pricing", "site-merchandising-promotion" ], "type": "string" }, "type": "array" }, "topics": { "description": "Subject matter to favour.", "items": { "enum": [ "footwear", "accessories", "pricing-discounting", "denim-fabric", "color", "bottoms", "active-sports", "subcultures", "print-licensing", "intimates-swimwear", "tops", "outerwear", "details-trims", "diversity-inclusion", "dresses", "sustainability", "generation-demographic", "knitwear", "tailoring" ], "type": "string" }, "type": "array" }, "gender": { "description": "Audience to favour.", "items": { "enum": [ "men", "women", "children", "mid-mature" ], "type": "string" }, "type": "array" }, "season": { "description": "Retail season to favour. A ranking signal only — never converted to a date filter.", "items": { "enum": [ "spring-summer", "fall-winter", "pre-spring", "pre-fall" ], "type": "string" }, "type": "array" }, "year": { "description": "Four-digit year(s) the query targets, e.g. ['2025'].", "items": { "pattern": "^20[0-9]{2}$", "type": "string" }, "type": "array" }, "moments": { "description": "Named retail moments the query targets.", "items": { "enum": [ "black-friday", "lunar-new-year", "easter", "halloween", "international-womens-day", "mothers-day-uk", "mothers-day-us", "4th-july-us", "earth-month", "festival-season", "holiday-season", "ramadan", "valentines-day", "back-to-college", "back-to-school", "fathers-day", "occasion-season", "spring-break-us" ], "type": "string" }, "type": "array" } }, "required": [ "query" ] } ``` ## 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": { "properties": { "total": { "type": "integer" }, "images": { "items": { "properties": { "report_id": { "type": "integer" }, "title": { "type": "string" }, "date": { "format": "date", "type": "string" }, "score": { "type": "number" }, "url": { "type": "string" }, "caption": { "type": "string" }, "copyright": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "format": { "anyOf": [ { "enum": [ "png", "jpeg", "webp", "jpg" ], "type": "string" }, { "type": "null" } ] }, "image_id": { "type": "string" }, "tags": { "description": "The parent report's curated taxonomy tags, keyed by the public intent axes.\n\nPopulated on every search hit, browse row and report lookup so a consumer can\nsee a report's audience, season and subject matter *at discovery time* — a\ntitle alone does not say whether a \"SS27 Buyers' Guide\" is menswear or\nwomenswear, and grounding a womenswear claim in a menswear report is the\nfailure this prevents.\n\nThe values are drawn from the vocabulary the search tools' intent params\naccept, so a tag read off a hit can usually be threaded straight back in to\nnarrow a follow-up search. Two limits on that, both worth knowing before\ntreating a threaded tag as a guaranteed narrowing:\n\n- **The vocabularies are not identical.** These are curated report-side\n labels, so a value can exist here that the params do not recognise (prod\n carries ``moments: [\"pride\"]``, which is outside ``MomentsParam``'s enum).\n Threading such a value is ignored by ``normalise_intent`` or rejected by the\n param schema — never a silent narrowing.\n- **``year`` and ``moments`` are not pure ranking axes.** Unlike the other\n four, they also derive the publish-date window when no explicit dates are\n given (see ``retrieval/date_window.py``), so threading one back moves the\n filter as well as the boost.\n\nEvery axis is always present on the model; an untagged axis is an empty list.\nCoverage is uneven and heavily skewed by report class — buyers' guides are\nclose to fully tagged for ``gender``, while a large minority of the corpus\ncarries no taxonomy at all — so an empty axis is common and means \"not\ncurated\", never \"out of scope\". (One near-exception: a topic tag that exists\nonly as an all-encompassing label is dropped rather than surfaced, matching\nwhat the scorer sees — see ``canonicalise_topics``.)\n\nTwo exceptions, both id re-fetches: the chunk and image indices carry no\ntaxonomy tags and ``get_chunk`` / ``get_image`` do not spend a second query\nresolving them, so the field stays at its empty default there — and the\nresearch_get_chunk / research_get_image tools serve ``ChunkRow`` / ``ImageRow``,\nwhich have no ``tags`` field at all, rather than let an empty axis read as\n\"this report is untagged\". So `tags` is absent from those two tools' published\nschemas; wherever it *is* published, an empty axis does mean untagged.", "properties": { "article_types": { "items": { "type": "string" }, "type": "array" }, "topics": { "items": { "type": "string" }, "type": "array" }, "gender": { "items": { "type": "string" }, "type": "array" }, "season": { "items": { "type": "string" }, "type": "array" }, "year": { "items": { "type": "string" }, "type": "array" }, "moments": { "items": { "type": "string" }, "type": "array" } }, "type": "object" } }, "required": [ "report_id", "title", "date", "score", "url", "caption", "copyright", "format", "image_id" ], "type": "object" }, "type": "array" } }, "required": [ "total", "images" ], "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 } ``` --- ## List Reports (Research) Source: https://build.edited.com/reference/tools/researchmcp-research_list_reports # List Reports (Research) researchmcp-research_list_reports Browse what has been published lately, newest-first by publish date. This is a browsing tool, not a discovery tool — reach for it only when the question is about recency itself: "what has EDITED published lately", "what reports do we have from last month". It orders by publish date with no topic, audience or coverage filter, so it will not surface the reports that define a topic even when they exist. For anything topic-shaped — a trend, a category, a season's direction — do not start here: call research_analyze_query and then research_article_search (drilling in with research_chunk_search), which rank on topic, audience and season rather than date order. The date window is the report's publish date, not the season it is about: a report covering SS27 typically publishes months earlier, so a calendar-SS27 window would miss it. Both dates are optional; research_analyze_query converts a natural-language window into them. Omit cursor on the first call. Try queries like "what reports do we have from last month", "list reports since January", "anything published this week". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | start_date | string | no | — | Inclusive lower bound (YYYY-MM-DD). | | end_date | string | no | — | Inclusive upper bound (YYYY-MM-DD). | | limit | integer | no | default: 20 · 1–100 | Page size. | | cursor | string | no | — | Opaque pagination token from a previous response's next_cursor. | ## Returns - On success: {"result": {"total": int, "reports": list[ReportSummary], - "next_cursor": str | None}}. Each report carries report_id, - title, date, modified, excerpt, and tags — the report's - curated taxonomy tags (article_types, topics, gender, season, - year, moments), which tell you a "SS27 Buyers' Guide" is menswear - before you open it. An untagged axis is an empty list. For a public - link to a report_id call research_report_links; for the full article - text call research_read_report. - total is the number of reports on this page (it feeds the - mcp.tool.result_count metric); use next_cursor (None on the - last page) to gauge whether more exist. - On retrieval failure or invalid cursor/limit: {"error": {code, message}} - code is invalid_input, upstream_unavailable, - internal (our own bad data; a retry returns the same error), or - not_entitled (authorization refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "researchmcp-research_list_reports", "arguments": { "start_date": "example", "end_date": "example", "limit": 20, "cursor": "example" } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "researchmcp-research_list_reports", arguments: { "start_date": "example", "end_date": "example", "limit": 20, "cursor": "example" }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "researchmcp-research_list_reports", {"start_date": "example", "end_date": "example", "limit": 20, "cursor": "example"}, ) ``` ## Input schema ``` { "type": "object", "properties": { "start_date": { "description": "Inclusive lower bound (YYYY-MM-DD).", "format": "date", "type": "string" }, "end_date": { "description": "Inclusive upper bound (YYYY-MM-DD).", "format": "date", "type": "string" }, "limit": { "default": 20, "description": "Page size.", "maximum": 100, "minimum": 1, "type": "integer" }, "cursor": { "description": "Opaque pagination token from a previous response's `next_cursor`.", "type": "string" } } } ``` ## 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": { "properties": { "total": { "type": "integer" }, "reports": { "items": { "properties": { "report_id": { "type": "integer" }, "title": { "type": "string" }, "date": { "format": "date", "type": "string" }, "modified": { "format": "date-time", "type": "string" }, "excerpt": { "type": "string" }, "tags": { "description": "The parent report's curated taxonomy tags, keyed by the public intent axes.\n\nPopulated on every search hit, browse row and report lookup so a consumer can\nsee a report's audience, season and subject matter *at discovery time* — a\ntitle alone does not say whether a \"SS27 Buyers' Guide\" is menswear or\nwomenswear, and grounding a womenswear claim in a menswear report is the\nfailure this prevents.\n\nThe values are drawn from the vocabulary the search tools' intent params\naccept, so a tag read off a hit can usually be threaded straight back in to\nnarrow a follow-up search. Two limits on that, both worth knowing before\ntreating a threaded tag as a guaranteed narrowing:\n\n- **The vocabularies are not identical.** These are curated report-side\n labels, so a value can exist here that the params do not recognise (prod\n carries ``moments: [\"pride\"]``, which is outside ``MomentsParam``'s enum).\n Threading such a value is ignored by ``normalise_intent`` or rejected by the\n param schema — never a silent narrowing.\n- **``year`` and ``moments`` are not pure ranking axes.** Unlike the other\n four, they also derive the publish-date window when no explicit dates are\n given (see ``retrieval/date_window.py``), so threading one back moves the\n filter as well as the boost.\n\nEvery axis is always present on the model; an untagged axis is an empty list.\nCoverage is uneven and heavily skewed by report class — buyers' guides are\nclose to fully tagged for ``gender``, while a large minority of the corpus\ncarries no taxonomy at all — so an empty axis is common and means \"not\ncurated\", never \"out of scope\". (One near-exception: a topic tag that exists\nonly as an all-encompassing label is dropped rather than surfaced, matching\nwhat the scorer sees — see ``canonicalise_topics``.)\n\nTwo exceptions, both id re-fetches: the chunk and image indices carry no\ntaxonomy tags and ``get_chunk`` / ``get_image`` do not spend a second query\nresolving them, so the field stays at its empty default there — and the\nresearch_get_chunk / research_get_image tools serve ``ChunkRow`` / ``ImageRow``,\nwhich have no ``tags`` field at all, rather than let an empty axis read as\n\"this report is untagged\". So `tags` is absent from those two tools' published\nschemas; wherever it *is* published, an empty axis does mean untagged.", "properties": { "article_types": { "items": { "type": "string" }, "type": "array" }, "topics": { "items": { "type": "string" }, "type": "array" }, "gender": { "items": { "type": "string" }, "type": "array" }, "season": { "items": { "type": "string" }, "type": "array" }, "year": { "items": { "type": "string" }, "type": "array" }, "moments": { "items": { "type": "string" }, "type": "array" } }, "type": "object" } }, "required": [ "report_id", "title", "date", "modified", "excerpt" ], "type": "object" }, "type": "array" }, "next_cursor": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "total", "reports", "next_cursor" ], "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 } ``` --- ## Match Images to Text (Research) Source: https://build.edited.com/reference/tools/researchmcp-research_match_images_to_text # Match Images to Text (Research) researchmcp-research_match_images_to_text Find images that match arbitrary text by meaning — "find me more of this". Two jobs, not one: - Illustration: you have an answer and want images that visually support it. - Discovery expansion: feed a synthesis paragraph back in (with report_ids omitted) and the hits point at adjacent reports you did not retrieve — co-occurring themes a keyword-shaped query would miss. Read the report_id and tags on each hit, not just the picture: this is a cheap way to widen a trend read after your first search round. Differs from research_image_search in two ways: - The input is text (typically longer than a query — e.g. an already-generated answer or a paragraph of synthesis). - No date filters. Use report_ids to constrain scope, or omit it to let the search reach the whole corpus (what you want when expanding). Match-only: the response is the raw search hits. The caller is responsible for any perceptual-hash dedup or LLM relevance filtering if desired. Before presenting results, resolve the report_id values you cite — including any report this expansion surfaced — with research_report_links (one batched call) so the user gets clickable EDITED Research URLs. This follow-up is expected, not optional; use research_read_report when the user wants the article text rather than a link. Ranking blends relevance with recency, plus the optional intent axes (article_types, topics, gender, season, year, moments) when supplied — none is required and unknown slugs are ignored. No date window is applied here, so year/moments bias ranking only and report_ids is the one thing that constrains scope. The score on each hit is this composite value. Try queries like "Oversized tailoring dominated the autumn runways.", "Retailers are leaning into minimalist, recyclable packaging this season.". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | text | string | yes | — | Free-form text — an answer, paragraph, or anything — to find images that match its meaning. Typically longer than a search query. | | report_ids | integer[] | no | — | Constrain the search to images from specific reports. Pass the report_id values from a previous search. Omit to search the full corpus. | | k | integer | no | default: 5 · 1–10 | Number of images to return. | | article_types | string[] | no | — | Editorial format of the reports to favour; research_analyze_query can supply these. | | topics | string[] | no | — | Subject matter to favour. | | gender | string[] | no | — | Audience to favour. | | season | string[] | no | — | Retail season to favour. A ranking signal only — never converted to a date filter. | | year | string[] | no | — | Four-digit year(s) the query targets, e.g. ['2025']. | | moments | string[] | no | — | Named retail moments the query targets. | ## Returns - On success: {"result": {"total": int, "images": list[ImageHit]}} where each hit - carries report_id, title, date, score, url, caption, - copyright (image attribution string or null), format, image_id, - and tags — the parent report's taxonomy tags (article_types, - topics, gender, season, year, moments), so you can check the - audience/season of an expanded hit before grounding a claim in it. - total is the number of images returned (<= k). - On retrieval failure or invalid k: {"error": {code, message}} - code is invalid_input, upstream_unavailable, - internal (our own bad data; a retry returns the same error), or - not_entitled (authorization refused). - image_id is opaque and session-scoped: redeem it with research_get_image - in the same session, and never persist it — re-ingestion reassigns ids. ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "researchmcp-research_match_images_to_text", "arguments": { "text": "example", "k": 5 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "researchmcp-research_match_images_to_text", arguments: { "text": "example", "k": 5 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "researchmcp-research_match_images_to_text", {"text": "example", "k": 5}, ) ``` ## Input schema ``` { "type": "object", "properties": { "text": { "description": "Free-form text — an answer, paragraph, or anything — to find images that match its meaning. Typically longer than a search query.", "type": "string" }, "report_ids": { "description": "Constrain the search to images from specific reports. Pass the `report_id` values from a previous search. Omit to search the full corpus.", "items": { "type": "integer" }, "type": "array" }, "k": { "default": 5, "description": "Number of images to return.", "maximum": 10, "minimum": 1, "type": "integer" }, "article_types": { "description": "Editorial format of the reports to favour; `research_analyze_query` can supply these.", "items": { "enum": [ "trend-analysis", "trends-by-season", "trends-by-city", "street-style", "retailer-messaging", "buyers-guide", "consumer-moments", "hindsighting", "consumer-strategy", "forecasting", "product-newness", "collections-by-city", "events-opportunities", "messaging-calendars", "assortment-pricing", "site-merchandising-promotion" ], "type": "string" }, "type": "array" }, "topics": { "description": "Subject matter to favour.", "items": { "enum": [ "footwear", "accessories", "pricing-discounting", "denim-fabric", "color", "bottoms", "active-sports", "subcultures", "print-licensing", "intimates-swimwear", "tops", "outerwear", "details-trims", "diversity-inclusion", "dresses", "sustainability", "generation-demographic", "knitwear", "tailoring" ], "type": "string" }, "type": "array" }, "gender": { "description": "Audience to favour.", "items": { "enum": [ "men", "women", "children", "mid-mature" ], "type": "string" }, "type": "array" }, "season": { "description": "Retail season to favour. A ranking signal only — never converted to a date filter.", "items": { "enum": [ "spring-summer", "fall-winter", "pre-spring", "pre-fall" ], "type": "string" }, "type": "array" }, "year": { "description": "Four-digit year(s) the query targets, e.g. ['2025'].", "items": { "pattern": "^20[0-9]{2}$", "type": "string" }, "type": "array" }, "moments": { "description": "Named retail moments the query targets.", "items": { "enum": [ "black-friday", "lunar-new-year", "easter", "halloween", "international-womens-day", "mothers-day-uk", "mothers-day-us", "4th-july-us", "earth-month", "festival-season", "holiday-season", "ramadan", "valentines-day", "back-to-college", "back-to-school", "fathers-day", "occasion-season", "spring-break-us" ], "type": "string" }, "type": "array" } }, "required": [ "text" ] } ``` ## 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": { "properties": { "total": { "type": "integer" }, "images": { "items": { "properties": { "report_id": { "type": "integer" }, "title": { "type": "string" }, "date": { "format": "date", "type": "string" }, "score": { "type": "number" }, "url": { "type": "string" }, "caption": { "type": "string" }, "copyright": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "format": { "anyOf": [ { "enum": [ "png", "jpeg", "webp", "jpg" ], "type": "string" }, { "type": "null" } ] }, "image_id": { "type": "string" }, "tags": { "description": "The parent report's curated taxonomy tags, keyed by the public intent axes.\n\nPopulated on every search hit, browse row and report lookup so a consumer can\nsee a report's audience, season and subject matter *at discovery time* — a\ntitle alone does not say whether a \"SS27 Buyers' Guide\" is menswear or\nwomenswear, and grounding a womenswear claim in a menswear report is the\nfailure this prevents.\n\nThe values are drawn from the vocabulary the search tools' intent params\naccept, so a tag read off a hit can usually be threaded straight back in to\nnarrow a follow-up search. Two limits on that, both worth knowing before\ntreating a threaded tag as a guaranteed narrowing:\n\n- **The vocabularies are not identical.** These are curated report-side\n labels, so a value can exist here that the params do not recognise (prod\n carries ``moments: [\"pride\"]``, which is outside ``MomentsParam``'s enum).\n Threading such a value is ignored by ``normalise_intent`` or rejected by the\n param schema — never a silent narrowing.\n- **``year`` and ``moments`` are not pure ranking axes.** Unlike the other\n four, they also derive the publish-date window when no explicit dates are\n given (see ``retrieval/date_window.py``), so threading one back moves the\n filter as well as the boost.\n\nEvery axis is always present on the model; an untagged axis is an empty list.\nCoverage is uneven and heavily skewed by report class — buyers' guides are\nclose to fully tagged for ``gender``, while a large minority of the corpus\ncarries no taxonomy at all — so an empty axis is common and means \"not\ncurated\", never \"out of scope\". (One near-exception: a topic tag that exists\nonly as an all-encompassing label is dropped rather than surfaced, matching\nwhat the scorer sees — see ``canonicalise_topics``.)\n\nTwo exceptions, both id re-fetches: the chunk and image indices carry no\ntaxonomy tags and ``get_chunk`` / ``get_image`` do not spend a second query\nresolving them, so the field stays at its empty default there — and the\nresearch_get_chunk / research_get_image tools serve ``ChunkRow`` / ``ImageRow``,\nwhich have no ``tags`` field at all, rather than let an empty axis read as\n\"this report is untagged\". So `tags` is absent from those two tools' published\nschemas; wherever it *is* published, an empty axis does mean untagged.", "properties": { "article_types": { "items": { "type": "string" }, "type": "array" }, "topics": { "items": { "type": "string" }, "type": "array" }, "gender": { "items": { "type": "string" }, "type": "array" }, "season": { "items": { "type": "string" }, "type": "array" }, "year": { "items": { "type": "string" }, "type": "array" }, "moments": { "items": { "type": "string" }, "type": "array" } }, "type": "object" } }, "required": [ "report_id", "title", "date", "score", "url", "caption", "copyright", "format", "image_id" ], "type": "object" }, "type": "array" } }, "required": [ "total", "images" ], "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 } ``` --- ## Read Full Report (Research) Source: https://build.edited.com/reference/tools/researchmcp-research_read_report # Read Full Report (Research) researchmcp-research_read_report Read the full article text for a known report_id. Serves the cleaned article body straight from the index — no WordPress call and no login. Prefer this over research_report_links when the user wants to read or work with the report's content rather than open the public page (which requires login). Use research_get_report instead for just the short metadata/excerpt. Note: content is the full article text and can be large — expect a sizeable token payload. Research articles are accessible to every authenticated Research user by design, so the body is returned unfiltered (no per-account scoping). Try queries like research_read_report(report_id=152777) on an id from a prior search hit; "summarise report 152777", "what does report 160001 say". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | report_id | integer | yes | — | The report_id returned by a previous search tool. | ## Returns - On hit: {"result": {"report": {report_id, title, date, content, tags}}} - where content is the full article text and tags is the report's - curated taxonomy tags (article_types, topics, gender, season, - year, moments), empty list per untagged axis. - On miss: {"result": {"report": None}}. Not an error. - On retrieval failure: {"error": {code, message}} - code is upstream_unavailable, internal (our own bad - data; a retry returns the same error), or not_entitled (authorization - refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "researchmcp-research_read_report", "arguments": { "report_id": 1 } } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "researchmcp-research_read_report", arguments: { "report_id": 1 }, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "researchmcp-research_read_report", {"report_id": 1}, ) ``` ## Input schema ``` { "type": "object", "properties": { "report_id": { "description": "The report_id returned by a previous search tool.", "type": "integer" } }, "required": [ "report_id" ] } ``` ## 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": { "properties": { "report": { "anyOf": [ { "properties": { "report_id": { "type": "integer" }, "title": { "type": "string" }, "date": { "format": "date", "type": "string" }, "content": { "type": "string" }, "tags": { "description": "The parent report's curated taxonomy tags, keyed by the public intent axes.\n\nPopulated on every search hit, browse row and report lookup so a consumer can\nsee a report's audience, season and subject matter *at discovery time* — a\ntitle alone does not say whether a \"SS27 Buyers' Guide\" is menswear or\nwomenswear, and grounding a womenswear claim in a menswear report is the\nfailure this prevents.\n\nThe values are drawn from the vocabulary the search tools' intent params\naccept, so a tag read off a hit can usually be threaded straight back in to\nnarrow a follow-up search. Two limits on that, both worth knowing before\ntreating a threaded tag as a guaranteed narrowing:\n\n- **The vocabularies are not identical.** These are curated report-side\n labels, so a value can exist here that the params do not recognise (prod\n carries ``moments: [\"pride\"]``, which is outside ``MomentsParam``'s enum).\n Threading such a value is ignored by ``normalise_intent`` or rejected by the\n param schema — never a silent narrowing.\n- **``year`` and ``moments`` are not pure ranking axes.** Unlike the other\n four, they also derive the publish-date window when no explicit dates are\n given (see ``retrieval/date_window.py``), so threading one back moves the\n filter as well as the boost.\n\nEvery axis is always present on the model; an untagged axis is an empty list.\nCoverage is uneven and heavily skewed by report class — buyers' guides are\nclose to fully tagged for ``gender``, while a large minority of the corpus\ncarries no taxonomy at all — so an empty axis is common and means \"not\ncurated\", never \"out of scope\". (One near-exception: a topic tag that exists\nonly as an all-encompassing label is dropped rather than surfaced, matching\nwhat the scorer sees — see ``canonicalise_topics``.)\n\nTwo exceptions, both id re-fetches: the chunk and image indices carry no\ntaxonomy tags and ``get_chunk`` / ``get_image`` do not spend a second query\nresolving them, so the field stays at its empty default there — and the\nresearch_get_chunk / research_get_image tools serve ``ChunkRow`` / ``ImageRow``,\nwhich have no ``tags`` field at all, rather than let an empty axis read as\n\"this report is untagged\". So `tags` is absent from those two tools' published\nschemas; wherever it *is* published, an empty axis does mean untagged.", "properties": { "article_types": { "items": { "type": "string" }, "type": "array" }, "topics": { "items": { "type": "string" }, "type": "array" }, "gender": { "items": { "type": "string" }, "type": "array" }, "season": { "items": { "type": "string" }, "type": "array" }, "year": { "items": { "type": "string" }, "type": "array" }, "moments": { "items": { "type": "string" }, "type": "array" } }, "type": "object" } }, "required": [ "report_id", "title", "date", "content" ], "type": "object" }, { "type": "null" } ] } }, "required": [ "report" ], "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 } ``` --- ## Get Report Links (Research) Source: https://build.edited.com/reference/tools/researchmcp-research_report_links # Get Report Links (Research) researchmcp-research_report_links Convert between report_ids and public EDITED Research article URLs. Both directions resolve through the report's WordPress slug at read time — nothing about the URL is stored in the index — so this works for every report regardless of when it was embedded. Exactly one direction per call. report_ids -> URLs. Call it after any search, list, or get tool whose results you cite, batching all cited report_ids into one call, so your final answer includes clickable EDITED Research URLs. Note the public article page requires login: when the user wants the content itself rather than a link, prefer research_read_report. report_urls -> report_ids. Call it first when the user starts from a link they already have — an article they were notified about and want analysed. The returned report_id is what every other research tool takes: pass it to research_chunk_search(report_ids=[...], query=...) for the passages behind a claim, or to research_read_report for the whole article. A URL that resolves to null is a report that is not published (or not a research article at all) — say so and offer a title search instead of guessing an id. Try queries like research_report_links(report_ids=[152777, 160001]) on ids from the search hits you are about to cite; "share a link to report 152777". research_report_links(report_urls=[""]) for "start from this report", "what does this article say about denim". ## Parameters | Name | Type | Required | Constraints | Description | | --- | --- | --- | --- | --- | | report_ids | integer[] | no | max items: 20 | report_id values from a previous search/list/get tool, to resolve to article URLs. 1..20 per call — split larger sets across calls. Pass this or report_urls, not both. | | report_urls | string[] | no | max items: 20 | EDITED Research article URLs to resolve to report_ids — use when the user supplies a link rather than an id. Pass this or report_ids, not both. | ## Returns - On success, one direction populated and the other null: - {"result": {"links": {report_id (str): url | null}, "report_ids_by_url": null}} - {"result": {"links": null, "report_ids_by_url": {url (str): report_id | null}}} - A null value means WordPress resolved nothing for that key (an unknown - or unpublished report) — surface "link unavailable" / "report not - found", never invent a URL or an id. - On failure: {"error": {code, message}} - code is invalid_input (neither direction supplied, both supplied, - or no URL that a slug could be read from), upstream_unavailable - (WordPress, or resolving the caller's config) or not_entitled - (authorization refused). ## Try it Loading interactive widget… ## Code examples - curl - TypeScript - Python ``` 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": "researchmcp-research_report_links", "arguments": {} } }' ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "demo", version: "1.0.0" }, { capabilities: {} }); await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": process.env.MCP_API_KEY ?? "" } }, }), ); const result = await client.callTool({ name: "researchmcp-research_report_links", arguments: {}, }); ``` ``` import os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client headers = {"x-api-key": os.environ["MCP_API_KEY"]} async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() result = await session.call_tool( "researchmcp-research_report_links", {}, ) ``` ## Input schema ``` { "type": "object", "properties": { "report_ids": { "description": "report_id values from a previous search/list/get tool, to resolve to article URLs. 1..20 per call — split larger sets across calls. Pass this or `report_urls`, not both.", "items": { "type": "integer" }, "maxItems": 20, "minItems": 1, "type": "array" }, "report_urls": { "description": "EDITED Research article URLs to resolve to report_ids — use when the user supplies a link rather than an id. Pass this or `report_ids`, not both.", "items": { "type": "string" }, "maxItems": 20, "minItems": 1, "type": "array" } } } ``` ## 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": { "properties": { "links": { "description": "Object keyed by report id (as a string); the value is the public report URL, or null when WordPress returned no slug for that id. Null when the call resolved `report_urls` instead.", "title": "Links" }, "report_ids_by_url": { "description": "Object keyed by the article URL exactly as it was passed in; the value is the report_id, or null when no published report matches that URL. Null when the call resolved `report_ids` instead.", "title": "Report Ids By Url" } }, "required": [ "links", "report_ids_by_url" ], "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 } ``` --- ## API reference Source: https://build.edited.com/api-reference ## 🗃Query API 3 items --- ## Cancel Job Source: https://build.edited.com/api-reference/jobs-api/cancel-job # Cancel Job ``` POST /jobs/:job_id/cancel ``` Cancel a running job. Canceling a job will stop its execution. A canceled job can be resumed later, but any files produced by the job before its cancellation will be subject to the original retention policy. A job must be in processing, pending, or queued state to be canceled. ## Request ## Responses - 204 - 409 - 422 - 429 Job successfully canceled Job cannot be canceled in its current state Validation Error Too Many Requests. Wait a bit and try again. --- ## EDITED API Source: https://build.edited.com/api-reference/jobs-api/edited-api Version: 0.1.0 # EDITED API ## Authentication - API Key: API Key Header | Security Scheme Type: | apiKey | | --- | --- | | Header parameter name: | x-api-key | --- ## Get Job Source: https://build.edited.com/api-reference/jobs-api/get-job # Get Job ``` GET /jobs/:job_id ``` Retrieve status and metadata for a single job. ## Request ## Responses - 200 - 422 - 429 Successful Response Validation Error Too Many Requests. Wait a bit and try again. --- ## Get Job Result Files Source: https://build.edited.com/api-reference/jobs-api/get-job-result-files # Get Job Result Files ``` GET /jobs/:job_id/result-files ``` Get result files for a job, up to a maximum of size files. If next is provided (and is a next value from a previous response), the next set of result files is returned. If next is not provided, the first set of result files is returned. This can be used while a job is still processing. If results are available for the requested next value, the response will include urls that can be used to download the files and a results_available flag set to true. If results are not yet available, the response will have results_available set to false and no urls, and the next value will be the same as the one requested, indicating that the client should check again later. If the job has finished and no more files are available, the response will have next set to null. If the job is not found, a 404 error is returned. ## Request ## Responses - 200 - 422 - 429 Successful Response Validation Error Too Many Requests. Wait a bit and try again. --- ## Get Job Results Source: https://build.edited.com/api-reference/jobs-api/get-job-results # Get Job Results ``` GET /jobs/:job_id/results/:filename ``` Download result data for a job, up to the requested number of lines (default is 1 million) or 200 MB, whichever is smaller. If more than the requested number lines or more than 200 MB of results are available, the last line of the returned results will be . If the result limit was not reached and partial results are requested and the job is not yet complete, the last line of the returned results will be . ## Request ## Responses - 200 - 422 - 429 Successful Response Validation Error Too Many Requests. Wait a bit and try again. --- ## List Jobs Source: https://build.edited.com/api-reference/jobs-api/list-jobs # List Jobs ``` GET /jobs ``` Retrieve a list of up to 100 jobs, sorted by creation date in descending order. Jobs can be filtered using a query string. ## Request ## Responses - 200 - 422 - 429 Successful Response Validation Error Too Many Requests. Wait a bit and try again. --- ## Resume Job Source: https://build.edited.com/api-reference/jobs-api/resume-job # Resume Job ``` POST /jobs/:job_id/resume ``` Resume a canceled job. The job must be in canceled state to be resumed. ## Request ## Responses - 204 - 409 - 422 - 429 Job successfully resumed Job cannot be resumed in its current state Validation Error Too Many Requests. Wait a bit and try again. --- ## Submit Homeware SKUs History Job Source: https://build.edited.com/api-reference/jobs-api/submit-homeware-skus-history-job # Submit Homeware SKUs History Job ``` POST /homeware-skus/history/submit ``` Submit a job to fetch homeware SKU history data. ## Request ## Responses - 200 - 400 - 422 - 429 Successful Response Bad request Validation Error Too Many Requests. Wait a bit and try again. --- ## Submit Options History Job Source: https://build.edited.com/api-reference/jobs-api/submit-options-history-job # Submit Options History Job ``` POST /options/history/submit ``` Submit a job to fetch option history data. ## Request ## Responses - 200 - 400 - 422 - 429 Successful Response Bad request Validation Error Too Many Requests. Wait a bit and try again. --- ## EDITED Query API Source: https://build.edited.com/api-reference/query-api/edited-query-api Version: 1 # EDITED Query API The path segment after /query/v1/ is the vertical. This reference documents apparel; the same endpoints, parameters, and response shapes apply to beauty and homeware — swap the segment (for example /query/v1/beauty/options) to query another vertical. Every example URL below uses apparel. ## Authentication - API Key: apikey | Security Scheme Type: | apiKey | | --- | --- | | Header parameter name: | X-API-KEY | --- ## get_/option_history/ Source: https://build.edited.com/api-reference/query-api/get-option-history-string-option-id # get_/option_history/ ``` GET /option_history/:option_id ``` Fetch & return historical option documents. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Option history data Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## get_/option/ Source: https://build.edited.com/api-reference/query-api/get-option-string-option-id # get_/option/ ``` GET /option/:option_id ``` Fetch & return a single option document. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Option data Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/actions Source: https://build.edited.com/api-reference/query-api/post-actions # post_/actions ``` POST /stats/actions ``` Calculate & return actions and number of matching products for each action over time ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/activity Source: https://build.edited.com/api-reference/query-api/post-activity # post_/activity ``` POST /stats/activity ``` Calculate & return the date found/first discount and number of matching products for each. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/advertised_discount_percentage Source: https://build.edited.com/api-reference/query-api/post-advertised-discount-percentage # post_/advertised_discount_percentage ``` POST /stats/advertised_discount_percentage ``` Calculate & return the average advertised discount of all matching products. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/advertised_discounted Source: https://build.edited.com/api-reference/query-api/post-advertised-discounted # post_/advertised_discounted ``` POST /stats/advertised_discounted ``` Calculate & return the percentage of products with an advertised discount. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/advertised_discounting Source: https://build.edited.com/api-reference/query-api/post-advertised-discounting # post_/advertised_discounting ``` POST /stats/advertised_discounting ``` Calculate & return percentage and statistics for advertised product discounts. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/advertised_discounting_over_time Source: https://build.edited.com/api-reference/query-api/post-advertised-discounting-over-time # post_/advertised_discounting_over_time ``` POST /stats/advertised_discounting_over_time ``` Calculate & return percentage and statistics for advertised product discounts over time. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/colour_tree Source: https://build.edited.com/api-reference/query-api/post-colour-tree # post_/colour_tree ``` POST /stats/colour_tree ``` Calculate & return basic colours and TPX codes for each. Includes the number of matching options for each colour and TPX code ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/colours Source: https://build.edited.com/api-reference/query-api/post-colours # post_/colours ``` POST /stats/colours ``` Calculate & return TPX colours and number of options in each. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/count Source: https://build.edited.com/api-reference/query-api/post-count # post_/count ``` POST /stats/count ``` Fetch the number of matching options. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/count_over_time Source: https://build.edited.com/api-reference/query-api/post-count-over-time # post_/count_over_time ``` POST /stats/count_over_time ``` Fetch the number of matching options over time. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/date_histogram Source: https://build.edited.com/api-reference/query-api/post-date-histogram # post_/date_histogram ``` POST /stats/date_histogram ``` For each given bucket, calculate & return the different dates found and number of matching products for each. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/dates Source: https://build.edited.com/api-reference/query-api/post-dates # post_/dates ``` POST /stats/dates ``` Calculate & return the average number of days to first sellout/restock/discount/majority sellout/sellthrough. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/discount Source: https://build.edited.com/api-reference/query-api/post-discount # post_/discount ``` POST /stats/discount ``` Calculate & return a histogram of discount percentages and number of options in each price range. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/discount_deepest Source: https://build.edited.com/api-reference/query-api/post-discount-deepest # post_/discount_deepest ``` POST /stats/discount_deepest ``` Calculate & return a histogram of deepest discount percentages and number of options in each price range. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/discount_first Source: https://build.edited.com/api-reference/query-api/post-discount-first # post_/discount_first ``` POST /stats/discount_first ``` Calculate & return a histogram of first discount percentages and number of options in each price range. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/discount_over_time Source: https://build.edited.com/api-reference/query-api/post-discount-over-time # post_/discount_over_time ``` POST /stats/discount_over_time ``` Calculate & return percentage and statistics for product discounts over time. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/discount_percentage Source: https://build.edited.com/api-reference/query-api/post-discount-percentage # post_/discount_percentage ``` POST /stats/discount_percentage ``` Calculate & return the average discount of all matching products. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/discounted Source: https://build.edited.com/api-reference/query-api/post-discounted # post_/discounted ``` POST /stats/discounted ``` Calculate & return the percentage of products with a discount. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/discounting Source: https://build.edited.com/api-reference/query-api/post-discounting # post_/discounting ``` POST /stats/discounting ``` Calculate & return percentage and statistics for product discounts. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/discounting_over_time Source: https://build.edited.com/api-reference/query-api/post-discounting-over-time # post_/discounting_over_time ``` POST /stats/discounting_over_time ``` Calculate & return percentage and statistics for product discounts over time. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/market Source: https://build.edited.com/api-reference/query-api/post-market # post_/market ``` POST /stats/market ``` Calculate & return the percentage of products that are/were in stock. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/options Source: https://build.edited.com/api-reference/query-api/post-options # post_/options ``` POST /options ``` Search for & return matching option documents. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Options data Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/outlet Source: https://build.edited.com/api-reference/query-api/post-outlet # post_/outlet ``` POST /stats/outlet ``` Calculate & return the percentage of products that are outlet. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/patterns Source: https://build.edited.com/api-reference/query-api/post-patterns # post_/patterns ``` POST /stats/patterns ``` Calculate & return patterns and number of options in each. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/price_increase_percentage Source: https://build.edited.com/api-reference/query-api/post-price-increase-percentage # post_/price_increase_percentage ``` POST /stats/price_increase_percentage ``` Calculate & return the average price increase of all matching products. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/price_increased Source: https://build.edited.com/api-reference/query-api/post-price-increased # post_/price_increased ``` POST /stats/price_increased ``` Calculate & return the percentage of products with a price increase. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/price_increasing Source: https://build.edited.com/api-reference/query-api/post-price-increasing # post_/price_increasing ``` POST /stats/price_increasing ``` Calculate & return percentage and statistics for product price increases. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/priceogram Source: https://build.edited.com/api-reference/query-api/post-priceogram # post_/priceogram ``` POST /stats/priceogram ``` Calculate & return a histogram of prices and number of options in each price range. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/priceogram_first Source: https://build.edited.com/api-reference/query-api/post-priceogram-first # post_/priceogram_first ``` POST /stats/priceogram_first ``` Calculate & return a histogram of first prices and number of options in each price range. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/priceogram_full Source: https://build.edited.com/api-reference/query-api/post-priceogram-full # post_/priceogram_full ``` POST /stats/priceogram_full ``` Calculate & return a histogram of full prices and number of options in each price range. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/pricing Source: https://build.edited.com/api-reference/query-api/post-pricing # post_/pricing ``` POST /stats/pricing ``` Calculate & return price statistics. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/pricing_first Source: https://build.edited.com/api-reference/query-api/post-pricing-first # post_/pricing_first ``` POST /stats/pricing_first ``` Calculate & return first price statistics. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/pricing_full Source: https://build.edited.com/api-reference/query-api/post-pricing-full # post_/pricing_full ``` POST /stats/pricing_full ``` Calculate & return full price statistics. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/pricing_over_time Source: https://build.edited.com/api-reference/query-api/post-pricing-over-time # post_/pricing_over_time ``` POST /stats/pricing_over_time ``` Calculate & return price statistics over time. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/pricing_over_time_first Source: https://build.edited.com/api-reference/query-api/post-pricing-over-time-first # post_/pricing_over_time_first ``` POST /stats/pricing_over_time_first ``` Calculate & return first price statistics over time. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/pricing_over_time_full Source: https://build.edited.com/api-reference/query-api/post-pricing-over-time-full # post_/pricing_over_time_full ``` POST /stats/pricing_over_time_full ``` Calculate & return full price statistics over time. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/restock Source: https://build.edited.com/api-reference/query-api/post-restock # post_/restock ``` POST /stats/restock ``` Calculate the percentage of products that have been restocked (once or more). ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/sellout Source: https://build.edited.com/api-reference/query-api/post-sellout # post_/sellout ``` POST /stats/sellout ``` Calculate & return the number of days to sellout/majority sellout/sellthrough and number of matching options for each. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/skus Source: https://build.edited.com/api-reference/query-api/post-skus # post_/skus ``` POST /stats/skus ``` Calculate & return the total number of SKUs for all of the matching options. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## post_/terms Source: https://build.edited.com/api-reference/query-api/post-terms # post_/terms ``` POST /stats/terms ``` For each given bucket, calculate & return the different terms found and number of matching products for each. ## Request ## Responses - 200 - 400 - 401 - 403 - 503 Results Invalid request Unauthenticated, missing API key Unauthorized, this API key is not allowed to issue this request Service unavailable --- ## Recipes Source: https://build.edited.com/recipes ## 📄️Example workflow A worked end-to-end example: an agent resolves a fuzzy retail question into stable IDs, then queries the market-data tools for the answer — all through EDITED MCP. --- ## Connect Claude Code, Cursor & VS Code Source: https://build.edited.com/recipes/connect-claude-code # Connect Claude Code, Cursor & VS Code Claude Code, Cursor, and VS Code all speak MCP over Streamable HTTP natively — point them at the EDITED MCP gateway and pass your key as an x-api-key header. No mcp-remote bridge is needed (that's only for stdio-only clients like Claude Desktop). The connection details are identical across all three; only the config mechanism differs. In every case: - Endpoint: https://mcp.edited.com/mcp — no trailing slash. The gateway redirects …/mcp/ in a way HTTP clients can't follow, so the connection fails (see Concepts → Transports). - Auth: an x-api-key header on every request. See Authentication & access for how to get a key and keep it safe. The examples below use YOUR_API_KEY as a placeholder and reference it via an environment variable or secret prompt, so the key never lands in committed config. Can't get the key to connect? Some clients don't reliably expand ${env:…} / ${input:…} references. If the server won't connect, paste your key directly in place of the placeholder to confirm everything else is right — then, for any config you commit or share, move it back into an environment variable or secret prompt so the key stays out of version control. - Claude Code - Cursor - VS Code #### Prerequisites - The Claude Code CLI installed. #### Add the server ``` claude mcp add --transport http edited-mcp https://mcp.edited.com/mcp \ --header "x-api-key: YOUR_API_KEY" ``` The flags: - --transport http — Streamable HTTP (use stdio for stdio servers) - edited-mcp — local alias you'll use to refer to the server - --header "x-api-key: YOUR_API_KEY" — your API key, sent on every request To install scoped to the project (so the config lives in this repo and ships with collaborators): ``` claude mcp add --scope project --transport http edited-mcp https://mcp.edited.com/mcp \ --header "x-api-key: ${EDITED_MCP_API_KEY}" ``` #### Where the config lives - --scope user (default) installs into your global Claude Code config. - --scope project writes a .mcp.json at the repo root, checked into git. Reference your key via an environment variable (${EDITED_MCP_API_KEY}, as above) rather than pasting it in literally, so the committed .mcp.json stays free of secrets. #### Verify ``` claude mcp list # edited-mcp: https://mcp.edited.com/mcp (HTTP) - ✓ Connected ``` Then run claude in any directory and ask it something the server can answer — the tools are picked up automatically. To remove it later: claude mcp remove edited-mcp. #### Prerequisites - A recent version of Cursor with MCP support. No Node or bridge needed — Cursor connects over HTTP directly. #### Add the server Add an mcpServers entry to your Cursor MCP config: ``` { "mcpServers": { "edited-mcp": { "url": "https://mcp.edited.com/mcp", "headers": { "x-api-key": "${env:EDITED_MCP_API_KEY}" } } } } ``` Cursor resolves ${env:…} references in both url and headers, so set EDITED_MCP_API_KEY in your environment rather than pasting the key inline. (To paste it literally instead, replace the value with your key.) #### Where the config lives - Global: ~/.cursor/mcp.json — applies to every project. - Project: .cursor/mcp.json in the repo root — scoped to that project. #### Verify Open Cursor Settings → MCP. edited-mcp should show as connected, with its tools listed. Then ask something in chat that needs EDITED data and confirm Cursor calls one of the mdmcp-search_* tools. #### Prerequisites - A recent version of VS Code with MCP support (Agent mode). #### Add the server Create .vscode/mcp.json. VS Code uses a top-level servers object (note: not mcpServers), requires "type": "http" for remote servers, and can prompt for secrets via an inputs block so the key never lives in the file: ``` { "inputs": [ { "type": "promptString", "id": "edited-api-key", "description": "EDITED MCP API key", "password": true } ], "servers": { "edited-mcp": { "type": "http", "url": "https://mcp.edited.com/mcp", "headers": { "x-api-key": "${input:edited-api-key}" } } } } ``` On first connection VS Code prompts for the key and stores it securely, so it's never written into the config file. #### Where the config lives - Workspace: .vscode/mcp.json in the repo root. - User profile: run MCP: Open User Configuration from the Command Palette. #### Verify Run MCP: List Servers from the Command Palette — edited-mcp should show as running. In Agent mode the EDITED tools appear in the tools picker; ask something that needs the data and confirm a mdmcp-search_* tool is called. ## Tips - If permission prompts on every tools/call get noisy, allow them in your client's settings. - EDITED's server-defined prompts (e.g. mdmcp-market_data_query_guide) aren't auto-injected into your context by most clients — fetch them explicitly with prompts/get and prepend the returned messages. See How to query → Using the server-defined prompts. - Building your own agent instead of using an editor? See Connect a custom client for the MCP SDK and framework integrations. Still off after setup? Issues that aren't specific to Claude Code — trailing-slash connection failures, prompts that don't apply on their own — are collected in Operations → Troubleshooting. --- ## Connect Claude Desktop Source: https://build.edited.com/recipes/connect-claude-desktop # Connect Claude Desktop Claude Desktop talks to MCP servers over stdio, but EDITED MCP is HTTP-only. Bridge the two with mcp-remote: it spawns as a stdio server and forwards every JSON-RPC call to the remote HTTP endpoint. We're evaluating lighter-weight connection options for Claude Desktop and will update this guide as they become available. ## 1. Edit the Claude Desktop config Open the config file: - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json - Linux: ~/.config/Claude/claude_desktop_config.json - Windows: %APPDATA%\Claude\claude_desktop_config.json Add an mcpServers entry: ``` { "mcpServers": { "edited-mcp": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.edited.com/mcp", "--header", "x-api-key: YOUR_API_KEY", "--transport", "http-only" ] } } } ``` Replace YOUR_API_KEY with your key. It lives in this local config file (claude_desktop_config.json) on your machine — not in any repo. Two details in those args matter: - No trailing slash on the URL. The gateway redirects …/mcp/ to a plain http:// URL that drops the request, so mcp-remote can't complete the handshake through it — see Troubleshooting for the error this produces. - --transport http-only. EDITED MCP speaks Streamable HTTP only. Without this flag, mcp-remote falls back to the legacy SSE transport when a first attempt fails, which the gateway rejects with a 405. ### Windows Claude Desktop on Windows has a known bug where spaces inside args values are mangled when it invokes npx — and "x-api-key: YOUR_API_KEY" contains a space. Move the key into env and drop the space after the colon: ``` { "mcpServers": { "edited-mcp": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.edited.com/mcp", "--header", "x-api-key:${MCP_API_KEY}", "--transport", "http-only" ], "env": { "MCP_API_KEY": "YOUR_API_KEY" } } } } ``` ## 2. Restart Claude Desktop The server appears in Claude's tool list once the desktop app restarts. Look for the EDITED MCP tools (the mdmcp-search_* lookups) in the available-tools panel. ## 3. Verify Ask Claude something only this server can answer: "Find me three retailers similar to Zara in the UK." You should see Claude call mdmcp-search_retailers with country_code: "UK". Server prompts aren't applied automatically EDITED's server-defined prompts (like mdmcp-market_data_query_guide) are available to the client but aren't injected into your conversation on their own — invoke them explicitly (in Claude Desktop, from the prompt/command picker) or fetch them with prompts/get. See How to query → Using the server-defined prompts. ## Troubleshooting Tools don't appear - Check the MCP logs — mcp-remote writes connection errors there: - macOS: ~/Library/Logs/Claude/mcp.log and mcp-server-edited-mcp.log - Linux: ~/.config/Claude/logs/mcp.log and mcp-server-edited-mcp.log - Windows: %APPDATA%\Claude\logs\ - Confirm the server is reachable: curl -I https://mcp.edited.com/mcp should return an HTTP status (e.g. 401 without a key), not a connection error. SSE error: Non-200 status code (405) in the logs The URL in your config has a trailing slash (…/mcp/). The gateway 307-redirects that form to a plain-HTTP URL the proxy can't follow, so mcp-remote falls back to the legacy SSE transport — which EDITED MCP doesn't support, hence the 405. Remove the trailing slash (…/mcp) and add --transport http-only as shown above, then restart Claude Desktop. Server fails to start on Windows (ENOENT / spawn errors) Some Windows setups can't spawn npx directly. Invoke it through cmd instead: "command": "cmd", and prepend "/c", "npx" to args. mcp-remote keeps disconnecting - The desktop app times out idle servers. It'll reconnect on the next tool call — no action needed. Authentication Every request needs an x-api-key header, which mcp-remote forwards via its --header argument (shown above). A 401 in the logs means the key is missing or not enabled for MCP — see Authentication & access for how to get one. Still off after setup? Issues that aren't specific to Claude Desktop — schema-validator quirks, trailing-slash connection failures, prompts that don't apply on their own — are collected in Operations → Troubleshooting. --- ## Connect a custom client Source: https://build.edited.com/recipes/connect-custom-client # Connect a custom client If you're building your own agent rather than using Claude Desktop or Claude Code, connect directly with the official MCP SDK. Both TypeScript and Python SDKs ship with Streamable-HTTP transport built in. ## TypeScript ``` npm install @modelcontextprotocol/sdk ``` ``` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client( { name: "my-agent", version: "1.0.0" }, { capabilities: {} }, ); // No trailing slash on the URL — /mcp/ gets redirected and the handshake fails await client.connect( new StreamableHTTPClientTransport(new URL("https://mcp.edited.com/mcp"), { requestInit: { headers: { "x-api-key": "YOUR_API_KEY" } }, }), ); // Discover what's available const { tools } = await client.listTools(); // Call a tool const result = await client.callTool({ name: "mdmcp-search_brands", arguments: { query: "Nike", search_limit: 5 }, }); // Refusals reach you on two channels, so check both — see Concepts → Response // shape. `isError` means the call didn't complete: there is no structured // payload and the text is prose. Otherwise the payload carries exactly one of // `result` or `error`. if (result.isError) throw new Error(result.content?.[0]?.text ?? "call failed"); const body = result.structuredContent; if (body.error) throw Object.assign(new Error(body.error.message), body.error); const brands = body.result; console.log(brands); // → [{ name: "Nike", slug: "nike" }, ...] await client.close(); ``` ## Python ``` pip install mcp ``` ``` import asyncio from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client async def main(): headers = {"x-api-key": "YOUR_API_KEY"} # No trailing slash on the URL — /mcp/ gets redirected and the handshake fails async with streamablehttp_client("https://mcp.edited.com/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() result = await session.call_tool( "mdmcp-search_brands", {"query": "Nike", "search_limit": 5}, ) # Refusals reach you on two channels — see Concepts → Response shape. # isError means the call didn't complete, and structuredContent is # None there, so check it before touching the payload. if result.isError: text = result.content[0].text if result.content else "call failed" raise RuntimeError(text) body = result.structuredContent if "error" in body: raise RuntimeError( f"{body['error']['code']}: {body['error']['message']}" ) print(body["result"]) asyncio.run(main()) ``` warning Using the fastmcp client instead? Read .structured_content, not .data. .data unwraps correctly on success but yields None on a refusal, with the parse error swallowed and is_error still False — see Concepts → Response shape. ## Using your agent's framework Most agent frameworks speak MCP natively — point them at the same endpoint and pass your key as the x-api-key header. That header detail matters: EDITED MCP authenticates with x-api-key, not a bearer token, so the framework has to let you set an arbitrary request header. | Framework | Connect with x-api-key over Streamable HTTP | | --- | --- | | OpenAI Agents SDK | MCPServerStreamableHttp(params={"url": "…", "headers": {"x-api-key": "…"}}) — docs | | LangChain | MultiServerMCPClient with "transport": "streamable_http" and a "headers" dict — docs | | LlamaIndex | BasicMCPClient(url, headers={"x-api-key": "…"}) + McpToolSpec — docs | | Pydantic AI | MCPServerStreamableHTTP(url, headers={"x-api-key": "…"}) — docs | | Vercel AI SDK | createMCPClient with an http transport that takes a headers map — docs | | Mastra | new MCPClient({ servers: { edited: { url, requestInit: { headers: { "x-api-key": "…" } } } } }) — docs | In each case the URL is the same MCP endpoint and the framework handles the JSON-RPC handshake; you supply the x-api-key header. Claude via the Anthropic API Anthropic's hosted MCP connector (the mcp_servers parameter on the Messages API) only forwards a bearer token via authorization_token, so it can't send the x-api-key EDITED MCP requires. To call EDITED MCP from Claude, connect with the official MCP SDK client yourself (the TypeScript / Python examples above) and hand the resulting tools to the model with Anthropic's client-side MCP helpers — or use the ready-made app integrations in Connect Claude Code and Connect Claude Desktop. ## What to do with the result The identifier-resolution tools return objects with stable identifiers (slug for retailers/brands, id for everything else). Feed those identifiers into the market-data query tools to pull the actual data — see Recipes → Example workflow for an end-to-end demo. ## Troubleshooting 401 Unauthorized on every call - Missing or wrong x-api-key, or a key that isn't enabled for MCP. Set the header on the transport (TS) or pass headers= (Python), as shown above — see Authentication & access for how to get a key. Connection refused / timeout - Wrong URL, or the endpoint isn't reachable from your network. Confirm with curl -I https://mcp.edited.com/mcp — an HTTP status (e.g. 401) means it's reachable; a connection error means it isn't. Handshake fails with a 307 or the connection dies after the first call - The URL has a trailing slash. Use https://mcp.edited.com/mcp — the /mcp/ form is redirected in a way most HTTP clients can't follow (see Concepts → Transports). MCP error -32600 - The envelope wasn't usable. On a tools/call, the usual cause is no usable name: either you omitted it, or you passed params positionally — a non-object params is read as empty, so name goes missing with it. If instead every call fails with HTTP 400, the body isn't reaching the server as JSON-RPC at all; check you're sending JSON rather than form-encoded. It is not the MCP-Protocol-Version header — this gateway never validates it, so adding or removing it won't change the result. See Operations → Errors. The handshake reports a protocol version I didn't ask for - Expected. The revision is negotiated: offer one of 2024-11-05, 2025-03-26 or 2025-06-18 and you get it back; offer anything else and you get 2025-06-18, the newest we speak. Read result.protocolVersion for what you actually got — and note that negotiating an older revision doesn't reduce what the server sends. See Operations → Versioning. Empty result on a real query - Vector search is misspelling-tolerant but not magic. Try a more specific query or batch several phrasings — see Concepts → Tools. For the full error model — HTTP statuses, JSON-RPC codes, and retry guidance — see Operations → Errors. For cross-client issues that aren't specific to your setup, see Operations → Troubleshooting. --- ## Example workflow Source: https://build.edited.com/recipes/example-workflow # Example workflow A worked example showing how an agent uses EDITED MCP end-to-end. The user asks a fuzzy retail question; the agent uses MCP tools to resolve fuzzy concepts to stable IDs, then feeds those IDs into the market-data tools to fetch the answer — all over the same MCP connection. ## The scenario "Compare dress assortments at Zara and H&M in the UK over the last quarter." To answer this, the agent needs three things from the EDITED catalogue: - Stable retailer slugs for "Zara" and "H&M" — and only the UK storefronts - A product-category ID for "dresses" - A market ID for the UK market It can't guess these. They live in EDITED's taxonomy, behind MCP. ## One unwrapper, used at every step Every tool returns exactly one of result or error, so a single helper covers all four calls below. A refusal arrives with isError: false, which is why this checks the payload and not just the flag — see Concepts → Response shape. ``` function unwrap(result) { if (result.isError) { // never completed — the text is prose, not JSON throw new Error(result.content?.[0]?.text ?? "call failed"); } const body = result.structuredContent; if (body.error) throw new Error(`${body.error.code}: ${body.error.message}`); return body.result; } ``` ## Step 1 — Resolve the retailers ``` const result = await client.callTool({ name: "mdmcp-search_retailers", arguments: { query: ["Zara", "H&M"], // batch — both at once search_limit: 3, country_code: "UK", }, }); // search_retailers wraps its hits in an object, so a batch gives you one // wrapper per query — not a bare list of retailers. const [zaraResult, hmResult] = unwrap(result); // → zaraResult: { retailers: [...], search_limit: 3, at_search_limit: false } // → zaraResult.retailers[0]: { name: "Zara (UK)", slug: "zara", region: { iso_code: "UK", ... }, ... } // → hmResult.retailers[0]: { name: "H&M (UK)", slug: "hm", region: { iso_code: "UK", ... }, ... } const retailers = [zaraResult.retailers[0].slug, hmResult.retailers[0].slug]; // → ["zara", "hm"] ``` The agent picks the top match for each query. Note we batch both queries in a single call — faster, fewer round-trips, less context burned. Two things to notice, because they bite everyone once. search_retailers is the odd one out among the resolvers: it returns a retailers wrapper, while search_brands, search_markets and search_product_searches return a bare list. And the slug is not the name with a country suffix — Zara in the UK is zara, not zara-uk. Always read the slug you were given rather than constructing one. ## Step 2 — Resolve the product category ``` const result = await client.callTool({ name: "mdmcp-search_product_searches", arguments: { query: "dresses", search_limit: 3 }, }); const categories = unwrap(result); // → categories[0]: { id: 138, name: "Dresses", category: "dresses", vertical: "apparel", ... } const categoryId = categories[0].id; ``` ## Step 3 — Resolve the market ``` const result = await client.callTool({ name: "mdmcp-search_markets", arguments: { query: "United Kingdom", search_limit: 1 }, }); const markets = unwrap(result); // → markets[0]: { id: "UK", name: "United Kingdom (UK)" } const marketId = markets[0].id; ``` Search the full country name, not the abbreviation. query: "UK" with search_limit: 1 comes back with Ukraine — a two-letter query is thin material for a vector search, and the one result you asked for isn't the one you meant. Asking for a few candidates and picking the match you recognise is the more robust habit generally. ## Step 4 — Query the data With three resolved identifiers, the agent runs the comparison with market_data_table — another MCP call, so there's no separate API to wire up. The resolved slug and id values go straight in as filters: ``` const result = await client.callTool({ name: "mdmcp-market_data_table", arguments: { name: "average price for dresses at Zara and H&M in the UK", metrics: ["avg_price", "product_count"], start_date: "2026-04-01", end_date: "2026-06-30", filters: [ { field: "retailer", op: "in", value: retailers }, // ["zara", "hm"] (step 1) { field: "product_searches", op: "in", value: [categoryId] }, // 138 (step 2) { field: "market", op: "in", value: [marketId] }, // "UK" (step 3) ], group_by: [{ field: "retailer" }], }, }); const data = unwrap(result); // → { // columns: ["Retailer", "Average Price ($) [avg/period]", "Product Count [avg/period]"], // rows: [["H&M (UK)", 44.07, 4774.67], ["Zara (UK)", 51.99, 1277.67]], // total_row_count: 2, currency: "USD", vertical: "apparel", // granularity: "M", evaluated_window: "2026-04-01:2026-06-30", ... // } return data; // ← what the agent presents to the user ``` Every resolved identifier goes in as a filter, not as a top-level argument — filters is a list of { field, op, value } triples, and at least one is required. name is a free-text label for the table you're asking for, not an identifier. Dates are explicit start_date / end_date within the last two years; there is no date_range shorthand. The full grammar — every field, metric and operator — is in mdmcp-market_data_docs, one topic per call. See How to query → Picking the right tool for the full market_data_table grammar — metrics, group-by, histograms, trends, and period-over-period comparisons. ## Why resolve IDs first? You could ship every retailer slug, category ID, and market ID into the agent's system prompt. That doesn't scale — EDITED has thousands of each, and they change weekly. Resolving on demand lets the agent look them up the same way a junior analyst would: ask "what's the slug for Zara UK?", get the answer, then query the data — every step over the same MCP connection. The agent stays small and current; the catalogue stays in EDITED's source of truth. ## Patterns to steal - Batch parallel resolutions. When the agent needs two or three lookups to answer one question, batch them in a single tools/call with a list of queries. EDITED MCP runs them concurrently. - Always pick the top match for fuzzy queries. Vector search ranks by semantic similarity. The first hit is usually right; if it isn't, your query was ambiguous. - Log the slugs/IDs the agent resolves into. That's what you'll need when reproducing a session for debugging. --- ## Using the Reference Source: https://build.edited.com/recipes/using-the-reference # Using the Reference Every page in the Reference embeds a "Try it" widget so you can call the server straight from the docs. Here's the same widget pointed at mdmcp-search_brands: Loading interactive widget… ## How it works The widget makes two JSON-RPC requests: - initialize — handshake to negotiate the protocol version - The method shown on the page (tools/call, resources/read, etc.) Both carry the x-api-key you enter in the widget's key field and go to the endpoint shown in the input box. The response is rendered raw, exactly as the server returned it — what you'd see with curl or an SDK, minus the setup. Edit the JSON arguments before sending to explore a tool's behavior: change the query, raise the search_limit, or pass a list of queries to try batching. ## Limitations - Auth — paste your x-api-key into the widget's key field; it's stored in your browser only (localStorage) and sent on every call. Without an MCP-enabled key the server returns 401 — see Authentication & access. - Streaming — for tools that respond with SSE, only the final message is rendered; intermediate progress notifications are dropped. - One call at a time — the widget is for exploring single calls. For multi-step flows, connect a real client: see Connect a custom client. --- ## Operations Source: https://build.edited.com/operations ## 📄️Authentication & access One EDITED access key authenticates every surface — MCP, the Query API, and the Jobs API — sent in the x-api-key header on every request. How to get a key, send it, and keep it safe. --- ## Authentication & access Source: https://build.edited.com/operations/authentication # Authentication & access Applies toEDITED MCPQuery APIJobs API Every request to EDITED — whether you call the MCP server or either REST API — carries the same access key in the x-api-key header. One credential, issued once, works across every surface — there are no separate per-product keys to juggle. With key-based authentication there's nothing else to manage: no token refresh, no session — just one header, sent on every call. ## One key, every surface The access key you get from EDITED is not per-product. The same key authenticates MCP, the Query API, and the Jobs API — so a builder who already calls MCP holds the credential the REST API needs, and vice versa. What differs between surfaces is only the base URL you send it to, never the key or the header. | Surface | Base URL | | --- | --- | | EDITED MCP | https://mcp.edited.com/mcp | | Query API | https://apis.edited.com/query/v1/apparel/ | | Jobs API | https://api-us.edited.com/v1 · https://api-eu.edited.com/v1 | One key, but access is still per-subscription Authentication is unified: the same key proves who you are on every surface. Access is not. What your key can actually reach depends on your subscription — being provisioned for one surface or data domain doesn't automatically provision you for another. An unprovisioned call still authenticates, then is refused at the next layer (see Errors). Not sure which surface you want? See MCP or the API?. ## Get your key Your access key is issued by EDITED when your contract starts. To request one — or to check whether your existing EDITED account already includes access — contact your EDITED account manager or customer success representative, or email support@edited.com. ## Send the key Add the x-api-key header to every request. The header is identical on every surface; only the URL and the rest of the request differ. - MCP - Query API - Jobs API ``` 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: YOUR_API_KEY" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }' ``` The MCP-Protocol-Version and Accept headers are MCP's, not authentication's — see the Quickstart. With the official SDKs, set x-api-key once on the transport and every call inherits it — see Connect a custom client. ``` curl -s -X POST "https://apis.edited.com/query/v1/apparel/options?size=15" \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "filters": {} }' ``` options is a POST endpoint that takes a JSON filter body ({} matches everything). Swap apparel for beauty or homeware to match the data you're licensed for, and browse the endpoints in the API reference. ``` curl -s https://api-us.edited.com/v1/jobs \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" ``` Use api-eu.edited.com instead if your contract is in the EU region — the two regions serve the same API. Browse the endpoints in the API reference. ## When authentication fails A missing key gets 401 Unauthorized on every surface. A key that's present but not accepted — unknown, or not enabled for the surface you're calling — gets 401 on MCP but 403 Forbidden on the REST APIs. Either way it's about the key, not your request body, so it's the quickest way to check a new key: send any request and look at the status. Don't read that as "this data isn't included in my plan," though. A key that authenticates but lacks access to a specific surface or backend is refused a layer deeper — a JSON-RPC -32003 on MCP, or an HTTP 403 on the REST APIs. See Errors for the full model on each surface. ## Keep your key safe Treat the key like a password: - Use environment variables, not source code or committed config. The Claude Code recipe shows the ${EDITED_MCP_API_KEY} pattern that keeps a shared .mcp.json free of secrets; the same discipline applies to a REST client's config. - The "Try it" widget stores your key in your browser only (localStorage) — it is never sent anywhere except the MCP endpoint. - Rotate a leaked key immediately by contacting your EDITED account manager or support@edited.com. Because one key spans every surface, a leaked key exposes all of them — rotate it as soon as you suspect exposure. Wondering who can see your key, or what the connection can and can't reach? See Data privacy & security for the security shape of the connection. --- ## Data privacy & security Source: https://build.edited.com/operations/data-privacy # Data privacy & security Applies toEDITED MCP EDITED MCP is a read-only connection to EDITED's market data. Your AI client requests data — pricing for a set of products, say — and EDITED sends its market data back. A request goes in; a response comes back, as with an API. What the connection doesn't do: read your prompts, your files, or your conversation, or reach into your own systems. The only thing that reaches EDITED is the request your client makes — nothing else of yours. ## What this page is about This page is about EDITED's MCP connection specifically — not a general take on AI tool safety. Two things sit with you, not EDITED: what your organization allows into any AI tool, and what your AI client (Claude, Copilot, ChatGPT, and the like) does with data once it's there. Those are governed by your own policies and your agreement with that provider. Telling the two apart is the quickest way to scope a review. Much of what can feel like "AI risk" turns out to be within your control. The questions about the connection itself — what it can reach, what data flows, who can see it — are answered here and by EDITED's security review. The questions about what your AI tool does with your data — whether it trains on it, keeps it, or shares it — sit with your own policy and provider agreement. By the end of this page, you should be able to say plainly where EDITED's connection ends and your own controls begin. ## Can EDITED see my conversations, prompts, or internal systems? No. When you use an AI client, it's the client — not EDITED — that turns your words into a specific request, or "tool call." Your prompt stays between you and your client; only that request crosses to EDITED. (How clients build tool calls is covered in What is MCP.) So the MCP server only ever responds to an explicit tool call — a request for pricing data, say. It has no mechanism to read the surrounding conversation, your client's other tool calls, or any other content your AI client holds. EDITED MCP is a source your client calls — it doesn't reach back into your systems. It can't connect to your data warehouse, your store platform, or your internal tools; any wiring between EDITED's data and your own systems happens in your client, on your side. EDITED sees only the parameters passed to the tool that was called — nothing else. ## What data actually flows through the connector? EDITED's own market data — never yours. What moves through the connector is EDITED's data: scraped, captured, or authored by EDITED, flowing out to the caller. There is no customer data in the system to begin with — a simpler situation than most data-privacy questions assume. It spans three domains — Competitive Market Data, Trend & Research, and Messaging & Promotions. The data has the full breakdown. Every tool available today is read-only: there's no write path on the MCP surface, so nothing a caller sends can change EDITED's data or reach anything beyond the tool it called. (See What is MCP for how tools work.) ## What about deeper questions — credentials, logging, access controls, certifications? We can answer those. How credentials are handled, what's logged, who has access, which certifications apply — we can walk you through all of it. For many teams that's a straightforward conversation; where a full formal security review is the right step, we'll do that too — in detail, and under NDA. If you're already working with EDITED, your account manager or sales representative can start that process with you. If you're not sure who your contact is, or how to get started, our support team (support@edited.com) can point you to the right person. This page describes the market-data MCP connection only. If you use other EDITED products or services, those may involve different data and terms — your account manager can clarify. For the mechanics of how a key is issued, sent, and managed, see Authentication & access. ## In short EDITED MCP sends its own market data out to you, read-only. It can't see your prompts, your files, or your systems — only the request your client makes. Anything beyond that structural picture — the operational detail a security team works through — we're happy to walk through with you. Not sure who to ask? Email support@edited.com. --- ## Errors Source: https://build.edited.com/operations/errors # Errors Applies toEDITED MCPQuery APIJobs API EDITED's surfaces report failures in two different shapes. MCP answers over JSON-RPC, so a failure can hide in one of three layers — the HTTP status, the JSON-RPC envelope, or the tool payload. The REST APIs (Query and Jobs) report everything through plain HTTP status codes, the way a REST client expects. Read the section for the surface you're on. ## MCP EDITED MCP Failures surface at three layers: the HTTP status (did the request reach the server and authenticate?), the JSON-RPC envelope (did the call itself succeed?), and the tool payload (did the tool run and then refuse?). Check all three — the third is the one a shared response handler usually misses. ### HTTP status codes | Status | Meaning | | --- | --- | | 200 | The request was processed — but the body may still carry a JSON-RPC error (below) | | 400 | The body wasn't parseable JSON, wasn't a JSON-RPC envelope at all, or was a batch array over the 64-envelope cap — the error code is -32700 or -32600 | | 401 | Missing access key, or the key isn't enabled for MCP — see Authentication & access | | 5xx | Transient server-side failure — safe to retry with backoff | ### JSON-RPC errors A failed call returns an error envelope instead of a result: ``` { "jsonrpc": "2.0", "id": 1, "error": { "code": -32602, "message": "Invalid request parameters" } } ``` | Code | Meaning | Usual cause | | --- | --- | --- | | -32700 | Parse error | The body isn't valid JSON — arrives at HTTP 400 | | -32600 | Invalid request | The envelope wasn't usable. At HTTP 400, the body is valid JSON but not a JSON-RPC object or batch array — or a batch over the 64-envelope cap. At HTTP 200, a tools/call arrived with no usable name — either omitted outright, or because params was sent positionally and a non-object params is read as empty. Not caused by a missing MCP-Protocol-Version header — see Versioning | | -32601 | Method not found | Wrong method name — e.g. tool/call for tools/call | | -32602 | Invalid params | Malformed params: a non-object arguments, or a tool name whose namespace prefix we don't serve (mdmcp-search_brands, not search_brands) — check the Reference. Arguments that merely don't match a tool's input schema do not land here; that call succeeds at HTTP 200 and carries the refusal in its payload — see below | | -32603 | Internal error | Server-side failure while executing — safe to retry | | -32002 | Resource not found | Unknown resource URI — check the Reference for the ones this server publishes | | -32003 | Not authorised | Your key is valid, but your subscription doesn't include that backend — see below | | -32004 | Backend unavailable | That backend is being shed after repeated failures. Back off substantially longer than for -32603 — retrying quickly only extends it | | -32099 | Rate limited | Too many requests — see Rate limits | ### Most tool refusals arrive in the payload, not on isError When a tool runs and then refuses, the call succeeds at the protocol level and the refusal is in the payload — note isError is false: ``` { "isError": false, "structuredContent": { "error": { "code": "invalid_input", "message": "topic: Input should be 'filter_fields', 'metrics', …", "field": "topic" } } } ``` | code | Meaning | Retry? | | --- | --- | --- | | invalid_input | Arguments refused. Often with field naming the one to change — the market-data tools set it consistently, research and messaging only sometimes. | No — fix the request | | not_entitled | The backend refused your subscription's access to this data. | No | | upstream_unavailable | A dependency is failing or slow. | Yes, with backoff | | internal | Our side is wrong: a malformed record, or a guard we can't satisfy. A retry returns the identical error, which is why it isn't worth one. Distinct from -32603 above, which is the gateway failing rather than a tool. | No | A client branching only on isError reads an in-band refusal as a success, and then finds no data where it expected some. A client branching only on the payload crashes on the isError cases, where structuredContent is null. Check isError first — when it is true the text is prose, not JSON — then check the payload for error. See Concepts → Response shape for which refusals use which channel, and a ready-made unwrapper. #### "Not provisioned" has three different shapes Worth separating, because they arrive at three different layers and only the last one is a not_entitled payload: | What is wrong | How it arrives | | --- | --- | | The key is unrecognised, or not enabled for MCP | HTTP 401 — nothing reaches a tool | | The key is valid, but your subscription does not include that backend | JSON-RPC -32003 at HTTP 200 | | The key reaches the backend, which refuses the specific data | In-band not_entitled, isError: false | ### Empty results aren't errors A search that returns an empty list succeeded — no match cleared the relevance threshold. Rephrase the query, make it more specific, or batch several phrasings in one call (see Concepts → Tools). This is distinct from a refusal: an empty answer arrives on the result branch with no error at all. ### Retrying Every EDITED MCP tool is a read-only search with no side-effects, so retries are always safe. Retry 5xx, -32603 and -32099 with exponential backoff; on -32004 back off substantially longer, since that backend is already being shed and quick retries prolong it. Don't retry 401, -32700, -32600 or -32602 — those need a fix (key, request shape, or tool-name prefix), not patience. An isError: true result is worth exactly one retry. It carries no code to branch on, and the channel mixes two unlike things: a genuine bug, and a transient failure raised below the tool's own error handling. Retry once with backoff; if it comes back identically, treat it as a bug and report it. Among the in-band codes, only upstream_unavailable is worth retrying. internal is not — it means a retry returns the same error, not that we are having a bad minute. ## REST APIs Query API Jobs API The Query and Jobs APIs report success and failure through the HTTP status code — there's no JSON-RPC envelope, and every error carries a message body with more detail. As a rule of thumb a 4xx is your request to fix and a 5xx is ours — but the line isn't clean: some request and access problems surface as a 500, so read the body, not just the status. ### REST status codes | Status | Meaning | | --- | --- | | 200 | Success — read the result from the body. | | 204 | No Content — a Jobs cancel or resume returns 204 with an empty body. (A submit returns 200 with a job_id.) | | 400 | Bad request — malformed syntax or unparseable body. The message body says more. | | 401 | Unauthorised — the access key is missing. See Authentication & access. | | 403 | Forbidden — the key isn't permitted for what you're calling (unknown, or a valid key without access to this surface or data), or the request used plain http (EDITED accepts https only). | | 404 | Not found — an unknown job, option, or resource. | | 409 Jobs API | The job can't change state: you can only cancel a processing, pending, or queued job, and only resume a canceled or errored one. | | 422 Jobs API | Validation error — the request was well-formed JSON but invalid (e.g. a bad date range or filter). The message body says which field. | | 429 | Too many requests — you've hit a rate limit. Wait, then retry. | | 5xx | Server-side failure — usually transient (retry with backoff). But some request problems also come back as 500; read the body, and see the note under Retrying REST calls. | ### Retrying REST calls The read endpoints (Query, and the Jobs read/poll endpoints) are safe to retry. - 429 and 5xx — usually transient; back off and retry with exponential backoff. 429 responses don't carry a Retry-After; wait a little longer each time, and reduce how many requests you fire in a burst. Cap your retries: a 500 that returns the same error every time is usually a request or access problem in disguise (see the note below), not a blip. - 504 on a large option_history request — the request timed out. Split it into smaller date ranges, or paginate, rather than retrying the same call. - 400, 401, 403, 422, 409 — don't retry; each needs a fix (the request shape, the key, https, the body, or the job's state), not patience. Not every 500 is transient A 500 isn't automatically ours to retry. Transient failures are transient — so if the same 500 comes back on every attempt, something else is wrong, and it's a request to fix rather than one to wait out. The usual culprits surface as a 500 instead of a clean 4xx: - a date range beyond the stored window — only daily (D) granularity is limited, to about two years; a daily job reaching further back (say, all of 2023) fails rather than returning a validation error. Weekly (W) and monthly (M) granularity are kept for all time, so switch to one of those for older ranges; - an expired or revoked key; - the wrong environment or region for your key. These come back as an InternalServerError with a GatewayError detail: ``` { "status_code": 500, "type": "InternalServerError", "detail": { "type": "GatewayError" } } ``` Your actual response includes a detail.message with more specifics — worth quoting if you contact support. So when a 500 repeats, check the request, the key, and the environment before treating it as transient. A Jobs submit is not idempotent — retrying a submit that actually succeeded creates a second, separate job, with its own job_id and result files. On an ambiguous failure, list your jobs before resubmitting rather than firing the request again. ## Getting help If you're stuck — whether a call failed outright or the surface just doesn't look the way you expect — email support@edited.com with the details that pin down what you hit: - On MCP: the tool name and arguments you called; the full error envelope, if there was one — plenty of problems arrive without an error at all; serverInfo.version and the surface fingerprint from your initialize response (see Versioning); and your session ID — read the mdmcp+data://session_id resource right after the failing call so EDITED can find your requests in the logs. - On the REST APIs: the method and URL you called, the request body, and the HTTP status and response body you got back. For a Jobs problem, include the job_id. --- ## Rate limits & fair use Source: https://build.edited.com/operations/rate-limits # Rate limits & fair use Applies toEDITED MCPQuery APIJobs API How much you can ask for, and how fast, differs by surface. MCP publishes no per-key limits today and runs on fair use. The REST APIs have concrete limits: the Query API caps requests per minute per endpoint, and the Jobs API limits concurrency, job size, and how long results stick around. ## MCP EDITED MCP EDITED MCP currently enforces no published per-key rate limits. Usage is subject to fair use: the service is shared, and clients that hammer it with unnecessary traffic may be throttled or contacted. If hard limits are introduced, they'll be announced in the changelog before they take effect. ### Stay fast and friendly The patterns that keep you inside fair use are the same ones that make your agent faster: - Batch instead of looping. The market-data search tools accept a list of up to ten queries and run them concurrently — one round-trip instead of ten. See Concepts → Tools. - Cache resolved identifiers. The slug/id values that searches return are stable — once your agent has resolved "Zara UK" to zara-uk, reuse it rather than searching again in the same session. - Back off on 5xx. Retry transient failures with exponential backoff rather than tight loops — see Errors. Write clients to treat a 429 Too Many Requests the same way; it isn't sent today, but a client that already handles it won't break if limits arrive. ## REST APIs Query API Jobs API The REST APIs have real limits, written into contracts and enforced to protect shared infrastructure. Exceeding a rate limit returns 429 Too Many Requests — back off and retry. ### Query API — requests per minute Query API Limits are per endpoint, in requests per minute: | Endpoint | What it does | Requests / min | | --- | --- | --- | | /option/* | Fetch a single option document | 1000 | | /options/* | Filter and list option documents | 100 | | /option_history/* | All tracked documents for one option, over time | 10 | | /stats/* | Statistics and aggregations | 10 | A single response is also capped at 10,000 records; to pull more, page with the /page suffix (1,000 records per page). A very large /option_history request can time out with a 504 — split it into smaller date ranges. See the API reference for the endpoints and pagination. ### Jobs API — concurrency, size, and polling Jobs API The Jobs API is built for bulk over-time extraction, so its limits are about job shape rather than request rate: - One job at a time per account, by default. Further submissions are queued behind the running job; manage the order by cancelling and resuming. - 500,000,000 results per job. A job estimated to exceed 500M documents is rejected at submit — split the work into smaller jobs by timeframe or filter. - Poll at least once every 2 hours. A job running longer than two hours is cancelled for inactivity if it isn't polled (via Get Job or Get Job Result Files); a cancelled job can be resumed. Jobs endpoints are rate-limited too, though the per-endpoint limits aren't individually published. The one builders hit most often is retrieving a large job's result files all at once — page through them with the next token at a measured pace rather than requesting everything at once, and treat a 429 as a signal to slow down rather than to retry straight away. ### Retention Jobs API Job results don't stick around forever — download them promptly: | What | Retained for | | --- | --- | | Job details | 60 days | | Result files | 30 days | --- ## Troubleshooting Source: https://build.edited.com/operations/troubleshooting # Troubleshooting Applies toEDITED MCP Setup problems specific to one client live in that client's recipe — Claude Desktop's log locations, a Windows npx spawn quirk, and the like are called out inline in Connect Claude Desktop, Connect Claude Code, and Connect a custom client, right where you'd hit them. This page is for the other kind: "I thought I was connected, but something's still off." These issues aren't tied to one client's install flow — they show up across clients, often after everything looked fine. For raw HTTP statuses and JSON-RPC error codes (401, -32602, and friends), see Errors; that page owns the full error model. ## Your client falls back to SSE, or you get a 405 / 307 on connect Symptoms vary by client — mcp-remote logs SSE error: Non-200 status code (405), SDK clients see a 307 or lose the connection right after the first call, curl just prints a redirect — but the cause is one thing: a trailing slash on the endpoint. The gateway 307-redirects …/mcp/ to a URL most clients can't follow, so they never complete the handshake. Fix: connect to https://mcp.edited.com/mcp with no trailing slash. The full mechanism is in Concepts → Transports. Some clients also need a client-specific flag — e.g. mcp-remote wants --transport http-only — which is covered in that client's recipe. ## Claude via the Anthropic API can't connect Anthropic's hosted MCP connector (the mcp_servers parameter on the Messages API) only forwards a bearer token via authorization_token, so it can't send the x-api-key header EDITED MCP requires — the connection will fail no matter how the key is set. Fix: connect with the official MCP SDK client yourself and hand the resulting tools to the model with Anthropic's client-side MCP helpers. The custom-client recipe has TypeScript and Python examples; the ready-made app integrations in Connect Claude Code and Connect Claude Desktop are another route. See Authentication & access for the key itself. ## Server-defined prompts don't do anything on their own EDITED's server-defined prompts (like mdmcp-market_data_query_guide) are offered to the client, but they aren't injected into your conversation automatically — so a fresh connection that "has" them can still behave as if they aren't there. Invoke them explicitly (in Claude Desktop, from the prompt/command picker) or fetch them with prompts/get. See How to query → Using the server-defined prompts. ## Google Agent Development Kit (ADK): value2 schema validation error Connecting through Google's ADK, if every tool call fails — often on your first message — with an error like: ``` 400 INVALID_ARGUMENT — Unable to submit request because `ed_mdmcp-market_data_table` functionDeclaration `parameters.filters.value2` schema didn't specify the schema type field ``` this is a known bug in the Gemini schema validation (google-genai), not an issue with EDITED's surface — the schemas EDITED serves are valid JSON Schema. ADK forwards every tool declaration to Google up front, and it's that validation, not ADK, that rejects the batch — so one declaration it won't accept blocks all your calls (which is why even a plain "hi" can fail naming a parameter). Upstream tracked this at google/adk-python#3424, closed in December 2025 as a google-genai issue rather than an ADK one — so there's no ADK fix to wait for and no workaround in the thread. The one datapoint that holds: the reporter saw it disappear on ADK v1.17.0 with google-genai pinned to 1.44.0. Which backend and version combination breaks isn't settled upstream, so if this is blocking you, email support@edited.com and we'll dig in with you. (As of August 2026.) ## Getting help If you hit a cross-client issue that isn't listed here, email support@edited.com with the client you're using, the full error message, and the tool name and arguments you called. --- ## Versioning & breaking changes Source: https://build.edited.com/operations/versioning # Versioning & breaking changes Applies toEDITED MCPQuery APIJobs API EDITED's surfaces version differently. MCP evolves continuously — one moving deployment, with a contract version and a surface fingerprint you read straight off the connection. The REST APIs carry their version in the URL path (/v1) and pin the shape of the data with a schema version. Both announce breaking changes in the changelog. ## MCP EDITED MCP EDITED MCP evolves continuously — new tools, new parameters, occasionally a breaking change. There's no release train to subscribe to: one deployment moves forward, and every change that affects callers lands as a dated entry in the changelog, with breaking changes flagged ⚠ Breaking and migration steps. What the surface promises at any moment is carried by a contract version and a fingerprint you can read straight off the connection. ### The short answer You cannot tie a hosted endpoint to a specific build. What you get instead: - a stable major surface at a stable URL that changes additively only; - the gateway build version, the surface contract version, and a fingerprint of the entire tool surface returned on every initialize, so you can log them and assert on them in your own CI; - a changelog entry for anything breaking — and from 1.0, 30 days' notice and a published cutover date with it; - and a versioned URL, /mcp/v1, to pin to — which declares the major you expect, and lets you be held on it where a break can be served in parallel. Assert on the fingerprint. It catches surface drift the day it happens, which is stronger than reading a version number. ### Which version am I talking to? Every initialize response carries the answer in _meta: ``` { "protocolVersion": "2025-06-18", "capabilities": { "tools": {}, "prompts": {}, "resources": { "subscribe": false, "listChanged": false } }, "serverInfo": { "name": "msv-mcp-runtime", "version": "0.2.0" }, "_meta": { "com.edited/gateway": { "version": "0.2.0", "build": "dbe50346574179e033886cde2d865c590d4c2273" }, "com.edited/surface": { "version": "1.0", "fingerprint": "sha256:7d83ef929d1e185f", "backends": { "mdmcp": { "contract": "1.0", "fingerprint": "sha256:a47d44aae3ae2fb3" }, "researchmcp": { "contract": "1.0", "fingerprint": "sha256:05e26cf6549e659a" }, "messagingmcp": { "contract": "1.0", "fingerprint": "sha256:b984c7037f2f4d99" } } } } } ``` Four numbers, four jobs. They're easy to conflate, and conflating them is what makes a version useless to you — a build number read as a contract moves on every deploy and gives you nothing stable to build against. | Number | What it tells you | | --- | --- | | protocolVersion | Which revision of the MCP spec answered your initialize. See Protocol version below. | | serverInfo.version | Which build of the gateway you reached, also at _meta → com.edited/gateway.version. Support identity, not a contract — quote it in a bug report; don't branch on it. | | com.edited/surface.version | The aggregate tool surface. This is the one to assert on. | | com.edited/surface.backends[…].contract | Each backend's own declared contract version, for provenance — which part of the surface moved, and whether the team that moved it said so. It is not the number to act on; that's com.edited/surface.version above. | Plus the two with the most practical value for a builder, covered in detail below: com.edited/surface.fingerprint, a hash over the whole published catalogue, and the matching per-backend fingerprint under com.edited/surface.backends. _meta sits on the result, which the MCP spec defines on every Result, and clients that don't read it ignore it safely. Nothing about your existing integration changes because it appeared. com.edited/gateway.build is the full commit SHA, not a short one. Log it verbatim; don't parse or truncate it. Read these defensively — two of them are optional. A backend's contract may be absent, and a backend's entry in backends may be missing entirely. In both cases absence means "not known", never "unchanged" — so use a defaulting read rather than direct indexing, and don't treat a vanished key as a signal that nothing moved. #### The fingerprint A hash over every tool, prompt and resource in our published catalogue: each one's name, plus a tool's input and output schemas, a prompt's arguments, and a resource's media type. It deliberately excludes descriptions and titles at every depth, including the ones on individual arguments and response fields, so wording improvements don't move it and you don't learn to ignore it. It is the only value here that is derived rather than asserted: it moves when the surface moves, independently of any version number. Treat the whole string as an opaque token. Compare it for equality; don't parse it, slice it, or validate its shape. The sha256: prefix names the algorithm, but what follows is a truncated digest — 16 hex characters, not the 64 of a full SHA-256 — so a client validating against something like ^sha256:[0-9a-f]{64}$ rejects every value we have ever sent. If you need a pattern at all, match the prefix and leave the rest alone. Two things it is not, both worth knowing before you rely on it: It describes the whole catalogue, not your subset. Every caller gets the same fingerprint, whatever their key is entitled to. If your key is scoped to some backends and not others, the fingerprint still covers all of them — so it can move for a change to a tool you cannot call. That's deliberate: a per-caller fingerprint would mean two clients seeing different values for the same gateway on the same day, which makes a support conversation harder rather than easier. Compare tools/list against your own last-known list when you want a change scoped to what you actually use. It is a statement about shape, not availability. When a backend fails repeatedly we stop listing its tools, so you don't call something certain to fail — but the published catalogue hasn't changed, so the fingerprint doesn't move. A shorter tools/list with an unchanged fingerprint means "briefly degraded", not "the surface changed". The inverse holds too: an unchanged fingerprint is not a promise that every tool in it is answering right now. Handle a missing or failing tool at call time regardless of what the fingerprint says. A fingerprint change with a shorter tools/list is degradation, not a release If the gateway restarts while a backend is unreachable, that backend's tools are missing from the catalogue it builds, so the fingerprint moves even though the surface didn't change. Re-read both before acting on the change. A real surface change moves the fingerprint while the catalogue stays the same size or grows. #### Assert on the fingerprint in your CI Record it once, compare on every run, and re-read tools/list when it changes. That catches surface drift on your side the day it happens: ``` EXPECTED = "sha256:7d83ef929d1e185f" result = await session.initialize() surface = (result.meta or {}).get("com.edited/surface", {}) actual = surface.get("fingerprint") assert actual == EXPECTED, f"EDITED MCP surface changed: {actual}" ``` The per-backend fingerprints narrow it further: if only mdmcp moved, nothing your agent does against researchmcp needs re-checking. Read it as a release-time signal — "has the contract moved since we last looked?" — rather than a runtime health check, which is the one job it doesn't do. ### Protocol version The MCP protocol revision is negotiated in each initialize call. Your client offers a revision in params.protocolVersion; the gateway answers with the revision it will speak in result.protocolVersion. Read that field if you need to know which one you got. | You offer | We answer | | --- | --- | | 2024-11-05 | 2024-11-05 | | 2025-03-26 | 2025-03-26 | | 2025-06-18 | 2025-06-18 | | anything else — a newer revision, an unrecognised value, or nothing at all | 2025-06-18 | An offer we don't recognise gets our newest revision rather than an error: initialize is the one call a confused client still needs answered. 2025-06-18 is the newest revision we speak. That number can go up; it won't go down. Removing one of the older revisions from the list above would be a breaking change, and would be announced in the changelog before it happened. #### Negotiating an older revision doesn't narrow what you receive The gateway is stateless: it keeps nothing from your initialize call, so there is no stored revision it could tailor later responses to. It relays what the backends produce. A client that offers 2024-11-05 still receives every field that later revisions introduced. | Field | Introduced in | | --- | --- | | structuredContent on tool results | 2025-06-18 | | outputSchema on tools | 2025-06-18 | | top-level title on tools, prompts and resources | 2025-06-18 | | annotations / readOnlyHint | 2025-03-26 | The negotiated revision doesn't filter the catalogue: tools/list returns the same tools carrying the same fields whichever revision you negotiate. The practical rule: don't gate your parsing on the negotiated revision. A client that negotiates 2024-11-05 and therefore declines to read structuredContent is throwing away the machine-readable payload that is sitting right there. Read the fields you find; ignore the ones you don't recognise. #### The MCP-Protocol-Version header 2025-06-18 added an MCP-Protocol-Version request header, for clients to restate the negotiated revision on the calls that follow initialize. The spec is firm about it and this gateway is more lenient than the spec requires — both halves matter: - The spec requires you to send it. "If using HTTP, the client MUST include the MCP-Protocol-Version: HTTP header on all subsequent requests to the MCP server." The official SDKs do this for you. - The spec requires a server to reject a bad one. "If the server receives a request with an invalid or unsupported MCP-Protocol-Version, it MUST respond with 400 Bad Request." Omitting it is different: a server that never sees the header, and has nothing else to identify the revision by, SHOULD assume 2025-03-26 — a silent downgrade, not a rejection. - This gateway does none of that. It doesn't require the header, doesn't validate it, and doesn't use it to choose a revision — that happens in initialize and nowhere else. A missing or unrecognised value is ignored, not rejected. Two things follow. Don't reach for this header when diagnosing a failure here: a missing or wrong MCP-Protocol-Version has never been the cause of one. But don't carry that leniency to other servers — a conforming one rejects a value it doesn't support, and treats a missing header as 2025-03-26, which may not be the revision you negotiated. Keep sending the negotiated value. If we tighten this to match the spec, the changelog will say so first. ### What counts as breaking | Change | Breaking? | | --- | --- | | New tool, resource, or prompt | no | | New optional parameter | no | | New fields in result objects | no | | Improved descriptions, titles, or documentation | no | | Tool, resource, or prompt renamed or removed | yes | | Parameter type changed or made required | yes | | New required parameter | yes | | Parameter removed | yes | | A published response property removed, or its type narrowed | yes | | Response shape changed | yes | The parameter rows apply to prompt arguments too — a prompt's arguments are its parameters, and the drift rules treat them the same. Everything in the "no" column happens regularly and without notice. Write clients that tolerate unknown fields and don't treat a longer tool list as an error. #### What we guarantee at a breaking change From 1.0, breaking changes come with: - 30 days' notice before the change reaches production. - A changelog entry describing what moves and what to do about it. - A published cutover date, so the migration window is a date you can plan against rather than an open question. - Where practical, a deprecation window in which the old name keeps working — a renamed tool keeps its old name as an alias, and the deprecation is stated in the tool's own description, so an agent reading the catalogue sees it too. Notice arrives out of band — release notes and email — not over the protocol. MCP's mechanism for pushing a catalogue change (notifications/tools/list_changed) requires a persistent connection, and this gateway is stateless request/response by design. If you hold a cached tool list, nothing will arrive over the wire to tell you it changed — re-read it with a fresh tools/list after a notice. ### Versioned URLs | URL | Use | | --- | --- | | https://mcp.edited.com/mcp | The endpoint used throughout these docs. Alias for the current stable major: the URL never goes away, but when a v2 becomes current it moves with it. | | https://mcp.edited.com/mcp/v1 | Available, and the one to pin to. Same surface, explicit about which major you expect. | Both serve the same surface today, so moving costs you nothing. What pinning /mcp/v1 changes is what your client has declared: a client on /mcp has said nothing about which major it expects, so it moves with everyone else, while a client on /mcp/v1 can be held on v1's behaviour if a future major arrives at /mcp/v2. The rest of these docs still show the bare /mcp — that's the alias doing its job, not a different endpoint. Neither form takes a trailing slash. /mcp/ and /mcp/v1/ both answer 307 with a redirect to a plain-http URL, which most clients either refuse to follow or follow in a way that drops the request body — see Transports → Endpoint. Where a break can practically be served in parallel — the old surface at /mcp/v1 alongside the new one at /mcp/v2 — the notice will say so. Treat parallel majors as per-change rather than guaranteed: the gateway relays backend responses verbatim with no translation layer, so serving both shapes depends on the backends behind it being able to, which is not true of every change. Plan a migration on the notice period and the cutover date, which you always get, rather than on an overlap you might. ### Build for change Agents that follow MCP's grain barely notice non-breaking changes: - Discover, don't hardcode. Read the catalogue with tools/list at runtime instead of baking tool names and schemas into your code — that's what the catalogue is for. - Ignore unknown fields. Result objects gain fields over time; parse what you need and skip the rest. - Watch the changelog before upgrading or redeploying agents that call EDITED MCP — breaking entries include what changed and what callers need to do. ### If the surface looks wrong A tool that has changed shape, a version that reads oddly, a fingerprint that moved when you expected it not to — none of these produce an error envelope, so there's nothing to paste. Quote serverInfo.version and the surface fingerprint from your initialize response instead, and see Errors → Getting help for the full list. ## REST APIs Query API Jobs API The REST APIs version in two independent places: the API version in the URL path, and the data version — the schema the documents follow. ### API version in the path Both REST families carry their major version as a path segment: | Surface | Versioned base | | --- | --- | | Query API | https://apis.edited.com/query/v1/… | | Jobs API | https://api-us.edited.com/v1/… · https://api-eu.edited.com/v1/… | The two Jobs regions serve the same v1 — a divergence between them would be a deploy fault, not a version difference — so the reference is one document that applies to both. ### Schema version Separately from the API version, the data carries its own version. Every option document is tagged with the schema version that generated it — the schema fixes the field set and each field's definition, which is what makes a field's meaning stable over time. The Schema API serves the definitions for a given version: ``` GET /schema/v1/ ``` The default schema version is 2018-04-18. Both the Query and Jobs APIs return option and SKU documents that follow this schema, so a field's definition applies the same way whichever REST surface you pull it through. ### Breaking changes Changes to the REST API are governed by your EDITED agreement, not by a policy this page sets. Under that agreement a modification comes with advance written notice and won't materially reduce the API's functionality — your agreement is the authoritative source for the notice you're entitled to. EDITED serves one current version of the REST API, and your integration should track it. Changes are surfaced in the changelog, with breaking changes flagged ⚠ Breaking. Additive changes — a new endpoint, or new fields on a document — ship without notice, so build clients that tolerate unknown fields and don't break on a longer response. --- ## Changelog Source: https://build.edited.com/changelog # Changelog Date-based log of changes to the EDITED MCP server and these docs. Newest entries at the top. Stability policy EDITED MCP evolves continuously. Each entry below documents what changed and, where relevant, what callers need to do; breaking changes are flagged ⚠ Breaking with migration steps. See Versioning & breaking changes for what counts as breaking, how to build agents that survive change, and how to detect a surface change from your own CI. ## 2026-09-16 ### Added - Every lookup hit now carries a match tier. The six identifier resolution tools — mdmcp-search_brands, mdmcp-search_retailers, mdmcp-search_markets, mdmcp-search_product_searches, mdmcp-search_size_group and mdmcp-search_size_options — return a match value on every hit: exact (the query is the entity's name or slug, compared leniently), lexical (text search ranked it — a typo, a partial, or the name plus or minus a word), or semantic_only (only the vector arm returned it). Read match; don't trust rank. When nothing matches the query strongly, the vector arm fills the remaining result slots with nearest neighbours, so a plausible-looking name can rank first without being your entity. Treat a semantic_only hit as a candidate to verify, and a result set where nothing beats semantic_only as not found. There is no score to threshold, and no tier is evidence that EDITED holds data for the entity — an exact retailer hit still needs mdmcp-get_retailer_coverage before you trust it over your window. The field is additive, so existing calls keep working; code that took the first hit unconditionally should now check the tier. See How to query → Identifier resolution for where this lands in a query, and the new lookup_matching topic below for the per-tool rules. - New lookup_matching documentation topic. mdmcp-market_data_docs accepts topic: "lookup_matching", covering what each lookup tool compares before it calls a hit exact (case, accents, punctuation and spacing are ignored, but a separator between two digits is kept, so 3-6 months is not 36 months), why the tiers are ordered labels rather than a score, and why a name match is not evidence of data behind the name. Read it before writing the code that decides whether a lookup found what the user asked for. ## 2026-08-26 ### Added - researchmcp-research_report_links now runs in both directions. It resolved report_ids to public EDITED Research article URLs; a new optional report_urls (string[], 1–20 per call) asks for the reverse. That is the call you want when the user starts from a link they were sent rather than an id — resolve it once, then pass the returned report_id to any other research tool: researchmcp-research_chunk_search for the passages behind a claim, researchmcp-research_read_report for the whole article. URL → report_id ``` { "report_urls": [ "https://my.edited.com/research/articles/assortment-mixed-trend-sleepwear-athleisure-020925" ] } ``` Only the last path segment identifies the report, so a trailing slash, ?utm_* params or a fragment are all fine. A link no slug can be read from — a bare host, a click-tracking wrapper, two URLs joined into one string — refuses the whole call with invalid_input and field: "report_urls", so open a shortened link first and pass its final URL. The result carries a new report_ids_by_url map alongside links, and the direction you did not ask for is null: Response — resolving URLs ``` { "result": { "links": null, "report_ids_by_url": { "https://my.edited.com/research/articles/assortment-mixed-trend-sleepwear-athleisure-020925": 152777 } } } ``` Calls that pass report_ids are unchanged: links is populated exactly as before, with report_ids_by_url: null. A null value inside either map still means what it always did — nothing resolved for that key, an unknown or unpublished report — so surface "link unavailable" / "report not found" rather than inventing one. See Concepts → Response shape for the envelope the two maps sit in. ### Changed - report_ids is optional, and exactly one direction is required. It is no longer a required argument, because report_urls can take its place — but passing neither or both is refused with invalid_input. Calls that pass report_ids are unaffected: the argument only widened, so no migration is needed. ## 2026-08-13 ### Added - New product_searches documentation topic. mdmcp-market_data_docs accepts topic: "product_searches", covering what a flat result table hides: the product_searches and product_details category dimensions are trees, returned as one list of rows at mixed depths with > separating the levels and no column marking a parent as a parent. An ancestor row already contains every descendant row, so adding up a category breakdown counts the same products twice — Tops and Tops > T-Shirts are one category listed twice, not two categories worth their sum. It also names what is safely summable (one depth level under one parent, and product_searches only — product_details is multi-valued and additive at no depth), the top-level roll-up row that looks like an ordinary sibling, why filtering to one category still returns its ancestors, and how to get from a path label to the integer id the filter takes. Read it before doing arithmetic across a category breakdown. ## 2026-08-12 ### Changed - The protocol revision is negotiated in initialize. The gateway previously answered every handshake with a hardcoded 2024-11-05. It now echoes the revision you offer when it speaks it — 2024-11-05, 2025-03-26 or 2025-06-18 — and answers 2025-06-18, its newest, to anything else (including no offer at all). Nothing changes for callers beyond the handshake: the MCP-Protocol-Version header is still accepted and still not validated, and negotiating an older revision doesn't narrow what any later call returns. See Versioning → Protocol version. ## 2026-08-11 ### Added - New response_shape documentation topic. mdmcp-market_data_docs accepts topic: "response_shape", covering how to read what a tool sends back: the result / error split, what each error code means (invalid_input, not_entitled, upstream_unavailable, internal) and which one is worth retrying, why an empty answer — an unknown id, a filter matching nothing — is a success rather than a failure, and the one family of refusals that still arrives as isError with a plain-text message instead. Read it before writing the code that decides whether a call failed. ## 2026-08-10 ### ⚠ Breaking - Optional parameters no longer advertise null. 81 optional parameters across 16 tools — the promotions (messagingmcp-*) and research (researchmcp-*) sets — published as a union with null (string | null, string[] | null, boolean | null, integer | null) and defaulted to null. They now publish as the plain type with no default. They are still optional, and leaving one out still means "no filter". Omit the parameter instead of passing null. If your code maps "no filter" to an explicit null, drop the key from the arguments object: Before — messagingmcp-list_promos ``` { "retailers": ["zara"], "regions": null } ``` After ``` { "retailers": ["zara"] } ``` The server itself still accepts an explicit null and treats it exactly as if the parameter were absent, so calls that reach it keep working. The breakage is client-side: SDKs and agent tool-loading layers that validate arguments against the published schema reject the null before the call goes out. Affected tools — promotions: aggregate_promos, count_promos, enrich_with_vm, explain_promo_data, get_promo, list_promos, list_retailers, messaging_search, resolve_time_period, search_promo_text. Research: research_analyze_query, research_article_search, research_chunk_search, research_image_search, research_list_reports, research_match_images_to_text. ### Added - New composition documentation topic. mdmcp-market_data_docs accepts topic: "composition", covering how fabric percentages are captured, why percentage bands are not additive, and the per-retailer coverage caveats. Read it before banding fibre content or comparing retailers on it. ### Changed - Allowed values are now published for parameters that already enforced them. group_by (retailer, promo_type, region, shoot) and interval (day, week, month) on messagingmcp-aggregate_promos and messagingmcp-count_promos, and topic (overview, fields, identity, dates, inferred, quality) on messagingmcp-explain_promo_data. Which values the server accepts hasn't changed — it rejected anything outside these lists before too. They were hidden inside the null union, and are now visible to clients and agents choosing a value. ## 2026-07-29 EDITED MCP is now in public beta. From here, this log tracks changes to the tool surface — new and changed tools, parameters, resources, and prompts — with ⚠ Breaking changes called out and migration steps where you need them. --- ## Concepts Source: https://build.edited.com/concepts ## 📄️What is MCP? An introduction to the Model Context Protocol — the open standard EDITED MCP uses to connect AI agents to EDITED's market data — and how it differs from a REST API. --- ## Prompts Source: https://build.edited.com/concepts/prompts # Prompts A prompt is a reusable prompt template the server defines and the client instantiates. Where a tool does something and a resource is something to read, a prompt is guidance — instructions the server maintains so every client doesn't have to re-implement them. Instantiating a prompt returns a list of messages you prepend to your model's context. ## Discover prompts ``` const { prompts } = await client.listPrompts(); // → [{ name: "...", description: "...", arguments: [...] }, ...] ``` Each prompt exposes: - name — the stable identifier, used in prompts/get - description — when and why to use it - arguments — the parameters it accepts (name, description, whether required) ## Get a prompt ``` const result = await client.getPrompt({ name: "", arguments: {}, }); // result.messages — role/content pairs to prepend to your conversation ``` The server fills the template with your arguments and returns the resulting messages. Prepend them to your own message history before you call your model. ## Prompt, tool, or resource | Use case | Pick | | --- | --- | | Agent behaviour — routing, stop conditions, source rules | prompt | | Searches, lookups, anything parameterized | tool | | Reference data the agent reads on demand | resource | A prompt is instructions (how to act), a tool is a verb (do something), and a resource is a noun (read something). ## When to reach for a prompt Server-defined prompts shine when the server knows best how its own tools should be used: - Shared policy. Routing rules, stop conditions, and source-attribution guidance live in one place instead of being copied into every agent. - Updatable without redeploys. Change the prompt server-side; clients pick up the new guidance on their next listPrompts call. - Capability-coupled. The server can encode how to compose its own tools — knowledge that would otherwise be hardcoded in each client. ## What EDITED MCP serves The prompt catalogue is environment-specific and grows over time, so rather than hardcode it here: discover it at runtime with listPrompts (above), or browse the Reference, which is generated from the live server and lists exactly what your gateway exposes. --- ## Resources Source: https://build.edited.com/concepts/resources # Resources A resource is read-only data the server exposes to the client. Where tools do work, resources are addressable, cacheable documents — identified by URI and free of side-effects. ## Discover resources ``` const { resources } = await client.listResources(); // → [{ uri: "mdmcp+data://session_id", name: "session_id", ... }] ``` ## Read a resource ``` const result = await client.readResource({ uri: "mdmcp+data://session_id" }); // result.contents[0] = { uri, mimeType, text } | { uri, mimeType, blob } ``` ## Available resources mdmcp+data://session_id — the current MCP session ID. Diagnostic only; useful when you're contacting support and need to point EDITED at a specific session in the logs. More resources will land as schemas are formalized — typically reference data the agent can read on demand (country code → region mappings, vertical taxonomies) without spending a tool call. ## Resource or tool — which to use | Use case | Pick | | --- | --- | | Searches, filters, anything parameterized | tool | | Reference data the agent reads on demand | resource | | Side-effectful operations | tool | | Static documents the agent quotes verbatim | resource | If in doubt: a tool is a verb (do something), a resource is a noun (read something). --- ## Response shape Source: https://build.edited.com/concepts/response-shape # Response shape A tool call that runs returns an object with exactly one of result or error — the same split as the JSON-RPC envelope one level up, applied to the tool payload. But a refusal can also arrive on a second channel, so the unwrapper that works everywhere checks both, in this order: ``` function unwrap(result) { if (result.isError) { // the call never completed — the text is prose, not JSON throw Object.assign(new Error(result.content?.[0]?.text ?? "call failed"), { channel: "isError", }); } const body = result.structuredContent; if (body.error) throw Object.assign(new Error(body.error.message), body.error); return body.result; } ``` Only the in-band throw carries the structured error — code included, which is what a per-code retry decision is made on. The isError channel has no structured detail to carry: that throw is tagged channel: "isError" so a handler can tell the two apart, and its only message is the prose text — which is why it gets one blind retry rather than a decision. See Two channels. error is {code, message, field?, details?}. Branch on code; message is for humans and its wording is not a contract. | code | Meaning | Retry? | | --- | --- | --- | | invalid_input | Your arguments were refused. Often with field naming the one to change — market-data sets it consistently, the research and messaging tools only sometimes. | No — fix the request | | not_entitled | The backend refused your subscription's access to this data. Distinct from an unrecognised key (HTTP 401) and from a backend your key cannot reach at all (-32003) — see Operations → Errors. | No | | upstream_unavailable | A dependency is failing or slow. | Yes, with backoff | | internal | Our side is wrong: a malformed record, or a guard we can't satisfy. A retry returns the identical error, which is why it isn't worth one — see Operations → Errors. | No | ## Two channels, and which one you get This is the one a shared response handler gets wrong. A refusal reaches you on one of two channels, and which one depends on where it was raised, not on which tool you called: | How the call was refused | Channel | | --- | --- | | The tool ran and refused — a bad date range, an unanalysable query, an entitlement check | In-band: isError: false, payload {error: {code, …}} | | An argument failed the tool's inputSchema before the body ran | isError: true with structuredContent: null and a plain-text message — except on the market-data tools, which map these in-band as invalid_input | | An internal guard raised an unclassified error (e.g. an unsupported filter-operator combination) | isError: true, plain text — it is indistinguishable from a bug at the boundary, so it is not dressed up as a refusal | | A failure raised below the tool's own error handling — including a transient one, like a timeout reaching a search index | isError: true, plain text. Not every isError is a bug, which is why it is worth one retry — see Operations → Errors | The in-band case is the one that surprises people, because isError is false: ``` { "isError": false, "structuredContent": { "error": { "code": "invalid_input", "message": "the comparison window this request implies falls outside …", "field": "compare" } } } ``` A client that branches only on isError reads that as a success and then finds no data where it expected some. A client that branches only on the payload crashes on the isError cases, because structuredContent is null there. Handle both and you do not need to know which channel any given refusal uses — which is why the unwrap above leads with isError. ## An empty answer is a success An unknown id returns a null or empty payload; a filter matching nothing returns zero rows. Both arrive on the result branch. Treating that as a failure — or reading it as "EDITED has no data for this retailer" — is the specific mistake this contract exists to prevent. It is worth being blunt about because it has already cost real debugging time: a fixed-shape client that unwraps at the wrong level returns an empty collection rather than raising, so the call looks like a legitimate "no data" answer when in fact the data was there and the reader was wrong. ## content vs structuredContent They serve different readers and deliberately differ. - structuredContent is the machine contract: always the envelope, always matching the published outputSchema. - content[0].text is what an LLM sees. On success it is the payload serialized without the envelope. On a refusal it is the plain message — prose, not JSON. Read structuredContent for anything your code branches on. warning Do not parse content as the payload when structuredContent is missing. The shapes differ, so the fallback silently yields a different answer instead of failing. The one legitimate read of content is on the isError path, where structuredContent is null and the text is the only message there is — which is exactly what unwrap does above, and no further. Two consequences worth knowing if you have existing code reading content: - On a successful call to a tool returning a list or object, JSON.parse(result.content[0].text) still yields the payload — so such code does not break on the happy path. It breaks on refusals, on both channels: the text is prose, so JSON.parse throws, and on the in-band channel it does so while isError is false. - For a tool whose payload is a plain string, content[0].text is that string, so JSON.parse throws even on success. Both are reasons to read structuredContent.result rather than to patch the parse. ## Python: read .structured_content, not .data If you use the fastmcp client, prefer .structured_content. Every tool's schema carries x-fastmcp-wrap-result and the client unwraps against it — which works on success and yields None on a refusal, with the parse error swallowed and is_error still False. So .data turns a refusal into a silent None. ``` def unwrap(result): """The `.data` equivalent that does not swallow a refusal.""" body = result.structured_content if body is None: # schema violation caught above the tool body raise RuntimeError(result.content[0].text if result.content else "no payload") if "error" in body: raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}") return body["result"] ``` ## See also - Operations → Errors — HTTP statuses, JSON-RPC codes, and retry guidance - Concepts → Tools — discovery, calling, and batch queries - Each tool's page in the Reference publishes its outputSchema, which describes both branches --- ## Tools Source: https://build.edited.com/concepts/tools # Tools A tool is a function the server exposes for an agent to call. The agent picks the tool and supplies arguments; the server runs it and returns the result. EDITED MCP's tools span three data domains — Competitive Market Data, Trend & Research, and Messaging & Promotions — plus a set of mdmcp helpers. Some resolve fuzzy concepts into stable identifiers (slug, id); others return product data, analytics, article passages, or promotions directly. See The data for the full map, or the Reference for every tool's schema. ## Discover tools ``` const { tools } = await client.listTools(); // → [{ name: "mdmcp-search_retailers", description: "...", inputSchema: {...}, // outputSchema: {...} }, ...] ``` Each tool exposes: - name — the stable identifier, used in tools/call - description — prose the agent reads to decide when to call - inputSchema — JSON Schema for the arguments - outputSchema — JSON Schema for the response, covering both branches of the envelope described in Result shapes below ## Call a tool ``` const result = await client.callTool({ name: "mdmcp-search_brands", arguments: { query: "Nike", search_limit: 5 }, }); // Read the payload from result.structuredContent — see Result shapes below. // result.content also exists, but it is the LLM-facing channel, not the contract. ``` The auto-generated Reference lists every tool with its full schema and a live "Try it" widget. ## Batch queries The market-data search tools accept either a single string or a list of up to ten strings. Pass a list and the server runs the searches concurrently, returning results in the same order: ``` await client.callTool({ name: "mdmcp-search_retailers", arguments: { query: ["Zara", "H&M", "Uniqlo"], search_limit: 3 }, }); // → one { retailers, search_limit, at_search_limit } wrapper per query, in // order — search_retailers is the only search tool that wraps its hits; the // others return one bare list per query ``` When you need to resolve several entities at once, batch — it's faster than serial calls and lighter on context. ## Result shapes A tool call that runs returns an object with exactly one of result or error, read from structuredContent — the machine contract, which always matches the tool's published outputSchema. Some refusals arrive on a second channel instead, so check isError first: ``` if (result.isError) { // never completed — the text is prose, not JSON throw new Error(result.content?.[0]?.text ?? "call failed"); } const body = result.structuredContent; if (body.error) throw Object.assign(new Error(body.error.message), body.error); const brands = body.result; // matches the Brand model ``` Tools also return a content array of TextContent blocks. That channel is what an LLM sees and it deliberately differs: on success it carries the payload without the envelope, and on a refusal it carries a plain prose message rather than JSON. Read structuredContent for anything you branch on — the one exception being the isError path above, where it is null and the text is the only message there is. An in-band refusal arrives with isError: false and structuredContent.error, so a client checking only isError reads it as a success — while a client checking only the payload crashes on the isError cases. See Concepts → Response shape for which refusals use which channel, the error codes, and the Python .data caveat. ## Errors Server-side failures surface as JSON-RPC errors: ``` { "jsonrpc": "2.0", "id": 1, "error": { "code": -32602, "message": "Invalid request parameters" } } ``` See Operations → Errors for every code, the HTTP-level statuses, and retry guidance. --- ## Transports Source: https://build.edited.com/concepts/transports # Transports EDITED MCP speaks Streamable HTTP — a single endpoint that handles both standard request/response and, where needed, server-pushed events over SSE. | Transport | Used here? | Notes | | --- | --- | --- | | stdio | no | Process-local; clients spawn the server as a child | | SSE (legacy) | no | Replaced by Streamable HTTP in the 2025-06 spec | | Streamable HTTP | yes | One endpoint at /mcp accepting POST JSON-RPC | ## Endpoint ``` POST https://mcp.edited.com/mcp ``` No trailing slash. The gateway answers at /mcp; the /mcp/ form gets a 307 redirect to an absolute plain-http URL, which HTTP clients either refuse to follow or follow in a way that drops the request body. Symptoms of the slashed form: bare 307 responses from curl, failed handshakes from SDK clients, and 405 errors from clients that fall back to the legacy SSE transport (EDITED MCP doesn't serve SSE). Required headers: | Header | Value | | --- | --- | | Content-Type | application/json | | Accept | application/json, text/event-stream | | x-api-key | YOUR_API_KEY | Optional: | Header | Value | | --- | --- | | MCP-Protocol-Version | the negotiated revision, e.g. 2025-06-18 | The official SDKs send MCP-Protocol-Version automatically — and the 2025-06-18 spec requires clients to send it, so it's optional only in the sense that this gateway doesn't enforce that. The gateway accepts it, doesn't validate it, and does not use it to select a protocol revision — that happens in initialize. See Versioning → Protocol version. The body is a single JSON-RPC 2.0 envelope. A batch array of envelopes is also accepted, up to a cap of 64 — a larger batch is rejected with HTTP 400 / -32600. But JSON-RPC batching was removed from MCP in 2025-06-18 — treat it as backwards compatibility for older clients rather than something to build on, and send one envelope per request. ## Authentication Every request must carry an x-api-key header. Without a key — or with one that isn't enabled for MCP — the server replies 401 Unauthorized. Replace YOUR_API_KEY above with your own key; see Authentication & access for how to get one and keep it safe. ## Response shape The server replies with one of: - Content-Type: application/json — a single JSON-RPC response (most calls) - Content-Type: text/event-stream — an SSE stream of one or more responses, used for long-running operations and progress notifications The protocolVersion coming back is the negotiated revision — the one you offered if we speak it, otherwise our newest. Because the server is stateless, that answer is scoped to the initialize response itself: nothing here remembers it afterwards, and no later call behaves differently because of it. See Versioning → Protocol version. ## Stateless mode The server runs statelessly, so: - Every request is independent — no session ID to track - No per-client state lives between calls - Any replica can serve any request, so horizontal scaling is trivial A client that ignores Mcp-Session-Id headers entirely still works against this server — it simply doesn't issue them. ## Why HTTP and not stdio? EDITED MCP depends on shared backend services, so a process-per-client (stdio) model isn't practical. HTTP is the right shape for a service tier; stdio is the right shape for local desktop integrations. If you specifically need stdio (e.g. air-gapped Claude Desktop), see Recipes → Connect Claude Desktop — Claude Desktop reaches remote servers via the mcp-remote proxy, which bridges stdio to HTTP. --- ## What is MCP? Source: https://build.edited.com/concepts/what-is-mcp # What is MCP? The Model Context Protocol is an open standard for connecting AI agents to external data and tools. Think of it as USB for LLMs: a single plug-and-play interface, regardless of which agent you use or which service you're connecting to. ## Why a protocol exists Every agent framework — Claude Desktop, Claude Code, OpenAI Agents, LangChain, your in-house stack — eventually needs the same thing: a way to let the model call a function, read a document, or fetch data from your service. Before MCP, every framework rolled its own glue. Result: every service had to build N integrations, one per framework. MCP replaces those N integrations with one. A server speaks MCP; any client that speaks MCP can use it. No per-client adapters. ## The three primitives | Primitive | Owns | Best for | | --- | --- | --- | | Tool | Server | Functions the agent calls — searches, side-effects, lookups | | Resource | Server | Read-only documents the agent fetches by URI | | Prompt | Server | Reusable prompt templates the agent can instantiate | EDITED MCP's tools span competitive market data, trend research, and messaging & promotions — some resolving fuzzy concepts into stable IDs, others returning data directly — and a diagnostic resource (mdmcp+data://session_id) is available too. Browse the live catalogue — tools, resources, and any prompts — in the Reference, which is generated from the server itself and always matches what's deployed. For the deep dive on each, see: - Concepts → Tools - Concepts → Resources - Concepts → Prompts - The data — the entities EDITED resolves and their stable identifiers ## How agents actually use MCP The agent reads the tool catalogue once, decides which tool to call, and the server executes. The agent never has to know how the search backend works, what the schema is, or where the data lives — the tool description and input schema tell it everything. ## How it differs from a REST API | | REST API | MCP | | --- | --- | --- | | Discovery | Out-of-band (OpenAPI doc) | Built-in (tools/list) | | Calling | HTTP verb + URL | JSON-RPC method | | Auth | Per-endpoint | Per-server | | Streaming | Mixed (SSE, WebSockets, …) | Built into the transport | | Audience | Humans + machines | Agents (designed for tool-use) | You could wrap a REST API in an agent framework's tool layer. MCP formalizes the same idea, so the wrapper is the same everywhere. This page is about MCP as a protocol. EDITED's MCP isn't a wrapper over its REST API, though: it's a distinct surface, designed with agent workflows in mind, with its own tools and normalized results — and for Trend & Research and Messaging & Promotions there's no REST API behind it at all. For which EDITED surface to reach for on a given job — MCP, the Query API, or the Jobs API — see MCP or the API?. ## Going deeper - The spec: modelcontextprotocol.io - Why MCP for EDITED specifically: Recipes → Example workflow - The security shape of EDITED's connector: Operations → Data privacy & security