Company Logo
MCIP
Client

Response Handling

MCIP provides two search modes — simple vector search and agentic LangGraph-powered search — each returning predictable, normalized responses. Every response is designed for AI agents to parse easily, with semantic scores, applied filter transparency, and graceful degradation when things go wrong.

Responses Are Conversations, Not Data Dumps

Think about asking a knowledgeable friend for shopping advice. They don't hand you a spreadsheet of product specifications. They give you organized, relevant information with context about why each option matters. That's exactly how MCIP structures its responses.

Every MCIP response is crafted for two audiences simultaneously: AI agents that need structured data to process, and the humans those agents serve. As the Machine Customer Interaction Protocol, product discovery responses are the first building block — the same response patterns will extend to cart management, checkout, and order tracking as the protocol evolves.


Two Search Modes, Two Response Flavors

MCIP provides two complementary search endpoints. Both return the same response structure, but the journey to those results — and the metadata they carry — differs significantly.

GET /search?q={query}&take={limit}&skip={offset}

Direct vector similarity search in Qdrant. Fast, low-latency, no LLM calls. Best for straightforward queries where you want raw semantic matching.

GET /hard-filtering/search?q={query}&take={limit}&skip={offset}

Full LangGraph 4-stage workflow: parallel filter extraction (brand, category, price via GPT-4o-mini), brand validation against the actual catalog (Qdrant facet search), hybrid vector + payload search, and LLM verification of results. Best for complex natural language queries with implicit filters like "Nike shoes under $100 but not running shoes."

The agentic endpoint does more work — and returns richer metadata about what it understood from your query.


Search Response Format

The Standard Structure

Both endpoints return this consistent two-part structure:

{
  "meta": {
    "count": 5,
    "take": 10,
    "skip": 0,
    "q": "nike shoes under 100",
    "filteringStatus": "AI_FILTERED",
    "appliedFilters": {
      "brand": ["Nike"],
      "priceRange": {
        "min": null,
        "max": 100,
        "currency": "UAH"
      }
    }
  },
  "items": [
    {
      "externalId": "prod_123",
      "url": "https://store.com/products/nike-air-max",
      "title": "Nike Air Max 90",
      "description": "Classic sneakers with Air cushioning",
      "brand": "Nike",
      "category": "Shoes",
      "price": {
        "amount": 89.99,
        "currency": "USD"
      },
      "mainImage": "https://cdn.store.com/images/nike-air-max.jpg",
      "attributes": [
        { "name": "Color", "value": "White" },
        { "name": "Material", "value": "Leather" }
      ],
      "variants": [
        { "sku": "NAM90-W-42", "title": "White / 42", "price": null, "available": true }
      ],
      "keywords": ["nike", "sneakers", "running", "air max"],
      "score": 0.892
    }
  ]
}

Meta Object Fields

FieldTypeDescription
countnumberNumber of items in current response
takenumberRequested number of results
skipnumberPagination offset
qstringOriginal search query
filteringStatusstringVECTOR_ONLY, AI_FILTERED, or FALLBACK
appliedFiltersobjectExtracted and applied filters (present when AI_FILTERED)

Filtering Status Values

This is one of the most important fields for AI agents to interpret correctly:

StatusDescriptionWhen It Appears
VECTOR_ONLYPure vector similarity search, no filter extractionSimple search endpoint or when no filters detected
AI_FILTEREDLangGraph workflow extracted and applied filters from natural languageAgentic search with successful filter extraction
FALLBACKDegraded mode — embedding API failure triggered keyword fallbackWhen OpenAI embedding generation fails

AI agents should adapt their presentation based on this status. When AI_FILTERED, you can confidently tell users "I found Nike shoes under $100 for you." When VECTOR_ONLY, present results as "Here are the most relevant matches." When FALLBACK, consider informing users that results may be less precise than usual.


How the Agentic Pipeline Shapes Responses

