kylas-crm-mcp 1.0.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,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: kylas-crm-mcp
3
+ Version: 1.0.0
4
+ Summary: MCP server for Kylas CRM lead operations: create leads, search leads, lookup users/products/pipelines.
5
+ Author-email: akshaykylas94 <akshay.gunshetti@kylas.io>
6
+ License: MIT
7
+ Keywords: mcp,kylas,crm,model-context-protocol,leads
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: fastmcp>=2.0.0
19
+ Requires-Dist: httpx>=0.27.0
20
+ Requires-Dist: pydantic>=2.0.0
21
+ Requires-Dist: python-dotenv>=1.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
24
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
25
+
26
+ # Kylas CRM MCP Server (Lead Only)
27
+
28
+ Model Context Protocol server for **Kylas CRM** lead operations. Use it from Cursor, Claude Desktop, or any MCP client to create leads, search and filter leads, and look up users, products, and pipelines.
29
+
30
+ <!-- mcp-name: io.github.akshaykylas94/kylas-crm -->
31
+
32
+ ## Features
33
+
34
+ - **get_lead_field_instructions** – Get lead schema (standard + custom fields, picklist IDs)
35
+ - **create_lead** – Create a lead with dynamic fields from user context
36
+ - **search_leads** – Search/filter leads by multiple criteria
37
+ - **lookup_users** – Resolve user names to IDs (for owner, created by, etc.)
38
+ - **lookup_products** – Resolve product names to IDs
39
+ - **lookup_pipelines** / **get_pipeline_stages** – Resolve pipeline and stage for open/closed/won leads
40
+ - **search_idle_leads** – Find leads with no activity for N days
41
+
42
+ ## Requirements
43
+
44
+ - Python 3.10+
45
+ - [Kylas](https://kylas.io) account and API key
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install -e .
51
+ # or from PyPI (after publish): pip install kylas-crm-mcp
52
+ ```
53
+
54
+ ## Configuration
55
+
56
+ Set environment variables (or use a `.env` file):
57
+
58
+ | Variable | Required | Description |
59
+ |------------------|----------|--------------------------------------|
60
+ | `KYLAS_API_KEY` | Yes | Your Kylas API key |
61
+ | `KYLAS_BASE_URL` | No | API base URL (default: https://api.kylas.io/v1) |
62
+
63
+ ## Running the server
64
+
65
+ The server uses **stdio** transport (default for MCP). Run:
66
+
67
+ ```bash
68
+ python -m kylas_crm_mcp
69
+ # or: python main.py (when developing from repo root)
70
+ ```
71
+
72
+ MCP clients (e.g. Cursor) typically start this process and communicate via stdin/stdout.
73
+
74
+ ## Docker
75
+
76
+ ```bash
77
+ docker build -t kylas-crm-mcp .
78
+ docker run -e KYLAS_API_KEY=your_key -i kylas-crm-mcp
79
+ ```
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ pip install -e ".[dev]"
85
+ pytest
86
+ ```
87
+
88
+ ## License
89
+
90
+ See repository for license information.
@@ -0,0 +1,6 @@
1
+ main.py,sha256=53u5nXY1jZTKOMEUn7i2_GTaQWBwjGHeeIIwYy-1rfA,49437
2
+ kylas_crm_mcp-1.0.0.dist-info/METADATA,sha256=U_A3eHOqkOubecLwZmVydl7lAIOGtM2PZ2ObuoC2PeE,2884
3
+ kylas_crm_mcp-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
4
+ kylas_crm_mcp-1.0.0.dist-info/entry_points.txt,sha256=hsFTTAci2w5j0OvnQh13Iy9e-C2oCAsgytfHTEkyUZM,43
5
+ kylas_crm_mcp-1.0.0.dist-info/top_level.txt,sha256=ZAMgPdWghn6xTRBO6Kc3ML1y3ZrZLnjZlqbboKXc_AE,5
6
+ kylas_crm_mcp-1.0.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,2 @@
1
+ [console_scripts]
2
+ kylas-crm-mcp = main:run
@@ -0,0 +1 @@
1
+ main
main.py ADDED
@@ -0,0 +1,943 @@
1
+ """
2
+ Kylas CRM MCP Server - Lead Only
3
+
4
+ Model Context Protocol server for Kylas CRM lead operations.
5
+ - Tool 1: get_lead_field_instructions (call FIRST to get schema)
6
+ - Tool 2: create_lead (single tool with dynamic field_values from user context)
7
+ - Tool 3: search_leads (filter leads by multiple criteria; only filterable fields)
8
+ - Tool 4: lookup_users (resolve user name to ID for createdBy, updatedBy, ownerId, etc.)
9
+ - Tool 5: lookup_products (resolve product name to ID for filtering leads by product)
10
+ - Tool 6: lookup_pipelines / get_pipeline_stages (resolve pipeline and stages for open/closed/won lead filters)
11
+ - Tool 7: search_idle_leads (leads with no activity for N days; uses updatedAt and latestActivityCreatedAt)
12
+ """
13
+
14
+ import os
15
+ import logging
16
+ from datetime import datetime, timedelta
17
+ from typing import Dict, Any, Optional, List, Tuple
18
+ from zoneinfo import ZoneInfo
19
+
20
+ import httpx
21
+ from fastmcp import FastMCP
22
+ from dotenv import load_dotenv
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Configuration & Logging
26
+ # ---------------------------------------------------------------------------
27
+
28
+ load_dotenv()
29
+
30
+ logging.basicConfig(
31
+ level=logging.INFO,
32
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
33
+ )
34
+ logger = logging.getLogger("kylas-mcp")
35
+
36
+ BASE_URL = os.getenv("KYLAS_BASE_URL", "https://api.kylas.io/v1")
37
+ API_KEY = os.getenv("KYLAS_API_KEY")
38
+
39
+
40
+ def _get_default_timezone() -> str:
41
+ """
42
+ Default timezone for date/datetime filters. Used when the user doesn't pass timeZone in a filter.
43
+ Fixed to Asia/Calcutta for now.
44
+ """
45
+ return "Asia/Calcutta"
46
+
47
+
48
+ DEFAULT_TIMEZONE = _get_default_timezone()
49
+
50
+
51
+ def _threshold_iso_days_ago(days: int, time_zone: str) -> str:
52
+ """Return (now - days) in the given timezone as ISO string (UTC with Z)."""
53
+ try:
54
+ tz = ZoneInfo(time_zone)
55
+ except Exception:
56
+ tz = ZoneInfo("UTC")
57
+ now = datetime.now(tz)
58
+ threshold = now - timedelta(days=days)
59
+ return threshold.astimezone(ZoneInfo("UTC")).strftime("%Y-%m-%dT%H:%M:%S.000Z")
60
+
61
+
62
+ if not API_KEY:
63
+ logger.warning("KYLAS_API_KEY environment variable not set. API calls will fail.")
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # System Instructions: ALWAYS get fields first, then create from user context
67
+ # ---------------------------------------------------------------------------
68
+
69
+ SYSTEM_INSTRUCTIONS = """
70
+ # Kylas CRM MCP Server - Lead Only
71
+
72
+ ## CRITICAL: Workflow
73
+
74
+ ### Step 1: ALWAYS call `get_lead_field_instructions` FIRST
75
+ Before creating a lead, you MUST call `get_lead_field_instructions` to get:
76
+ - All available lead fields (standard and custom)
77
+ - API names for standard fields (e.g. firstName, lastName, emails, companyName)
78
+ - Field IDs for custom fields (e.g. "57256")
79
+ - Picklist option IDs for dropdowns (e.g. leadSource: 12345)
80
+
81
+ ### Step 2: Create lead from user context only
82
+ - Do NOT use a fixed list of fields. Infer from the user's message what they want to create.
83
+ - Build `field_values` with ONLY the fields the user provided or implied.
84
+ - Keys: use API Name for standard fields (from cheat sheet), or Field ID string for custom fields.
85
+ - Values: use the exact format expected by Kylas (see below).
86
+
87
+ ### Field value formats (from Kylas API)
88
+ - **Standard fields** (firstName, lastName, companyName, isNew, etc.): use API name as key at top level.
89
+ - **emails**: array of objects, or pass "email": "user@example.com" to normalize.
90
+ - **phoneNumbers**: array of objects, or pass "phone": "5551234567" and optionally "phone_country_code": "+1".
91
+ - **Picklist fields** (e.g. leadSource, salutation): use the **Option ID** (number) from the cheat sheet.
92
+ - **Custom fields**: MUST go in "customFieldValues" with **internal name** as key (e.g. "customFieldValues": {"cfLeadCheck": "Checked"}). Never use field ID as the key in the request—the API expects internal names (e.g. cfLeadCheck). If you pass a field ID by mistake, the server will resolve it to the internal name automatically. From the cheat sheet, use the "Field ID" only to identify the field; for the payload use the field's **name** (internal/API name) in customFieldValues.
93
+
94
+ ### NEVER guess IDs
95
+ - Always use the cheat sheet from `get_lead_field_instructions` for API names and IDs.
96
+ - Omit any field the user did not mention; do not add static/default fields.
97
+
98
+ ### Search/Filter leads
99
+ - Call `get_lead_field_instructions` first to see which fields are **filterable** (marked in cheat sheet).
100
+ - Only fields with filterable=true can be used in search filters.
101
+ - For PICK_LIST/MULTI_PICKLIST: use **Option ID** (number) in filter value, except for: requirementCurrency, companyBusinessType, country, timezone, companyIndustry — for these use **internal name** (string).
102
+ - Use the correct operator for the field type (see operator list in search_leads docstring).
103
+
104
+ ### User look-up fields (createdBy, updatedBy, convertedBy, ownerId, importedBy)
105
+ - These fields reference **users**; filter value must be the **user ID** (number), not the name.
106
+ - When the user asks e.g. "leads where created by is Last": (1) Call **lookup_users** with query in field:value form (e.g. "firstName:Last" or "name:Last"). (2) If **more than one** user is returned, ask the user explicitly which person they mean and list the matches (id and name). (3) Once exactly one user is identified, call **search_leads** with filter e.g. {"field": "createdBy", "operator": "equal", "value": <user_id>}.
107
+ - Do not guess user IDs; always use lookup_users first when filtering by created by / updated by / owner / imported by / converted by.
108
+
109
+ ### Product filter (products field)
110
+ - The **products** field on leads references products; filter value must be the **product ID** (number), not the name.
111
+ - When the user asks e.g. "leads with product X" or "leads that have product Y": (1) Call **lookup_products** with query e.g. "name:X" or "name:Y". (2) If **more than one** product is returned, ask the user which product they mean and list the matches (id and name). (3) Once exactly one product is identified, call **search_leads** with filter {"field": "products", "operator": "equal", "value": <product_id>}.
112
+ - Do not guess product IDs; always use lookup_products first when filtering by product name.
113
+
114
+ ### Pipeline and pipeline stage (open leads, closed leads, Won, etc.)
115
+ - When the user asks for leads by **stage** (e.g. "open leads", "closed leads", "leads in Won", "Won stage") and the **pipeline is not clear**:
116
+ 1. **Ask for pipeline first:** Call **lookup_pipelines** only (entityType=LEAD, query by name or empty for all). Do **not** call get_pipeline_stages yet.
117
+ 2. **Get confirmation:** Present the pipeline(s) (id and name) and ask the user which pipeline they mean. If only one pipeline is found, still ask the user to confirm (e.g. "I found one pipeline: [name]. Should I use this one?").
118
+ 3. **After confirmation only:** Once the user confirms the pipeline, call **get_pipeline_stages** with that pipeline ID to get stages for that pipeline only.
119
+ 4. Map the user's intent to stage(s): e.g. "open" → stages with forecastingType OPEN; "closed" → CLOSED_*; "won" → CLOSED_WON; "lost" → CLOSED_LOST. If more than one stage matches (e.g. two OPEN stages), ask which stage they mean. Then call **search_leads** with filters: {"field": "pipeline", "operator": "equal", "value": pipeline_id} and {"field": "pipelineStage", "operator": "equal", "value": stage_id} (or "in" with a list of stage IDs if the user wants multiple stages).
120
+ - Do not guess pipeline or pipeline stage IDs. Do not call get_pipeline_stages until the user has confirmed which pipeline to use.
121
+
122
+ ### Idle / Stagnant leads (no activity for N days)
123
+ - "Idle" or "stagnant" means no activity on the lead for at least N days. Use **last activity** = the **later** of `updatedAt` and `latestActivityCreatedAt`; the lead is idle if that date is before (today − N days).
124
+ - Since the API cannot filter on "max of two fields", use **both** conditions: `updatedAt` ≤ threshold **and** `latestActivityCreatedAt` ≤ threshold (threshold = now − N days in ISO). That way the lead is returned only when both dates are old, i.e. the effective last activity is before the threshold.
125
+ - Prefer the **search_idle_leads** tool when the user asks for idle/stagnant/inactive leads (e.g. "no activity since 10 days"). Otherwise build search_leads filters as above with operator "less_or_equal" and value = ISO date string for (now − N days).
126
+ """
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Search: Operator mapping by field type & picklists that use internal name
130
+ # ---------------------------------------------------------------------------
131
+
132
+ OPERATOR_MAPPING = {
133
+ "TEXT_FIELD": ["equal", "not_equal", "contains", "not_contains", "in", "not_in", "is_empty", "is_not_empty", "begins_with"],
134
+ "PARAGRAPH_TEXT": ["equal", "not_equal", "contains", "not_contains", "in", "not_in", "is_empty", "is_not_empty", "begins_with"],
135
+ "NUMBER": ["equal", "not_equal", "greater", "greater_or_equal", "less", "less_or_equal", "between", "not_between", "in", "not_in", "is_null", "is_not_null"],
136
+ "URL": ["equal", "not_equal", "contains", "not_contains", "in", "not_in", "is_empty", "is_not_empty", "begins_with"],
137
+ "CHECKBOX": ["equal", "not_equal"],
138
+ "PICK_LIST": ["equal", "not_equal", "is_not_null", "is_null", "in", "not_in"],
139
+ "MULTI_PICKLIST": ["equal", "not_equal", "is_not_null", "is_null", "in", "not_in"],
140
+ "DATETIME_PICKER": ["greater", "greater_or_equal", "less", "less_or_equal", "between", "not_between", "is_not_null", "is_null", "today", "yesterday", "tomorrow", "last_seven_days", "next_seven_days", "last_fifteen_days", "next_fifteen_days", "last_thirty_days", "next_thirty_days", "week_to_date", "current_week", "last_week", "next_week", "month_to_date", "current_month", "last_month", "next_month", "quarter_to_date", "current_quarter", "last_quarter", "next_quarter", "year_to_date", "current_year", "last_year", "next_year", "before_current_date_and_time", "after_current_date_and_time"],
141
+ "DATE": ["greater", "greater_or_equal", "less", "less_or_equal", "between", "not_between", "is_not_null", "is_null", "today", "yesterday", "tomorrow", "last_seven_days", "next_seven_days", "last_fifteen_days", "next_fifteen_days", "last_thirty_days", "next_thirty_days", "week_to_date", "current_week", "last_week", "next_week", "month_to_date", "current_month", "last_month", "next_month", "quarter_to_date", "current_quarter", "last_quarter", "next_quarter", "year_to_date", "current_year", "last_year", "next_year", "before_current_date_and_time", "after_current_date_and_time"],
142
+ "DATE_PICKER": ["greater", "greater_or_equal", "less", "less_or_equal", "between", "not_between", "is_not_null", "is_null", "today", "yesterday", "tomorrow", "last_seven_days", "next_seven_days", "last_fifteen_days", "next_fifteen_days", "last_thirty_days", "next_thirty_days", "week_to_date", "current_week", "last_week", "next_week", "month_to_date", "current_month", "last_month", "next_month", "quarter_to_date", "current_quarter", "last_quarter", "next_quarter", "year_to_date", "current_year", "last_year", "next_year", "before_current_date_and_time", "after_current_date_and_time"],
143
+ "EMAIL": ["equal", "not_equal", "contains", "not_contains", "in", "not_in", "is_empty", "is_not_empty", "begins_with"],
144
+ "PHONE": ["equal", "not_equal", "contains", "not_contains", "in", "not_in", "is_empty", "is_not_empty", "begins_with"],
145
+ "TOGGLE": ["equal", "not_equal"],
146
+ "FORECASTING_TYPE": ["equal", "not_equal", "in", "not_in", "is_empty", "is_not_empty"],
147
+ "ENTITY_FIELDS": ["equal", "not_equal", "in", "not_in", "is_not_null", "is_null"],
148
+ "LOOK_UP": ["equal", "not_equal", "is_not_null", "is_null", "in", "not_in"],
149
+ "PIPELINE_STAGE": ["equal", "not_equal", "in", "not_in"],
150
+ "PIPELINE": ["equal", "not_equal", "is_not_null", "is_null", "in", "not_in"],
151
+ }
152
+
153
+ # Picklist fields that use internal name (string) in search; all others use Option ID (long)
154
+ PICKLIST_FIELDS_USE_INTERNAL_NAME = {"requirementCurrency", "companyBusinessType", "country", "timezone", "companyIndustry"}
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # HTTP Client & Errors
158
+ # ---------------------------------------------------------------------------
159
+
160
+ class KylasAPIError(Exception):
161
+ def __init__(self, message: str, status_code: Optional[int] = None, response_body: Optional[str] = None):
162
+ self.message = message
163
+ self.status_code = status_code
164
+ self.response_body = response_body
165
+ super().__init__(self.message)
166
+
167
+
168
+ def get_client() -> httpx.AsyncClient:
169
+ if not API_KEY:
170
+ raise KylasAPIError("KYLAS_API_KEY environment variable is not set")
171
+ return httpx.AsyncClient(
172
+ base_url=BASE_URL,
173
+ headers={
174
+ "api-key": API_KEY,
175
+ "Content-Type": "application/json",
176
+ "Accept": "application/json"
177
+ },
178
+ timeout=30.0
179
+ )
180
+
181
+
182
+ async def handle_api_response(response: httpx.Response, operation: str) -> Dict[str, Any]:
183
+ try:
184
+ response.raise_for_status()
185
+ return response.json()
186
+ except httpx.HTTPStatusError as e:
187
+ error_body = e.response.text
188
+ logger.error(f"{operation} failed: {e.response.status_code} - {error_body}")
189
+ raise KylasAPIError(
190
+ f"{operation} failed: {e.response.status_code}",
191
+ status_code=e.response.status_code,
192
+ response_body=error_body
193
+ )
194
+ except Exception as e:
195
+ logger.error(f"{operation} failed: {str(e)}")
196
+ raise KylasAPIError(f"{operation} failed: {str(e)}")
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # MCP Server
201
+ # ---------------------------------------------------------------------------
202
+
203
+ mcp = FastMCP("Kylas CRM (Lead)", instructions=SYSTEM_INSTRUCTIONS)
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Tool 1: Get Lead Field Instructions (call FIRST)
208
+ # ---------------------------------------------------------------------------
209
+
210
+ def _format_field(field: Dict[str, Any], include_filterable: bool = False) -> List[str]:
211
+ lines = []
212
+ label = field.get("displayName") or field.get("label") or "Unknown"
213
+ name = field.get("name", "")
214
+ field_id = field.get("id", "")
215
+ field_type = field.get("type", "UNKNOWN")
216
+ is_standard = field.get("standard", False)
217
+ is_required = field.get("required", False)
218
+ filterable = field.get("filterable", False)
219
+ prefix = "[STANDARD]" if is_standard else "[CUSTOM]"
220
+ if is_standard:
221
+ identifier = f"API Name: '{name}'"
222
+ else:
223
+ identifier = f"Field ID: '{field_id}', Internal Name for customFieldValues: '{name}'"
224
+ required_marker = " *REQUIRED*" if is_required else ""
225
+ filterable_marker = " [FILTERABLE]" if (include_filterable and filterable) else ""
226
+ lines.append(f"{prefix} '{label}' ({identifier}) - Type: {field_type}{required_marker}{filterable_marker}")
227
+ if field_type in ["PICK_LIST", "MULTI_PICKLIST"]:
228
+ picklist = field.get("picklist", {})
229
+ values = picklist.get("values", [])
230
+ if values:
231
+ use_name = name in PICKLIST_FIELDS_USE_INTERNAL_NAME
232
+ lines.append(" └─ Options (use internal name in search)" if use_name else " └─ Options (use ID in search):")
233
+ for val in values:
234
+ val_label = val.get("displayName") or val.get("label") or val.get("name") or "Unknown"
235
+ val_id = val.get("id", "")
236
+ val_name = val.get("name", "")
237
+ if use_name and val_name:
238
+ lines.append(f" • {val_label} (internal name: '{val_name}')")
239
+ else:
240
+ lines.append(f" • {val_label} (ID: {val_id})")
241
+ return lines
242
+
243
+
244
+ async def _fetch_lead_fields() -> List[Dict[str, Any]]:
245
+ """Fetch lead field metadata from Kylas API. Returns list of field dicts."""
246
+ async with get_client() as client:
247
+ response = await client.get(
248
+ "/entities/lead/fields",
249
+ params={"entityType": "lead", "custom-only": "false", "page": 0, "size": 100}
250
+ )
251
+ data = await handle_api_response(response, "Fetch lead fields")
252
+ if isinstance(data, list):
253
+ fields = data
254
+ else:
255
+ fields = data.get("data", data.get("content", []))
256
+ return [f for f in fields if f.get("active", True)]
257
+
258
+
259
+ async def _get_custom_field_id_to_name() -> Dict[str, str]:
260
+ """Return mapping of custom field ID (string) -> internal name (e.g. cfLeadCheck)."""
261
+ fields = await _fetch_lead_fields()
262
+ custom = [f for f in fields if not f.get("standard", False)]
263
+ return {str(f["id"]): (f.get("name") or str(f["id"])) for f in custom if f.get("id") is not None}
264
+
265
+
266
+ def _get_filterable_fields_map(fields: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
267
+ """Return map of field name -> {type, standard} for active+filterable fields only."""
268
+ return {
269
+ (f.get("name") or str(f.get("id", ""))): {"type": f.get("type", "TEXT_FIELD"), "standard": f.get("standard", False)}
270
+ for f in fields
271
+ if f.get("active", True) and f.get("filterable", False) and (f.get("name") or f.get("id") is not None)
272
+ }
273
+
274
+
275
+ def _rule_type_for_value(field_type: str, field_name: str, value: Any) -> str:
276
+ """Return jsonRule rule 'type' (string, long, or date) for the given field type and value."""
277
+ if field_type in ("PICK_LIST", "MULTI_PICKLIST"):
278
+ return "string" if field_name in PICKLIST_FIELDS_USE_INTERNAL_NAME else "long"
279
+ if field_type == "NUMBER":
280
+ return "long"
281
+ # User look-up fields: createdBy, updatedBy, convertedBy, ownerId, importedBy — value is user ID (long)
282
+ if field_type in ("LOOK_UP", "ENTITY_FIELDS"):
283
+ return "long"
284
+ # Date/datetime: standard and custom (e.g. cfDateField); value = single ISO string, [start,end], or null
285
+ if field_type in ("DATETIME_PICKER", "DATE", "DATE_PICKER"):
286
+ return "date"
287
+ return "string"
288
+
289
+
290
+ def _build_search_json_rule(
291
+ filters: List[Dict[str, Any]],
292
+ filterable_map: Dict[str, Dict[str, Any]],
293
+ ) -> Tuple[Dict[str, Any], Optional[str]]:
294
+ """
295
+ Build jsonRule for POST /search/lead. Returns (jsonRule, error_message).
296
+ Each filter: { "field": "<name>", "operator": "<op>", "value": <val>, "type": "<FIELD_TYPE>" }.
297
+ """
298
+ rules = []
299
+ for i, f in enumerate(filters):
300
+ field_name = f.get("field")
301
+ operator = (f.get("operator") or "equal").strip().lower().replace(" ", "_")
302
+ value = f.get("value")
303
+ field_type_key = (f.get("type") or "TEXT_FIELD").strip().upper().replace(" ", "_")
304
+
305
+ if not field_name:
306
+ return {}, f"Filter #{i + 1}: missing 'field'."
307
+ if field_name not in filterable_map:
308
+ return {}, f"Filter #{i + 1}: field '{field_name}' is not filterable or not found. Use only [FILTERABLE] fields from get_lead_field_instructions."
309
+ meta = filterable_map[field_name]
310
+ api_type = meta.get("type", "TEXT_FIELD")
311
+ allowed = OPERATOR_MAPPING.get(api_type) or OPERATOR_MAPPING.get("TEXT_FIELD", [])
312
+ if operator not in allowed:
313
+ return {}, f"Filter #{i + 1}: operator '{operator}' not allowed for field '{field_name}' (type {api_type}). Allowed: {', '.join(allowed)}."
314
+
315
+ rule_type = _rule_type_for_value(api_type, field_name, value)
316
+ if rule_type == "long" and value is not None and not isinstance(value, (int, float)):
317
+ try:
318
+ value = int(value)
319
+ except (TypeError, ValueError):
320
+ value = value
321
+ # Date rules: value left as-is — single ISO string (greater/less), [start,end] (between), or null (today/is_null)
322
+
323
+ # Custom fields: API expects field path "customFieldValues.cfFruits" or "customFieldValues.cfDateField"; standard fields use field name only
324
+ is_custom = not meta.get("standard", True)
325
+ rule_field = f"customFieldValues.{field_name}" if is_custom else field_name
326
+
327
+ rule = {
328
+ "operator": operator,
329
+ "id": field_name,
330
+ "field": rule_field,
331
+ "type": rule_type,
332
+ "value": value,
333
+ "relatedFieldIds": None,
334
+ }
335
+ # Pipeline/pipelineStage: API expects dependentFieldIds and relatedFieldIds for lead search
336
+ if field_name == "pipeline":
337
+ rule["dependentFieldIds"] = ["pipelineStage", "pipelineStageReason"]
338
+ elif field_name == "pipelineStage":
339
+ rule["relatedFieldIds"] = ["pipeline"]
340
+ # Date/datetime fields: API requires timeZone (e.g. today, between, is_not_null)
341
+ if rule_type == "date":
342
+ rule["timeZone"] = f.get("timeZone") or DEFAULT_TIMEZONE
343
+ rules.append(rule)
344
+
345
+ return {"rules": rules, "condition": "AND", "valid": True}, None
346
+
347
+
348
+ async def get_lead_field_instructions_logic() -> str:
349
+ fields = await _fetch_lead_fields()
350
+ standard = [f for f in fields if f.get("standard", False)]
351
+ custom = [f for f in fields if not f.get("standard", False)]
352
+ lines = [
353
+ "=" * 60,
354
+ "KYLAS CRM - LEAD FIELDS CHEAT SHEET",
355
+ "=" * 60,
356
+ "",
357
+ "## STANDARD FIELDS",
358
+ "-" * 40,
359
+ ]
360
+ for f in standard:
361
+ lines.extend(_format_field(f, include_filterable=True))
362
+ if custom:
363
+ lines.extend(["", "## CUSTOM FIELDS", "-" * 40])
364
+ for f in custom:
365
+ lines.extend(_format_field(f, include_filterable=True))
366
+ lines.extend(["", "=" * 60, "END OF CHEAT SHEET", "=" * 60])
367
+ return "\n".join(lines)
368
+
369
+
370
+ @mcp.tool()
371
+ async def get_lead_field_instructions() -> str:
372
+ """
373
+ Get all lead fields for the current tenant. CALL THIS FIRST before creating a lead.
374
+ Returns a cheat sheet with API names (standard fields), Field IDs (custom fields), and Picklist Option IDs.
375
+ Use this to build field_values for create_lead based on what the user wants—do not use static fields.
376
+ """
377
+ try:
378
+ logger.info("Fetching lead field instructions")
379
+ result = await get_lead_field_instructions_logic()
380
+ return result
381
+ except KylasAPIError as e:
382
+ return f"Error: {e.message}"
383
+ except Exception as e:
384
+ logger.exception("get_lead_field_instructions")
385
+ return f"Unexpected error: {str(e)}"
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Tool 2: Lookup Users (for createdBy, updatedBy, ownerId, importedBy, convertedBy filters)
390
+ # ---------------------------------------------------------------------------
391
+
392
+ async def lookup_users_logic(
393
+ query: str, page: int = 0, size: int = 50, fetch_all_pages: bool = False
394
+ ) -> str:
395
+ """
396
+ Call GET /users/lookup?q=<query> and return a formatted list of users (id, name).
397
+ Use this when the user asks for leads by "created by X", "owner is Y", etc., to resolve X/Y to a user ID.
398
+ If fetch_all_pages is True, request all pages and return all users in one response (cap at 500).
399
+ """
400
+ if not query or not str(query).strip():
401
+ return "Error: query cannot be empty. Provide a name or search term (e.g. 'last' or 'firstName:last'), or use query 'name:' with return_all=True to list all users."
402
+ q = str(query).strip()
403
+ page_size = min(size, 50)
404
+ content: List[Dict[str, Any]] = []
405
+ total = 0
406
+ total_pages = 1
407
+ current_page = page
408
+ max_users = 500 if fetch_all_pages else page_size
409
+
410
+ async with get_client() as client:
411
+ while True:
412
+ response = await client.get(
413
+ "/users/lookup",
414
+ params={"q": q, "page": current_page, "size": page_size},
415
+ )
416
+ data = await handle_api_response(response, "User lookup")
417
+ chunk = data.get("content", data.get("data", []))
418
+ total = data.get("totalElements", data.get("total", len(chunk) + len(content)))
419
+ total_pages = data.get("totalPages", 1)
420
+ content.extend(chunk)
421
+ if not fetch_all_pages or current_page >= total_pages - 1 or len(content) >= max_users or len(chunk) < page_size:
422
+ break
423
+ current_page += 1
424
+
425
+ if not content:
426
+ return f"No users found matching '{q}'."
427
+ if fetch_all_pages:
428
+ header = f"Found {len(content)} user(s)" + (f" matching '{q}'" if q != "name:" else "") + f" (total {total}, all returned in one list)"
429
+ else:
430
+ header = f"Found {len(content)} user(s) matching '{q}' (total {total}, page {page + 1} of {total_pages})"
431
+ lines = [header, "-" * 50]
432
+ for u in content:
433
+ uid = u.get("id", "?")
434
+ name = u.get("name", "—")
435
+ lines.append(f" • ID: {uid} | Name: {name}")
436
+ lines.append("-" * 50)
437
+ if len(content) > 1 and not fetch_all_pages:
438
+ lines.append("More than one user matched. Ask the user which one they mean, then use that ID in search_leads (e.g. filter createdBy / ownerId equal to that ID).")
439
+ elif len(content) == 1:
440
+ lines.append(f"Use user ID {content[0].get('id')} in search_leads when filtering by created by / owner / etc.")
441
+ return "\n".join(lines)
442
+
443
+
444
+ @mcp.tool()
445
+ async def lookup_users(
446
+ query: str = "name:",
447
+ page: int = 0,
448
+ size: int = 50,
449
+ return_all: bool = False,
450
+ ) -> str:
451
+ """
452
+ Look up users by name, or list all users in the system.
453
+ - Use return_all=True (with query "name:" or empty) to fetch all users in one response (all pages combined).
454
+ - For name search: query in field:value form (e.g. "firstName:last", "name:Last"). If one user is found, use that ID in search_leads; if multiple, ask which one.
455
+ query: Search string (e.g. "firstName:last", "name:Last"). Use "name:" or leave default to list all when return_all=True.
456
+ page: 0-based page (default 0). Ignored when return_all=True.
457
+ size: Page size, max 50 (default 50). Used per page when return_all=True.
458
+ return_all: If True, fetch all pages and return every user in one response (cap 500).
459
+ """
460
+ try:
461
+ q = (query or "name:").strip() or "name:"
462
+ logger.info("User lookup: q=%s return_all=%s", q, return_all)
463
+ return await lookup_users_logic(q, page, size, fetch_all_pages=return_all)
464
+ except KylasAPIError as e:
465
+ return f"Error: {e.message}"
466
+ except Exception as e:
467
+ logger.exception("lookup_users")
468
+ return f"Unexpected error: {str(e)}"
469
+
470
+
471
+ # ---------------------------------------------------------------------------
472
+ # Tool 3b: Lookup Products (for products filter on leads)
473
+ # ---------------------------------------------------------------------------
474
+
475
+ async def lookup_products_logic(query: str, page: int = 0, size: int = 50) -> str:
476
+ """
477
+ Call GET /products/lookup?q=<query> and return a formatted list of products (id, name).
478
+ Use this when the user asks for leads by product name (e.g. "leads with product X") to resolve X to a product ID.
479
+ """
480
+ if not query or not str(query).strip():
481
+ return "Error: query cannot be empty. Provide a product name or search term (e.g. 'name:Widget' or 'Widget')."
482
+ q = str(query).strip()
483
+ # If user passed plain text, treat as product name for API (name:value form)
484
+ if ":" not in q:
485
+ q = f"name:{q}"
486
+ async with get_client() as client:
487
+ response = await client.get(
488
+ "/products/lookup",
489
+ params={"q": q, "page": page, "size": min(size, 50)},
490
+ )
491
+ data = await handle_api_response(response, "Product lookup")
492
+ content = data.get("content", data.get("data", []))
493
+ total = data.get("totalElements", data.get("total", len(content)))
494
+ total_pages = data.get("totalPages", 1)
495
+ if not content:
496
+ return f"No products found matching '{q}'."
497
+ lines = [f"Found {len(content)} product(s) matching '{q}' (total {total}, page {page + 1} of {total_pages})", "-" * 50]
498
+ for p in content:
499
+ pid = p.get("id", "?")
500
+ name = p.get("name", p.get("displayName", "—"))
501
+ lines.append(f" • ID: {pid} | Name: {name}")
502
+ lines.append("-" * 50)
503
+ if total > 1:
504
+ lines.append("More than one product matched. Ask the user which one they mean, then use that ID in search_leads (e.g. filter products equal to that ID).")
505
+ else:
506
+ lines.append(f"Use product ID {content[0].get('id')} in search_leads when filtering by product (e.g. {{\"field\": \"products\", \"operator\": \"equal\", \"value\": <id>}}).")
507
+ return "\n".join(lines)
508
+
509
+
510
+ @mcp.tool()
511
+ async def lookup_products(query: str, page: int = 0, size: int = 50) -> str:
512
+ """
513
+ Look up products by name. Use this BEFORE filtering leads by product when the user gives a product name.
514
+ - If one product is found, use that product's ID in search_leads (e.g. {"field": "products", "operator": "equal", "value": <id>}).
515
+ - If multiple products are found, ask the user which product they mean (list the options), then use the chosen product's ID in search_leads.
516
+ query: Search string. Use "name:<product_name>" (e.g. "name:Widget") or just the product name (e.g. "Widget"); the server will send name:value to the API.
517
+ page: 0-based page (default 0).
518
+ size: Max 50 (default 50).
519
+ """
520
+ try:
521
+ logger.info("Product lookup: q=%s", query)
522
+ return await lookup_products_logic(query, page, size)
523
+ except KylasAPIError as e:
524
+ return f"Error: {e.message}"
525
+ except Exception as e:
526
+ logger.exception("lookup_products")
527
+ return f"Unexpected error: {str(e)}"
528
+
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # Tool 3c: Lookup Pipelines (for pipeline + stage filters on leads)
532
+ # ---------------------------------------------------------------------------
533
+
534
+ async def lookup_pipelines_logic(
535
+ query: str = "",
536
+ entity_type: str = "LEAD",
537
+ page: int = 0,
538
+ size: int = 50,
539
+ ) -> str:
540
+ """
541
+ Call GET /pipelines/lookup?entityType=<entity_type>&q=<query> and return a formatted list of pipelines (id, name).
542
+ Use when the user asks for leads by stage (e.g. open/closed/won) but pipeline is not specified; then ask user to select a pipeline.
543
+ """
544
+ q = str(query).strip() if query else ""
545
+ if ":" not in q and q:
546
+ q = f"name:{q}"
547
+ # Empty q: some APIs return all pipelines when q=name:
548
+ if not q:
549
+ q = "name:"
550
+ async with get_client() as client:
551
+ response = await client.get(
552
+ "/pipelines/lookup",
553
+ params={"entityType": entity_type, "q": q, "page": page, "size": min(size, 50)},
554
+ )
555
+ data = await handle_api_response(response, "Pipeline lookup")
556
+ content = data.get("content", data.get("data", []))
557
+ total = data.get("totalElements", data.get("total", len(content)))
558
+ total_pages = data.get("totalPages", 1)
559
+ if not content:
560
+ return f"No pipelines found for entity {entity_type}" + (f" matching '{q}'." if q else ".")
561
+ lines = [
562
+ f"Found {len(content)} pipeline(s) (entityType={entity_type}, total {total}, page {page + 1} of {total_pages})",
563
+ "-" * 50,
564
+ ]
565
+ for p in content:
566
+ pid = p.get("id", "?")
567
+ name = p.get("name", p.get("displayName", "—"))
568
+ lines.append(f" • ID: {pid} | Name: {name}")
569
+ lines.append("-" * 50)
570
+ lines.append("Ask the user to confirm which pipeline to use (list id and name). Do NOT call get_pipeline_stages until the user has confirmed. After confirmation, call get_pipeline_stages with that pipeline ID only, then search_leads with pipeline + pipelineStage filters.")
571
+ return "\n".join(lines)
572
+
573
+
574
+ @mcp.tool()
575
+ async def lookup_pipelines(
576
+ query: str = "",
577
+ entity_type: str = "LEAD",
578
+ page: int = 0,
579
+ size: int = 50,
580
+ ) -> str:
581
+ """
582
+ Look up pipelines by name (for leads). Use when the user asks for leads by stage (e.g. open/closed/won/lost) but does not specify which pipeline.
583
+ - Call this first; do NOT call get_pipeline_stages until after the user confirms the pipeline.
584
+ - Present the pipeline(s) (id and name) and ask the user which pipeline they mean. If only one pipeline is found, still ask for confirmation.
585
+ - Only after the user confirms, call get_pipeline_stages with that pipeline ID to get stages for that pipeline, then search_leads.
586
+ query: Search string. Use "name:<pipeline_name>" or just the pipeline name; empty string returns all pipelines for the entity.
587
+ entity_type: Entity type (default LEAD).
588
+ page: 0-based page (default 0).
589
+ size: Max 50 (default 50).
590
+ """
591
+ try:
592
+ logger.info("Pipeline lookup: entityType=%s q=%s", entity_type, query)
593
+ return await lookup_pipelines_logic(query, entity_type, page, size)
594
+ except KylasAPIError as e:
595
+ return f"Error: {e.message}"
596
+ except Exception as e:
597
+ logger.exception("lookup_pipelines")
598
+ return f"Unexpected error: {str(e)}"
599
+
600
+
601
+ async def get_pipeline_stages_logic(pipeline_id: int) -> str:
602
+ """
603
+ Call POST /pipelines/summary with jsonRule filtering by pipeline id(s). Returns pipeline name and list of stages (id, name, forecastingType).
604
+ Use after the user has selected a pipeline; then map user intent (open/closed/won/lost) to stage id(s) and call search_leads.
605
+ """
606
+ payload = {
607
+ "jsonRule": {
608
+ "condition": "AND",
609
+ "rules": [{"operator": "in", "id": "id", "field": "id", "type": "long", "value": [pipeline_id]}],
610
+ "valid": True,
611
+ }
612
+ }
613
+ async with get_client() as client:
614
+ response = await client.post("/pipelines/summary", json=payload)
615
+ data = await handle_api_response(response, "Pipeline summary")
616
+ # Response is array of {id, name, stages: [{id, name, position, forecastingType}]}
617
+ pipelines = data if isinstance(data, list) else data.get("content", data.get("data", []))
618
+ if not pipelines:
619
+ return f"No pipeline found with ID {pipeline_id}."
620
+ lines = []
621
+ for pl in pipelines:
622
+ pl_id = pl.get("id", "?")
623
+ pl_name = pl.get("name", "—")
624
+ lines.append(f"Pipeline: {pl_name} (ID: {pl_id})")
625
+ stages = pl.get("stages", [])
626
+ if not stages:
627
+ lines.append(" (no stages)")
628
+ else:
629
+ for s in stages:
630
+ sid = s.get("id", "?")
631
+ sname = s.get("name", "—")
632
+ ftype = s.get("forecastingType", "")
633
+ lines.append(f" • Stage ID: {sid} | Name: {sname} | forecastingType: {ftype}")
634
+ lines.append("")
635
+ lines.append("Map user intent to stage: 'open' → OPEN; 'won' → CLOSED_WON; 'lost' → CLOSED_LOST; 'closed unqualified' → CLOSED_UNQUALIFIED. If multiple stages match (e.g. several OPEN stages), ask the user which stage they mean, then use that stage ID in search_leads with pipeline and pipelineStage filters.")
636
+ return "\n".join(lines).strip()
637
+
638
+
639
+ @mcp.tool()
640
+ async def get_pipeline_stages(pipeline_id: int) -> str:
641
+ """
642
+ Get stages for a pipeline. Call this only after the user has confirmed which pipeline to use (from lookup_pipelines). Do not call before pipeline confirmation.
643
+ Returns pipeline name and list of stages for that pipeline only, with id, name, and forecastingType (OPEN, CLOSED_WON, CLOSED_LOST, CLOSED_UNQUALIFIED).
644
+ Use the stage IDs in search_leads: filters [{"field": "pipeline", "operator": "equal", "value": pipeline_id}, {"field": "pipelineStage", "operator": "equal", "value": stage_id}].
645
+ If the user said "open leads" or "closed leads" and more than one stage has the same forecastingType, ask which stage they mean.
646
+ pipeline_id: The pipeline ID (from lookup_pipelines).
647
+ """
648
+ try:
649
+ pipeline_id = int(pipeline_id)
650
+ except (TypeError, ValueError):
651
+ return "Error: pipeline_id must be a number."
652
+ try:
653
+ logger.info("Pipeline stages: pipeline_id=%s", pipeline_id)
654
+ return await get_pipeline_stages_logic(pipeline_id)
655
+ except KylasAPIError as e:
656
+ return f"Error: {e.message}"
657
+ except Exception as e:
658
+ logger.exception("get_pipeline_stages")
659
+ return f"Unexpected error: {str(e)}"
660
+
661
+
662
+ # ---------------------------------------------------------------------------
663
+ # Tool 4: Create Lead (single tool, dynamic field_values)
664
+ # ---------------------------------------------------------------------------
665
+
666
+ def _normalize_field_values(
667
+ field_values: Dict[str, Any],
668
+ custom_field_id_to_name: Optional[Dict[str, str]] = None,
669
+ ) -> Dict[str, Any]:
670
+ """
671
+ Build Kylas create-lead payload from dynamic field_values.
672
+ - Custom fields (numeric keys or in customFieldValues) → customFieldValues with INTERNAL NAME as key (never ID).
673
+ - Explicit "customFieldValues" dict → merged; keys must be internal names (e.g. cfLeadCheck).
674
+ - "email" string → emails array
675
+ - "phone" / "phoneNumber" + optional "phone_country_code" → phoneNumbers array
676
+ - Rest → top-level payload (standard fields)
677
+ """
678
+ payload: Dict[str, Any] = {}
679
+ custom: Dict[str, Any] = {}
680
+ fv = dict(field_values)
681
+ id_to_name = custom_field_id_to_name or {}
682
+
683
+ phone_code = fv.pop("phone_country_code", None) or "+1"
684
+
685
+ # Explicit customFieldValues: merge into custom (keys must be internal names, e.g. cfLeadCheck)
686
+ if "customFieldValues" in fv:
687
+ cf = fv.pop("customFieldValues")
688
+ if isinstance(cf, dict):
689
+ for k, v in cf.items():
690
+ if v is not None:
691
+ custom[str(k)] = v
692
+
693
+ for key, value in fv.items():
694
+ if value is None:
695
+ continue
696
+ # Custom field: key is numeric string (Field ID) → use internal name in customFieldValues
697
+ if str(key).isdigit():
698
+ custom_key = id_to_name.get(str(key), str(key))
699
+ custom[custom_key] = value
700
+ continue
701
+ # Normalize single email string to Kylas emails array
702
+ if key == "email" and isinstance(value, str):
703
+ payload["emails"] = [{"type": "OFFICE", "value": value.strip(), "primary": True}]
704
+ continue
705
+ # Normalize single phone string to Kylas phoneNumbers array
706
+ if key in ("phone", "phoneNumber") and isinstance(value, str):
707
+ payload["phoneNumbers"] = [{"type": "MOBILE", "code": phone_code, "value": value.strip(), "primary": True}]
708
+ continue
709
+ # Already in API shape
710
+ if key in ("emails", "phoneNumbers"):
711
+ payload[key] = value
712
+ continue
713
+ # All other standard fields at top level
714
+ payload[key] = value
715
+
716
+ if custom:
717
+ payload["customFieldValues"] = custom
718
+ return payload
719
+
720
+
721
+ async def create_lead_logic(field_values: Dict[str, Any]) -> Dict[str, Any]:
722
+ """Create a lead with the given dynamic field_values (Kylas API payload shape)."""
723
+ fv = dict(field_values)
724
+ # Resolve custom field IDs to internal names so customFieldValues uses names, not IDs
725
+ has_custom_by_id = any(str(k).isdigit() for k in fv if k != "customFieldValues")
726
+ id_to_name = await _get_custom_field_id_to_name() if has_custom_by_id else {}
727
+ payload = _normalize_field_values(fv, custom_field_id_to_name=id_to_name)
728
+ if not payload:
729
+ raise KylasAPIError("field_values cannot be empty")
730
+ logger.info("Creating lead with fields: %s", list(payload.keys()))
731
+ async with get_client() as client:
732
+ response = await client.post("/leads", json=payload)
733
+ result = await handle_api_response(response, "Create lead")
734
+ logger.info("Lead created with ID: %s", result.get("id"))
735
+ return result
736
+
737
+
738
+ @mcp.tool()
739
+ async def create_lead(field_values: Dict[str, Any]) -> str:
740
+ """
741
+ Create a lead in Kylas CRM with only the fields the user wants (no static field list).
742
+
743
+ You MUST call get_lead_field_instructions FIRST to get valid API names and Field IDs.
744
+ Infer from user context which fields to send; include only those in field_values.
745
+
746
+ field_values: Map of field identifier to value.
747
+ - Standard fields: use API name as key at top level (e.g. firstName, lastName, companyName, emails, phoneNumbers, leadSource, isNew).
748
+ - Custom fields: MUST be under "customFieldValues" with **internal name** as key (e.g. "customFieldValues": {"cfLeadCheck": "Checked"}). Do not use field ID as key—Kylas expects internal names. If you pass a field ID (e.g. "1210985"), the server will resolve it to the internal name (e.g. cfLeadCheck) automatically.
749
+ - For a single email use "email": "user@example.com"; for phones "phone": "5551234567" and optionally "phone_country_code": "+1".
750
+ - For picklists use the Option ID (number) from the cheat sheet.
751
+ """
752
+ try:
753
+ result = await create_lead_logic(field_values)
754
+ lead_id = result.get("id", "?")
755
+ name = f"{result.get('firstName', '')} {result.get('lastName', '')}".strip() or "Lead"
756
+ return f"✓ Lead created successfully.\n ID: {lead_id}\n Name: {name}"
757
+ except KylasAPIError as e:
758
+ return f"✗ Failed to create lead: {e.message}\n Details: {e.response_body}"
759
+ except Exception as e:
760
+ logger.exception("create_lead")
761
+ return f"✗ Unexpected error: {str(e)}"
762
+
763
+
764
+ # ---------------------------------------------------------------------------
765
+ # Tool 4: Search / Filter Leads
766
+ # ---------------------------------------------------------------------------
767
+
768
+ def _extract_primary_email(emails: Any) -> str:
769
+ if not emails or not isinstance(emails, list):
770
+ return "-"
771
+ for e in emails:
772
+ if e and e.get("primary"):
773
+ return e.get("value", "-")
774
+ return emails[0].get("value", "-") if emails and emails[0] else "-"
775
+
776
+
777
+ def _extract_primary_phone(phones: Any) -> str:
778
+ if not phones or not isinstance(phones, list):
779
+ return "-"
780
+ for p in phones:
781
+ if p and p.get("primary"):
782
+ return f"{p.get('code', '')} {p.get('value', '')}".strip() or "-"
783
+ if phones and phones[0]:
784
+ return f"{phones[0].get('code', '')} {phones[0].get('value', '')}".strip() or "-"
785
+ return "-"
786
+
787
+
788
+ async def search_leads_logic(
789
+ filters: List[Dict[str, Any]],
790
+ page: int = 0,
791
+ size: int = 20,
792
+ sort: Optional[str] = "createdAt,desc",
793
+ ) -> str:
794
+ """Search leads with jsonRule; only filterable fields allowed."""
795
+ fields_list = await _fetch_lead_fields()
796
+ filterable_map = _get_filterable_fields_map(fields_list)
797
+ if not filterable_map:
798
+ return "No filterable lead fields found for this tenant."
799
+ json_rule, err = _build_search_json_rule(filters, filterable_map)
800
+ if err:
801
+ return f"Invalid filters: {err}"
802
+ payload = {
803
+ "fields": ["id", "firstName", "lastName", "emails", "phoneNumbers", "ownerId", "companyName", "createdAt"],
804
+ "jsonRule": json_rule,
805
+ }
806
+ params = {"page": page, "size": min(size, 100)}
807
+ if sort:
808
+ params["sort"] = sort
809
+ logger.info("Searching leads with %d filter(s)", len(filters))
810
+ async with get_client() as client:
811
+ response = await client.post("/search/lead", params=params, json=payload)
812
+ data = await handle_api_response(response, "Search leads")
813
+ results = data.get("content", data.get("data", []))
814
+ total = data.get("totalElements", data.get("total", len(results)))
815
+ total_pages = data.get("totalPages", 1)
816
+ if not results:
817
+ return f"No leads found matching the filters. (Total in DB: {total})"
818
+ lines = [f"Found {len(results)} lead(s) (page {page + 1} of {total_pages}, total {total})", "-" * 60]
819
+ for lead in results:
820
+ lid = lead.get("id", "?")
821
+ fn = lead.get("firstName") or ""
822
+ ln = lead.get("lastName") or ""
823
+ name = f"{fn} {ln}".strip() or "—"
824
+ email = _extract_primary_email(lead.get("emails"))
825
+ phone = _extract_primary_phone(lead.get("phoneNumbers"))
826
+ lines.append(f"• ID: {lid} | Name: {name} | Email: {email} | Phone: {phone}")
827
+ lines.append("-" * 60)
828
+ return "\n".join(lines)
829
+
830
+
831
+ @mcp.tool()
832
+ async def search_leads(
833
+ filters: List[Dict[str, Any]],
834
+ page: int = 0,
835
+ size: int = 20,
836
+ sort: Optional[str] = "createdAt,desc",
837
+ ) -> str:
838
+ """
839
+ Search/filter leads. Only fields marked [FILTERABLE] in get_lead_field_instructions can be used.
840
+ Call get_lead_field_instructions first to get filterable fields and their types.
841
+
842
+ filters: List of filter objects. Each must have:
843
+ - field (str): Field internal/API name (e.g. firstName, country, source, createdAt).
844
+ - operator (str): One of the allowed operators for that field type (e.g. equal, contains, greater).
845
+ - value: Value to compare. For PICK_LIST/MULTI_PICKLIST use Option ID (number), except
846
+ requirementCurrency, companyBusinessType, country, timezone, companyIndustry — use internal name (string).
847
+ For date/datetime (incl. custom e.g. cfDateField): value null for today/is_null/is_not_null; single ISO string
848
+ for greater/greater_or_equal/less/less_or_equal e.g. "2026-02-02T18:30:00.000Z"; for between use [startISO, endISO].
849
+ - timeZone (str, optional): For date/datetime filters only; default from server or env.
850
+ - type (str, optional): Field type from cheat sheet. If omitted, inferred from schema.
851
+ For user look-up fields (createdBy, updatedBy, convertedBy, ownerId, importedBy): value must be user ID (number). Call lookup_users first.
852
+ For the products field: value must be product ID (number). Call lookup_products first; if multiple matches, ask which product, then use that ID here.
853
+ For pipeline / pipelineStage (e.g. open leads, closed leads): call lookup_pipelines first, ask the user to confirm which pipeline, then call get_pipeline_stages for that pipeline only; if stage is ambiguous ask which stage, then use pipeline + pipelineStage filters here.
854
+ page: 0-based page (default 0).
855
+ size: Page size, max 100 (default 20).
856
+ sort: Sort e.g. "createdAt,desc" (default).
857
+
858
+ Operators by type (examples): TEXT_FIELD: equal, contains, is_empty. NUMBER: equal, greater, between, is_null. PICK_LIST: equal, in, is_null. DATETIME_PICKER: today, yesterday, between, is_not_null, greater, less, current_week, etc.
859
+ """
860
+ try:
861
+ if not filters:
862
+ return "Error: filters list cannot be empty. Provide at least one filter with field, operator, and value."
863
+ return await search_leads_logic(filters, page, size, sort)
864
+ except KylasAPIError as e:
865
+ return f"✗ Search failed: {e.message}\n Details: {e.response_body}"
866
+ except Exception as e:
867
+ logger.exception("search_leads")
868
+ return f"✗ Unexpected error: {str(e)}"
869
+
870
+
871
+ # ---------------------------------------------------------------------------
872
+ # Tool 5: Search idle / stagnant leads (no activity for N days)
873
+ # ---------------------------------------------------------------------------
874
+
875
+ async def search_idle_leads_logic(
876
+ days: int,
877
+ time_zone: Optional[str] = None,
878
+ page: int = 0,
879
+ size: int = 20,
880
+ sort: Optional[str] = "createdAt,desc",
881
+ ) -> str:
882
+ """
883
+ Find leads with no activity for at least `days` days.
884
+ Uses last-activity = max(updatedAt, latestActivityCreatedAt); a lead is idle when both
885
+ updatedAt and latestActivityCreatedAt are on or before (now - days).
886
+ If only one of the two fields is filterable, uses that one (and notes it in the result).
887
+ """
888
+ tz = time_zone or DEFAULT_TIMEZONE
889
+ threshold_iso = _threshold_iso_days_ago(days, tz)
890
+ base = {"operator": "less_or_equal", "value": threshold_iso, "timeZone": tz}
891
+ fields_list = await _fetch_lead_fields()
892
+ filterable_map = _get_filterable_fields_map(fields_list)
893
+ filters = []
894
+ for name in ("updatedAt", "latestActivityCreatedAt"):
895
+ if name in filterable_map:
896
+ filters.append({"field": name, **base})
897
+ if not filters:
898
+ return "Error: Neither 'updatedAt' nor 'latestActivityCreatedAt' is filterable for this tenant. Check get_lead_field_instructions."
899
+ return await search_leads_logic(filters, page=page, size=size, sort=sort)
900
+
901
+
902
+ @mcp.tool()
903
+ async def search_idle_leads(
904
+ days: int,
905
+ time_zone: Optional[str] = None,
906
+ page: int = 0,
907
+ size: int = 20,
908
+ sort: Optional[str] = "createdAt,desc",
909
+ ) -> str:
910
+ """
911
+ Search for idle/stagnant leads: no activity for at least the given number of days.
912
+ Uses both updatedAt and latestActivityCreatedAt; a lead is returned only when BOTH dates
913
+ are on or before (today − days), so the effective last activity is before the threshold.
914
+
915
+ days: Minimum days with no activity (e.g. 10 for "no activity since 10 days").
916
+ time_zone: IANA timezone for threshold (e.g. America/New_York). Default: Asia/Calcutta.
917
+ page: 0-based page (default 0).
918
+ size: Page size, max 100 (default 20).
919
+ sort: Sort e.g. "createdAt,desc" (default).
920
+ """
921
+ try:
922
+ if days < 0:
923
+ return "Error: days must be non-negative."
924
+ return await search_idle_leads_logic(days, time_zone, page, size, sort)
925
+ except KylasAPIError as e:
926
+ return f"✗ Search idle leads failed: {e.message}\n Details: {e.response_body}"
927
+ except Exception as e:
928
+ logger.exception("search_idle_leads")
929
+ return f"✗ Unexpected error: {str(e)}"
930
+
931
+
932
+ # ---------------------------------------------------------------------------
933
+ # Entry Point
934
+ # ---------------------------------------------------------------------------
935
+
936
+ def run() -> None:
937
+ """Entry point for console script (e.g. kylas-crm-mcp)."""
938
+ logger.info("Starting Kylas CRM MCP Server (Lead only)...")
939
+ mcp.run()
940
+
941
+
942
+ if __name__ == "__main__":
943
+ run()