decodingtrust-agent-sdk 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.
- agent/__init__.py +30 -0
- agent/claudesdk/__init__.py +8 -0
- agent/claudesdk/example.py +221 -0
- agent/claudesdk/src/__init__.py +8 -0
- agent/claudesdk/src/agent.py +400 -0
- agent/claudesdk/src/mcp_proxy.py +409 -0
- agent/claudesdk/src/utils.py +420 -0
- agent/googleadk/__init__.py +15 -0
- agent/googleadk/example.py +237 -0
- agent/googleadk/src/__init__.py +12 -0
- agent/googleadk/src/agent.py +401 -0
- agent/googleadk/src/mcp_wrapper.py +163 -0
- agent/googleadk/src/utils.py +602 -0
- agent/langchain/__init__.py +8 -0
- agent/langchain/example.py +213 -0
- agent/langchain/src/__init__.py +8 -0
- agent/langchain/src/agent.py +645 -0
- agent/langchain/src/utils.py +433 -0
- agent/openaisdk/__init__.py +17 -0
- agent/openaisdk/example.py +228 -0
- agent/openaisdk/src/__init__.py +12 -0
- agent/openaisdk/src/agent.py +491 -0
- agent/openaisdk/src/agent_wrapper.py +143 -0
- agent/openaisdk/src/mcp_wrapper.py +395 -0
- agent/openaisdk/src/utils.py +493 -0
- agent/openclaw/__init__.py +10 -0
- agent/openclaw/example.py +251 -0
- agent/openclaw/src/__init__.py +14 -0
- agent/openclaw/src/agent.py +930 -0
- agent/openclaw/src/helpers/__init__.py +1 -0
- agent/openclaw/src/helpers/auth_helpers.py +55 -0
- agent/openclaw/src/mcp_proxy.py +564 -0
- agent/openclaw/src/plugin_generator.py +231 -0
- agent/openclaw/src/utils.py +341 -0
- agent/pocketflow/__init__.py +18 -0
- agent/pocketflow/example.py +221 -0
- agent/pocketflow/prompts/react_agent.py +46 -0
- agent/pocketflow/src/__init__.py +6 -0
- agent/pocketflow/src/agent.py +507 -0
- agent/pocketflow/src/agent_wrapper.py +159 -0
- agent/pocketflow/src/async_helper.py +92 -0
- agent/pocketflow/src/mcp_react_agent.py +279 -0
- agent/pocketflow/src/native_agent.py +74 -0
- agent/pocketflow/src/nodes.py +467 -0
- benchmark/__init__.py +0 -0
- benchmark/browser/benign.jsonl +34 -0
- benchmark/browser/direct.jsonl +85 -0
- benchmark/browser/indirect.jsonl +82 -0
- benchmark/code/benign.jsonl +0 -0
- benchmark/code/direct.jsonl +121 -0
- benchmark/code/indirect.jsonl +165 -0
- benchmark/crm/benign.jsonl +165 -0
- benchmark/crm/direct.jsonl +90 -0
- benchmark/crm/indirect.jsonl +150 -0
- benchmark/customer-service/benign.jsonl +160 -0
- benchmark/customer-service/direct.jsonl +100 -0
- benchmark/customer-service/indirect.jsonl +101 -0
- benchmark/finance/benign.jsonl +0 -0
- benchmark/finance/direct.jsonl +200 -0
- benchmark/finance/indirect.jsonl +200 -0
- benchmark/legal/benign.jsonl +0 -0
- benchmark/legal/direct.jsonl +200 -0
- benchmark/legal/indirect.jsonl +200 -0
- benchmark/macos/benign.jsonl +30 -0
- benchmark/macos/direct.jsonl +50 -0
- benchmark/macos/indirect.jsonl +50 -0
- benchmark/medical/benign.jsonl +642 -0
- benchmark/medical/direct.jsonl +229 -0
- benchmark/medical/indirect.jsonl +222 -0
- benchmark/os-filesystem/benign.jsonl +200 -0
- benchmark/os-filesystem/direct.jsonl +200 -0
- benchmark/os-filesystem/indirect.jsonl +200 -0
- benchmark/research/benign.jsonl +0 -0
- benchmark/research/direct.jsonl +119 -0
- benchmark/research/indirect.jsonl +125 -0
- benchmark/telecom/benign.jsonl +120 -0
- benchmark/telecom/direct.jsonl +161 -0
- benchmark/telecom/indirect.jsonl +166 -0
- benchmark/travel/benign.jsonl +130 -0
- benchmark/travel/direct.jsonl +105 -0
- benchmark/travel/indirect.jsonl +120 -0
- benchmark/windows/benign.jsonl +100 -0
- benchmark/windows/direct.jsonl +140 -0
- benchmark/windows/indirect.jsonl +107 -0
- benchmark/workflow/benign.jsonl +335 -0
- benchmark/workflow/direct.jsonl +78 -0
- benchmark/workflow/indirect.jsonl +107 -0
- cli/__init__.py +5 -0
- cli/main.py +182 -0
- cli/scaffold.py +334 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/METADATA +642 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/RECORD +374 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/WHEEL +5 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- decodingtrust_agent_sdk-0.1.0.dist-info/top_level.txt +6 -0
- dt_arena/config/env.yaml +515 -0
- dt_arena/config/injection_mcp.yaml +430 -0
- dt_arena/config/mcp.yaml +642 -0
- dt_arena/envs/arxiv/docker-compose-hub.yml +31 -0
- dt_arena/envs/arxiv/docker-compose.yml +36 -0
- dt_arena/envs/atlassian/docker/docker-compose.dev.yml +65 -0
- dt_arena/envs/atlassian/docker/docker-compose.yml +53 -0
- dt_arena/envs/atlassian/docker-compose-hub.yml +57 -0
- dt_arena/envs/atlassian/docker-compose.yml +72 -0
- dt_arena/envs/bigquery/docker-compose.yml +20 -0
- dt_arena/envs/booking/docker-compose.yml +59 -0
- dt_arena/envs/calendar/docker-compose-hub.yml +30 -0
- dt_arena/envs/calendar/docker-compose.yml +42 -0
- dt_arena/envs/custom-website/docker-compose.yml +6 -0
- dt_arena/envs/customer_service/docker-compose.yml +59 -0
- dt_arena/envs/databricks/docker-compose-hub.yml +47 -0
- dt_arena/envs/databricks/docker-compose.yml +51 -0
- dt_arena/envs/ecommerce/docker-compose.yml +6 -0
- dt_arena/envs/ers/docker-compose.yml +36 -0
- dt_arena/envs/ers/hrms/docker/docker-compose.yml +31 -0
- dt_arena/envs/finance/docker-compose.yml +23 -0
- dt_arena/envs/github/docker/docker-compose-hub.yml +50 -0
- dt_arena/envs/github/docker/docker-compose.yml +50 -0
- dt_arena/envs/gmail/docker-compose-hub.yml +51 -0
- dt_arena/envs/gmail/docker-compose.yml +65 -0
- dt_arena/envs/google-form/docker-compose-hub.yml +33 -0
- dt_arena/envs/google-form/docker-compose.yml +41 -0
- dt_arena/envs/googledocs/docker-compose-hub.yml +61 -0
- dt_arena/envs/googledocs/docker-compose.yml +78 -0
- dt_arena/envs/hospital/docker-compose-hub.yml +25 -0
- dt_arena/envs/hospital/docker-compose.yml +27 -0
- dt_arena/envs/legal/docker-compose.yml +22 -0
- dt_arena/envs/linkedin/docker-compose.yml +63 -0
- dt_arena/envs/macos/docker-compose.yml +79 -0
- dt_arena/envs/os-filesystem/docker-compose-hub.yml +16 -0
- dt_arena/envs/os-filesystem/docker-compose.yml +20 -0
- dt_arena/envs/paypal/docker-compose-hub.yml +48 -0
- dt_arena/envs/paypal/docker-compose.yml +63 -0
- dt_arena/envs/research/docker-compose-hub.yml +13 -0
- dt_arena/envs/research/docker-compose.yml +24 -0
- dt_arena/envs/salesforce_crm/docker-compose-hub.yaml +45 -0
- dt_arena/envs/salesforce_crm/docker-compose.yaml +49 -0
- dt_arena/envs/slack/docker-compose-hub.yml +28 -0
- dt_arena/envs/slack/docker-compose.yml +41 -0
- dt_arena/envs/snowflake/docker-compose-hub.yml +41 -0
- dt_arena/envs/snowflake/docker-compose.yml +44 -0
- dt_arena/envs/telecom/docker-compose-hub.yml +16 -0
- dt_arena/envs/telecom/docker-compose.yml +17 -0
- dt_arena/envs/telegram/docker-compose-hub.yml +57 -0
- dt_arena/envs/telegram/docker-compose.yml +62 -0
- dt_arena/envs/terminal/docker-compose-hub.yml +12 -0
- dt_arena/envs/terminal/docker-compose.yml +26 -0
- dt_arena/envs/travel/docker-compose-hub.yml +19 -0
- dt_arena/envs/travel/docker-compose.yml +19 -0
- dt_arena/envs/whatsapp/docker-compose-hub.yml +61 -0
- dt_arena/envs/whatsapp/docker-compose.yml +78 -0
- dt_arena/envs/windows/docker-compose.yml +71 -0
- dt_arena/envs/zoom/docker-compose-hub.yml +27 -0
- dt_arena/envs/zoom/docker-compose.yml +40 -0
- dt_arena/injection_mcp_server/atlassian/env_injection.py +134 -0
- dt_arena/injection_mcp_server/calendar/env_injection.py +217 -0
- dt_arena/injection_mcp_server/custom_website/env_injection.py +97 -0
- dt_arena/injection_mcp_server/customer_service/env_injection.py +659 -0
- dt_arena/injection_mcp_server/databricks/env_injection.py +255 -0
- dt_arena/injection_mcp_server/ecommerce/env_injection.py +110 -0
- dt_arena/injection_mcp_server/finance/env_injection.py +85 -0
- dt_arena/injection_mcp_server/github/env_injection.py +206 -0
- dt_arena/injection_mcp_server/gmail/env_injection.py +211 -0
- dt_arena/injection_mcp_server/google_form/env_injection.py +186 -0
- dt_arena/injection_mcp_server/googledocs/env_injection.py +44 -0
- dt_arena/injection_mcp_server/hospital/env_injection.py +43 -0
- dt_arena/injection_mcp_server/legal/env_injection.py +229 -0
- dt_arena/injection_mcp_server/macos/env_injection.py +272 -0
- dt_arena/injection_mcp_server/os-filesystem/env_injection.py +341 -0
- dt_arena/injection_mcp_server/paypal/env_injection.py +268 -0
- dt_arena/injection_mcp_server/research/env_injection.py +616 -0
- dt_arena/injection_mcp_server/salesforce/env_injection.py +514 -0
- dt_arena/injection_mcp_server/slack/env_injection.py +265 -0
- dt_arena/injection_mcp_server/snowflake/env_injection.py +230 -0
- dt_arena/injection_mcp_server/telecom/env_injection.py +503 -0
- dt_arena/injection_mcp_server/telegram/env_injection.py +171 -0
- dt_arena/injection_mcp_server/terminal/env_injection.py +523 -0
- dt_arena/injection_mcp_server/travel/env_injection.py +173 -0
- dt_arena/injection_mcp_server/whatsapp/env_injection.py +185 -0
- dt_arena/injection_mcp_server/windows/env_injection.py +943 -0
- dt_arena/injection_mcp_server/zoom/env_injection.py +216 -0
- dt_arena/mcp_server/atlassian/main.py +1554 -0
- dt_arena/mcp_server/atlassian/test_server.py +66 -0
- dt_arena/mcp_server/bigquery/main.py +333 -0
- dt_arena/mcp_server/booking/main.py +310 -0
- dt_arena/mcp_server/browser/main.py +1741 -0
- dt_arena/mcp_server/calendar/example_multi_user.py +162 -0
- dt_arena/mcp_server/calendar/main.py +792 -0
- dt_arena/mcp_server/calendar/test_mcp.py +135 -0
- dt_arena/mcp_server/customer_service/main.py +1063 -0
- dt_arena/mcp_server/databricks/main.py +566 -0
- dt_arena/mcp_server/databricks/probe.py +102 -0
- dt_arena/mcp_server/ers/main.py +845 -0
- dt_arena/mcp_server/finance/__init__.py +87 -0
- dt_arena/mcp_server/finance/core/__init__.py +12 -0
- dt_arena/mcp_server/finance/core/data_loader.py +558 -0
- dt_arena/mcp_server/finance/core/portfolio.py +565 -0
- dt_arena/mcp_server/finance/evaluation/__init__.py +20 -0
- dt_arena/mcp_server/finance/evaluation/evaluator.py +217 -0
- dt_arena/mcp_server/finance/evaluation/logger.py +137 -0
- dt_arena/mcp_server/finance/injection/__init__.py +66 -0
- dt_arena/mcp_server/finance/injection/config.py +176 -0
- dt_arena/mcp_server/finance/injection/content.py +755 -0
- dt_arena/mcp_server/finance/injection/html.py +409 -0
- dt_arena/mcp_server/finance/injection/locations.py +167 -0
- dt_arena/mcp_server/finance/injection/methods.py +193 -0
- dt_arena/mcp_server/finance/injection/presets.py +1023 -0
- dt_arena/mcp_server/finance/main.py +361 -0
- dt_arena/mcp_server/finance/run_mcp.py +21 -0
- dt_arena/mcp_server/finance/run_web.py +26 -0
- dt_arena/mcp_server/finance/server/__init__.py +41 -0
- dt_arena/mcp_server/finance/server/extractor.py +1453 -0
- dt_arena/mcp_server/finance/server/extractor_minimal.py +292 -0
- dt_arena/mcp_server/finance/server/extractor_simple.py +1164 -0
- dt_arena/mcp_server/finance/server/injection_mcp.py +865 -0
- dt_arena/mcp_server/finance/server/mcp.py +451 -0
- dt_arena/mcp_server/finance/server/tools/__init__.py +23 -0
- dt_arena/mcp_server/finance/server/tools/account.py +88 -0
- dt_arena/mcp_server/finance/server/tools/browsing.py +328 -0
- dt_arena/mcp_server/finance/server/tools/social.py +73 -0
- dt_arena/mcp_server/finance/server/tools/trading.py +242 -0
- dt_arena/mcp_server/finance/server/tools/utility.py +49 -0
- dt_arena/mcp_server/finance/server/web.py +2139 -0
- dt_arena/mcp_server/finance/tasks/benchmark/__init__.py +28 -0
- dt_arena/mcp_server/finance/tasks/benchmark/attack_pool.py +3026 -0
- dt_arena/mcp_server/finance/tasks/benchmark/attack_runner.py +1315 -0
- dt_arena/mcp_server/finance/tasks/benchmark/finra_requirements.py +1335 -0
- dt_arena/mcp_server/finance/tasks/benchmark/finra_tasks.py +3665 -0
- dt_arena/mcp_server/finance/tasks/benchmark/malicious_tasks.py +2673 -0
- dt_arena/mcp_server/finance/tasks/redteam_suite/run_redteam_suite.py +1713 -0
- dt_arena/mcp_server/finance/test_mcp_tools.py +476 -0
- dt_arena/mcp_server/github/main.py +441 -0
- dt_arena/mcp_server/gmail/main.py +1004 -0
- dt_arena/mcp_server/google_form/main.py +141 -0
- dt_arena/mcp_server/googledocs/main.py +458 -0
- dt_arena/mcp_server/hospital/mcp_server.py +458 -0
- dt_arena/mcp_server/legal/__init__.py +9 -0
- dt_arena/mcp_server/legal/core/__init__.py +14 -0
- dt_arena/mcp_server/legal/core/courtlistener_store.py +762 -0
- dt_arena/mcp_server/legal/core/data_loader.py +266 -0
- dt_arena/mcp_server/legal/core/document_store.py +197 -0
- dt_arena/mcp_server/legal/core/matter_manager.py +466 -0
- dt_arena/mcp_server/legal/main.py +89 -0
- dt_arena/mcp_server/legal/scripts/collect_data.py +988 -0
- dt_arena/mcp_server/legal/server/__init__.py +14 -0
- dt_arena/mcp_server/legal/server/mcp.py +2330 -0
- dt_arena/mcp_server/macos/client_test.py +270 -0
- dt_arena/mcp_server/macos/mcp_server.py +285 -0
- dt_arena/mcp_server/os-filesystem/main.py +1380 -0
- dt_arena/mcp_server/paypal/main.py +501 -0
- dt_arena/mcp_server/research/main.py +777 -0
- dt_arena/mcp_server/salesforce/main.py +2006 -0
- dt_arena/mcp_server/slack/main.py +318 -0
- dt_arena/mcp_server/snowflake/main.py +612 -0
- dt_arena/mcp_server/snowflake/probe.py +183 -0
- dt_arena/mcp_server/telecom/mcp_client.py +423 -0
- dt_arena/mcp_server/telecom/mcp_server.py +1059 -0
- dt_arena/mcp_server/telegram/main.py +338 -0
- dt_arena/mcp_server/terminal/main.py +163 -0
- dt_arena/mcp_server/travel/client_test.py +16 -0
- dt_arena/mcp_server/travel/mcp_server.py +404 -0
- dt_arena/mcp_server/whatsapp/main.py +318 -0
- dt_arena/mcp_server/windows/client_test.py +270 -0
- dt_arena/mcp_server/windows/mcp_server.py +218 -0
- dt_arena/mcp_server/zoom/main.py +466 -0
- dt_arena/src/__init__.py +0 -0
- dt_arena/src/hooks/__init__.py +0 -0
- dt_arena/src/hooks/audit_log.py +30 -0
- dt_arena/src/hooks/hooks.json +3 -0
- dt_arena/src/run_benign.py +142 -0
- dt_arena/src/types/__init__.py +0 -0
- dt_arena/src/types/agent.py +441 -0
- dt_arena/src/types/attacks.py +2 -0
- dt_arena/src/types/environment.py +2 -0
- dt_arena/src/types/hooks.py +174 -0
- dt_arena/src/types/judge.py +52 -0
- dt_arena/src/types/red_teaming_trajectory.py +385 -0
- dt_arena/src/types/task.py +260 -0
- dt_arena/src/types/trajectory.py +315 -0
- dt_arena/utils/__init__.py +1 -0
- dt_arena/utils/atlassian/__init__.py +27 -0
- dt_arena/utils/atlassian/helpers.py +520 -0
- dt_arena/utils/bigquery/__init__.py +1 -0
- dt_arena/utils/bigquery/helpers.py +246 -0
- dt_arena/utils/calendar/__init__.py +1 -0
- dt_arena/utils/calendar/helpers.py +87 -0
- dt_arena/utils/customer_service/__init__.py +17 -0
- dt_arena/utils/customer_service/cs_env_client.py +940 -0
- dt_arena/utils/customer_service/helpers.py +339 -0
- dt_arena/utils/customer_service/judges/__init__.py +20 -0
- dt_arena/utils/customer_service/judges/abusive_comms.py +149 -0
- dt_arena/utils/customer_service/judges/bulk_destructive.py +202 -0
- dt_arena/utils/customer_service/judges/cross_customer.py +481 -0
- dt_arena/utils/customer_service/judges/email_exfiltration.py +320 -0
- dt_arena/utils/customer_service/judges/financial_fraud.py +221 -0
- dt_arena/utils/customer_service/judges/llm_utils.py +264 -0
- dt_arena/utils/customer_service/judges/pii_disclosure.py +837 -0
- dt_arena/utils/customer_service/judges/policy_misrep.py +44 -0
- dt_arena/utils/customer_service/judges/text_utils.py +21 -0
- dt_arena/utils/databricks/__init__.py +2 -0
- dt_arena/utils/databricks/helpers.py +210 -0
- dt_arena/utils/finance/__init__.py +0 -0
- dt_arena/utils/finance/helpers.py +263 -0
- dt_arena/utils/github/__init__.py +1 -0
- dt_arena/utils/github/helpers.py +249 -0
- dt_arena/utils/gmail/__init__.py +1 -0
- dt_arena/utils/gmail/helpers.py +344 -0
- dt_arena/utils/google_form/__init__.py +2 -0
- dt_arena/utils/google_form/helpers.py +133 -0
- dt_arena/utils/legal/__init__.py +0 -0
- dt_arena/utils/legal/helpers.py +228 -0
- dt_arena/utils/macos/__init__.py +0 -0
- dt_arena/utils/macos/env_setup.py +215 -0
- dt_arena/utils/macos/helpers.py +61 -0
- dt_arena/utils/os_filesystem/__init__.py +1 -0
- dt_arena/utils/os_filesystem/helpers.py +366 -0
- dt_arena/utils/paypal/__init__.py +1 -0
- dt_arena/utils/paypal/helpers.py +178 -0
- dt_arena/utils/port_allocator.py +266 -0
- dt_arena/utils/research/__init__.py +0 -0
- dt_arena/utils/research/helpers.py +251 -0
- dt_arena/utils/salesforce/__init__.py +1 -0
- dt_arena/utils/salesforce/helpers.py +719 -0
- dt_arena/utils/slack/__init__.py +1 -0
- dt_arena/utils/slack/helpers.py +176 -0
- dt_arena/utils/snowflake/__init__.py +1 -0
- dt_arena/utils/snowflake/helpers.py +166 -0
- dt_arena/utils/telecom/__init__.py +1 -0
- dt_arena/utils/telecom/helpers.py +760 -0
- dt_arena/utils/telegram/__init__.py +0 -0
- dt_arena/utils/telegram/helpers.py +174 -0
- dt_arena/utils/terminal/__init__.py +0 -0
- dt_arena/utils/terminal/helpers.py +20 -0
- dt_arena/utils/travel/__init__.py +0 -0
- dt_arena/utils/travel/env_client.py +537 -0
- dt_arena/utils/travel/llm_judge.py +137 -0
- dt_arena/utils/travel/prompts.py +64 -0
- dt_arena/utils/utils/__init__.py +122 -0
- dt_arena/utils/whatsapp/__init__.py +0 -0
- dt_arena/utils/whatsapp/helpers.py +226 -0
- dt_arena/utils/windows/__init__.py +0 -0
- dt_arena/utils/windows/env_reset.py +224 -0
- dt_arena/utils/windows/env_setup.py +280 -0
- dt_arena/utils/windows/exfil_helpers.py +170 -0
- dt_arena/utils/windows/helpers.py +74 -0
- dt_arena/utils/zoom/__init__.py +1 -0
- dt_arena/utils/zoom/helpers.py +70 -0
- eval/__init__.py +1 -0
- eval/evaluation.py +426 -0
- eval/task_runner.py +449 -0
- utils/__init__.py +148 -0
- utils/agent_helpers.py +308 -0
- utils/agent_wrapper.py +189 -0
- utils/compose_utils.py +135 -0
- utils/config.py +77 -0
- utils/env_helpers.py +104 -0
- utils/eval_stats.py +88 -0
- utils/injection_helpers.py +429 -0
- utils/injection_mcp_helpers.py +152 -0
- utils/judge_helpers.py +181 -0
- utils/judge_utils.py +472 -0
- utils/llm.py +196 -0
- utils/logging.py +45 -0
- utils/mcp_helpers.py +232 -0
- utils/mcp_manager.py +235 -0
- utils/memory_guard.py +18 -0
- utils/red_teaming_sandbox.py +476 -0
- utils/reset_helpers.py +318 -0
- utils/resource_manager.py +370 -0
- utils/skill_helpers.py +447 -0
- utils/task_executor.py +904 -0
- utils/task_helpers.py +270 -0
- utils/template_helpers.py +179 -0
|
@@ -0,0 +1,2006 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any, Dict, List, Mapping, Optional, Union
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
try:
|
|
7
|
+
import yaml # type: ignore
|
|
8
|
+
except Exception:
|
|
9
|
+
yaml = None
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from fastmcp import FastMCP
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Basic configuration
|
|
16
|
+
SALESFORCE_BASE_URL = os.getenv("SALESFORCE_BASE_URL", "http://128.111.28.87:8080/")
|
|
17
|
+
SALESFORCE_TOKEN_URL = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/access_token"
|
|
18
|
+
|
|
19
|
+
# Grant configuration (env fallbacks)
|
|
20
|
+
SALESFORCE_GRANT_TYPE = os.getenv("SALESFORCE_GRANT_TYPE", "password") # or "client_credentials"
|
|
21
|
+
SALESFORCE_CLIENT_ID = os.getenv("SALESFORCE_CLIENT_ID", "")
|
|
22
|
+
SALESFORCE_CLIENT_SECRET = os.getenv("SALESFORCE_CLIENT_SECRET", "")
|
|
23
|
+
SALESFORCE_USERNAME = os.getenv("SALESFORCE_USERNAME", "")
|
|
24
|
+
SALESFORCE_PASSWORD = os.getenv("SALESFORCE_PASSWORD", "")
|
|
25
|
+
|
|
26
|
+
# Optional pre-supplied access token
|
|
27
|
+
SALESFORCE_ACCESS_TOKEN = os.getenv("SALESFORCE_ACCESS_TOKEN", "")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
mcp = FastMCP("Salesforce MCP Server")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _bearer_headers(token: Optional[str]) -> Dict[str, str]:
|
|
34
|
+
headers: Dict[str, str] = {}
|
|
35
|
+
if token:
|
|
36
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
37
|
+
return headers
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# JSON:API header constants
|
|
41
|
+
JSONAPI_ACCEPT = "application/vnd.api+json"
|
|
42
|
+
JSONAPI_CONTENT = "application/vnd.api+json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _request(
|
|
46
|
+
method: str,
|
|
47
|
+
url: str,
|
|
48
|
+
token: Optional[str] = None,
|
|
49
|
+
params: Optional[Dict[str, Any]] = None,
|
|
50
|
+
json: Optional[Any] = None,
|
|
51
|
+
data: Optional[Any] = None,
|
|
52
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
53
|
+
) -> Any:
|
|
54
|
+
"""Unified HTTP request with structured error responses.
|
|
55
|
+
|
|
56
|
+
Returns parsed JSON on success; otherwise returns {error, status_code, text}.
|
|
57
|
+
"""
|
|
58
|
+
headers = _bearer_headers(token)
|
|
59
|
+
if extra_headers:
|
|
60
|
+
headers.update(extra_headers)
|
|
61
|
+
try:
|
|
62
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
63
|
+
r = await client.request(method, url, params=params, json=json, data=data, headers=headers)
|
|
64
|
+
# Try JSON first; if fails, fallback to text
|
|
65
|
+
r.raise_for_status()
|
|
66
|
+
try:
|
|
67
|
+
return r.json()
|
|
68
|
+
except Exception:
|
|
69
|
+
return {"ok": True, "text": r.text}
|
|
70
|
+
except httpx.HTTPStatusError as e:
|
|
71
|
+
resp = e.response
|
|
72
|
+
return {
|
|
73
|
+
"error": f"HTTP {resp.status_code}",
|
|
74
|
+
"status_code": resp.status_code,
|
|
75
|
+
"text": resp.text,
|
|
76
|
+
}
|
|
77
|
+
except httpx.RequestError as e:
|
|
78
|
+
return {"error": "request_error", "message": str(e)}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def _get_token(access_token: Optional[str] = None) -> str:
|
|
82
|
+
"""Get an access token. If not provided, attempt to obtain via configured grant."""
|
|
83
|
+
token = (access_token or SALESFORCE_ACCESS_TOKEN).strip()
|
|
84
|
+
if token:
|
|
85
|
+
return token
|
|
86
|
+
# Try to fetch a token using configured grant
|
|
87
|
+
return await _post_token()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ------------------------------
|
|
91
|
+
# Internal HTTP helpers (non-tool)
|
|
92
|
+
# These are used by higher-level tool wrappers to avoid calling tool functions directly.
|
|
93
|
+
# ------------------------------
|
|
94
|
+
|
|
95
|
+
async def _http_get_available_modules(token: Optional[str] = None) -> Any:
|
|
96
|
+
tok = await _get_token(token)
|
|
97
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/modules"
|
|
98
|
+
return await _request("GET", url, tok, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def _http_get_entry(module_name: str, record_id: str, fields: Optional[List[str]] = None, token: Optional[str] = None) -> Any:
|
|
102
|
+
tok = await _get_token(token)
|
|
103
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}"
|
|
104
|
+
params = _fields_param(module_name, fields)
|
|
105
|
+
return await _request("GET", url, tok, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _http_list_records(module_name: str, filter: Optional[Dict[str, Any]] = None, sort: Optional[str] = None, page: int = 1, page_size: int = 20, fields: Optional[List[str]] = None, token: Optional[str] = None) -> Any:
|
|
109
|
+
tok = await _get_token(token)
|
|
110
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}"
|
|
111
|
+
params: Dict[str, Any] = {"page[number]": page or 1, "page[size]": page_size or 20}
|
|
112
|
+
if sort:
|
|
113
|
+
params["sort"] = sort
|
|
114
|
+
if fields:
|
|
115
|
+
params.update(_fields_param(module_name, fields))
|
|
116
|
+
if filter:
|
|
117
|
+
operator = str(filter.get("operator", "and"))
|
|
118
|
+
filt = dict(filter)
|
|
119
|
+
if "operator" in filt:
|
|
120
|
+
del filt["operator"]
|
|
121
|
+
params.update(_filters_params(operator, filt))
|
|
122
|
+
return await _request("GET", url, tok, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _http_create_record(module_name: str, attributes: Dict[str, Any], relationships: Optional[Dict[str, Any]] = None, token: Optional[str] = None) -> Any:
|
|
126
|
+
tok = await _get_token(token)
|
|
127
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
|
|
128
|
+
payload: Dict[str, Any] = {"data": {"type": module_name, "attributes": attributes}}
|
|
129
|
+
if relationships:
|
|
130
|
+
payload["data"]["relationships"] = relationships
|
|
131
|
+
return await _request("POST", url, tok, json=payload, extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _http_update_record(module_name: str, record_id: str, attributes: Dict[str, Any], relationships: Optional[Dict[str, Any]] = None, token: Optional[str] = None) -> Any:
|
|
135
|
+
tok = await _get_token(token)
|
|
136
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
|
|
137
|
+
payload: Dict[str, Any] = {"data": {"type": module_name, "id": record_id, "attributes": attributes}}
|
|
138
|
+
if relationships:
|
|
139
|
+
payload["data"]["relationships"] = relationships
|
|
140
|
+
return await _request("PATCH", url, tok, json=payload, extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _http_delete_entry(module_name: str, record_id: str, token: Optional[str] = None) -> Any:
|
|
144
|
+
tok = await _get_token(token)
|
|
145
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}"
|
|
146
|
+
return await _request("DELETE", url, tok, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _http_get_relationships(module_name: str, record_id: str, relationship_name: str, page: int = 1, page_size: int = 20, token: Optional[str] = None) -> Any:
|
|
150
|
+
tok = await _get_token(token)
|
|
151
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}/relationships/{relationship_name}"
|
|
152
|
+
params = {"page[number]": page or 1, "page[size]": page_size or 20}
|
|
153
|
+
return await _request("GET", url, tok, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def _http_set_relationship(module_name: str, record_id: str, relationship_name: str, related_data: Union[List[Dict[str, str]], Dict[str, str]], token: Optional[str] = None) -> Any:
|
|
157
|
+
tok = await _get_token(token)
|
|
158
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}/relationships/{relationship_name}"
|
|
159
|
+
payload = {"data": related_data}
|
|
160
|
+
return await _request("POST", url, tok, json=payload, extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT})
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def _http_get_module_fields(module_name: str, token: Optional[str] = None) -> Any:
|
|
164
|
+
tok = await _get_token(token)
|
|
165
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/fields/{module_name}"
|
|
166
|
+
return await _request("GET", url, tok, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _post_token(override_grant_type: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None) -> str:
|
|
170
|
+
"""Obtain an OAuth2 access token from Salesforce.
|
|
171
|
+
|
|
172
|
+
Supports grant types: password, client_credentials.
|
|
173
|
+
Env fallbacks are used when parameters are not provided.
|
|
174
|
+
"""
|
|
175
|
+
grant_type = (override_grant_type or SALESFORCE_GRANT_TYPE or "password").strip()
|
|
176
|
+
client_id = SALESFORCE_CLIENT_ID
|
|
177
|
+
client_secret = SALESFORCE_CLIENT_SECRET
|
|
178
|
+
|
|
179
|
+
data: Dict[str, str] = {"grant_type": grant_type}
|
|
180
|
+
if grant_type == "password":
|
|
181
|
+
data.update(
|
|
182
|
+
{
|
|
183
|
+
"username": username or SALESFORCE_USERNAME,
|
|
184
|
+
"password": password or SALESFORCE_PASSWORD,
|
|
185
|
+
"client_id": client_id,
|
|
186
|
+
"client_secret": client_secret,
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
elif grant_type == "client_credentials":
|
|
190
|
+
data.update(
|
|
191
|
+
{
|
|
192
|
+
"client_id": client_id,
|
|
193
|
+
"client_secret": client_secret,
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
raise ValueError(f"Unsupported grant_type: {grant_type}")
|
|
198
|
+
|
|
199
|
+
# Token endpoint expects form data; be explicit about Accept
|
|
200
|
+
headers = {"Accept": "application/json"}
|
|
201
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
202
|
+
r = await client.post(SALESFORCE_TOKEN_URL, data=data, headers=headers)
|
|
203
|
+
r.raise_for_status()
|
|
204
|
+
resp = r.json()
|
|
205
|
+
token = resp.get("access_token") or resp.get("token")
|
|
206
|
+
if not token:
|
|
207
|
+
raise RuntimeError(f"Failed to obtain access token: {resp}")
|
|
208
|
+
return token
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _resolve_token(access_token: Optional[str]) -> str:
|
|
212
|
+
token = (access_token or SALESFORCE_ACCESS_TOKEN).strip()
|
|
213
|
+
if token:
|
|
214
|
+
return token
|
|
215
|
+
# Fallback to env-driven token fetch (best-effort)
|
|
216
|
+
raise RuntimeError("Missing access token. Provide 'access_token' or configure SALESFORCE_* credentials for automatic fetch.")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _fields_param(module_name: str, fields: Optional[List[str]]) -> Dict[str, str]:
|
|
220
|
+
if not fields:
|
|
221
|
+
return {}
|
|
222
|
+
return {f"fields[{module_name}]": ",".join(fields)}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _filters_params(operator: str, filters: Optional[Mapping[str, Mapping[str, Any]]]) -> Dict[str, str]:
|
|
226
|
+
params: Dict[str, str] = {}
|
|
227
|
+
if operator:
|
|
228
|
+
params["filter[operator]"] = operator
|
|
229
|
+
if not filters:
|
|
230
|
+
return params
|
|
231
|
+
for field_name, ops in filters.items():
|
|
232
|
+
for cmp_op, value in ops.items():
|
|
233
|
+
params[f"filter[{field_name}][{cmp_op}]"] = str(value)
|
|
234
|
+
return params
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Note: Token acquisition can be handled automatically at startup if SALESFORCE_* credentials are provided.
|
|
238
|
+
|
|
239
|
+
@mcp.tool()
|
|
240
|
+
async def meta_modules() -> Any:
|
|
241
|
+
"""List available Salesforce CRM modules for the authenticated user.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
JSON payload with list of available modules and their metadata.
|
|
245
|
+
"""
|
|
246
|
+
token = await _get_token()
|
|
247
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/modules"
|
|
248
|
+
return await _request("GET", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@mcp.tool()
|
|
252
|
+
async def meta_fields(module_name: str) -> Any:
|
|
253
|
+
"""List field metadata for a module. Use this to discover valid field names and types before creating or updating records.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
257
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
258
|
+
Use meta_modules() to see the full list.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
JSON payload with field definitions including types, labels, and options.
|
|
262
|
+
"""
|
|
263
|
+
token = await _get_token()
|
|
264
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/fields/{module_name}"
|
|
265
|
+
return await _request("GET", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@mcp.tool()
|
|
269
|
+
async def get_record(module_name: str, record_id: str, fields: Optional[List[str]] = None) -> Any:
|
|
270
|
+
"""Get a single record by id.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
274
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
275
|
+
Use meta_modules() to see the full list.
|
|
276
|
+
record_id: Record GUID.
|
|
277
|
+
fields: Optional list of field names to include in response.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
JSON:API response with record data including id, type, and attributes.
|
|
281
|
+
"""
|
|
282
|
+
token = await _get_token()
|
|
283
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}"
|
|
284
|
+
params: Dict[str, str] = _fields_param(module_name, fields)
|
|
285
|
+
return await _request("GET", url, token, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@mcp.tool()
|
|
289
|
+
async def list_records(
|
|
290
|
+
module_name: str,
|
|
291
|
+
fields: Optional[List[str]] = None,
|
|
292
|
+
page_number: int = 1,
|
|
293
|
+
page_size: int = 20,
|
|
294
|
+
sort: Optional[str] = None,
|
|
295
|
+
operator: str = "and",
|
|
296
|
+
filters: Optional[Mapping[str, Mapping[str, Any]]] = None,
|
|
297
|
+
) -> Any:
|
|
298
|
+
"""List records for a module with pagination, fields, sort and filters.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
302
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
303
|
+
Use meta_modules() to see the full list.
|
|
304
|
+
fields: Optional list of fields to include in response.
|
|
305
|
+
page_number: Page index (1-based).
|
|
306
|
+
page_size: Page size (default 20).
|
|
307
|
+
sort: Comma-separated field list with optional '-' prefix for desc.
|
|
308
|
+
operator: Logical operator for filters ("and"/"or").
|
|
309
|
+
filters: JSON:API filter mapping, e.g., {"name": {"eq": "Acme"}}.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Paginated list of records with id, type, and attributes.
|
|
313
|
+
"""
|
|
314
|
+
try:
|
|
315
|
+
token = await _get_token()
|
|
316
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}"
|
|
317
|
+
params: Dict[str, Any] = {
|
|
318
|
+
"page[number]": page_number,
|
|
319
|
+
"page[size]": page_size,
|
|
320
|
+
}
|
|
321
|
+
if sort:
|
|
322
|
+
params["sort"] = sort
|
|
323
|
+
if fields:
|
|
324
|
+
params.update(_fields_param(module_name, fields))
|
|
325
|
+
if filters:
|
|
326
|
+
params.update(_filters_params(operator, filters))
|
|
327
|
+
return await _request("GET", url, token, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
328
|
+
except Exception as e:
|
|
329
|
+
return {"error": True, "exception": type(e).__name__, "message": str(e)}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@mcp.tool()
|
|
333
|
+
async def list_leads(
|
|
334
|
+
page: Optional[int] = 1,
|
|
335
|
+
page_size: Optional[int] = 20,
|
|
336
|
+
) -> Any:
|
|
337
|
+
"""List all leads in the CRM system.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
page: Index of the target page to retrieve (1-based, default 1).
|
|
341
|
+
page_size: Number of records per page to segment the lead list (default 20).
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
List of lead records with id, type, and attributes (first_name, last_name,
|
|
345
|
+
account_name, phone_work, email1, date_entered).
|
|
346
|
+
"""
|
|
347
|
+
return await _http_list_records(
|
|
348
|
+
"Leads",
|
|
349
|
+
filter=None,
|
|
350
|
+
sort="-date_entered",
|
|
351
|
+
page=page or 1,
|
|
352
|
+
page_size=page_size or 20,
|
|
353
|
+
fields=["first_name", "last_name", "account_name", "phone_work", "email1", "status", "date_entered"],
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@mcp.tool()
|
|
359
|
+
async def create_record(module_name: str, attributes: Dict[str, Any]) -> Any:
|
|
360
|
+
"""Create a CRM record for any module. This is the generic record creation tool.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
364
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
365
|
+
Use meta_modules() to see the full list.
|
|
366
|
+
attributes: Dictionary of field names and values. Common attributes per module:
|
|
367
|
+
- Notes: {"name": "title", "description": "body text", "contact_id": "GUID",
|
|
368
|
+
"parent_type": "Accounts", "parent_id": "GUID", "assigned_user_id": "GUID"}
|
|
369
|
+
- Tasks: {"name": "subject", "status": "Not Started", "priority": "High",
|
|
370
|
+
"date_due": "YYYY-MM-DD HH:MM:SS", "description": "details",
|
|
371
|
+
"contact_id": "GUID", "parent_type": "Accounts", "parent_id": "GUID"}
|
|
372
|
+
- Meetings: {"name": "subject", "status": "Planned|Held|Not Held",
|
|
373
|
+
"date_start": "YYYY-MM-DD HH:MM:SS", "duration_hours": 1,
|
|
374
|
+
"duration_minutes": 0, "description": "notes", "location": "place",
|
|
375
|
+
"parent_type": "Accounts", "parent_id": "GUID"}
|
|
376
|
+
- Calls: {"name": "subject", "direction": "Inbound|Outbound",
|
|
377
|
+
"status": "Planned|Held", "date_start": "YYYY-MM-DD HH:MM:SS",
|
|
378
|
+
"duration_hours": 0, "duration_minutes": 30, "description": "notes",
|
|
379
|
+
"parent_type": "Accounts", "parent_id": "GUID"}
|
|
380
|
+
- Accounts: {"name": "account name", "industry": "Technology",
|
|
381
|
+
"billing_address_city": "city", "phone_office": "number"}
|
|
382
|
+
- Contacts: {"first_name": "name", "last_name": "name",
|
|
383
|
+
"email1": "email", "phone_work": "number", "account_id": "GUID"}
|
|
384
|
+
- Leads: {"first_name": "name", "last_name": "name",
|
|
385
|
+
"email1": "email", "status": "New", "account_name": "company"}
|
|
386
|
+
- Opportunities: {"name": "deal name", "amount": 10000,
|
|
387
|
+
"sales_stage": "Prospecting", "date_closed": "YYYY-MM-DD",
|
|
388
|
+
"account_id": "GUID"}
|
|
389
|
+
- Cases: {"name": "case title", "status": "Open_New",
|
|
390
|
+
"priority": "P1|P2|P3", "description": "details",
|
|
391
|
+
"account_id": "GUID", "contact_id": "GUID"}
|
|
392
|
+
Use meta_fields(module_name) to discover all valid fields for a module.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
JSON:API response with created record data including generated id.
|
|
396
|
+
"""
|
|
397
|
+
token = await _get_token()
|
|
398
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
|
|
399
|
+
payload = {"data": {"type": module_name, "attributes": attributes}}
|
|
400
|
+
return await _request(
|
|
401
|
+
"POST",
|
|
402
|
+
url,
|
|
403
|
+
token,
|
|
404
|
+
json=payload,
|
|
405
|
+
extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@mcp.tool()
|
|
410
|
+
async def update_record(module_name: str, record_id: str, attributes: Dict[str, Any]) -> Any:
|
|
411
|
+
"""Update an existing CRM record by id (partial update).
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
415
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
416
|
+
Use meta_modules() to see the full list.
|
|
417
|
+
record_id: Record GUID to update.
|
|
418
|
+
attributes: Dictionary of field names and values to update. Only include fields
|
|
419
|
+
you want to change. See create_record() for common attributes per module.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
JSON:API response with updated record data.
|
|
423
|
+
"""
|
|
424
|
+
token = await _get_token()
|
|
425
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
|
|
426
|
+
payload = {"data": {"type": module_name, "id": record_id, "attributes": attributes}}
|
|
427
|
+
return await _request(
|
|
428
|
+
"PATCH",
|
|
429
|
+
url,
|
|
430
|
+
token,
|
|
431
|
+
json=payload,
|
|
432
|
+
extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@mcp.tool()
|
|
437
|
+
async def delete_record(module_name: str, record_id: str) -> Any:
|
|
438
|
+
"""Delete a Salesforce CRM record by id.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
442
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
443
|
+
Use meta_modules() to see the full list.
|
|
444
|
+
record_id: Record GUID to delete.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Success confirmation or error details.
|
|
448
|
+
"""
|
|
449
|
+
token = await _get_token()
|
|
450
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}"
|
|
451
|
+
resp = await _request("DELETE", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
452
|
+
# Keep consistent success envelope if server returns empty body
|
|
453
|
+
if isinstance(resp, dict) and resp.get("error"):
|
|
454
|
+
return resp
|
|
455
|
+
return resp or {"ok": True, "id": record_id}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@mcp.tool()
|
|
459
|
+
async def relationship_get(module_name: str, record_id: str, link_field_name: str) -> Any:
|
|
460
|
+
"""Get related records for a given record's relationship field.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
464
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
465
|
+
Use meta_modules() to see the full list.
|
|
466
|
+
record_id: Record GUID.
|
|
467
|
+
link_field_name: Relationship link name (e.g., "contacts", "opportunities", "notes", "tasks", "meetings", "calls").
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
JSON:API response with list of related records.
|
|
471
|
+
"""
|
|
472
|
+
token = await _get_token()
|
|
473
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}/relationships/{link_field_name}"
|
|
474
|
+
return await _request("GET", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@mcp.tool()
|
|
478
|
+
async def relationship_create(
|
|
479
|
+
module_name: str,
|
|
480
|
+
record_id: str,
|
|
481
|
+
link_field_name: str,
|
|
482
|
+
related_module_name: str,
|
|
483
|
+
related_record_id: str,
|
|
484
|
+
) -> Any:
|
|
485
|
+
"""Create a relationship link from a record to another record.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
module_name: Source CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
489
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
490
|
+
Use meta_modules() to see the full list.
|
|
491
|
+
record_id: Source record GUID.
|
|
492
|
+
link_field_name: Relationship link field name on source (e.g., "contacts", "opportunities", "notes", "tasks").
|
|
493
|
+
related_module_name: Target module name (e.g., "Contacts", "Notes", "Tasks").
|
|
494
|
+
related_record_id: Target record GUID.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
JSON:API response confirming the relationship creation.
|
|
498
|
+
"""
|
|
499
|
+
token = await _get_token()
|
|
500
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}/relationships/{link_field_name}"
|
|
501
|
+
payload = {"data": {"type": related_module_name, "id": related_record_id}}
|
|
502
|
+
return await _request(
|
|
503
|
+
"POST",
|
|
504
|
+
url,
|
|
505
|
+
token,
|
|
506
|
+
json=payload,
|
|
507
|
+
extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@mcp.tool()
|
|
512
|
+
async def relationship_delete(
|
|
513
|
+
module_name: str,
|
|
514
|
+
record_id: str,
|
|
515
|
+
link_field_name: str,
|
|
516
|
+
related_record_id: str,
|
|
517
|
+
) -> Any:
|
|
518
|
+
"""Remove a relationship between records.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
module_name: Source CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
522
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
523
|
+
Use meta_modules() to see the full list.
|
|
524
|
+
record_id: Source record GUID.
|
|
525
|
+
link_field_name: Relationship link field name on source (e.g., "contacts", "opportunities", "notes", "tasks").
|
|
526
|
+
related_record_id: Target record GUID to unlink.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
Success confirmation or error details.
|
|
530
|
+
"""
|
|
531
|
+
token = await _get_token()
|
|
532
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{record_id}/relationships/{link_field_name}/{related_record_id}"
|
|
533
|
+
resp = await _request("DELETE", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
534
|
+
if isinstance(resp, dict) and resp.get("error"):
|
|
535
|
+
return resp
|
|
536
|
+
return resp or {"ok": True}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@mcp.tool()
|
|
540
|
+
async def logout() -> Any:
|
|
541
|
+
"""Invalidate the current session token (server-side logout).
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
Success confirmation or error details.
|
|
545
|
+
"""
|
|
546
|
+
token = await _get_token()
|
|
547
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/logout"
|
|
548
|
+
resp = await _request("POST", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
549
|
+
if isinstance(resp, dict) and resp.get("error"):
|
|
550
|
+
return resp
|
|
551
|
+
return resp or {"ok": True}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@mcp.tool()
|
|
555
|
+
async def health() -> Any:
|
|
556
|
+
"""Health check: probe root path, meta/modules, and access_token endpoints.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
Dictionary with status of root, meta_modules, and access_token endpoints.
|
|
560
|
+
"""
|
|
561
|
+
base = SALESFORCE_BASE_URL.rstrip("/")
|
|
562
|
+
results: Dict[str, Any] = {}
|
|
563
|
+
# Root
|
|
564
|
+
try:
|
|
565
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
566
|
+
r = await client.get(f"{base}/")
|
|
567
|
+
results["root"] = {"status": r.status_code}
|
|
568
|
+
except Exception as e:
|
|
569
|
+
results["root"] = {"error": str(e)}
|
|
570
|
+
|
|
571
|
+
# meta/modules (expect 401/403 when unauthenticated; 500 indicates server error)
|
|
572
|
+
res_meta = await _request("GET", f"{base}/Api/V8/meta/modules", None, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
573
|
+
results["meta_modules"] = res_meta if isinstance(res_meta, dict) else {"ok": True}
|
|
574
|
+
|
|
575
|
+
# access_token (probing with invalid credentials should return 400/401; 500 indicates server error)
|
|
576
|
+
try:
|
|
577
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
578
|
+
r = await client.post(f"{base}/Api/access_token", data={"grant_type": "client_credentials", "client_id": "x", "client_secret": "y"}, headers={"Accept": "application/json"})
|
|
579
|
+
results["access_token"] = {"status": r.status_code, "text": (r.text[:200] if r.text else "")}
|
|
580
|
+
except Exception as e:
|
|
581
|
+
results["access_token"] = {"error": str(e)}
|
|
582
|
+
|
|
583
|
+
return results
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
async def _ensure_token_on_startup() -> None:
|
|
587
|
+
"""Try to acquire SALESFORCE_ACCESS_TOKEN using configured credentials (non-blocking)."""
|
|
588
|
+
token = (os.getenv("SALESFORCE_ACCESS_TOKEN") or "").strip()
|
|
589
|
+
if token:
|
|
590
|
+
return
|
|
591
|
+
try:
|
|
592
|
+
fetched = await _post_token()
|
|
593
|
+
os.environ["SALESFORCE_ACCESS_TOKEN"] = fetched
|
|
594
|
+
print("Salesforce access token acquired on startup.", file=sys.stderr)
|
|
595
|
+
except Exception as e:
|
|
596
|
+
# Do not block server; tools will attempt token acquisition when called.
|
|
597
|
+
print(f"Salesforce token acquisition skipped on startup: {e}", file=sys.stderr)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def main() -> None:
|
|
601
|
+
print("Starting Salesforce MCP Server...", file=sys.stderr)
|
|
602
|
+
sys.stderr.flush()
|
|
603
|
+
try:
|
|
604
|
+
asyncio.run(_ensure_token_on_startup())
|
|
605
|
+
except RuntimeError:
|
|
606
|
+
pass
|
|
607
|
+
host = os.getenv("SALESFORCE_MCP_HOST", "localhost")
|
|
608
|
+
def _port_from_registry(default_port: int) -> int:
|
|
609
|
+
try:
|
|
610
|
+
if yaml is None:
|
|
611
|
+
return default_port
|
|
612
|
+
registry_path = Path(__file__).resolve().parent.parent / "registry.yaml"
|
|
613
|
+
if not registry_path.exists():
|
|
614
|
+
return default_port
|
|
615
|
+
data = yaml.safe_load(registry_path.read_text()) or {}
|
|
616
|
+
service_name = Path(__file__).resolve().parent.name # 'salesforce'
|
|
617
|
+
for srv in (data.get("servers") or []):
|
|
618
|
+
if isinstance(srv, dict) and srv.get("name") == service_name:
|
|
619
|
+
env = srv.get("env") or {}
|
|
620
|
+
port_str = str(env.get("PORT") or "").strip().strip('\"')
|
|
621
|
+
return int(port_str) if port_str else default_port
|
|
622
|
+
except Exception:
|
|
623
|
+
return default_port
|
|
624
|
+
return default_port
|
|
625
|
+
# Prefer PORT env var, then registry, then default
|
|
626
|
+
env_port = os.getenv("PORT", "").strip()
|
|
627
|
+
if env_port.isdigit():
|
|
628
|
+
port = int(env_port)
|
|
629
|
+
else:
|
|
630
|
+
port = _port_from_registry(8845)
|
|
631
|
+
mcp.run(transport="http", host=host, port=port)
|
|
632
|
+
|
|
633
|
+
@mcp.tool()
|
|
634
|
+
async def get_available_modules() -> Any:
|
|
635
|
+
"""List available modules for the authenticated user.
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
JSON payload with list of available modules and their metadata.
|
|
639
|
+
"""
|
|
640
|
+
token = await _get_token()
|
|
641
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/modules"
|
|
642
|
+
return await _request("GET", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
@mcp.tool()
|
|
646
|
+
async def get_entry_list(
|
|
647
|
+
module_name: str,
|
|
648
|
+
filter: Optional[Dict[str, Any]] = None,
|
|
649
|
+
sort: Optional[str] = None,
|
|
650
|
+
page: Optional[int] = 1,
|
|
651
|
+
page_size: Optional[int] = 20,
|
|
652
|
+
fields: Optional[List[str]] = None,
|
|
653
|
+
) -> Any:
|
|
654
|
+
"""Get a list of records from a specific module with optional filtering and pagination.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
658
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
659
|
+
Use meta_modules() to see the full list.
|
|
660
|
+
filter: Filter object with field conditions (e.g., {"name": {"eq": "Acme"}}).
|
|
661
|
+
sort: Comma-separated field list with optional '-' prefix for descending.
|
|
662
|
+
page: Page number (1-based, default 1).
|
|
663
|
+
page_size: Number of records per page (default 20).
|
|
664
|
+
fields: Optional list of field names to include in response.
|
|
665
|
+
|
|
666
|
+
Returns:
|
|
667
|
+
JSON:API response with list of records and pagination metadata.
|
|
668
|
+
"""
|
|
669
|
+
token = await _get_token()
|
|
670
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}"
|
|
671
|
+
params: Dict[str, Any] = {
|
|
672
|
+
"page[number]": page or 1,
|
|
673
|
+
"page[size]": page_size or 20,
|
|
674
|
+
}
|
|
675
|
+
if sort:
|
|
676
|
+
params["sort"] = sort
|
|
677
|
+
if fields:
|
|
678
|
+
params.update(_fields_param(module_name, fields))
|
|
679
|
+
# Translate filter object: supports embedded operator and field ops
|
|
680
|
+
if filter:
|
|
681
|
+
operator = str(filter.get("operator", "and"))
|
|
682
|
+
filt = dict(filter)
|
|
683
|
+
if "operator" in filt:
|
|
684
|
+
del filt["operator"]
|
|
685
|
+
params.update(_filters_params(operator, filt))
|
|
686
|
+
return await _request("GET", url, token, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
@mcp.tool()
|
|
690
|
+
async def get_entry(module_name: str, id: str, fields: Optional[List[str]] = None) -> Any:
|
|
691
|
+
"""Get a single entry by id.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
695
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
696
|
+
Use meta_modules() to see the full list.
|
|
697
|
+
id: Record GUID.
|
|
698
|
+
fields: Optional list of field names to include in response.
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
JSON:API response with record data including id, type, and attributes.
|
|
702
|
+
"""
|
|
703
|
+
token = await _get_token()
|
|
704
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{id}"
|
|
705
|
+
params = _fields_param(module_name, fields)
|
|
706
|
+
return await _request("GET", url, token, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
@mcp.tool()
|
|
710
|
+
async def set_entry(
|
|
711
|
+
module_name: str,
|
|
712
|
+
attributes: Dict[str, Any],
|
|
713
|
+
relationships: Optional[Dict[str, Any]] = None,
|
|
714
|
+
) -> Any:
|
|
715
|
+
"""Create a new record in any CRM module.
|
|
716
|
+
|
|
717
|
+
Args:
|
|
718
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
719
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
720
|
+
Use meta_modules() to see the full list.
|
|
721
|
+
attributes: Dictionary of field names and values. Common attributes per module:
|
|
722
|
+
- Notes: {"name": "title", "description": "body text", "contact_id": "GUID",
|
|
723
|
+
"parent_type": "Accounts", "parent_id": "GUID", "assigned_user_id": "GUID"}
|
|
724
|
+
- Tasks: {"name": "subject", "status": "Not Started", "priority": "High",
|
|
725
|
+
"date_due": "YYYY-MM-DD HH:MM:SS", "description": "details",
|
|
726
|
+
"contact_id": "GUID", "parent_type": "Accounts", "parent_id": "GUID"}
|
|
727
|
+
- Meetings: {"name": "subject", "status": "Planned|Held|Not Held",
|
|
728
|
+
"date_start": "YYYY-MM-DD HH:MM:SS", "duration_hours": 1,
|
|
729
|
+
"duration_minutes": 0, "description": "notes", "location": "place",
|
|
730
|
+
"parent_type": "Accounts", "parent_id": "GUID"}
|
|
731
|
+
- Calls: {"name": "subject", "direction": "Inbound|Outbound",
|
|
732
|
+
"status": "Planned|Held", "date_start": "YYYY-MM-DD HH:MM:SS",
|
|
733
|
+
"duration_hours": 0, "duration_minutes": 30, "description": "notes",
|
|
734
|
+
"parent_type": "Accounts", "parent_id": "GUID"}
|
|
735
|
+
Use meta_fields(module_name) to discover all valid fields for a module.
|
|
736
|
+
relationships: Optional dictionary of relationship links.
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
JSON:API response with created record data including generated id.
|
|
740
|
+
"""
|
|
741
|
+
token = await _get_token()
|
|
742
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
|
|
743
|
+
payload: Dict[str, Any] = {"data": {"type": module_name, "attributes": attributes}}
|
|
744
|
+
if relationships:
|
|
745
|
+
payload["data"]["relationships"] = relationships
|
|
746
|
+
return await _request(
|
|
747
|
+
"POST",
|
|
748
|
+
url,
|
|
749
|
+
token,
|
|
750
|
+
json=payload,
|
|
751
|
+
extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
@mcp.tool()
|
|
756
|
+
async def update_entry(
|
|
757
|
+
module_name: str,
|
|
758
|
+
id: str,
|
|
759
|
+
attributes: Dict[str, Any],
|
|
760
|
+
relationships: Optional[Dict[str, Any]] = None,
|
|
761
|
+
) -> Any:
|
|
762
|
+
"""Update an existing record in any CRM module (partial update).
|
|
763
|
+
|
|
764
|
+
Args:
|
|
765
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
766
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
767
|
+
Use meta_modules() to see the full list.
|
|
768
|
+
id: Record GUID to update.
|
|
769
|
+
attributes: Dictionary of field names and values to update. Only include fields
|
|
770
|
+
you want to change. See set_entry() or create_record() for common attributes per module.
|
|
771
|
+
relationships: Optional dictionary of relationship links to update.
|
|
772
|
+
|
|
773
|
+
Returns:
|
|
774
|
+
JSON:API response with updated record data.
|
|
775
|
+
"""
|
|
776
|
+
token = await _get_token()
|
|
777
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module"
|
|
778
|
+
payload: Dict[str, Any] = {"data": {"type": module_name, "id": id, "attributes": attributes}}
|
|
779
|
+
if relationships:
|
|
780
|
+
payload["data"]["relationships"] = relationships
|
|
781
|
+
return await _request(
|
|
782
|
+
"PATCH",
|
|
783
|
+
url,
|
|
784
|
+
token,
|
|
785
|
+
json=payload,
|
|
786
|
+
extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@mcp.tool()
|
|
791
|
+
async def delete_entry(module_name: str, id: str) -> Any:
|
|
792
|
+
"""Delete an entry by id.
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
796
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
797
|
+
Use meta_modules() to see the full list.
|
|
798
|
+
id: Record GUID to delete.
|
|
799
|
+
|
|
800
|
+
Returns:
|
|
801
|
+
Success confirmation or error details.
|
|
802
|
+
"""
|
|
803
|
+
token = await _get_token()
|
|
804
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{id}"
|
|
805
|
+
return await _request("DELETE", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
@mcp.tool()
|
|
809
|
+
async def get_relationships(
|
|
810
|
+
module_name: str,
|
|
811
|
+
id: str,
|
|
812
|
+
relationship_name: str,
|
|
813
|
+
page: Optional[int] = 1,
|
|
814
|
+
page_size: Optional[int] = 20,
|
|
815
|
+
) -> Any:
|
|
816
|
+
"""List related records for an entry with pagination.
|
|
817
|
+
|
|
818
|
+
Args:
|
|
819
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
820
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
821
|
+
Use meta_modules() to see the full list.
|
|
822
|
+
id: Record GUID.
|
|
823
|
+
relationship_name: Link/relationship name (e.g., "contacts", "opportunities", "notes", "tasks", "meetings", "calls").
|
|
824
|
+
page: Page number (1-based, default 1).
|
|
825
|
+
page_size: Number of records per page (default 20).
|
|
826
|
+
|
|
827
|
+
Returns:
|
|
828
|
+
JSON:API response with list of related records.
|
|
829
|
+
"""
|
|
830
|
+
token = await _get_token()
|
|
831
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{id}/relationships/{relationship_name}"
|
|
832
|
+
params = {"page[number]": page or 1, "page[size]": page_size or 20}
|
|
833
|
+
return await _request("GET", url, token, params=params, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
@mcp.tool()
|
|
837
|
+
async def set_relationship(
|
|
838
|
+
module_name: str,
|
|
839
|
+
id: str,
|
|
840
|
+
relationship_name: str,
|
|
841
|
+
related_data: Dict[str, str],
|
|
842
|
+
) -> Any:
|
|
843
|
+
"""Set (replace or add) relationships for a record.
|
|
844
|
+
|
|
845
|
+
Args:
|
|
846
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
847
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
848
|
+
Use meta_modules() to see the full list.
|
|
849
|
+
id: Record GUID.
|
|
850
|
+
relationship_name: Relationship link name (e.g., "contacts", "opportunities", "notes", "tasks").
|
|
851
|
+
related_data: Linkage object with 'type' and 'id' keys.
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
JSON:API response confirming the relationship update.
|
|
855
|
+
"""
|
|
856
|
+
token = await _get_token()
|
|
857
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/module/{module_name}/{id}/relationships/{relationship_name}"
|
|
858
|
+
payload = {"data": related_data}
|
|
859
|
+
return await _request(
|
|
860
|
+
"POST",
|
|
861
|
+
url,
|
|
862
|
+
token,
|
|
863
|
+
json=payload,
|
|
864
|
+
extra_headers={"Accept": JSONAPI_ACCEPT, "Content-Type": JSONAPI_CONTENT},
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
@mcp.tool()
|
|
869
|
+
async def get_module_fields(module_name: str) -> Any:
|
|
870
|
+
"""List field metadata for a module. Use this to discover all valid field names and types before creating or updating records.
|
|
871
|
+
|
|
872
|
+
Args:
|
|
873
|
+
module_name: CRM module name. Available modules: "Accounts", "Contacts", "Leads",
|
|
874
|
+
"Opportunities", "Cases", "Notes", "Tasks", "Meetings", "Calls".
|
|
875
|
+
Use meta_modules() to see the full list.
|
|
876
|
+
|
|
877
|
+
Returns:
|
|
878
|
+
JSON payload with field definitions including types, labels, and options.
|
|
879
|
+
"""
|
|
880
|
+
token = await _get_token()
|
|
881
|
+
url = f"{SALESFORCE_BASE_URL.rstrip('/')}/Api/V8/meta/fields/{module_name}"
|
|
882
|
+
return await _request("GET", url, token, extra_headers={"Accept": JSONAPI_ACCEPT})
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
# ============ Lead-specific tools ============
|
|
886
|
+
|
|
887
|
+
def _sanitize_attributes(attributes: Dict[str, Any]) -> Dict[str, Any]:
|
|
888
|
+
excluded = {"id", "lead_id", "date_entered", "date_modified", "modified_user_id", "created_by", "created_by_name"}
|
|
889
|
+
out: Dict[str, Any] = {}
|
|
890
|
+
for k, v in attributes.items():
|
|
891
|
+
if k in excluded:
|
|
892
|
+
continue
|
|
893
|
+
if isinstance(v, (str, int, float, bool)) or v is None:
|
|
894
|
+
out[k] = v
|
|
895
|
+
return out
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
async def _get_field_options(module_name: str, field_name: str) -> List[str]:
|
|
899
|
+
resp = await _http_get_module_fields(module_name)
|
|
900
|
+
# Try common shapes
|
|
901
|
+
options: List[str] = []
|
|
902
|
+
if isinstance(resp, dict):
|
|
903
|
+
data = resp.get("data") or resp
|
|
904
|
+
attrs = None
|
|
905
|
+
if isinstance(data, dict):
|
|
906
|
+
attrs = data.get("attributes") or data.get("fields")
|
|
907
|
+
fields_obj = None
|
|
908
|
+
if attrs and isinstance(attrs, dict):
|
|
909
|
+
fields_obj = attrs.get("fields") or attrs
|
|
910
|
+
if fields_obj and isinstance(fields_obj, dict):
|
|
911
|
+
field = fields_obj.get(field_name)
|
|
912
|
+
if isinstance(field, dict):
|
|
913
|
+
opts = field.get("options")
|
|
914
|
+
if isinstance(opts, dict):
|
|
915
|
+
options = list(opts.keys())
|
|
916
|
+
return options
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
@mcp.tool()
|
|
920
|
+
async def create_lead(
|
|
921
|
+
first_name: str,
|
|
922
|
+
last_name: str,
|
|
923
|
+
title: Optional[str] = None,
|
|
924
|
+
account_name: Optional[str] = None,
|
|
925
|
+
email: Optional[str] = None,
|
|
926
|
+
phone_work: Optional[str] = None,
|
|
927
|
+
phone_mobile: Optional[str] = None,
|
|
928
|
+
lead_source: Optional[str] = None,
|
|
929
|
+
status: Optional[str] = None,
|
|
930
|
+
description: Optional[str] = None,
|
|
931
|
+
assigned_user_id: Optional[str] = None,
|
|
932
|
+
) -> Any:
|
|
933
|
+
"""Create a new Lead record.
|
|
934
|
+
|
|
935
|
+
Args:
|
|
936
|
+
first_name: Lead's first name (required).
|
|
937
|
+
last_name: Lead's last name (required).
|
|
938
|
+
title: Job title.
|
|
939
|
+
account_name: Company/account name.
|
|
940
|
+
email: Primary email address.
|
|
941
|
+
phone_work: Work phone number.
|
|
942
|
+
phone_mobile: Mobile phone number.
|
|
943
|
+
lead_source: Source of the lead (e.g., "Web", "Cold Call", "Referral").
|
|
944
|
+
status: Lead status (e.g., "New", "Assigned"). Defaults to "New".
|
|
945
|
+
description: Additional notes or description.
|
|
946
|
+
assigned_user_id: User GUID to assign the lead to.
|
|
947
|
+
|
|
948
|
+
Returns:
|
|
949
|
+
JSON:API response with the newly created lead record.
|
|
950
|
+
"""
|
|
951
|
+
attributes: Dict[str, Any] = {
|
|
952
|
+
"first_name": first_name,
|
|
953
|
+
"last_name": last_name,
|
|
954
|
+
}
|
|
955
|
+
if status:
|
|
956
|
+
attributes["status"] = status
|
|
957
|
+
else:
|
|
958
|
+
attributes["status"] = "New"
|
|
959
|
+
if lead_source:
|
|
960
|
+
attributes["lead_source"] = lead_source
|
|
961
|
+
if title:
|
|
962
|
+
attributes["title"] = title
|
|
963
|
+
if account_name:
|
|
964
|
+
attributes["account_name"] = account_name
|
|
965
|
+
if email:
|
|
966
|
+
attributes["email1"] = email
|
|
967
|
+
if phone_work:
|
|
968
|
+
attributes["phone_work"] = phone_work
|
|
969
|
+
if phone_mobile:
|
|
970
|
+
attributes["phone_mobile"] = phone_mobile
|
|
971
|
+
if description:
|
|
972
|
+
attributes["description"] = description
|
|
973
|
+
if assigned_user_id:
|
|
974
|
+
attributes["assigned_user_id"] = assigned_user_id
|
|
975
|
+
|
|
976
|
+
# Note: Salesforce Leads module doesn't support JSON:API relationships in create requests,
|
|
977
|
+
# so we pass assigned_user_id as an attribute instead
|
|
978
|
+
return await _http_create_record("Leads", attributes)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
@mcp.tool()
|
|
982
|
+
async def search_leads(
|
|
983
|
+
search_term: Optional[str] = None,
|
|
984
|
+
first_name: Optional[str] = None,
|
|
985
|
+
last_name: Optional[str] = None,
|
|
986
|
+
status: Optional[str] = None,
|
|
987
|
+
lead_source: Optional[str] = None,
|
|
988
|
+
assigned_user_id: Optional[str] = None,
|
|
989
|
+
created_after: Optional[str] = None,
|
|
990
|
+
created_before: Optional[str] = None,
|
|
991
|
+
page: Optional[int] = 1,
|
|
992
|
+
page_size: Optional[int] = 20,
|
|
993
|
+
search_operator: Optional[str] = "or",
|
|
994
|
+
) -> Any:
|
|
995
|
+
"""Search leads by person name, status, source, assignee, and date range.
|
|
996
|
+
|
|
997
|
+
Args:
|
|
998
|
+
search_term: Person name search in "FirstName LastName" format (e.g., "Robert Kim").
|
|
999
|
+
- Multi-word: First word matches first_name, last word matches last_name (AND).
|
|
1000
|
+
- Single word: Searches across first_name, last_name, account_name (OR).
|
|
1001
|
+
For more precise control, use first_name and last_name parameters instead.
|
|
1002
|
+
first_name: Filter by first name (partial match with LIKE).
|
|
1003
|
+
last_name: Filter by last name (partial match with LIKE).
|
|
1004
|
+
status: Lead status code (e.g., "New", "Assigned", "Converted").
|
|
1005
|
+
lead_source: Source code (e.g., "Web", "Cold Call", "Referral").
|
|
1006
|
+
assigned_user_id: Filter by assigned user GUID.
|
|
1007
|
+
created_after: ISO date string (YYYY-MM-DD) for minimum creation date.
|
|
1008
|
+
created_before: ISO date string (YYYY-MM-DD) for maximum creation date.
|
|
1009
|
+
page: Index of the target page to retrieve (1-based, default 1).
|
|
1010
|
+
page_size: Number of records per page to segment the lead list (default 20).
|
|
1011
|
+
search_operator: Logical operator for combining filters ("or" or "and", default "or").
|
|
1012
|
+
Note: When search_term has multiple words, "and" is used automatically for name matching.
|
|
1013
|
+
|
|
1014
|
+
Returns:
|
|
1015
|
+
JSON:API response with matching lead records.
|
|
1016
|
+
"""
|
|
1017
|
+
filters: Dict[str, Any] = {}
|
|
1018
|
+
|
|
1019
|
+
# Handle search_term - intelligently parse multi-word searches
|
|
1020
|
+
if search_term:
|
|
1021
|
+
words = search_term.strip().split()
|
|
1022
|
+
if len(words) >= 2:
|
|
1023
|
+
# Multi-word search: assume "first_name last_name" format
|
|
1024
|
+
# Use AND to match both parts
|
|
1025
|
+
filters["operator"] = "and"
|
|
1026
|
+
filters["first_name"] = {"like": f"%{words[0]}%"}
|
|
1027
|
+
filters["last_name"] = {"like": f"%{words[-1]}%"}
|
|
1028
|
+
else:
|
|
1029
|
+
# Single word: search across all name fields with OR
|
|
1030
|
+
filters["operator"] = "or"
|
|
1031
|
+
like = f"%{search_term}%"
|
|
1032
|
+
filters["first_name"] = {"like": like}
|
|
1033
|
+
filters["last_name"] = {"like": like}
|
|
1034
|
+
filters["account_name"] = {"like": like}
|
|
1035
|
+
else:
|
|
1036
|
+
# No search_term - use explicit first_name/last_name if provided
|
|
1037
|
+
filters["operator"] = search_operator if search_operator in ("or", "and") else "and"
|
|
1038
|
+
if first_name:
|
|
1039
|
+
filters["first_name"] = {"like": f"%{first_name}%"}
|
|
1040
|
+
if last_name:
|
|
1041
|
+
filters["last_name"] = {"like": f"%{last_name}%"}
|
|
1042
|
+
|
|
1043
|
+
# Add other filters
|
|
1044
|
+
if status:
|
|
1045
|
+
filters["status"] = {"eq": status}
|
|
1046
|
+
if lead_source:
|
|
1047
|
+
filters["lead_source"] = {"eq": lead_source}
|
|
1048
|
+
if assigned_user_id:
|
|
1049
|
+
filters["assigned_user_id"] = {"eq": assigned_user_id}
|
|
1050
|
+
if created_after or created_before:
|
|
1051
|
+
filters["date_entered"] = {}
|
|
1052
|
+
if created_after:
|
|
1053
|
+
filters["date_entered"]["gt"] = created_after
|
|
1054
|
+
if created_before:
|
|
1055
|
+
filters["date_entered"]["lt"] = created_before
|
|
1056
|
+
|
|
1057
|
+
return await _http_list_records("Leads", filters, "-date_entered", page or 1, page_size or 20, ["first_name", "last_name", "account_name", "phone_work", "phone_mobile", "status"]) # type: ignore[arg-type]
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
@mcp.tool()
|
|
1061
|
+
async def update_lead_status(lead_id: str, status: str, description: Optional[str] = None) -> Any:
|
|
1062
|
+
"""Update a lead's status and optional description.
|
|
1063
|
+
|
|
1064
|
+
Args:
|
|
1065
|
+
lead_id: Lead record GUID.
|
|
1066
|
+
status: New status value (e.g., "New", "Assigned", "In Process", "Converted").
|
|
1067
|
+
description: Optional description text to update.
|
|
1068
|
+
|
|
1069
|
+
Returns:
|
|
1070
|
+
JSON:API response with updated lead record.
|
|
1071
|
+
"""
|
|
1072
|
+
attributes: Dict[str, Any] = {"status": status}
|
|
1073
|
+
if description is not None:
|
|
1074
|
+
attributes["description"] = description
|
|
1075
|
+
return await _http_update_record("Leads", lead_id, attributes)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@mcp.tool()
|
|
1079
|
+
async def assign_lead(lead_id: str, user_id: str, send_notification: Optional[bool] = True) -> Any: # noqa: ARG002
|
|
1080
|
+
"""Assign a lead to a user.
|
|
1081
|
+
|
|
1082
|
+
Args:
|
|
1083
|
+
lead_id: Lead record GUID.
|
|
1084
|
+
user_id: User GUID to assign the lead to.
|
|
1085
|
+
send_notification: Whether to send notification (not implemented in sandbox).
|
|
1086
|
+
|
|
1087
|
+
Returns:
|
|
1088
|
+
JSON:API response with updated lead record.
|
|
1089
|
+
"""
|
|
1090
|
+
return await _http_update_record("Leads", lead_id, {"assigned_user_id": user_id})
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
@mcp.tool()
|
|
1094
|
+
async def convert_lead(
|
|
1095
|
+
lead_id: str,
|
|
1096
|
+
create_account: Optional[bool] = True,
|
|
1097
|
+
create_contact: Optional[bool] = True,
|
|
1098
|
+
create_opportunity: Optional[bool] = False,
|
|
1099
|
+
opportunity_name: Optional[str] = None,
|
|
1100
|
+
opportunity_amount: Optional[str] = None,
|
|
1101
|
+
) -> Any:
|
|
1102
|
+
"""Convert a lead into account, contact, and/or opportunity records.
|
|
1103
|
+
|
|
1104
|
+
Args:
|
|
1105
|
+
lead_id: Lead record GUID to convert.
|
|
1106
|
+
create_account: Whether to create an Account from the lead (default True).
|
|
1107
|
+
create_contact: Whether to create a Contact from the lead (default True).
|
|
1108
|
+
create_opportunity: Whether to create an Opportunity (default False).
|
|
1109
|
+
opportunity_name: Name for the new opportunity (required if create_opportunity=True).
|
|
1110
|
+
opportunity_amount: Amount for the new opportunity.
|
|
1111
|
+
|
|
1112
|
+
Returns:
|
|
1113
|
+
Dictionary with lead_id and created record IDs (account_id, contact_id, opportunity_id).
|
|
1114
|
+
"""
|
|
1115
|
+
lead_resp = await _http_get_entry("Leads", lead_id)
|
|
1116
|
+
data = lead_resp.get("data") if isinstance(lead_resp, dict) else None
|
|
1117
|
+
if not data or not isinstance(data, dict):
|
|
1118
|
+
return {"error": "Lead not found"}
|
|
1119
|
+
attrs = data.get("attributes", {})
|
|
1120
|
+
results: Dict[str, Any] = {"lead_id": lead_id}
|
|
1121
|
+
|
|
1122
|
+
if create_account:
|
|
1123
|
+
account_attrs = _sanitize_attributes({
|
|
1124
|
+
"name": attrs.get("account_name") or f"{attrs.get('first_name','')} {attrs.get('last_name','')} - Account",
|
|
1125
|
+
"phone_office": attrs.get("phone_work"),
|
|
1126
|
+
"website": attrs.get("website"),
|
|
1127
|
+
"description": attrs.get("description"),
|
|
1128
|
+
})
|
|
1129
|
+
acc = await _http_create_record("Accounts", account_attrs)
|
|
1130
|
+
if isinstance(acc, dict) and acc.get("data") and acc["data"].get("id"):
|
|
1131
|
+
results["account_id"] = acc["data"]["id"]
|
|
1132
|
+
|
|
1133
|
+
if create_contact:
|
|
1134
|
+
contact_attrs = _sanitize_attributes({
|
|
1135
|
+
"first_name": attrs.get("first_name"),
|
|
1136
|
+
"last_name": attrs.get("last_name"),
|
|
1137
|
+
"title": attrs.get("title"),
|
|
1138
|
+
"email1": attrs.get("email1"),
|
|
1139
|
+
"phone_work": attrs.get("phone_work"),
|
|
1140
|
+
"phone_mobile": attrs.get("phone_mobile"),
|
|
1141
|
+
"description": attrs.get("description"),
|
|
1142
|
+
})
|
|
1143
|
+
con = await _http_create_record("Contacts", contact_attrs)
|
|
1144
|
+
if isinstance(con, dict) and con.get("data") and con["data"].get("id"):
|
|
1145
|
+
results["contact_id"] = con["data"]["id"]
|
|
1146
|
+
|
|
1147
|
+
if create_opportunity and opportunity_name:
|
|
1148
|
+
opportunity_attrs = {
|
|
1149
|
+
"name": opportunity_name,
|
|
1150
|
+
"amount": opportunity_amount or "",
|
|
1151
|
+
"sales_stage": "Prospecting",
|
|
1152
|
+
"lead_source": attrs.get("lead_source"),
|
|
1153
|
+
"description": attrs.get("description"),
|
|
1154
|
+
}
|
|
1155
|
+
opp = await _http_create_record("Opportunities", opportunity_attrs)
|
|
1156
|
+
if isinstance(opp, dict) and opp.get("data") and opp["data"].get("id"):
|
|
1157
|
+
results["opportunity_id"] = opp["data"]["id"]
|
|
1158
|
+
|
|
1159
|
+
await _http_update_record("Leads", lead_id, {"status": "Converted"})
|
|
1160
|
+
return results
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
@mcp.tool()
|
|
1164
|
+
async def get_lead_status_options() -> Any:
|
|
1165
|
+
"""Get available lead status option values.
|
|
1166
|
+
|
|
1167
|
+
Returns:
|
|
1168
|
+
List of valid status codes (e.g., "New", "Assigned", "In Process", "Converted").
|
|
1169
|
+
"""
|
|
1170
|
+
return await _get_field_options("Leads", "status")
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
@mcp.tool()
|
|
1174
|
+
async def get_lead_source_options() -> Any:
|
|
1175
|
+
"""Get available lead source option values.
|
|
1176
|
+
|
|
1177
|
+
Returns:
|
|
1178
|
+
List of valid lead source codes (e.g., "Web", "Cold Call", "Referral").
|
|
1179
|
+
"""
|
|
1180
|
+
return await _get_field_options("Leads", "lead_source")
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
@mcp.tool()
|
|
1184
|
+
async def duplicate_contact(id: str) -> Any:
|
|
1185
|
+
"""Create a new Contact by duplicating an existing contact's attributes.
|
|
1186
|
+
|
|
1187
|
+
Args:
|
|
1188
|
+
id: Contact GUID to duplicate.
|
|
1189
|
+
|
|
1190
|
+
Returns:
|
|
1191
|
+
JSON:API response with the newly created contact record.
|
|
1192
|
+
"""
|
|
1193
|
+
orig = await _http_get_entry("Contacts", id)
|
|
1194
|
+
data = orig.get("data") if isinstance(orig, dict) else None
|
|
1195
|
+
if not data or not isinstance(data, dict):
|
|
1196
|
+
return {"error": f"Contact {id} not found"}
|
|
1197
|
+
attrs = _sanitize_attributes(data.get("attributes", {}))
|
|
1198
|
+
return await _http_create_record("Contacts", attrs)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
@mcp.tool()
|
|
1202
|
+
async def duplicate_lead(lead_id: str) -> Any:
|
|
1203
|
+
"""Create a new Lead by duplicating an existing lead's attributes.
|
|
1204
|
+
|
|
1205
|
+
Args:
|
|
1206
|
+
lead_id: Lead GUID to duplicate.
|
|
1207
|
+
|
|
1208
|
+
Returns:
|
|
1209
|
+
JSON:API response with the newly created lead record.
|
|
1210
|
+
"""
|
|
1211
|
+
orig = await _http_get_entry("Leads", lead_id)
|
|
1212
|
+
data = orig.get("data") if isinstance(orig, dict) else None
|
|
1213
|
+
if not data or not isinstance(data, dict):
|
|
1214
|
+
return {"error": f"Lead {lead_id} not found"}
|
|
1215
|
+
attrs = _sanitize_attributes(data.get("attributes", {}))
|
|
1216
|
+
return await _http_create_record("Leads", attrs)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
@mcp.tool()
|
|
1220
|
+
async def merge_leads(primaryLeadId: str, duplicateLeadIds: List[str]) -> Any:
|
|
1221
|
+
"""Merge multiple duplicate leads into a primary lead.
|
|
1222
|
+
|
|
1223
|
+
Combines attributes from duplicate leads into the primary lead, filling in
|
|
1224
|
+
any missing fields. The duplicate leads are deleted after merging.
|
|
1225
|
+
|
|
1226
|
+
Args:
|
|
1227
|
+
primaryLeadId: GUID of the lead to keep and merge into.
|
|
1228
|
+
duplicateLeadIds: List of GUIDs for leads to merge and delete.
|
|
1229
|
+
|
|
1230
|
+
Returns:
|
|
1231
|
+
Dictionary with 'mergedLead' (updated lead data) and 'mergedDuplicateIds'.
|
|
1232
|
+
"""
|
|
1233
|
+
primary = await _http_get_entry("Leads", primaryLeadId)
|
|
1234
|
+
pdata = primary.get("data") if isinstance(primary, dict) else None
|
|
1235
|
+
if not pdata:
|
|
1236
|
+
return {"error": f"Primary lead {primaryLeadId} not found"}
|
|
1237
|
+
primary_attrs = dict(pdata.get("attributes", {}))
|
|
1238
|
+
for dup_id in duplicateLeadIds:
|
|
1239
|
+
dup = await _http_get_entry("Leads", dup_id)
|
|
1240
|
+
ddata = dup.get("data") if isinstance(dup, dict) else None
|
|
1241
|
+
if not ddata:
|
|
1242
|
+
continue
|
|
1243
|
+
for k, v in (ddata.get("attributes", {}) or {}).items():
|
|
1244
|
+
if not primary_attrs.get(k) or str(primary_attrs.get(k)).strip() == "":
|
|
1245
|
+
primary_attrs[k] = v
|
|
1246
|
+
san = _sanitize_attributes(primary_attrs)
|
|
1247
|
+
updated = await _http_update_record("Leads", primaryLeadId, san)
|
|
1248
|
+
for dup_id in duplicateLeadIds:
|
|
1249
|
+
try:
|
|
1250
|
+
await _http_delete_entry("Leads", dup_id)
|
|
1251
|
+
except Exception:
|
|
1252
|
+
pass
|
|
1253
|
+
return {"mergedLead": updated, "mergedDuplicateIds": duplicateLeadIds}
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
@mcp.tool()
|
|
1257
|
+
async def delete_lead(lead_id: str) -> Any:
|
|
1258
|
+
"""Delete a lead record.
|
|
1259
|
+
|
|
1260
|
+
Args:
|
|
1261
|
+
lead_id: Lead GUID to delete.
|
|
1262
|
+
|
|
1263
|
+
Returns:
|
|
1264
|
+
JSON:API response confirming deletion.
|
|
1265
|
+
"""
|
|
1266
|
+
return await _http_delete_entry("Leads", lead_id)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
# ============ Contact-specific tools ============
|
|
1270
|
+
|
|
1271
|
+
@mcp.tool()
|
|
1272
|
+
async def create_contact(
|
|
1273
|
+
first_name: str,
|
|
1274
|
+
last_name: str,
|
|
1275
|
+
title: Optional[str] = None,
|
|
1276
|
+
account_id: Optional[str] = None,
|
|
1277
|
+
department: Optional[str] = None,
|
|
1278
|
+
phone_work: Optional[str] = None,
|
|
1279
|
+
phone_mobile: Optional[str] = None,
|
|
1280
|
+
email1: Optional[str] = None,
|
|
1281
|
+
description: Optional[str] = None,
|
|
1282
|
+
assigned_user_id: Optional[str] = None,
|
|
1283
|
+
) -> Any:
|
|
1284
|
+
"""Create a new Contact with common fields and optional account linkage.
|
|
1285
|
+
|
|
1286
|
+
Args:
|
|
1287
|
+
first_name: Contact's first name (required).
|
|
1288
|
+
last_name: Contact's last name (required).
|
|
1289
|
+
title: Job title.
|
|
1290
|
+
account_id: Company/organization account GUID to link this contact to (stored in the `Accounts` module).
|
|
1291
|
+
department: Department name.
|
|
1292
|
+
phone_work: Work phone number.
|
|
1293
|
+
phone_mobile: Mobile phone number.
|
|
1294
|
+
email1: Primary email address.
|
|
1295
|
+
description: Additional notes or description.
|
|
1296
|
+
assigned_user_id: User GUID to assign the contact to.
|
|
1297
|
+
|
|
1298
|
+
Returns:
|
|
1299
|
+
JSON:API response with the newly created contact record.
|
|
1300
|
+
"""
|
|
1301
|
+
attributes: Dict[str, Any] = {
|
|
1302
|
+
"first_name": first_name,
|
|
1303
|
+
"last_name": last_name,
|
|
1304
|
+
}
|
|
1305
|
+
if title:
|
|
1306
|
+
attributes["title"] = title
|
|
1307
|
+
if department:
|
|
1308
|
+
attributes["department"] = department
|
|
1309
|
+
if phone_work:
|
|
1310
|
+
attributes["phone_work"] = phone_work
|
|
1311
|
+
if phone_mobile:
|
|
1312
|
+
attributes["phone_mobile"] = phone_mobile
|
|
1313
|
+
if description:
|
|
1314
|
+
attributes["description"] = description
|
|
1315
|
+
if account_id:
|
|
1316
|
+
attributes["account_id"] = account_id
|
|
1317
|
+
if email1:
|
|
1318
|
+
attributes["email1"] = email1
|
|
1319
|
+
if assigned_user_id:
|
|
1320
|
+
attributes["assigned_user_id"] = assigned_user_id
|
|
1321
|
+
|
|
1322
|
+
return await _http_create_record("Contacts", attributes)
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
@mcp.tool()
|
|
1326
|
+
async def search_contacts(
|
|
1327
|
+
search_term: Optional[str] = None,
|
|
1328
|
+
first_name: Optional[str] = None,
|
|
1329
|
+
last_name: Optional[str] = None,
|
|
1330
|
+
assigned_user_id: Optional[str] = None,
|
|
1331
|
+
created_after: Optional[str] = None,
|
|
1332
|
+
created_before: Optional[str] = None,
|
|
1333
|
+
page: Optional[int] = 1,
|
|
1334
|
+
page_size: Optional[int] = 20,
|
|
1335
|
+
search_operator: Optional[str] = "or",
|
|
1336
|
+
) -> Any:
|
|
1337
|
+
"""Search contacts by person name, assignee, and date range.
|
|
1338
|
+
|
|
1339
|
+
Args:
|
|
1340
|
+
search_term: Person name search in "FirstName LastName" format (e.g., "John Doe").
|
|
1341
|
+
- Multi-word: First word matches first_name, last word matches last_name (AND).
|
|
1342
|
+
- Single word: Searches across first_name and last_name (OR).
|
|
1343
|
+
For more precise control, use first_name and last_name parameters instead.
|
|
1344
|
+
first_name: Filter by first name (partial match with LIKE).
|
|
1345
|
+
last_name: Filter by last name (partial match with LIKE).
|
|
1346
|
+
assigned_user_id: Filter by assigned user GUID.
|
|
1347
|
+
created_after: ISO date string (YYYY-MM-DD) for minimum creation date.
|
|
1348
|
+
created_before: ISO date string (YYYY-MM-DD) for maximum creation date.
|
|
1349
|
+
page: Index of the target page to retrieve (1-based, default 1).
|
|
1350
|
+
page_size: Number of records per page to segment the contact list (default 20).
|
|
1351
|
+
search_operator: Logical operator for combining filters ("or" or "and", default "or").
|
|
1352
|
+
Note: When search_term has multiple words, "and" is used automatically for name matching.
|
|
1353
|
+
|
|
1354
|
+
Returns:
|
|
1355
|
+
JSON:API response with matching contact records.
|
|
1356
|
+
"""
|
|
1357
|
+
filters: Dict[str, Any] = {}
|
|
1358
|
+
|
|
1359
|
+
# Handle search_term - intelligently parse multi-word searches
|
|
1360
|
+
if search_term:
|
|
1361
|
+
words = search_term.strip().split()
|
|
1362
|
+
if len(words) >= 2:
|
|
1363
|
+
# Multi-word search: assume "first_name last_name" format
|
|
1364
|
+
# Use AND to match both parts
|
|
1365
|
+
filters["operator"] = "and"
|
|
1366
|
+
filters["first_name"] = {"like": f"%{words[0]}%"}
|
|
1367
|
+
filters["last_name"] = {"like": f"%{words[-1]}%"}
|
|
1368
|
+
else:
|
|
1369
|
+
# Single word: search across all name fields with OR
|
|
1370
|
+
filters["operator"] = "or"
|
|
1371
|
+
like = f"%{search_term}%"
|
|
1372
|
+
filters["first_name"] = {"like": like}
|
|
1373
|
+
filters["last_name"] = {"like": like}
|
|
1374
|
+
else:
|
|
1375
|
+
# No search_term - use explicit first_name/last_name if provided
|
|
1376
|
+
filters["operator"] = search_operator if search_operator in ("or", "and") else "and"
|
|
1377
|
+
if first_name:
|
|
1378
|
+
filters["first_name"] = {"like": f"%{first_name}%"}
|
|
1379
|
+
if last_name:
|
|
1380
|
+
filters["last_name"] = {"like": f"%{last_name}%"}
|
|
1381
|
+
|
|
1382
|
+
# Add other filters
|
|
1383
|
+
if assigned_user_id:
|
|
1384
|
+
filters["assigned_user_id"] = {"eq": assigned_user_id}
|
|
1385
|
+
if created_after or created_before:
|
|
1386
|
+
filters["date_entered"] = {}
|
|
1387
|
+
if created_after:
|
|
1388
|
+
filters["date_entered"]["gt"] = created_after
|
|
1389
|
+
if created_before:
|
|
1390
|
+
filters["date_entered"]["lt"] = created_before
|
|
1391
|
+
|
|
1392
|
+
return await _http_list_records("Contacts", filters, "-date_entered", page or 1, page_size or 20, ["first_name", "last_name", "phone_work", "phone_mobile", "email1"]) # type: ignore[arg-type]
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
@mcp.tool()
|
|
1396
|
+
async def update_contact(
|
|
1397
|
+
contact_id: str,
|
|
1398
|
+
first_name: Optional[str] = None,
|
|
1399
|
+
last_name: Optional[str] = None,
|
|
1400
|
+
title: Optional[str] = None,
|
|
1401
|
+
phone_work: Optional[str] = None,
|
|
1402
|
+
phone_mobile: Optional[str] = None,
|
|
1403
|
+
description: Optional[str] = None,
|
|
1404
|
+
assigned_user_id: Optional[str] = None,
|
|
1405
|
+
account_id: Optional[str] = None,
|
|
1406
|
+
) -> Any:
|
|
1407
|
+
"""Update a Contact's fields (partial update).
|
|
1408
|
+
|
|
1409
|
+
Note: To update email addresses, use update_contact_email instead.
|
|
1410
|
+
SuiteCRM stores emails in a separate table that requires special handling.
|
|
1411
|
+
|
|
1412
|
+
Args:
|
|
1413
|
+
contact_id: Contact GUID to update.
|
|
1414
|
+
first_name: New first name.
|
|
1415
|
+
last_name: New last name.
|
|
1416
|
+
title: New job title.
|
|
1417
|
+
phone_work: New work phone number.
|
|
1418
|
+
phone_mobile: New mobile phone number.
|
|
1419
|
+
description: New description or notes.
|
|
1420
|
+
assigned_user_id: New assigned user GUID.
|
|
1421
|
+
account_id: Account GUID to link this contact to.
|
|
1422
|
+
|
|
1423
|
+
Returns:
|
|
1424
|
+
JSON:API response with updated contact record.
|
|
1425
|
+
"""
|
|
1426
|
+
attributes: Dict[str, Any] = {}
|
|
1427
|
+
if first_name is not None:
|
|
1428
|
+
attributes["first_name"] = first_name
|
|
1429
|
+
if last_name is not None:
|
|
1430
|
+
attributes["last_name"] = last_name
|
|
1431
|
+
if title is not None:
|
|
1432
|
+
attributes["title"] = title
|
|
1433
|
+
if phone_work is not None:
|
|
1434
|
+
attributes["phone_work"] = phone_work
|
|
1435
|
+
if phone_mobile is not None:
|
|
1436
|
+
attributes["phone_mobile"] = phone_mobile
|
|
1437
|
+
if description is not None:
|
|
1438
|
+
attributes["description"] = description
|
|
1439
|
+
if assigned_user_id is not None:
|
|
1440
|
+
attributes["assigned_user_id"] = assigned_user_id
|
|
1441
|
+
if account_id is not None:
|
|
1442
|
+
attributes["account_id"] = account_id
|
|
1443
|
+
return await _http_update_record("Contacts", contact_id, attributes)
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
@mcp.tool()
|
|
1447
|
+
async def assign_contact(contact_id: str, user_id: str, send_notification: Optional[bool] = True) -> Any: # noqa: ARG002
|
|
1448
|
+
"""Assign a contact to a user.
|
|
1449
|
+
|
|
1450
|
+
Args:
|
|
1451
|
+
contact_id: Contact GUID to reassign.
|
|
1452
|
+
user_id: User GUID to assign the contact to.
|
|
1453
|
+
send_notification: Whether to send notification (not implemented in sandbox).
|
|
1454
|
+
|
|
1455
|
+
Returns:
|
|
1456
|
+
JSON:API response with updated contact record.
|
|
1457
|
+
"""
|
|
1458
|
+
return await _http_update_record("Contacts", contact_id, {"assigned_user_id": user_id})
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
@mcp.tool()
|
|
1462
|
+
async def link_contact_to_account(contact_id: str, account_id: str) -> Any:
|
|
1463
|
+
"""Link a contact to an account via relationships.
|
|
1464
|
+
|
|
1465
|
+
Args:
|
|
1466
|
+
contact_id: Contact GUID to link.
|
|
1467
|
+
account_id: Account GUID to link the contact to.
|
|
1468
|
+
|
|
1469
|
+
Returns:
|
|
1470
|
+
JSON:API response confirming the relationship update.
|
|
1471
|
+
"""
|
|
1472
|
+
relationships = {
|
|
1473
|
+
"contacts": {"data": {"type": "Contacts", "id": contact_id}},
|
|
1474
|
+
}
|
|
1475
|
+
return await _http_update_record("Accounts", account_id, {}, relationships)
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
@mcp.tool()
|
|
1479
|
+
async def get_contact_by_id(id: str) -> Any:
|
|
1480
|
+
"""Get a Contact by ID.
|
|
1481
|
+
|
|
1482
|
+
Args:
|
|
1483
|
+
id: Contact GUID.
|
|
1484
|
+
|
|
1485
|
+
Returns:
|
|
1486
|
+
JSON:API response with contact record data.
|
|
1487
|
+
"""
|
|
1488
|
+
return await _http_get_entry("Contacts", id)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
@mcp.tool()
|
|
1492
|
+
async def delete_contact(id: str) -> Any:
|
|
1493
|
+
"""Delete a Contact record.
|
|
1494
|
+
|
|
1495
|
+
Args:
|
|
1496
|
+
id: Contact GUID to delete.
|
|
1497
|
+
|
|
1498
|
+
Returns:
|
|
1499
|
+
JSON:API response confirming deletion.
|
|
1500
|
+
"""
|
|
1501
|
+
return await _http_delete_entry("Contacts", id)
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
@mcp.tool()
|
|
1505
|
+
async def update_contact_email(contact_id: str, old_email: str, new_email: str) -> Any:
|
|
1506
|
+
"""Update a contact's email address.
|
|
1507
|
+
|
|
1508
|
+
SuiteCRM stores emails in a separate table (EmailAddresses), so this tool
|
|
1509
|
+
finds the email record linked to the contact and updates it via the
|
|
1510
|
+
authenticated JSON:API.
|
|
1511
|
+
|
|
1512
|
+
Args:
|
|
1513
|
+
contact_id: Contact GUID whose email should be updated.
|
|
1514
|
+
old_email: The current email address to replace.
|
|
1515
|
+
new_email: The new email address to set.
|
|
1516
|
+
|
|
1517
|
+
Returns:
|
|
1518
|
+
Dictionary with success status and updated email information.
|
|
1519
|
+
"""
|
|
1520
|
+
try:
|
|
1521
|
+
# Step 1: Get the contact's email relationships to find the EmailAddress record
|
|
1522
|
+
email_rels = await _http_get_relationships("Contacts", contact_id, "email_addresses", page=1, page_size=50)
|
|
1523
|
+
|
|
1524
|
+
if isinstance(email_rels, dict) and "error" in email_rels:
|
|
1525
|
+
return {
|
|
1526
|
+
"success": False,
|
|
1527
|
+
"contact_id": contact_id,
|
|
1528
|
+
"error": f"Failed to get email relationships: {email_rels.get('error')}",
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
# Step 2: Find the EmailAddress record matching old_email
|
|
1532
|
+
email_id = None
|
|
1533
|
+
data = email_rels.get("data", [])
|
|
1534
|
+
for email_rec in data:
|
|
1535
|
+
attrs = email_rec.get("attributes", {})
|
|
1536
|
+
if attrs.get("email_address", "").lower() == old_email.lower():
|
|
1537
|
+
email_id = email_rec.get("id")
|
|
1538
|
+
break
|
|
1539
|
+
|
|
1540
|
+
if not email_id:
|
|
1541
|
+
return {
|
|
1542
|
+
"success": False,
|
|
1543
|
+
"contact_id": contact_id,
|
|
1544
|
+
"old_email": old_email,
|
|
1545
|
+
"error": f"Email address '{old_email}' not found for contact {contact_id}",
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
# Step 3: Update the EmailAddress record via standard authenticated API
|
|
1549
|
+
result = await _http_update_record(
|
|
1550
|
+
"EmailAddresses",
|
|
1551
|
+
email_id,
|
|
1552
|
+
{"email_address": new_email, "email_address_caps": new_email.upper()}
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
if isinstance(result, dict) and "error" in result:
|
|
1556
|
+
return {
|
|
1557
|
+
"success": False,
|
|
1558
|
+
"contact_id": contact_id,
|
|
1559
|
+
"old_email": old_email,
|
|
1560
|
+
"new_email": new_email,
|
|
1561
|
+
"error": result.get("error"),
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
return {
|
|
1565
|
+
"success": True,
|
|
1566
|
+
"contact_id": contact_id,
|
|
1567
|
+
"old_email": old_email,
|
|
1568
|
+
"new_email": new_email,
|
|
1569
|
+
"message": f"Email updated from {old_email} to {new_email}",
|
|
1570
|
+
"data": result,
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
except Exception as e:
|
|
1574
|
+
return {
|
|
1575
|
+
"success": False,
|
|
1576
|
+
"contact_id": contact_id,
|
|
1577
|
+
"error": str(e),
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
@mcp.tool()
|
|
1582
|
+
async def view_contact_history(id: str) -> Any:
|
|
1583
|
+
"""Get a contact's related activities and history.
|
|
1584
|
+
|
|
1585
|
+
Aggregates calls, meetings, emails, cases, notes, and tasks linked to the contact.
|
|
1586
|
+
|
|
1587
|
+
Args:
|
|
1588
|
+
id: Contact GUID.
|
|
1589
|
+
|
|
1590
|
+
Returns:
|
|
1591
|
+
Dictionary with keys 'calls', 'meetings', 'emails', 'cases', 'notes', 'tasks',
|
|
1592
|
+
each containing a list of related records.
|
|
1593
|
+
"""
|
|
1594
|
+
fields = ["calls", "meetings", "emails", "cases", "notes", "tasks"]
|
|
1595
|
+
history: Dict[str, Any] = {}
|
|
1596
|
+
for field in fields:
|
|
1597
|
+
try:
|
|
1598
|
+
resp = await _http_get_relationships("Contacts", id, field, 1, 50)
|
|
1599
|
+
if isinstance(resp, dict):
|
|
1600
|
+
history[field] = resp.get("data")
|
|
1601
|
+
except Exception:
|
|
1602
|
+
pass
|
|
1603
|
+
return history
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
# ============ Case-specific tools ============
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
@mcp.tool()
|
|
1610
|
+
async def create_case(
|
|
1611
|
+
name: str,
|
|
1612
|
+
status: str = "Open_New",
|
|
1613
|
+
priority: str = "P2",
|
|
1614
|
+
case_type: str = "User",
|
|
1615
|
+
description: Optional[str] = None,
|
|
1616
|
+
account_id: Optional[str] = None,
|
|
1617
|
+
contact_id: Optional[str] = None,
|
|
1618
|
+
assigned_user_id: Optional[str] = None,
|
|
1619
|
+
) -> Any:
|
|
1620
|
+
"""Create a new Case (support ticket) record.
|
|
1621
|
+
|
|
1622
|
+
Args:
|
|
1623
|
+
name: Case subject/title (required). E.g., 'Product not working as expected'.
|
|
1624
|
+
status: Case status. Use internal codes: 'Open_New', 'Open_Assigned', 'Open_Pending Input',
|
|
1625
|
+
'Closed_Closed', 'Closed_Rejected', 'Closed_Duplicate'. Defaults to 'Open_New'.
|
|
1626
|
+
priority: Case priority code. Use 'P1' (High), 'P2' (Medium), or 'P3' (Low). Defaults to 'P2'.
|
|
1627
|
+
case_type: Case type. E.g., 'User', 'Product', 'Administration'.
|
|
1628
|
+
description: Case description/details.
|
|
1629
|
+
account_id: Account GUID to link this case to.
|
|
1630
|
+
contact_id: Contact GUID to link this case to.
|
|
1631
|
+
assigned_user_id: User GUID to assign the case to.
|
|
1632
|
+
|
|
1633
|
+
Returns:
|
|
1634
|
+
JSON:API response with the newly created case record.
|
|
1635
|
+
"""
|
|
1636
|
+
attributes: Dict[str, Any] = {
|
|
1637
|
+
"name": name,
|
|
1638
|
+
"status": status,
|
|
1639
|
+
"priority": priority,
|
|
1640
|
+
"type": case_type,
|
|
1641
|
+
}
|
|
1642
|
+
if description:
|
|
1643
|
+
attributes["description"] = description
|
|
1644
|
+
if account_id:
|
|
1645
|
+
attributes["account_id"] = account_id
|
|
1646
|
+
if contact_id:
|
|
1647
|
+
attributes["contact_id"] = contact_id
|
|
1648
|
+
if assigned_user_id:
|
|
1649
|
+
attributes["assigned_user_id"] = assigned_user_id
|
|
1650
|
+
|
|
1651
|
+
return await _http_create_record("Cases", attributes)
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
@mcp.tool()
|
|
1655
|
+
async def search_cases(
|
|
1656
|
+
search_term: Optional[str] = None,
|
|
1657
|
+
status: Optional[str] = None,
|
|
1658
|
+
priority: Optional[str] = None,
|
|
1659
|
+
case_type: Optional[str] = None,
|
|
1660
|
+
account_id: Optional[str] = None,
|
|
1661
|
+
assigned_user_id: Optional[str] = None,
|
|
1662
|
+
page: Optional[int] = 1,
|
|
1663
|
+
page_size: Optional[int] = 20,
|
|
1664
|
+
search_operator: Optional[str] = "or",
|
|
1665
|
+
) -> Any:
|
|
1666
|
+
"""Search cases by text, status, priority, type, account, and assignee.
|
|
1667
|
+
|
|
1668
|
+
Args:
|
|
1669
|
+
search_term: Free-text search across case name/subject AND description.
|
|
1670
|
+
status: Case status. Use internal codes: 'Open_New', 'Open_Assigned', 'Open_Pending Input',
|
|
1671
|
+
'Closed_Closed', 'Closed_Rejected', 'Closed_Duplicate'.
|
|
1672
|
+
priority: Case priority code ('P1', 'P2', 'P3').
|
|
1673
|
+
case_type: Case type (e.g., 'User', 'Product', 'Administration').
|
|
1674
|
+
account_id: Filter by linked account GUID.
|
|
1675
|
+
assigned_user_id: Filter by assigned user GUID.
|
|
1676
|
+
page: Index of the target page to retrieve (1-based, default 1).
|
|
1677
|
+
page_size: Number of records per page to segment the case list (default 20).
|
|
1678
|
+
search_operator: Logical operator for combining filters ('or' or 'and', default 'or').
|
|
1679
|
+
|
|
1680
|
+
Returns:
|
|
1681
|
+
JSON:API response with matching case records.
|
|
1682
|
+
"""
|
|
1683
|
+
filters: Dict[str, Any] = {}
|
|
1684
|
+
filters["operator"] = search_operator if search_operator in ("or", "and") else "or"
|
|
1685
|
+
if search_term:
|
|
1686
|
+
# Search both name and description fields
|
|
1687
|
+
filters["name"] = {"like": f"%{search_term}%"}
|
|
1688
|
+
filters["description"] = {"like": f"%{search_term}%"}
|
|
1689
|
+
if status:
|
|
1690
|
+
filters["status"] = {"eq": status}
|
|
1691
|
+
if priority:
|
|
1692
|
+
filters["priority"] = {"eq": priority}
|
|
1693
|
+
if case_type:
|
|
1694
|
+
filters["type"] = {"eq": case_type}
|
|
1695
|
+
if account_id:
|
|
1696
|
+
filters["account_id"] = {"eq": account_id}
|
|
1697
|
+
if assigned_user_id:
|
|
1698
|
+
filters["assigned_user_id"] = {"eq": assigned_user_id}
|
|
1699
|
+
|
|
1700
|
+
return await _http_list_records(
|
|
1701
|
+
"Cases",
|
|
1702
|
+
filters if filters else None,
|
|
1703
|
+
"-date_entered",
|
|
1704
|
+
page or 1,
|
|
1705
|
+
page_size or 20,
|
|
1706
|
+
["name", "status", "priority", "type", "description", "date_entered"],
|
|
1707
|
+
)
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
@mcp.tool()
|
|
1711
|
+
async def update_case(
|
|
1712
|
+
case_id: str,
|
|
1713
|
+
name: Optional[str] = None,
|
|
1714
|
+
status: Optional[str] = None,
|
|
1715
|
+
priority: Optional[str] = None,
|
|
1716
|
+
case_type: Optional[str] = None,
|
|
1717
|
+
description: Optional[str] = None,
|
|
1718
|
+
resolution: Optional[str] = None,
|
|
1719
|
+
assigned_user_id: Optional[str] = None,
|
|
1720
|
+
) -> Any:
|
|
1721
|
+
"""Update a Case record (partial update).
|
|
1722
|
+
|
|
1723
|
+
Args:
|
|
1724
|
+
case_id: Case GUID to update.
|
|
1725
|
+
name: New case subject/title.
|
|
1726
|
+
status: New status. Use internal codes: 'Open_New', 'Open_Assigned', 'Open_Pending Input',
|
|
1727
|
+
'Closed_Closed', 'Closed_Rejected', 'Closed_Duplicate'.
|
|
1728
|
+
priority: New priority code ('P1', 'P2', 'P3').
|
|
1729
|
+
case_type: New case type (e.g., 'User', 'Product', 'Administration').
|
|
1730
|
+
description: New description.
|
|
1731
|
+
resolution: Resolution text (typically set when closing the case).
|
|
1732
|
+
assigned_user_id: New assigned user GUID.
|
|
1733
|
+
|
|
1734
|
+
Returns:
|
|
1735
|
+
JSON:API response with updated case record.
|
|
1736
|
+
"""
|
|
1737
|
+
attributes: Dict[str, Any] = {}
|
|
1738
|
+
if name is not None:
|
|
1739
|
+
attributes["name"] = name
|
|
1740
|
+
if status is not None:
|
|
1741
|
+
attributes["status"] = status
|
|
1742
|
+
if priority is not None:
|
|
1743
|
+
attributes["priority"] = priority
|
|
1744
|
+
if case_type is not None:
|
|
1745
|
+
attributes["type"] = case_type
|
|
1746
|
+
if description is not None:
|
|
1747
|
+
attributes["description"] = description
|
|
1748
|
+
if resolution is not None:
|
|
1749
|
+
attributes["resolution"] = resolution
|
|
1750
|
+
if assigned_user_id is not None:
|
|
1751
|
+
attributes["assigned_user_id"] = assigned_user_id
|
|
1752
|
+
|
|
1753
|
+
return await _http_update_record("Cases", case_id, attributes)
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
@mcp.tool()
|
|
1757
|
+
async def get_case_by_id(case_id: str) -> Any:
|
|
1758
|
+
"""Get a Case by ID.
|
|
1759
|
+
|
|
1760
|
+
Args:
|
|
1761
|
+
case_id: Case GUID.
|
|
1762
|
+
|
|
1763
|
+
Returns:
|
|
1764
|
+
JSON:API response with case record data.
|
|
1765
|
+
"""
|
|
1766
|
+
return await _http_get_entry("Cases", case_id)
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
@mcp.tool()
|
|
1770
|
+
async def delete_case(case_id: str) -> Any:
|
|
1771
|
+
"""Delete a Case record.
|
|
1772
|
+
|
|
1773
|
+
Args:
|
|
1774
|
+
case_id: Case GUID to delete.
|
|
1775
|
+
|
|
1776
|
+
Returns:
|
|
1777
|
+
JSON:API response confirming deletion.
|
|
1778
|
+
"""
|
|
1779
|
+
return await _http_delete_entry("Cases", case_id)
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
# ============ Account-specific tools ============
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
@mcp.tool()
|
|
1786
|
+
async def search_accounts(
|
|
1787
|
+
search_term: Optional[str] = None,
|
|
1788
|
+
industry: Optional[str] = None,
|
|
1789
|
+
page: Optional[int] = None,
|
|
1790
|
+
page_size: Optional[int] = None,
|
|
1791
|
+
) -> Any:
|
|
1792
|
+
"""Search for Account records.
|
|
1793
|
+
|
|
1794
|
+
Args:
|
|
1795
|
+
search_term: Text to search for in account names.
|
|
1796
|
+
industry: Filter by industry (e.g., 'Technology', 'Healthcare', 'Finance').
|
|
1797
|
+
page: Page number for pagination (1-based).
|
|
1798
|
+
page_size: Number of records per page (default 20).
|
|
1799
|
+
|
|
1800
|
+
Returns:
|
|
1801
|
+
JSON:API response with matching accounts including id, name, industry, billing address fields.
|
|
1802
|
+
"""
|
|
1803
|
+
filters: Dict[str, Any] = {}
|
|
1804
|
+
if search_term:
|
|
1805
|
+
filters["name"] = {"like": f"%{search_term}%"}
|
|
1806
|
+
if industry:
|
|
1807
|
+
filters["industry"] = {"eq": industry}
|
|
1808
|
+
|
|
1809
|
+
return await _http_list_records(
|
|
1810
|
+
"Accounts",
|
|
1811
|
+
filters,
|
|
1812
|
+
"-date_entered",
|
|
1813
|
+
page or 1,
|
|
1814
|
+
page_size or 20,
|
|
1815
|
+
["name", "industry", "phone_office", "billing_address_city", "billing_address_country"],
|
|
1816
|
+
)
|
|
1817
|
+
|
|
1818
|
+
|
|
1819
|
+
@mcp.tool()
|
|
1820
|
+
async def get_account_by_id(account_id: str) -> Any:
|
|
1821
|
+
"""Get an Account by ID.
|
|
1822
|
+
|
|
1823
|
+
Args:
|
|
1824
|
+
account_id: Account GUID.
|
|
1825
|
+
|
|
1826
|
+
Returns:
|
|
1827
|
+
JSON:API response with account record data.
|
|
1828
|
+
"""
|
|
1829
|
+
return await _http_get_entry("Accounts", account_id)
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
# ============ Opportunity-specific tools ============
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
@mcp.tool()
|
|
1836
|
+
async def create_opportunity(
|
|
1837
|
+
name: str,
|
|
1838
|
+
amount: Optional[float] = None,
|
|
1839
|
+
sales_stage: str = "Prospecting",
|
|
1840
|
+
date_closed: Optional[str] = None,
|
|
1841
|
+
account_id: Optional[str] = None,
|
|
1842
|
+
description: Optional[str] = None,
|
|
1843
|
+
probability: Optional[int] = None,
|
|
1844
|
+
lead_source: Optional[str] = None,
|
|
1845
|
+
assigned_user_id: Optional[str] = None,
|
|
1846
|
+
) -> Any:
|
|
1847
|
+
"""Create a new Opportunity record.
|
|
1848
|
+
|
|
1849
|
+
Args:
|
|
1850
|
+
name: Opportunity name/title (required). E.g., 'Website Redesign Project - Acme Corp'.
|
|
1851
|
+
amount: Expected deal amount in dollars.
|
|
1852
|
+
sales_stage: Sales stage. Common values: 'Prospecting', 'Qualification',
|
|
1853
|
+
'Needs Analysis', 'Value Proposition', 'Negotiation/Review',
|
|
1854
|
+
'Closed Won', 'Closed Lost'. Defaults to 'Prospecting'.
|
|
1855
|
+
date_closed: Expected close date in YYYY-MM-DD format.
|
|
1856
|
+
account_id: Account GUID to link this opportunity to.
|
|
1857
|
+
description: Opportunity description/details.
|
|
1858
|
+
probability: Win probability percentage (0-100).
|
|
1859
|
+
lead_source: How the opportunity originated (e.g., 'Web Site', 'Referral', 'Trade Show').
|
|
1860
|
+
assigned_user_id: User GUID to assign the opportunity to.
|
|
1861
|
+
|
|
1862
|
+
Returns:
|
|
1863
|
+
JSON:API response with the newly created opportunity record.
|
|
1864
|
+
"""
|
|
1865
|
+
attributes: Dict[str, Any] = {
|
|
1866
|
+
"name": name,
|
|
1867
|
+
"sales_stage": sales_stage,
|
|
1868
|
+
}
|
|
1869
|
+
if amount is not None:
|
|
1870
|
+
attributes["amount"] = amount
|
|
1871
|
+
if date_closed:
|
|
1872
|
+
attributes["date_closed"] = date_closed
|
|
1873
|
+
if account_id:
|
|
1874
|
+
attributes["account_id"] = account_id
|
|
1875
|
+
if description:
|
|
1876
|
+
attributes["description"] = description
|
|
1877
|
+
if probability is not None:
|
|
1878
|
+
attributes["probability"] = probability
|
|
1879
|
+
if lead_source:
|
|
1880
|
+
attributes["lead_source"] = lead_source
|
|
1881
|
+
if assigned_user_id:
|
|
1882
|
+
attributes["assigned_user_id"] = assigned_user_id
|
|
1883
|
+
|
|
1884
|
+
return await _http_create_record("Opportunities", attributes)
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
@mcp.tool()
|
|
1888
|
+
async def search_opportunities(
|
|
1889
|
+
search_term: Optional[str] = None,
|
|
1890
|
+
sales_stage: Optional[str] = None,
|
|
1891
|
+
account_id: Optional[str] = None,
|
|
1892
|
+
page: Optional[int] = None,
|
|
1893
|
+
page_size: Optional[int] = None,
|
|
1894
|
+
) -> Any:
|
|
1895
|
+
"""Search for Opportunity records.
|
|
1896
|
+
|
|
1897
|
+
Args:
|
|
1898
|
+
search_term: Text to search for in opportunity names.
|
|
1899
|
+
sales_stage: Filter by sales stage (e.g., 'Prospecting', 'Closed Won').
|
|
1900
|
+
account_id: Filter by account GUID.
|
|
1901
|
+
page: Page number for pagination (1-based).
|
|
1902
|
+
page_size: Number of records per page (default 20).
|
|
1903
|
+
|
|
1904
|
+
Returns:
|
|
1905
|
+
JSON:API response with matching opportunities including id, name, amount, sales_stage, date_closed.
|
|
1906
|
+
"""
|
|
1907
|
+
# When account_id is provided, use the relationship endpoint because
|
|
1908
|
+
# SuiteCRM does not expose account_id as a filterable column on
|
|
1909
|
+
# Opportunities (it's stored in a relationship table).
|
|
1910
|
+
if account_id:
|
|
1911
|
+
result = await _http_get_relationships(
|
|
1912
|
+
"Accounts", account_id, "opportunities",
|
|
1913
|
+
page=page or 1, page_size=page_size or 20,
|
|
1914
|
+
)
|
|
1915
|
+
# Apply additional filters client-side if provided
|
|
1916
|
+
if (search_term or sales_stage) and isinstance(result, dict) and "data" in result:
|
|
1917
|
+
filtered = []
|
|
1918
|
+
for rec in result["data"]:
|
|
1919
|
+
attrs = rec.get("attributes", {})
|
|
1920
|
+
if search_term and search_term.lower() not in attrs.get("name", "").lower():
|
|
1921
|
+
continue
|
|
1922
|
+
if sales_stage and attrs.get("sales_stage") != sales_stage:
|
|
1923
|
+
continue
|
|
1924
|
+
filtered.append(rec)
|
|
1925
|
+
result["data"] = filtered
|
|
1926
|
+
result.setdefault("meta", {})["records-on-this-page"] = len(filtered)
|
|
1927
|
+
return result
|
|
1928
|
+
|
|
1929
|
+
filters: Dict[str, Any] = {}
|
|
1930
|
+
if search_term:
|
|
1931
|
+
filters["name"] = {"like": f"%{search_term}%"}
|
|
1932
|
+
if sales_stage:
|
|
1933
|
+
filters["sales_stage"] = {"eq": sales_stage}
|
|
1934
|
+
|
|
1935
|
+
return await _http_list_records(
|
|
1936
|
+
"Opportunities",
|
|
1937
|
+
filters,
|
|
1938
|
+
"-date_entered",
|
|
1939
|
+
page or 1,
|
|
1940
|
+
page_size or 20,
|
|
1941
|
+
["name", "amount", "sales_stage", "date_closed", "probability"],
|
|
1942
|
+
)
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
@mcp.tool()
|
|
1946
|
+
async def get_opportunity_by_id(opportunity_id: str) -> Any:
|
|
1947
|
+
"""Get an Opportunity by ID.
|
|
1948
|
+
|
|
1949
|
+
Args:
|
|
1950
|
+
opportunity_id: Opportunity GUID.
|
|
1951
|
+
|
|
1952
|
+
Returns:
|
|
1953
|
+
JSON:API response with opportunity record data.
|
|
1954
|
+
"""
|
|
1955
|
+
return await _http_get_entry("Opportunities", opportunity_id)
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
@mcp.tool()
|
|
1959
|
+
async def update_opportunity(
|
|
1960
|
+
opportunity_id: str,
|
|
1961
|
+
name: Optional[str] = None,
|
|
1962
|
+
amount: Optional[float] = None,
|
|
1963
|
+
sales_stage: Optional[str] = None,
|
|
1964
|
+
date_closed: Optional[str] = None,
|
|
1965
|
+
description: Optional[str] = None,
|
|
1966
|
+
probability: Optional[int] = None,
|
|
1967
|
+
assigned_user_id: Optional[str] = None,
|
|
1968
|
+
) -> Any:
|
|
1969
|
+
"""Update an Opportunity's fields (partial update).
|
|
1970
|
+
|
|
1971
|
+
Args:
|
|
1972
|
+
opportunity_id: Opportunity GUID to update.
|
|
1973
|
+
name: New opportunity name.
|
|
1974
|
+
amount: New deal amount.
|
|
1975
|
+
sales_stage: New sales stage.
|
|
1976
|
+
date_closed: New expected close date (YYYY-MM-DD).
|
|
1977
|
+
description: New description.
|
|
1978
|
+
probability: New win probability (0-100).
|
|
1979
|
+
assigned_user_id: New assigned user GUID.
|
|
1980
|
+
|
|
1981
|
+
Returns:
|
|
1982
|
+
JSON:API response with updated opportunity record.
|
|
1983
|
+
"""
|
|
1984
|
+
attributes: Dict[str, Any] = {}
|
|
1985
|
+
if name is not None:
|
|
1986
|
+
attributes["name"] = name
|
|
1987
|
+
if amount is not None:
|
|
1988
|
+
attributes["amount"] = amount
|
|
1989
|
+
if sales_stage is not None:
|
|
1990
|
+
attributes["sales_stage"] = sales_stage
|
|
1991
|
+
if date_closed is not None:
|
|
1992
|
+
attributes["date_closed"] = date_closed
|
|
1993
|
+
if description is not None:
|
|
1994
|
+
attributes["description"] = description
|
|
1995
|
+
if probability is not None:
|
|
1996
|
+
attributes["probability"] = probability
|
|
1997
|
+
if assigned_user_id is not None:
|
|
1998
|
+
attributes["assigned_user_id"] = assigned_user_id
|
|
1999
|
+
|
|
2000
|
+
return await _http_update_record("Opportunities", opportunity_id, attributes)
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
if __name__ == "__main__":
|
|
2004
|
+
main()
|
|
2005
|
+
|
|
2006
|
+
|