When you use the /hard-filtering/search endpoint, your query passes through a 4-stage LangGraph state machine. Understanding this pipeline helps you interpret responses correctly:

Stage 1 — Parallel Filter Extraction: Three GPT-4o-mini calls run in parallel to extract categories, brands, and price constraints from the query. This produces the appliedFilters object in the response meta.

Stage 2 — Brand Validation: Extracted brands are validated against the actual catalog using Qdrant facet search (getFacetValues("brand")). If a requested brand doesn't exist in the store, the response returns with zero items immediately — this is intentional, not an error. The appliedFilters will still show the extracted brand so your agent can explain: "This store doesn't carry Nike products."

Stage 3 — Hybrid Search: The query is embedded (1536-dimensional vector via OpenAI text-embedding-3-small) and searched in Qdrant using both vector similarity and payload filtering (brand, category, price range). This is why scores from the agentic endpoint tend to be higher — results are pre-filtered for relevance.

Stage 4 — LLM Verification: Results pass through GPT-4o-mini for semantic verification against the original intent. Products that don't truly match are removed. The top 5 verified products are returned.

This means agentic search responses are typically smaller (5 items by default), more precise, and carry richer filter metadata than simple vector search.


Normalized Product Schema: Order from Chaos

The Tower of Babel Problem

Every e-commerce platform speaks its own dialect. Shopify calls it title, WooCommerce calls it name. One platform uses inventory_quantity, another uses stock_level. Without normalization, AI agents would need to understand every platform's peculiar vocabulary.

MCIP solves this through its adapter pattern. Every product, regardless of source platform, is transformed into the UnifiedProduct schema — validated at ingestion time with Zod schemas to guarantee consistency.

The UnifiedProduct Schema

Every product in MCIP responses contains these fields:

interface UnifiedProduct {
  // Identity
  externalId: string;          // Unique ID from source (e.g., "prod_123")
  url: string;                 // Product page URL

  // Core Content
  title: string;               // Product name (min 3 chars)
  description: string;         // Plain text, no HTML

  // Categorization (optional but recommended)
  brand?: string;              // "Nike", "Apple", etc.
  category?: string;           // "Laptops", "Shoes", etc.

  // Commercial
  price: {
    amount: number;            // e.g., 99.99
    currency: "UAH" | "USD" | "EUR";
  };

  // Visuals
  mainImage: string;           // Primary image URL

  // Details
  attributes: Array<{
    name: string;              // e.g., "Color"
    value: string | number | boolean;  // e.g., "Red"
  }>;

  variants: Array<{
    sku: string;               // Stock keeping unit
    title: string;             // e.g., "Red / XL"
    price: { amount: number; currency: string } | null;
    available: boolean;
  }>;

  // AI Metadata
  keywords: string[];          // 5-10 SEO keywords
}

Search Results Extension

When returned from search, products include an additional score field:

interface SearchResult extends UnifiedProduct {
  score: number;  // Semantic similarity score (0-1)
}

Score Interpretation: The Relevance Revolution

Beyond Binary Matching

Traditional search returns binary results — a product either matches or it doesn't. MCIP's semantic search (powered by Qdrant vector database with OpenAI text-embedding-3-small embeddings at 1536 dimensions) returns graduated relevance scores from 0 to 1.

In the agentic search mode, scores tend to be higher overall because the LangGraph pipeline pre-filters results through brand validation and payload filtering before vector similarity is applied. In simple vector search, you'll see a wider score distribution.

Score Ranges and Meaning

RangeQualityDescription
0.90–1.00Perfect MatchExactly what the user described
0.70–0.89Strong MatchHighly relevant, minor differences
0.50–0.69Relevant AlternativeRelated but not exactly what was asked
0.30–0.49Loose ConnectionTangentially related
Below 0.30NoiseNot really relevant

Practical Example

