cloudsense-customer-compass 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .venv/
9
+ venv/
10
+ .env
11
+ *.swp
12
+ .DS_Store
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: cloudsense-customer-compass
3
+ Version: 0.1.0
4
+ Summary: MCP server for CloudSense customer upgrade assessment, analysis, and impact verification.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: mcp>=1.0.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # CloudSense Customer Compass
14
+
15
+ MCP server for CloudSense customer upgrade assessment, analysis, and impact verification.
@@ -0,0 +1,3 @@
1
+ # CloudSense Customer Compass
2
+
3
+ MCP server for CloudSense customer upgrade assessment, analysis, and impact verification.
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cloudsense-customer-compass"
7
+ version = "0.1.0"
8
+ description = "MCP server for CloudSense customer upgrade assessment, analysis, and impact verification."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = [
13
+ "mcp>=1.0.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
18
+
19
+ [project.scripts]
20
+ cloudsense-customer-compass = "cloudsense_customer_compass.server:main"
21
+
22
+ [tool.pytest.ini_options]
23
+ asyncio_mode = "auto"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/cloudsense_customer_compass"]
@@ -0,0 +1,3 @@
1
+ """CloudSense Customer Compass - MCP server for upgrade assessment and analysis."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m cloudsense_customer_compass"""
2
+
3
+ from .server import main
4
+
5
+ main()
@@ -0,0 +1,96 @@
1
+ """CloudSense Customer Compass MCP server.
2
+
3
+ Run with: python -m cloudsense_customer_compass.server
4
+ Or via CLI: cloudsense-customer-compass
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import sys
10
+
11
+ from anyio import BrokenResourceError, ClosedResourceError
12
+ from mcp.server import Server
13
+ from mcp.server.stdio import stdio_server
14
+ import mcp.types as types
15
+
16
+ from . import __version__
17
+ from .tools import connect_lma
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ server = Server("cloudsense-customer-compass")
22
+
23
+ TOOLS = {
24
+ "connect_lma": connect_lma,
25
+ }
26
+
27
+ SERVER_INSTRUCTIONS = (
28
+ "You are connected to the CloudSense Customer Compass MCP server.\n\n"
29
+ "SAFETY: This server is STRICTLY READ-ONLY against customer Salesforce orgs. "
30
+ "NEVER deploy, push, insert, update, delete, or modify anything in the org.\n\n"
31
+ "WORKFLOW:\n"
32
+ "1. connect_lma -- Connect to the License Management Portal (FIRST step)\n"
33
+ "2. (more tools coming soon)\n\n"
34
+ "ORG RESOLUTION: If the user mentions an org, pass it as org_alias. "
35
+ "If not, omit — the tool uses defaults or state.\n"
36
+ )
37
+
38
+
39
+ @server.list_tools()
40
+ async def list_tools() -> list[types.Tool]:
41
+ return [
42
+ types.Tool(
43
+ name=mod.TOOL_DEFINITION["name"],
44
+ description=mod.TOOL_DEFINITION["description"],
45
+ inputSchema=mod.TOOL_DEFINITION["inputSchema"],
46
+ )
47
+ for mod in TOOLS.values()
48
+ ]
49
+
50
+
51
+ @server.call_tool()
52
+ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
53
+ tool_module = TOOLS.get(name)
54
+ if tool_module is None:
55
+ raise ValueError(f"Unknown tool: {name}")
56
+ result = await tool_module.run(arguments)
57
+ return [types.TextContent(type="text", text=result)]
58
+
59
+
60
+ def _is_disconnect(exc: BaseException) -> bool:
61
+ if isinstance(exc, (BrokenResourceError, ClosedResourceError)):
62
+ return True
63
+ if isinstance(exc, BaseExceptionGroup):
64
+ return all(_is_disconnect(e) for e in exc.exceptions)
65
+ return False
66
+
67
+
68
+ async def _run_server():
69
+ logger.info("CloudSense Customer Compass v%s starting...", __version__)
70
+ try:
71
+ async with stdio_server() as (read_stream, write_stream):
72
+ init_options = server.create_initialization_options()
73
+ init_options.instructions = SERVER_INSTRUCTIONS
74
+ await server.run(read_stream, write_stream, init_options)
75
+ except BaseExceptionGroup as eg:
76
+ if _is_disconnect(eg):
77
+ logger.info("Client disconnected — shutting down.")
78
+ else:
79
+ raise
80
+ except (BrokenResourceError, ClosedResourceError):
81
+ logger.info("Client disconnected — shutting down.")
82
+
83
+
84
+ def main():
85
+ logging.basicConfig(level=logging.INFO)
86
+ try:
87
+ asyncio.run(_run_server())
88
+ except KeyboardInterrupt:
89
+ pass
90
+ except (BrokenPipeError, EOFError):
91
+ logger.info("Pipe closed — exiting.")
92
+ sys.exit(0)
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
@@ -0,0 +1,5 @@
1
+ from . import connect_lma
2
+
3
+ __all__ = [
4
+ "connect_lma",
5
+ ]
@@ -0,0 +1,118 @@
1
+ """connect_lma -- Authorize the CloudSense License Management Portal.
2
+
3
+ Entry point for the workflow. Checks if the LMA org is already
4
+ authorized, opens browser login if not, and saves connection info
5
+ to assessment.json.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any
10
+
11
+ from ..utils import sf_cli, state
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ DEFAULT_ALIAS = "cs-lma"
16
+ DEFAULT_LOGIN_URL = "https://login.salesforce.com"
17
+
18
+ TOOL_DEFINITION = {
19
+ "name": "connect_lma",
20
+ "description": (
21
+ "Authorize the CloudSense License Management Portal. "
22
+ "This is the FIRST tool to call for any customer inquiry. "
23
+ "Checks if the LMA org is already authorized, opens browser "
24
+ "login if not, and saves connection info to state."
25
+ ),
26
+ "inputSchema": {
27
+ "type": "object",
28
+ "properties": {
29
+ "org_alias": {
30
+ "type": "string",
31
+ "description": (
32
+ "Alias for the LMA org. Defaults to 'cs-lma'. "
33
+ "If already authorized under this alias, login is skipped."
34
+ ),
35
+ },
36
+ "instance_url": {
37
+ "type": "string",
38
+ "description": (
39
+ "Salesforce login URL. Defaults to "
40
+ "'https://login.salesforce.com'. Override for custom domains."
41
+ ),
42
+ },
43
+ "working_directory": {
44
+ "type": "string",
45
+ "description": "Workspace directory. The AI passes this automatically.",
46
+ },
47
+ },
48
+ "required": [],
49
+ },
50
+ }
51
+
52
+
53
+ def _validate_org(alias: str) -> dict[str, Any] | None:
54
+ """Check if an org is authorized and accessible."""
55
+ try:
56
+ data = sf_cli.display_org(alias)
57
+ return data.get("result", {})
58
+ except sf_cli.SfCliError:
59
+ return None
60
+
61
+
62
+ async def run(arguments: dict[str, Any]) -> str:
63
+ """Execute the connect_lma tool."""
64
+ state.set_working_dir(arguments.get("working_directory"))
65
+
66
+ sf_check = sf_cli.check_sf_installed()
67
+ if not sf_check["installed"]:
68
+ return (
69
+ "## BLOCKER: Salesforce CLI not found\n\n"
70
+ "Install from: https://developer.salesforce.com/tools/salesforcecli\n"
71
+ "Then call connect_lma again."
72
+ )
73
+
74
+ alias = arguments.get("org_alias") or DEFAULT_ALIAS
75
+ instance_url = arguments.get("instance_url")
76
+
77
+ org_info = _validate_org(alias)
78
+
79
+ if not org_info:
80
+ login_url = instance_url or DEFAULT_LOGIN_URL
81
+ try:
82
+ sf_cli.run_sf(
83
+ f"org login web --alias {alias} --instance-url {login_url}",
84
+ timeout=300, json_output=False,
85
+ )
86
+ except sf_cli.SfCliError as e:
87
+ return (
88
+ f"## BLOCKER: Login failed\n\n"
89
+ f"Error: {e}\n\n"
90
+ f"Run manually if browser didn't open:\n"
91
+ f"```\nsf org login web --alias {alias} --instance-url {login_url}\n```"
92
+ )
93
+
94
+ org_info = _validate_org(alias)
95
+ if not org_info:
96
+ return (
97
+ "## BLOCKER: Login succeeded but validation failed\n\n"
98
+ "Call connect_lma again to retry."
99
+ )
100
+
101
+ username = org_info.get("username", "unknown")
102
+ org_id = org_info.get("id", "unknown")
103
+ instance = org_info.get("instanceUrl", "unknown")
104
+
105
+ state.update(
106
+ lma_org_alias=alias,
107
+ lma_org_username=username,
108
+ lma_org_id=org_id,
109
+ lma_instance_url=instance,
110
+ )
111
+
112
+ return (
113
+ f"## LMA Connected\n\n"
114
+ f"- **Alias:** {alias}\n"
115
+ f"- **Username:** {username}\n"
116
+ f"- **Org ID:** {org_id}\n"
117
+ f"- **Instance:** {instance}\n"
118
+ )
@@ -0,0 +1,106 @@
1
+ """Safety policy enforcement for Salesforce CLI commands.
2
+
3
+ Every sf CLI command MUST pass through validate_sf_command() before execution.
4
+ If a command is not on the whitelist, it is blocked.
5
+ """
6
+
7
+ import re
8
+
9
+
10
+ ALLOWED_SF_SUBCOMMANDS: set[str] = {
11
+ "org login web",
12
+ "org list",
13
+ "org display",
14
+ "project generate",
15
+ "project retrieve start",
16
+ "org list metadata",
17
+ "package installed list",
18
+ "sobject list",
19
+ "sobject describe",
20
+ "data query",
21
+ "data search",
22
+ }
23
+
24
+ LMA_ALLOWED_OBJECTS: set[str] = {
25
+ "ACCOUNT",
26
+ "SFLMA__LICENSE__C",
27
+ "SFLMA__PACKAGE__C",
28
+ "SFLMA__PACKAGE_VERSION__C",
29
+ "SUBSCRIBERPACKAGE",
30
+ }
31
+
32
+ BLOCKED_KEYWORDS: set[str] = {
33
+ "deploy",
34
+ "push",
35
+ "delete",
36
+ "insert",
37
+ "update",
38
+ "upsert",
39
+ "undelete",
40
+ "destroy",
41
+ "execute",
42
+ "apex run",
43
+ }
44
+
45
+
46
+ class SafetyViolationError(Exception):
47
+ """Raised when a command violates the read-only safety policy."""
48
+
49
+
50
+ def validate_sf_command(command: str) -> None:
51
+ """Validate that an sf CLI command is on the whitelist."""
52
+ normalized = " ".join(command.strip().split())
53
+
54
+ for blocked in BLOCKED_KEYWORDS:
55
+ if blocked in normalized.lower():
56
+ raise SafetyViolationError(
57
+ f"BLOCKED: Command contains '{blocked}' which is prohibited. "
58
+ f"Command: {command}"
59
+ )
60
+
61
+ if not any(allowed in normalized for allowed in ALLOWED_SF_SUBCOMMANDS):
62
+ raise SafetyViolationError(
63
+ f"BLOCKED: Command not on the whitelist. "
64
+ f"Allowed: {sorted(ALLOWED_SF_SUBCOMMANDS)}. "
65
+ f"Command: {command}"
66
+ )
67
+
68
+
69
+ def validate_soql_query(
70
+ query: str,
71
+ *,
72
+ target_org: str | None = None,
73
+ lma_org: str | None = None,
74
+ ) -> None:
75
+ """Validate that a SOQL query is read-only and safe."""
76
+ normalized = query.strip().upper()
77
+
78
+ if not normalized.startswith("SELECT"):
79
+ raise SafetyViolationError(
80
+ f"BLOCKED: Only SELECT queries are permitted. Query: {query}"
81
+ )
82
+
83
+ for keyword in ("INSERT", "UPDATE", "DELETE", "UPSERT", "MERGE"):
84
+ if re.search(rf"\b{keyword}\b", normalized):
85
+ raise SafetyViolationError(
86
+ f"BLOCKED: DML keyword '{keyword}' found. Query: {query}"
87
+ )
88
+
89
+ is_lma_context = (
90
+ target_org is not None
91
+ and lma_org is not None
92
+ and target_org == lma_org
93
+ )
94
+
95
+ business_objects = [
96
+ "ACCOUNT", "CONTACT", "LEAD", "OPPORTUNITY", "CASE",
97
+ "CONTRACT", "TASK", "EVENT", "CAMPAIGN",
98
+ ]
99
+ for obj in business_objects:
100
+ if re.search(rf"\bFROM\s+{obj}\b", normalized):
101
+ if is_lma_context and obj in LMA_ALLOWED_OBJECTS:
102
+ continue
103
+ raise SafetyViolationError(
104
+ f"BLOCKED: Querying business object '{obj}' is not permitted. "
105
+ f"Query: {query}"
106
+ )
@@ -0,0 +1,156 @@
1
+ """Safe wrapper around Salesforce CLI (sf).
2
+
3
+ Every command goes through the safety validator before execution.
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ import subprocess
9
+ import shutil
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .safety import validate_sf_command, validate_soql_query
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class SfCliError(Exception):
19
+ """Raised when an sf CLI command fails."""
20
+
21
+ def __init__(self, message: str, command: str = "", exit_code: int = -1, stderr: str = ""):
22
+ super().__init__(message)
23
+ self.command = command
24
+ self.exit_code = exit_code
25
+ self.stderr = stderr
26
+
27
+
28
+ def check_sf_installed() -> dict[str, Any]:
29
+ """Check if Salesforce CLI is installed and return version info."""
30
+ sf_path = shutil.which("sf")
31
+ if not sf_path:
32
+ return {"installed": False, "version": None, "path": None}
33
+
34
+ try:
35
+ result = subprocess.run(
36
+ ["sf", "version", "--json"],
37
+ capture_output=True, text=True, timeout=15,
38
+ )
39
+ if result.returncode == 0:
40
+ try:
41
+ data = json.loads(result.stdout)
42
+ version = data.get("cliVersion", result.stdout.strip())
43
+ except json.JSONDecodeError:
44
+ version = result.stdout.strip()
45
+ return {"installed": True, "version": version, "path": sf_path}
46
+ except (subprocess.TimeoutExpired, FileNotFoundError):
47
+ pass
48
+
49
+ return {"installed": True, "version": "unknown", "path": sf_path}
50
+
51
+
52
+ def run_sf(
53
+ command: str | list[str],
54
+ *,
55
+ cwd: Path | str | None = None,
56
+ timeout: int = 120,
57
+ json_output: bool = True,
58
+ ) -> dict[str, Any]:
59
+ """Run an sf CLI command safely.
60
+
61
+ The command string should NOT include 'sf' prefix.
62
+ """
63
+ if isinstance(command, list):
64
+ full_command = "sf " + " ".join(command)
65
+ args = ["sf"] + command
66
+ else:
67
+ full_command = f"sf {command}"
68
+ args = ["sf"] + command.split()
69
+
70
+ validate_sf_command(full_command)
71
+
72
+ if json_output and "--json" not in args:
73
+ args.append("--json")
74
+
75
+ logger.info("Running: %s (cwd=%s)", " ".join(args), cwd or ".")
76
+
77
+ try:
78
+ result = subprocess.run(
79
+ args, capture_output=True, text=True,
80
+ cwd=str(cwd) if cwd else None, timeout=timeout,
81
+ )
82
+ except subprocess.TimeoutExpired:
83
+ raise SfCliError(
84
+ f"Command timed out after {timeout}s: {full_command}",
85
+ command=full_command, exit_code=-1, stderr="TimeoutExpired",
86
+ )
87
+ except FileNotFoundError:
88
+ raise SfCliError(
89
+ "Salesforce CLI (sf) not found on PATH.",
90
+ command=full_command, exit_code=-1, stderr="FileNotFoundError",
91
+ )
92
+
93
+ if result.returncode != 0:
94
+ stderr = result.stderr.strip() or result.stdout.strip()
95
+ raise SfCliError(
96
+ f"sf command failed (exit {result.returncode}): {stderr}",
97
+ command=full_command, exit_code=result.returncode, stderr=stderr,
98
+ )
99
+
100
+ if json_output:
101
+ try:
102
+ return json.loads(result.stdout)
103
+ except json.JSONDecodeError:
104
+ return {"stdout": result.stdout.strip()}
105
+
106
+ return {"stdout": result.stdout.strip()}
107
+
108
+
109
+ def list_orgs(cwd: Path | str | None = None) -> list[dict[str, Any]]:
110
+ """List all authorized Salesforce orgs."""
111
+ data = run_sf("org list", cwd=cwd)
112
+ result = data.get("result", {})
113
+ orgs = []
114
+ for category in ("nonScratchOrgs", "scratchOrgs"):
115
+ orgs.extend(result.get(category, []))
116
+ return orgs
117
+
118
+
119
+ def display_org(alias: str, cwd: Path | str | None = None) -> dict[str, Any]:
120
+ """Get detailed info about a specific org."""
121
+ return run_sf(f"org display --target-org {alias}", cwd=cwd)
122
+
123
+
124
+ def lma_query(
125
+ query: str,
126
+ lma_org: str,
127
+ cwd: Path | str | None = None,
128
+ timeout: int = 120,
129
+ ) -> list[dict[str, Any]]:
130
+ """Run a read-only SOQL query against the LMA org."""
131
+ validate_soql_query(query, target_org=lma_org, lma_org=lma_org)
132
+ data = run_sf(
133
+ ["data", "query", "--query", query, "--target-org", lma_org],
134
+ cwd=cwd, timeout=timeout,
135
+ )
136
+ result = data.get("result", {})
137
+ records = result.get("records") or []
138
+ return records if isinstance(records, list) else []
139
+
140
+
141
+ def sosl_search(
142
+ search_term: str,
143
+ returning: str,
144
+ target_org: str,
145
+ cwd: Path | str | None = None,
146
+ timeout: int = 60,
147
+ ) -> list[dict[str, Any]]:
148
+ """Run a SOSL search via sf data search."""
149
+ sosl_query = f"FIND {{{search_term}}} IN NAME FIELDS RETURNING {returning}"
150
+ data = run_sf(
151
+ ["data", "search", "--query", sosl_query, "--target-org", target_org],
152
+ cwd=cwd, timeout=timeout,
153
+ )
154
+ result = data.get("result", {})
155
+ records = result.get("searchRecords") or []
156
+ return records if isinstance(records, list) else []
@@ -0,0 +1,61 @@
1
+ """Lean assessment state management.
2
+
3
+ Manages assessment.json — a minimal state file tracking workflow status
4
+ and connection info only. No data duplication; customer data lives in
5
+ customer/ directory files.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ STATE_FILENAME = "assessment.json"
17
+
18
+ _working_dir: Path | None = None
19
+
20
+
21
+ def set_working_dir(path: str | Path | None) -> None:
22
+ """Set the working directory from a tool argument."""
23
+ global _working_dir
24
+ if path:
25
+ _working_dir = Path(path)
26
+
27
+
28
+ def get_working_dir() -> Path:
29
+ """Return the working directory (explicit override or cwd)."""
30
+ return _working_dir or Path.cwd()
31
+
32
+
33
+ def _state_path() -> Path:
34
+ return get_working_dir() / STATE_FILENAME
35
+
36
+
37
+ def load() -> dict[str, Any]:
38
+ """Load the state file. Returns empty dict if it doesn't exist."""
39
+ path = _state_path()
40
+ if path.exists():
41
+ try:
42
+ return json.loads(path.read_text())
43
+ except (json.JSONDecodeError, OSError):
44
+ logger.warning("Could not read state file at %s", path)
45
+ return {}
46
+
47
+
48
+ def save(data: dict[str, Any]) -> None:
49
+ """Write the state file."""
50
+ path = _state_path()
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ path.write_text(json.dumps(data, indent=2, default=str))
53
+
54
+
55
+ def update(**fields: Any) -> dict[str, Any]:
56
+ """Merge fields into the existing state and save."""
57
+ current = load()
58
+ current.update(fields)
59
+ current["updated_at"] = datetime.now(timezone.utc).isoformat()
60
+ save(current)
61
+ return current