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.
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.
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.
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
}
]
}| Field | Type | Description |
|---|---|---|
count | number | Number of items in current response |
take | number | Requested number of results |
skip | number | Pagination offset |
q | string | Original search query |
filteringStatus | string | VECTOR_ONLY, AI_FILTERED, or FALLBACK |
appliedFilters | object | Extracted and applied filters (present when AI_FILTERED) |
This is one of the most important fields for AI agents to interpret correctly:
| Status | Description | When It Appears |
|---|---|---|
VECTOR_ONLY | Pure vector similarity search, no filter extraction | Simple search endpoint or when no filters detected |
AI_FILTERED | LangGraph workflow extracted and applied filters from natural language | Agentic search with successful filter extraction |
FALLBACK | Degraded mode — embedding API failure triggered keyword fallback | When 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.
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.
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.
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
}When returned from search, products include an additional score field:
interface SearchResult extends UnifiedProduct {
score: number; // Semantic similarity score (0-1)
}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.
| Range | Quality | Description |
|---|---|---|
| 0.90–1.00 | Perfect Match | Exactly what the user described |
| 0.70–0.89 | Strong Match | Highly relevant, minor differences |
| 0.50–0.69 | Relevant Alternative | Related but not exactly what was asked |
| 0.30–0.49 | Loose Connection | Tangentially related |
| Below 0.30 | Noise | Not really relevant |
Search: "affordable gaming laptop for students"
The same query can produce different score distributions depending on the endpoint:
/search): Broader results, wider score spread, useful for exploration/hard-filtering/search): Tighter results, higher average scores, pre-validated against extracted filtersMCIP uses NestJS standard exception handling. All errors return:
{
"statusCode": 400,
"message": "Error description here",
"error": "Bad Request"
}| Code | Meaning | When It Happens |
|---|---|---|
| 200 | Success | Request completed successfully |
| 400 | Bad Request | Invalid input, missing parameters |
| 401 | Unauthorized | Invalid or missing admin API key |
| 404 | Not Found | Resource doesn't exist |
| 500 | Internal Server Error | Server-side error (Qdrant, OpenAI, etc.) |
MCIP doesn't just fail — it degrades gracefully with specific fallback strategies per error type:
| Error Type | What Happens | User Impact | Recovery |
|---|---|---|---|
| Embedding API Failure | Falls back to keyword search | Degraded relevance, filteringStatus: "FALLBACK" | Automatic |
| Vector DB Timeout | Returns cached results | Possibly stale data | Automatic |
| Platform API Error | Returns partial results | Reduced inventory | Manual intervention |
| Session Timeout | Creates new session | Lost cart state | User action required |
| Rate Limiting | Queues and retries | Delayed response | Automatic 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."
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?"
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 };
}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.';
}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';
}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;
}
}
}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."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.
Start by handling basic responses, then add sophistication:
filteringStatus to understand the response qualityitems with score interpretationappliedFilters for transparencyAlways 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 ?? {};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)
};
}