Search: "affordable gaming laptop for students"

  • 0.92: Budget gaming laptop, 15.6", student discount available
  • 0.78: Gaming laptop under $1000 (no "student" mention)
  • 0.65: High-performance notebook (not marketed for gaming)
  • 0.45: Gaming mouse (accessory, not laptop)
  • 0.22: Office printer (no relation)

Score Differences Between Search Modes

The same query can produce different score distributions depending on the endpoint:

  • Simple search (/search): Broader results, wider score spread, useful for exploration
  • Agentic search (/hard-filtering/search): Tighter results, higher average scores, pre-validated against extracted filters

Error Response Handling

Standard Error Format

MCIP uses NestJS standard exception handling. All errors return:

{
  "statusCode": 400,
  "message": "Error description here",
  "error": "Bad Request"
}

HTTP Status Codes

CodeMeaningWhen It Happens
200SuccessRequest completed successfully
400Bad RequestInvalid input, missing parameters
401UnauthorizedInvalid or missing admin API key
404Not FoundResource doesn't exist
500Internal Server ErrorServer-side error (Qdrant, OpenAI, etc.)

Graceful Degradation Strategies

MCIP doesn't just fail — it degrades gracefully with specific fallback strategies per error type:

Error TypeWhat HappensUser ImpactRecovery
Embedding API FailureFalls back to keyword searchDegraded relevance, filteringStatus: "FALLBACK"Automatic
Vector DB TimeoutReturns cached resultsPossibly stale dataAutomatic
Platform API ErrorReturns partial resultsReduced inventoryManual intervention
Session TimeoutCreates new sessionLost cart stateUser action required
Rate LimitingQueues and retriesDelayed responseAutomatic with backoff

For AI agents, the filteringStatus field is your primary signal. When you see FALLBACK, consider adding a disclaimer to your response: "I found some results but my search precision is temporarily reduced."

Example Error Scenarios

Missing Search Query:

curl "http://localhost:8080/search"

{
  "statusCode": 400,
  "message": "Query parameter 'q' is required",
  "error": "Bad Request"
}

OpenAI API Failure (Graceful Degradation):

Instead of returning a 500 error, MCIP falls back to keyword-based search:

OpenAI API Failure (Graceful Degradation):

Instead of returning a 500 error, MCIP falls back to keyword-based search:

{
  "meta": {
    "count": 3,
    "take": 10,
    "skip": 0,
    "q": "gaming laptop",
    "filteringStatus": "FALLBACK",
    "appliedFilters": null
  },
  "items": [...]
}

Brand Not Found (Agentic Search):

When the LangGraph pipeline extracts a brand that doesn't exist in the catalog:

{
  "meta": {
    "count": 0,
    "take": 10,
    "skip": 0,
    "q": "gucci bags",
    "filteringStatus": "AI_FILTERED",
    "appliedFilters": {
      "brand": ["Gucci"]
    }
  },
  "items": []
}

This is intentional — the brand was validated against the catalog via facet search and wasn't found. Your agent can respond: "This store doesn't carry Gucci products. Would you like me to search for similar luxury bags?"


Handling Responses in Your Code

Basic Response Handling (Both Search Modes)

async function searchProducts(query, useAgentic = false) {
  const endpoint = useAgentic ? 'hard-filtering/search' : 'search';
  const url = `http://localhost:8080/${endpoint}?q=${encodeURIComponent(query)}`;
  
  const response = await fetch(url);
  const data = await response.json();
  
  if (!response.ok) {
    console.error(`Error ${data.statusCode}: ${data.message}`);
    return { items: [], meta: null, error: data };
  }
  
  // Check filtering status for response strategy
  switch (data.meta.filteringStatus) {
    case 'AI_FILTERED':
      console.log('Agentic search with filters:', data.meta.appliedFilters);
      break;
    case 'VECTOR_ONLY':
      console.log('Pure vector similarity results');
      break;
    case 'FALLBACK':
      console.warn('Degraded mode — results may be less precise');
      break;
  }
  
  return { items: data.items, meta: data.meta, error: null };
}

