iflow-mcp_particular-audience-mcp-search-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: iflow-mcp_particular-audience-mcp-search-server
3
+ Version: 0.1.0
4
+ Summary: MCP Search Server
5
+ Requires-Python: >=3.11
6
+ License-File: LICENSE
7
+ Requires-Dist: fastapi>=0.95.0
8
+ Requires-Dist: uvicorn>=0.21.0
9
+ Requires-Dist: requests>=2.28.0
10
+ Requires-Dist: pydantic>=1.10.7
11
+ Requires-Dist: mcp>=0.5.0
12
+ Requires-Dist: mcp-use>=1.2.0
13
+ Requires-Dist: fastembed>=0.4.2
14
+ Requires-Dist: python-dotenv>=1.0.0
15
+ Requires-Dist: gql>=3.0.0
16
+ Requires-Dist: requests-toolbelt>=1.0.0
17
+ Dynamic: license-file
@@ -0,0 +1,6 @@
1
+ mcp_search_server.py,sha256=Xgoems47suXCp6WzterNvVZ-GQMmIYkK9kS0Ryt8AP0,28109
2
+ iflow_mcp_particular_audience_mcp_search_server-0.1.0.dist-info/licenses/LICENSE,sha256=spt_u3CJ0WSeY_zZcmUcH-CA8hE7UjI3e6pPdC9TPhA,1084
3
+ iflow_mcp_particular_audience_mcp_search_server-0.1.0.dist-info/METADATA,sha256=AxTGsbfh4vUMO48_0jVIuIizQg-xNlldmwOl4V29dnc,502
4
+ iflow_mcp_particular_audience_mcp_search_server-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
5
+ iflow_mcp_particular_audience_mcp_search_server-0.1.0.dist-info/top_level.txt,sha256=JP7jjBTUhOC8G3bYqy_FsCS467gLGWFvVW_r1FSyPaQ,18
6
+ iflow_mcp_particular_audience_mcp_search_server-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Particular Audience.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mcp_search_server.py ADDED
@@ -0,0 +1,788 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ MCP Search Server
4
+
5
+ This MCP server provides tools for searching products using the Particular Audience Search API.
6
+ The server exposes three main tools:
7
+ - search: Basic product search with optional filters
8
+ - filtered_search: Search with required filters
9
+ - sorted_search: Search with custom sorting options
10
+
11
+ Usage:
12
+ python mcp_search_server.py
13
+
14
+ Configuration:
15
+ Set the following environment variables in a .env file:
16
+ - AUTH_ENDPOINT: Authentication endpoint
17
+ - SEARCH_API_ENDPOINT: Search API endpoint
18
+ - CLIENT_ID: Client ID for authentication (required)
19
+ - CLIENT_SHORTCODE: Client shortcode (required)
20
+ - CLIENT_SECRET: Client secret for authentication (required)
21
+ - HOST: Server host (default: 0.0.0.0)
22
+ - PORT: Server port (default: 3000)
23
+ - MESSAGE_PATH: Path for MCP messages (default: /mcp/messages/)
24
+ """
25
+
26
+ import os
27
+ import time
28
+ import logging
29
+ import json
30
+ from typing import Dict, Any, List, Optional
31
+ import traceback
32
+ from contextlib import asynccontextmanager
33
+
34
+ import requests
35
+ from fastapi import HTTPException
36
+ from pydantic import BaseModel, Field
37
+ from mcp.server.fastmcp import FastMCP, Context
38
+
39
+ # Import dotenv for environment variable loading
40
+ from dotenv import load_dotenv
41
+
42
+ # Load environment variables from .env file
43
+ load_dotenv()
44
+
45
+ # Configure logging
46
+ logging.basicConfig(
47
+ level=logging.INFO,
48
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
49
+ )
50
+ logger = logging.getLogger("mcp_search_server")
51
+
52
+ # Service configuration from environment variables
53
+ AUTH_ENDPOINT = os.environ.get("AUTH_ENDPOINT")
54
+ SEARCH_API_ENDPOINT = os.environ.get("SEARCH_API_ENDPOINT")
55
+ CLIENT_ID = os.environ.get("CLIENT_ID")
56
+ CLIENT_SHORTCODE = os.environ.get("CLIENT_SHORTCODE")
57
+ CLIENT_SECRET = os.environ.get("CLIENT_SECRET")
58
+
59
+ # Server configuration from environment variables
60
+ HOST = os.environ.get("HOST", "0.0.0.0")
61
+ PORT = int(os.environ.get("PORT", 3000))
62
+ MESSAGE_PATH = os.environ.get("MESSAGE_PATH", "/mcp/messages/")
63
+
64
+ # Token cache
65
+ token_cache = {}
66
+
67
+ # Validate required environment variables
68
+ if not CLIENT_ID:
69
+ raise ValueError("CLIENT_ID environment variable is required")
70
+ if not CLIENT_SHORTCODE:
71
+ raise ValueError("CLIENT_SHORTCODE environment variable is required")
72
+ if not CLIENT_SECRET:
73
+ raise ValueError("CLIENT_SECRET environment variable is required")
74
+
75
+ # Core models
76
+ class FilterSpec(BaseModel):
77
+ field: str
78
+ value: Any
79
+ operator: str = "eq"
80
+
81
+ class SortSpec(BaseModel):
82
+ field: str
83
+ order: str = "desc"
84
+ type: str = "number"
85
+
86
+ # Search response models
87
+ class PaginationInfo(BaseModel):
88
+ current_page: int
89
+ total_pages: int
90
+ total_results: int
91
+ page_size: int
92
+
93
+ class SearchResponse(BaseModel):
94
+ results: List[Dict[str, Any]]
95
+ pagination: PaginationInfo
96
+ aggregations: Optional[Dict[str, Any]] = None
97
+ suggestions: Optional[List[str]] = None
98
+ redirect_url: Optional[Dict[str, Any]] = None
99
+ execution_time_ms: Optional[int] = None
100
+
101
+ # Auth function
102
+ async def get_auth_token(client_id: str = None) -> str:
103
+ """
104
+ Get authentication token from the auth service.
105
+
106
+ Parameters:
107
+ - client_id: Optional client ID, defaults to CLIENT_ID from environment
108
+
109
+ Returns:
110
+ - Access token string
111
+
112
+ Raises:
113
+ - HTTPException: If authentication fails
114
+ """
115
+ client_id = client_id or CLIENT_ID
116
+ client_secret = CLIENT_SECRET
117
+
118
+ if not client_id:
119
+ raise HTTPException(status_code=400, detail="Client ID is required")
120
+
121
+ current_time = time.time()
122
+
123
+ # Use cached token if valid
124
+ if client_id in token_cache and token_cache[client_id].get("expires_at", 0) > current_time:
125
+ return token_cache[client_id]["access_token"]
126
+
127
+ # Fetch new token
128
+ logger.info(f"Fetching new auth token for client {client_id}")
129
+ try:
130
+ form_data = {
131
+ 'client_id': client_id,
132
+ 'grant_type': 'client_credentials'
133
+ }
134
+
135
+ if client_secret:
136
+ form_data['client_secret'] = client_secret
137
+
138
+ response = requests.post(
139
+ AUTH_ENDPOINT,
140
+ data=form_data,
141
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
142
+ timeout=10
143
+ )
144
+ response.raise_for_status()
145
+
146
+ data = response.json()
147
+ access_token = data.get("access_token")
148
+ expires_in = data.get("expires_in", 3600)
149
+ token_type = data.get("token_type", "Bearer")
150
+
151
+ # Cache token with 5-min safety margin
152
+ token_cache[client_id] = {
153
+ "access_token": access_token,
154
+ "token_type": token_type,
155
+ "expires_at": current_time + expires_in - 300
156
+ }
157
+
158
+ return access_token
159
+
160
+ except Exception as e:
161
+ logger.error(f"Auth token request failed: {e}")
162
+ raise HTTPException(status_code=500, detail=f"Failed to get auth token: {e}")
163
+
164
+ # Search API function
165
+ async def perform_search(
166
+ query: str,
167
+ start: int = 0,
168
+ size: int = 20,
169
+ filters: List[FilterSpec] = None,
170
+ sort_fields: List[SortSpec] = None
171
+ ) -> SearchResponse:
172
+ """
173
+ Perform product search using the Search API.
174
+
175
+ Parameters:
176
+ - query: Search query string
177
+ - start: Starting position for pagination (0-based)
178
+ - size: Number of results to return per page
179
+ - filters: Optional list of FilterSpec objects for narrowing results
180
+ - sort_fields: Optional list of SortSpec objects for custom sorting
181
+
182
+ Returns:
183
+ - SearchResponse object containing search results and metadata
184
+
185
+ Raises:
186
+ - HTTPException: If search operation fails
187
+ """
188
+ logger.info(f"Searching for '{query}' on website {CLIENT_ID}")
189
+
190
+ # Generate scope dictionary from filters
191
+ scope = {}
192
+ if filters:
193
+ for filter_spec in filters:
194
+ field = filter_spec.field
195
+ value = filter_spec.value
196
+ operator = filter_spec.operator
197
+ # Handle different operator types
198
+ if operator == "eq":
199
+ # Direct mapping for equality filters
200
+ scope[field] = value
201
+ elif operator == "range":
202
+ # For range filters, create or update min/max values
203
+ if field not in scope:
204
+ scope[field] = {}
205
+ # Handle range as dictionary with min/max keys
206
+ if isinstance(value, dict):
207
+ if "min" in value:
208
+ scope[field]["min"] = value["min"]
209
+ if "max" in value:
210
+ scope[field]["max"] = value["max"]
211
+ # Handle individual gte/lte operators
212
+ elif operator == "gte":
213
+ scope[field]["min"] = value
214
+ elif operator == "lte":
215
+ scope[field]["max"] = value
216
+ logger.debug(f"Generated scope from filters: {scope}")
217
+
218
+ if sort_fields:
219
+ sort_fields_list = [{sort_field.field: {"order": sort_field.order, "type": sort_field.type}} for sort_field in sort_fields]
220
+ logger.info(f"Sort fields: {sort_fields_list}")
221
+ else:
222
+ sort_fields_list = None
223
+
224
+ # Build search request based on sample-body.json format
225
+ search_request = {
226
+ "q": query,
227
+ "website_id": str(CLIENT_ID).lower(),
228
+ "client": CLIENT_SHORTCODE,
229
+ "size": size,
230
+ "start": start,
231
+ "scope": scope
232
+ }
233
+ if sort_fields_list:
234
+ search_request["sort_fields"] = sort_fields_list
235
+
236
+ # Call Search API
237
+ start_time = time.time()
238
+ max_retries = 3
239
+ for attempt in range(max_retries):
240
+ try:
241
+ token = await get_auth_token(client_id=CLIENT_ID)
242
+ logger.info(f"Sending search request (attempt {attempt+1}): {json.dumps(search_request)[:500]}...")
243
+ response = requests.post(
244
+ SEARCH_API_ENDPOINT,
245
+ json=search_request,
246
+ headers={
247
+ "Authorization": f"Bearer {token}",
248
+ "Content-Type": "application/json"
249
+ },
250
+ timeout=15
251
+ )
252
+ if response.status_code == 401:
253
+ logger.warning(f"Received 401 Unauthorized from search API (attempt {attempt+1}). Invalidating token cache and retrying...")
254
+ # Invalidate token cache for this client
255
+ if CLIENT_ID in token_cache:
256
+ del token_cache[CLIENT_ID]
257
+ if attempt < max_retries - 1:
258
+ continue # Try again with a new token
259
+ else:
260
+ response.raise_for_status() # Will raise HTTPError
261
+ response.raise_for_status()
262
+ data = response.json()
263
+ payload = data.get("payload")
264
+ logger.info(f"Search API response: {payload}")
265
+ execution_time_ms = int((time.time() - start_time) * 1000)
266
+ total_results = payload.get("total_results", 0)
267
+ page_size = size
268
+ total_pages = max(1, (total_results + page_size - 1) // page_size)
269
+ current_page = start // page_size + 1
270
+ pagination = PaginationInfo(
271
+ current_page=current_page,
272
+ total_pages=total_pages,
273
+ total_results=total_results,
274
+ page_size=page_size
275
+ )
276
+ return SearchResponse(
277
+ results=payload.get("results", []),
278
+ pagination=pagination,
279
+ aggregations=payload.get("aggregations"),
280
+ suggestions=payload.get("suggestions", {}).get("fuzzy_suggestions", []),
281
+ redirect_url=payload.get("redirect_url"),
282
+ execution_time_ms=execution_time_ms
283
+ )
284
+ except requests.HTTPError as e:
285
+ if hasattr(e.response, 'status_code') and e.response.status_code == 401:
286
+ logger.warning(f"HTTPError 401 on attempt {attempt+1}: {e}")
287
+ if CLIENT_ID in token_cache:
288
+ del token_cache[CLIENT_ID]
289
+ if attempt < max_retries - 1:
290
+ continue
291
+ logger.error(f"Search failed with HTTPError: {e}")
292
+ raise HTTPException(status_code=500, detail=f"Search operation failed: {e}")
293
+ except Exception as e:
294
+ logger.error(f"Search failed: {e}")
295
+ raise HTTPException(status_code=500, detail=f"Search operation failed: {e}")
296
+ # If we exit the loop, all retries failed
297
+ raise HTTPException(status_code=500, detail="Search operation failed after multiple retries due to authentication errors.")
298
+
299
+ # Create FastMCP server with SSE settings
300
+ mcp_server = FastMCP(
301
+ name="MCP Search Server",
302
+ host=HOST,
303
+ port=PORT,
304
+ message_path=MESSAGE_PATH,
305
+ debug=True,
306
+ log_level="INFO"
307
+ )
308
+
309
+ # Register MCP tools
310
+ @mcp_server.tool("search")
311
+ async def search(
312
+ query: str,
313
+ start: int = 0,
314
+ size: int = 20,
315
+ filters: List[FilterSpec] = None,
316
+ ctx: Context = None
317
+ ) -> SearchResponse:
318
+ """
319
+ Execute a product search with optional filters.
320
+
321
+ Parameters:
322
+ - query: The search query text (e.g., "blue shirts", "tires", "dress")
323
+ - start: Starting position for pagination (0-based index)
324
+ - size: Number of results to return per page
325
+ - filters: Optional list of filters to narrow search results
326
+ Format: [{"field": "color", "value": "blue", "operator": "eq"}]
327
+ Supported operators:
328
+ - "eq" (equals): {"field": "color", "value": "blue", "operator": "eq"}
329
+ - "range" (for min/max values): {"field": "price", "value": {"min": 20, "max": 100}, "operator": "range"}
330
+
331
+ Returns:
332
+ - SearchResponse object containing results, pagination info, and optional metadata
333
+
334
+ Authentication is handled automatically using environment variables.
335
+ """
336
+ # Log the search request
337
+ if ctx:
338
+ await ctx.info(f"Searching for '{query}' on website: {CLIENT_ID}")
339
+
340
+ return await perform_search(
341
+ query=query,
342
+ start=start,
343
+ size=size,
344
+ filters=filters
345
+ )
346
+
347
+ # Add specialized search tool
348
+ @mcp_server.tool("filtered_search")
349
+ async def filtered_search(
350
+ query: str,
351
+ filters: List[FilterSpec],
352
+ start: int = 0,
353
+ size: int = 20,
354
+ ctx: Context = None
355
+ ) -> SearchResponse:
356
+ """
357
+ Search products with specific filters.
358
+
359
+ Parameters:
360
+ - query: The search query text (can be empty if using filters only)
361
+ - filters: List of filters to narrow search results
362
+ Format: [{"field": "field_name", "value": value, "operator": "eq"}]
363
+ Examples:
364
+ - Category filter: {"field": "product_category", "value": "Clothing > Shirts", "operator": "eq"}
365
+ - Color filter: {"field": "color", "value": "blue", "operator": "eq"}
366
+ - Price range: {"field": "price", "value": {"min": 20, "max": 100}, "operator": "range"}
367
+ - start: Starting position for pagination (0-based index)
368
+ - size: Number of results to return per page
369
+
370
+ Returns:
371
+ - SearchResponse object containing results, pagination info, and optional metadata
372
+ """
373
+ if ctx:
374
+ await ctx.info(f"Filtered search for '{query}' with filters: {filters}")
375
+
376
+ return await perform_search(
377
+ query=query,
378
+ start=start,
379
+ size=size,
380
+ filters=filters
381
+ )
382
+
383
+ # Add sorted search tool
384
+ @mcp_server.tool("sorted_search")
385
+ async def sorted_search(
386
+ query: str,
387
+ sort: List[SortSpec],
388
+ start: int = 0,
389
+ size: int = 20,
390
+ filters: List[FilterSpec] = None,
391
+ ctx: Context = None
392
+ ) -> SearchResponse:
393
+ """
394
+ Search products with custom sorting options.
395
+
396
+ Parameters:
397
+ - query: The search query text (can be empty if using filters only)
398
+ - sort: List of sort specifications to order results
399
+ Format: [{"field": "field_name", "order": "asc|desc", "type": "number|text|date"}]
400
+ Examples:
401
+ - Price (lowest first): {"field": "price", "order": "asc", "type": "number"}
402
+ - Popularity (highest first): {"field": "popularity", "order": "desc", "type": "number"}
403
+ - Name (alphabetical): {"field": "title", "order": "asc", "type": "text"}
404
+ - start: Starting position for pagination (0-based index)
405
+ - size: Number of results to return per page
406
+ - filters: Optional list of filters to narrow search results
407
+ Same format as the 'filters' parameter in the search and filtered_search tools
408
+
409
+ Returns:
410
+ - SearchResponse object containing results, pagination info, and optional metadata
411
+ """
412
+ if ctx:
413
+ await ctx.info(f"Sorted search for '{query}' with sort: {sort}")
414
+
415
+ return await perform_search(
416
+ query=query,
417
+ start=start,
418
+ size=size,
419
+ filters=filters or [],
420
+ sort_fields=sort
421
+ )
422
+
423
+
424
+ # Register resources
425
+ @mcp_server.resource(
426
+ uri="resource://search/docs",
427
+ name="Search Documentation",
428
+ description="Documentation for the product search API",
429
+ mime_type="application/json",
430
+ )
431
+ async def search_docs_resource() -> str:
432
+ """
433
+ Provides comprehensive documentation about the search tools with parameter descriptions and examples.
434
+ This resource includes details on all available search tools, parameter formats, and usage examples.
435
+
436
+ Returns:
437
+ - JSON string containing search API documentation
438
+ """
439
+
440
+ docs = {
441
+ "name": "search",
442
+ "description": "Execute a product search against the Search API with optional filters",
443
+ "parameters": {
444
+ "query": {
445
+ "type": "string",
446
+ "description": "The search query string (e.g., 'blue shirts', 'tires')"
447
+ },
448
+ "start": {
449
+ "type": "integer",
450
+ "description": "Starting position for results (0-based)",
451
+ "default": 0
452
+ },
453
+ "size": {
454
+ "type": "integer",
455
+ "description": "Number of results per page",
456
+ "default": 20
457
+ },
458
+ "filters": {
459
+ "type": "array",
460
+ "description": "Optional filters to narrow search results",
461
+ "items": {
462
+ "type": "object",
463
+ "properties": {
464
+ "field": {
465
+ "type": "string",
466
+ "description": "Field name to filter on (e.g., 'color', 'price', 'product_category')"
467
+ },
468
+ "value": {
469
+ "type": ["string", "number", "object"],
470
+ "description": "Filter value - can be string/number for equality or object with min/max for ranges"
471
+ },
472
+ "operator": {
473
+ "type": "string",
474
+ "enum": ["eq", "range", "gte", "lte"],
475
+ "description": "Filter operator - 'eq' for equality, 'range' for min/max values",
476
+ "default": "eq"
477
+ }
478
+ },
479
+ "required": ["field", "value"]
480
+ }
481
+ }
482
+ },
483
+ "tools": {
484
+ "search": {
485
+ "description": "Basic search with optional filters",
486
+ "parameters": ["query", "start", "size", "filters"]
487
+ },
488
+ "filtered_search": {
489
+ "description": "Search with mandatory filters",
490
+ "parameters": ["query", "filters", "start", "size"]
491
+ },
492
+ "sorted_search": {
493
+ "description": "Search with custom sorting options",
494
+ "parameters": ["query", "sort", "start", "size", "filters"]
495
+ }
496
+ },
497
+ "sorting": {
498
+ "description": "Sorting specifications for sorted_search tool",
499
+ "sort_spec": {
500
+ "field": {
501
+ "type": "string",
502
+ "description": "Field name to sort by (e.g., 'price', 'popularity')"
503
+ },
504
+ "order": {
505
+ "type": "string",
506
+ "enum": ["asc", "desc"],
507
+ "description": "Sort order - 'asc' for ascending, 'desc' for descending",
508
+ "default": "desc"
509
+ },
510
+ "type": {
511
+ "type": "string",
512
+ "enum": ["number", "text", "date"],
513
+ "description": "Type of the field being sorted",
514
+ "default": "number"
515
+ }
516
+ },
517
+ "sort_examples": [
518
+ {
519
+ "description": "Sort by price (lowest first)",
520
+ "sort": {"field": "price", "order": "asc", "type": "number"}
521
+ },
522
+ {
523
+ "description": "Sort by popularity (highest first)",
524
+ "sort": {"field": "popularity", "order": "desc", "type": "number"}
525
+ },
526
+ {
527
+ "description": "Sort by name (alphabetical)",
528
+ "sort": {"field": "title", "order": "asc", "type": "text"}
529
+ }
530
+ ]
531
+ },
532
+ "internal_configuration": {
533
+ "website_id": CLIENT_ID,
534
+ "client_shortcode": CLIENT_SHORTCODE
535
+ },
536
+ "filter_examples": [
537
+ {
538
+ "description": "Filter by color (equality)",
539
+ "filter": {"field": "color", "value": "blue", "operator": "eq"}
540
+ },
541
+ {
542
+ "description": "Filter by category (equality)",
543
+ "filter": {"field": "product_category", "value": "Clothing > Shirts", "operator": "eq"}
544
+ },
545
+ {
546
+ "description": "Filter by price range",
547
+ "filter": {"field": "price", "value": {"min": 20, "max": 100}, "operator": "range"}
548
+ }
549
+ ],
550
+ "search_examples": [
551
+ {
552
+ "description": "Basic search",
553
+ "params": {
554
+ "query": "dress",
555
+ "start": 0,
556
+ "size": 20
557
+ }
558
+ },
559
+ {
560
+ "description": "Search for tyres with pagination",
561
+ "params": {
562
+ "query": "tyres tires wheels",
563
+ "start": 10,
564
+ "size": 15
565
+ }
566
+ },
567
+ {
568
+ "description": "Search with color filter",
569
+ "params": {
570
+ "query": "shirts",
571
+ "filters": [
572
+ {"field": "color", "value": "blue", "operator": "eq"}
573
+ ]
574
+ }
575
+ },
576
+ {
577
+ "description": "Category search with price range",
578
+ "params": {
579
+ "query": "",
580
+ "filters": [
581
+ {"field": "product_category", "value": "Clothing > Shirts", "operator": "eq"},
582
+ {"field": "price", "value": {"min": 20, "max": 100}, "operator": "range"}
583
+ ]
584
+ }
585
+ }
586
+ ]
587
+ }
588
+ return json.dumps(docs, indent=2)
589
+
590
+ @mcp_server.resource(
591
+ uri="resource://search/response-schema",
592
+ name="Search Response Schema",
593
+ description="Schema describing the format of search response",
594
+ mime_type="application/json",
595
+ )
596
+ async def search_response_schema_resource() -> str:
597
+ """
598
+ Provides the JSON schema for search response objects.
599
+ This resource documents the structure of responses returned by all search tools.
600
+
601
+ Returns:
602
+ - JSON string containing the search response schema
603
+ """
604
+
605
+ schema = {
606
+ "type": "object",
607
+ "properties": {
608
+ "results": {
609
+ "type": "array",
610
+ "description": "List of product results",
611
+ "items": {
612
+ "type": "object",
613
+ "properties": {
614
+ "description": "Product properties vary based on search results with no enforced schema"
615
+ }
616
+ }
617
+ },
618
+ "pagination": {
619
+ "type": "object",
620
+ "properties": {
621
+ "current_page": {"type": "integer", "description": "Current page number"},
622
+ "total_pages": {"type": "integer", "description": "Total number of pages"},
623
+ "total_results": {"type": "integer", "description": "Total number of results"},
624
+ "page_size": {"type": "integer", "description": "Number of results per page"}
625
+ }
626
+ },
627
+ "aggregations": {
628
+ "type": "object",
629
+ "description": "Aggregation information for filtering"
630
+ },
631
+ "suggestions": {
632
+ "type": "array",
633
+ "description": "Search suggestions if query has few results",
634
+ "items": {
635
+ "type": "string"
636
+ }
637
+ },
638
+ "redirect_url": {
639
+ "type": "object",
640
+ "description": "URL information for redirects on specific queries"
641
+ },
642
+ "execution_time_ms": {
643
+ "type": "integer",
644
+ "description": "Time taken to execute the search in milliseconds"
645
+ }
646
+ }
647
+ }
648
+
649
+ return json.dumps(schema, indent=2)
650
+
651
+ @mcp_server.resource(
652
+ uri="resource://search/examples",
653
+ name="Search Examples",
654
+ description="Examples of different search patterns",
655
+ mime_type="application/json",
656
+ )
657
+ async def search_examples_resource() -> str:
658
+ """
659
+ Provides ready-to-use example search requests for common use cases.
660
+ This resource includes complete examples for all search tools with various parameter combinations.
661
+
662
+ Returns:
663
+ - JSON string containing practical search examples
664
+ """
665
+ examples = {
666
+ "basic_search": {
667
+ "description": "Basic product search",
668
+ "tool": "search",
669
+ "parameters": {
670
+ "query": "blue shirt",
671
+ "start": 0,
672
+ "size": 20
673
+ }
674
+ },
675
+ "filtered_category_search": {
676
+ "description": "Search for products in a specific category",
677
+ "tool": "filtered_search",
678
+ "parameters": {
679
+ "query": "",
680
+ "filters": [
681
+ {"field": "product_category", "value": "Clothing > Shirts", "operator": "eq"}
682
+ ],
683
+ "start": 0,
684
+ "size": 20
685
+ }
686
+ },
687
+ "price_range_search": {
688
+ "description": "Search for products within a price range",
689
+ "tool": "filtered_search",
690
+ "parameters": {
691
+ "query": "dress",
692
+ "filters": [
693
+ {"field": "price", "value": {"min": 29.99, "max": 99.99}, "operator": "range"}
694
+ ],
695
+ "start": 0,
696
+ "size": 20
697
+ }
698
+ },
699
+ "tyres_search": {
700
+ "description": "Search for tyres/tires with multiple filters",
701
+ "tool": "filtered_search",
702
+ "parameters": {
703
+ "query": "tyres tires wheels",
704
+ "filters": [
705
+ {"field": "product_category", "value": "Automotive > Tires", "operator": "eq"},
706
+ {"field": "size", "value": "205/55R16", "operator": "eq"}
707
+ ],
708
+ "start": 0,
709
+ "size": 15
710
+ }
711
+ },
712
+ "sorted_price_search": {
713
+ "description": "Search for products sorted by price (lowest first)",
714
+ "tool": "sorted_search",
715
+ "parameters": {
716
+ "query": "laptop",
717
+ "sort": [
718
+ {"field": "price", "order": "asc", "type": "number"}
719
+ ],
720
+ "start": 0,
721
+ "size": 20
722
+ }
723
+ },
724
+ "sorted_filtered_search": {
725
+ "description": "Search with both filters and custom sorting",
726
+ "tool": "sorted_search",
727
+ "parameters": {
728
+ "query": "sneakers",
729
+ "sort": [
730
+ {"field": "popularity", "order": "desc", "type": "number"}
731
+ ],
732
+ "filters": [
733
+ {"field": "brand", "value": "Nike", "operator": "eq"},
734
+ {"field": "price", "value": {"min": 50, "max": 150}, "operator": "range"}
735
+ ],
736
+ "start": 0,
737
+ "size": 20
738
+ }
739
+ },
740
+ "multiple_sort_fields": {
741
+ "description": "Search with multiple sort fields (primary and secondary ordering)",
742
+ "tool": "sorted_search",
743
+ "parameters": {
744
+ "query": "shoes",
745
+ "sort": [
746
+ {"field": "price", "order": "asc", "type": "number"},
747
+ {"field": "rating", "order": "desc", "type": "number"}
748
+ ],
749
+ "start": 0,
750
+ "size": 20
751
+ }
752
+ }
753
+ }
754
+ return json.dumps(examples, indent=2)
755
+
756
+ # Add a simple lifespan function to handle server startup
757
+ @asynccontextmanager
758
+ async def server_lifespan(app: FastMCP):
759
+ """
760
+ Lifespan handler for the FastMCP server to manage startup and shutdown operations.
761
+
762
+ On startup:
763
+ - Verifies the authentication service is working
764
+ - Pre-fetches a token for faster initial requests
765
+
766
+ Parameters:
767
+ - app: The FastMCP server instance
768
+ """
769
+ logger.info("Server starting up")
770
+
771
+ # Pre-fetch a token to verify auth service is working
772
+ try:
773
+ await get_auth_token()
774
+ logger.info("Auth service verified successfully")
775
+ except Exception as e:
776
+ logger.error(f"Auth service check failed: {e}")
777
+
778
+ yield # Server runs
779
+
780
+ logger.info("Server shutting down")
781
+
782
+ # Assign lifespan
783
+ mcp_server.settings.lifespan = server_lifespan
784
+
785
+ # Main entry point: run via SSE
786
+ if __name__ == "__main__":
787
+ logger.info(f"Starting MCP Search Server on http://{HOST}:{PORT}")
788
+ mcp_server.run()