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.
- iflow_mcp_particular_audience_mcp_search_server-0.1.0.dist-info/METADATA +17 -0
- iflow_mcp_particular_audience_mcp_search_server-0.1.0.dist-info/RECORD +6 -0
- iflow_mcp_particular_audience_mcp_search_server-0.1.0.dist-info/WHEEL +5 -0
- iflow_mcp_particular_audience_mcp_search_server-0.1.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_particular_audience_mcp_search_server-0.1.0.dist-info/top_level.txt +1 -0
- mcp_search_server.py +788 -0
|
@@ -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,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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcp_search_server
|
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()
|