Handling Applied Filters

function describeFilters(meta) {
  if (meta.filteringStatus !== 'AI_FILTERED' || !meta.appliedFilters) {
    return 'Showing results based on semantic relevance.';
  }
  
  const parts = [];
  const filters = meta.appliedFilters;
  
  if (filters.brand?.length) {
    parts.push(`brand: ${filters.brand.join(', ')}`);
  }
  
  if (filters.priceRange) {
    const { min, max, currency } = filters.priceRange;
    if (min && max) parts.push(`price: ${min}–${max} ${currency}`);
    else if (max) parts.push(`under ${max} ${currency}`);
    else if (min) parts.push(`from ${min} ${currency}`);
  }
  
  return parts.length
    ? `Filtered by ${parts.join(', ')}.`
    : 'Showing results based on semantic relevance.';
}

Choosing the Right Search Mode

function chooseSearchMode(query) {
  // Use agentic search when the query contains implicit filters
  const hasFilters = /under|below|above|between|not|except|but not/i.test(query);
  const hasBrand = /nike|adidas|apple|samsung|sony/i.test(query);
  const hasPrice = /\$|\d+\s*(uah|usd|eur)/i.test(query);
  
  // Agentic search for complex queries, simple for basic ones
  return (hasFilters || hasBrand || hasPrice)
    ? 'hard-filtering/search'
    : 'search';
}

Retry with Backoff

async function searchWithRetry(query, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(
        `http://localhost:8080/search?q=${encodeURIComponent(query)}`
      );
      
      if (response.ok) return response.json();
      
      if (response.status >= 500) {
        // Server error — retry with exponential backoff
        await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
        continue;
      }
      
      // Client error — don't retry
      throw new Error(`Client error: ${response.status}`);
    } catch (error) {
      if (i === maxRetries - 1) throw error;
    }
  }
}

Response Handling Best Practices

1. Respect the Filtering Status

The filteringStatus field is your most important signal. Build your agent's conversational response around it:

  • AI_FILTERED: Confidently reference the applied filters — "I found 5 Nike shoes under $100."
  • VECTOR_ONLY: Present as relevance-based — "Here are the most relevant matches for your search."
  • FALLBACK: Add a caveat — "I found some results, but my search precision is temporarily reduced."

2. Handle Empty Agentic Results Gracefully

When the agentic endpoint returns zero items with AI_FILTERED, it usually means the extracted brand or category doesn't exist in the catalog. Don't treat this as an error — offer alternatives.

3. Progressive Enhancement

Start by handling basic responses, then add sophistication:

  1. First, check HTTP status code
  2. Then parse filteringStatus to understand the response quality
  3. Process items with score interpretation
  4. Display appliedFilters for transparency

4. Defensive Parsing

Always assume fields might be missing:

const brand = item.brand || 'Unknown Brand';
const price = item.price?.amount ?? 'Price not available';
const score = item.score ?? 0;
const filters = meta.appliedFilters ?? {};

5. Score Thresholds by Search Mode

Establish different thresholds depending on the search mode:

const THRESHOLDS = {
  agentic: {  // Pre-filtered, expect higher scores
    EXCELLENT: 0.85,
    GOOD: 0.70,
    MINIMUM: 0.50
  },
  simple: {   // Broader results, wider spread
    EXCELLENT: 0.80,
    GOOD: 0.60,
    MINIMUM: 0.40
  }
};

function categorizeResults(items, mode = 'simple') {
  const t = THRESHOLDS[mode];
  return {
    excellent: items.filter(i => i.score >= t.EXCELLENT),
    good: items.filter(i => i.score >= t.GOOD && i.score < t.EXCELLENT),
    other: items.filter(i => i.score >= t.MINIMUM && i.score < t.GOOD)
  };
}