mcpswitch-cli 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.
mcpswitch/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MCPSwitch — smart MCP profile manager for Claude Code."""
2
+
3
+ __version__ = "0.1.0"
mcpswitch/auto.py ADDED
@@ -0,0 +1,353 @@
1
+ """Context-aware automatic profile selection.
2
+
3
+ How it works:
4
+ 1. Read conversation context from ~/.claude/history.jsonl (last N messages)
5
+ 2. Scan project directory for tech stack signals
6
+ 3. Score each profile against detected context
7
+ 4. Switch to the best match — silently if confident, ask if not
8
+
9
+ This runs at session start via: mcpswitch auto
10
+ Or manually: mcpswitch auto --dir /path/to/project
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from .profiles import load_profiles, get_active_profile, set_active_profile
20
+ from .config import get_claude_code_config_path, set_mcp_servers
21
+ from .tokens import estimate_total_tokens
22
+
23
+ # ── Tech stack signals → MCP server names ────────────────────────────────────
24
+
25
+ # File presence signals: if these files exist, these servers are likely needed
26
+ FILE_SIGNALS: list[tuple[str, list[str]]] = [
27
+ # (glob pattern, [relevant mcp server names])
28
+ ("package.json", ["github", "playwright", "context7"]),
29
+ ("requirements.txt", ["github", "context7", "postgres"]),
30
+ ("pyproject.toml", ["github", "context7", "postgres"]),
31
+ ("go.mod", ["github", "context7"]),
32
+ ("Cargo.toml", ["github", "context7"]),
33
+ ("docker-compose.yml", ["github", "postgres", "sqlite"]),
34
+ (".github/workflows/*.yml",["github"]),
35
+ ("prisma/schema.prisma", ["github", "postgres"]),
36
+ ("supabase/**", ["github", "postgres"]),
37
+ ("*.test.*", ["playwright", "github"]),
38
+ ("playwright.config.*", ["playwright"]),
39
+ ("*.md", ["filesystem", "fetch"]),
40
+ ("docs/**", ["filesystem", "fetch"]),
41
+ ]
42
+
43
+ # Conversation keyword signals: words in recent messages → relevant servers
44
+ KEYWORD_SIGNALS: dict[str, list[str]] = {
45
+ # Code & git
46
+ "pull request": ["github"],
47
+ "pr review": ["github"],
48
+ "github": ["github"],
49
+ "commit": ["github"],
50
+ "branch": ["github"],
51
+ "merge": ["github"],
52
+
53
+ # Browser / frontend
54
+ "browser": ["playwright"],
55
+ "screenshot": ["playwright"],
56
+ "click": ["playwright"],
57
+ "scrape": ["playwright"],
58
+ "selenium": ["playwright"],
59
+ "test": ["playwright", "github"],
60
+
61
+ # Search
62
+ "search": ["brave-search"],
63
+ "google": ["brave-search"],
64
+ "find online": ["brave-search"],
65
+ "look up": ["brave-search"],
66
+
67
+ # Database
68
+ "database": ["postgres", "sqlite"],
69
+ "sql": ["postgres", "sqlite"],
70
+ "postgres": ["postgres"],
71
+ "sqlite": ["sqlite"],
72
+ "query": ["postgres", "sqlite"],
73
+
74
+ # Docs / files
75
+ "documentation": ["filesystem", "context7"],
76
+ "readme": ["filesystem"],
77
+ "file": ["filesystem"],
78
+ "read file": ["filesystem"],
79
+
80
+ # API docs
81
+ "api docs": ["context7"],
82
+ "documentation": ["context7"],
83
+ "latest docs": ["context7"],
84
+ "how to use": ["context7"],
85
+
86
+ # Notes / knowledge
87
+ "obsidian": ["obsidian"],
88
+ "notes": ["obsidian"],
89
+ "vault": ["obsidian"],
90
+ }
91
+
92
+ CONFIDENCE_THRESHOLD = 0.4 # switch silently above this, ask below
93
+
94
+
95
+ def _read_recent_history(n_messages: int = 20) -> str:
96
+ """Read the last N messages from Claude Code history.
97
+
98
+ Returns concatenated text of recent user messages.
99
+ """
100
+ history_file = Path.home() / ".claude" / "history.jsonl"
101
+ if not history_file.exists():
102
+ return ""
103
+
104
+ lines = []
105
+ try:
106
+ with open(history_file, "r", encoding="utf-8") as f:
107
+ lines = f.readlines()
108
+ except Exception:
109
+ return ""
110
+
111
+ messages = []
112
+ for line in reversed(lines[-200:]): # look at last 200 lines max
113
+ try:
114
+ entry = json.loads(line.strip())
115
+ # Extract user message text
116
+ if isinstance(entry, dict):
117
+ content = entry.get("message", "") or entry.get("content", "")
118
+ if isinstance(content, list):
119
+ for block in content:
120
+ if isinstance(block, dict) and block.get("type") == "text":
121
+ messages.append(block.get("text", ""))
122
+ elif isinstance(content, str):
123
+ messages.append(content)
124
+ except Exception:
125
+ continue
126
+ if len(messages) >= n_messages:
127
+ break
128
+
129
+ return " ".join(messages).lower()
130
+
131
+
132
+ def _scan_project_signals(directory: str) -> list[str]:
133
+ """Scan directory for tech stack files. Returns list of relevant MCP server names."""
134
+ path = Path(directory)
135
+ if not path.exists():
136
+ return []
137
+
138
+ relevant_servers = []
139
+ for pattern, servers in FILE_SIGNALS:
140
+ if "**" in pattern or "*" in pattern:
141
+ matches = list(path.glob(pattern))
142
+ else:
143
+ matches = [path / pattern] if (path / pattern).exists() else []
144
+
145
+ if matches:
146
+ relevant_servers.extend(servers)
147
+
148
+ return list(set(relevant_servers))
149
+
150
+
151
+ def _score_profile(
152
+ profile_name: str,
153
+ profile_servers: dict,
154
+ context_servers: list[str],
155
+ conversation_servers: list[str],
156
+ ) -> float:
157
+ """Score a profile 0.0–1.0 based on how well it matches detected context.
158
+
159
+ Higher = better match.
160
+ """
161
+ if not profile_servers:
162
+ return 0.0
163
+
164
+ loaded = set(profile_servers.keys())
165
+ needed = set(context_servers + conversation_servers)
166
+
167
+ if not needed:
168
+ return 0.0
169
+
170
+ # Coverage: what % of needed servers does this profile have?
171
+ coverage = len(loaded & needed) / len(needed)
172
+
173
+ # Precision: what % of loaded servers are actually needed? (penalize bloat)
174
+ precision = len(loaded & needed) / len(loaded) if loaded else 0.0
175
+
176
+ # Weighted: coverage matters more than precision
177
+ score = (coverage * 0.7) + (precision * 0.3)
178
+ return round(score, 3)
179
+
180
+
181
+ def select_best_profile(
182
+ directory: Optional[str] = None,
183
+ conversation_text: Optional[str] = None,
184
+ ) -> dict:
185
+ """Select the best profile for current context.
186
+
187
+ Returns:
188
+ {
189
+ "recommended": str, # profile name
190
+ "confidence": float, # 0.0 - 1.0
191
+ "reason": str, # human-readable explanation
192
+ "scores": {name: score}, # all profile scores
193
+ "context_servers": [...],# servers detected from project
194
+ "conversation_servers":[],# servers detected from conversation
195
+ "current_profile": str,
196
+ }
197
+ """
198
+ profiles = load_profiles()
199
+ current = get_active_profile()
200
+
201
+ if not profiles:
202
+ return {
203
+ "recommended": None,
204
+ "confidence": 0.0,
205
+ "reason": "No profiles configured. Run: mcpswitch import --name <name>",
206
+ "scores": {},
207
+ "context_servers": [],
208
+ "conversation_servers": [],
209
+ "current_profile": current,
210
+ }
211
+
212
+ # 1. Detect from project files
213
+ project_dir = directory or os.getcwd()
214
+ context_servers = _scan_project_signals(project_dir)
215
+
216
+ # 2. Detect from conversation history
217
+ if conversation_text is None:
218
+ conversation_text = _read_recent_history(20)
219
+
220
+ conversation_servers = []
221
+ for keyword, servers in KEYWORD_SIGNALS.items():
222
+ if keyword in conversation_text:
223
+ conversation_servers.extend(servers)
224
+ conversation_servers = list(set(conversation_servers))
225
+
226
+ # 3. Score all profiles
227
+ scores = {}
228
+ for name, servers in profiles.items():
229
+ scores[name] = _score_profile(name, servers, context_servers, conversation_servers)
230
+
231
+ if not any(scores.values()):
232
+ # No signals detected — recommend the profile with fewest tokens (lean default)
233
+ config_path = get_claude_code_config_path()
234
+ best = min(profiles.keys(), key=lambda n: estimate_total_tokens(profiles[n])["total"])
235
+ return {
236
+ "recommended": best,
237
+ "confidence": 0.2,
238
+ "reason": "No project signals detected. Recommending leanest profile.",
239
+ "scores": scores,
240
+ "context_servers": context_servers,
241
+ "conversation_servers": conversation_servers,
242
+ "current_profile": current,
243
+ }
244
+
245
+ best = max(scores, key=lambda k: scores[k])
246
+ confidence = scores[best]
247
+
248
+ # Build reason string
249
+ reason_parts = []
250
+ if context_servers:
251
+ reason_parts.append(f"project signals: {', '.join(context_servers[:3])}")
252
+ if conversation_servers:
253
+ reason_parts.append(f"conversation context: {', '.join(conversation_servers[:3])}")
254
+ reason = "Detected " + " | ".join(reason_parts) if reason_parts else "Best available match"
255
+
256
+ return {
257
+ "recommended": best,
258
+ "confidence": confidence,
259
+ "current_profile": current,
260
+ "scores": scores,
261
+ "context_servers": context_servers,
262
+ "conversation_servers": conversation_servers,
263
+ "reason": reason,
264
+ }
265
+
266
+
267
+ def auto_switch(
268
+ directory: Optional[str] = None,
269
+ confirm_threshold: float = CONFIDENCE_THRESHOLD,
270
+ force: bool = False,
271
+ silent: bool = False,
272
+ ) -> dict:
273
+ """Auto-select and switch profile based on context.
274
+
275
+ Returns:
276
+ {
277
+ "switched": bool,
278
+ "from_profile": str,
279
+ "to_profile": str,
280
+ "confidence": float,
281
+ "needs_confirmation": bool, # True if confidence below threshold
282
+ "reason": str,
283
+ }
284
+ """
285
+ result = select_best_profile(directory=directory)
286
+ recommended = result["recommended"]
287
+ confidence = result["confidence"]
288
+ current = result["current_profile"]
289
+
290
+ if not recommended:
291
+ return {
292
+ "switched": False,
293
+ "from_profile": current,
294
+ "to_profile": None,
295
+ "confidence": 0.0,
296
+ "needs_confirmation": False,
297
+ "reason": result["reason"],
298
+ }
299
+
300
+ # Already on the best profile
301
+ if recommended == current and not force:
302
+ return {
303
+ "switched": False,
304
+ "from_profile": current,
305
+ "to_profile": recommended,
306
+ "confidence": confidence,
307
+ "needs_confirmation": False,
308
+ "reason": f"Already on best profile '{current}'",
309
+ }
310
+
311
+ # Low confidence — ask user
312
+ if confidence < confirm_threshold and not force:
313
+ return {
314
+ "switched": False,
315
+ "from_profile": current,
316
+ "to_profile": recommended,
317
+ "confidence": confidence,
318
+ "needs_confirmation": True,
319
+ "reason": result["reason"],
320
+ }
321
+
322
+ # High confidence — switch silently
323
+ from .profiles import get_profile
324
+ from .config import get_claude_desktop_config_path
325
+
326
+ servers = get_profile(recommended)
327
+ if not servers and servers != {}:
328
+ return {
329
+ "switched": False,
330
+ "from_profile": current,
331
+ "to_profile": recommended,
332
+ "confidence": confidence,
333
+ "needs_confirmation": False,
334
+ "reason": f"Profile '{recommended}' not found",
335
+ }
336
+
337
+ config_path = get_claude_code_config_path()
338
+ set_mcp_servers(config_path, servers)
339
+
340
+ desktop_path = get_claude_desktop_config_path()
341
+ if desktop_path and desktop_path.exists():
342
+ set_mcp_servers(desktop_path, servers)
343
+
344
+ set_active_profile(recommended)
345
+
346
+ return {
347
+ "switched": True,
348
+ "from_profile": current,
349
+ "to_profile": recommended,
350
+ "confidence": confidence,
351
+ "needs_confirmation": False,
352
+ "reason": result["reason"],
353
+ }
mcpswitch/billing.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+ Billing and license-key management for MCPSwitch — Team tier only.
3
+
4
+ Payment processor: Lemon Squeezy
5
+ Store: mcpswitch.lemonsqueezy.com
6
+ Product: MCPSwitch Team — ₹499/month subscription
7
+
8
+ License key format
9
+ ------------------
10
+ MCPS-TEAM-{PAYLOAD}-{SIG}
11
+
12
+ PAYLOAD : Base32-encoded JSON {"e": email_sha256[:16], "t": unix_ts, "s": seats}
13
+ SIG : Base32-encoded first 10 bytes of HMAC-SHA256(secret, PAYLOAD)
14
+
15
+ Keys are validated offline — no network call required.
16
+ The same HMAC secret must be set in both the CLI build and the webhook server
17
+ via the MCPSWITCH_LICENSE_SECRET environment variable.
18
+ """
19
+
20
+ import base64
21
+ import hashlib
22
+ import hmac
23
+ import json
24
+ import os
25
+ import time
26
+ import webbrowser
27
+
28
+ # ── HMAC secret ───────────────────────────────────────────────────────────────
29
+ # Must match in both the CLI build and the webhook server.
30
+ # Set MCPSWITCH_LICENSE_SECRET in your Railway environment variables.
31
+ _DEFAULT_SECRET = "mcpswitch-change-me-in-production-v1"
32
+ _HMAC_SECRET: bytes = os.environ.get(
33
+ "MCPSWITCH_LICENSE_SECRET", _DEFAULT_SECRET
34
+ ).encode()
35
+
36
+ # ── Lemon Squeezy config ──────────────────────────────────────────────────────
37
+ LEMONSQUEEZY_API_KEY = os.environ.get("LEMONSQUEEZY_API_KEY", "")
38
+ LEMONSQUEEZY_WEBHOOK_SECRET = os.environ.get("LEMONSQUEEZY_WEBHOOK_SECRET", "")
39
+ LEMONSQUEEZY_STORE_ID = os.environ.get("LEMONSQUEEZY_STORE_ID", "")
40
+ LEMONSQUEEZY_VARIANT_ID = os.environ.get("LEMONSQUEEZY_VARIANT_ID", "1525680")
41
+ LEMONSQUEEZY_PRODUCT_ID = os.environ.get("LEMONSQUEEZY_PRODUCT_ID", "971803")
42
+
43
+ # Checkout URL — Lemon Squeezy hosted checkout for the Team variant
44
+ TEAM_PAYMENT_URL = os.environ.get(
45
+ "MCPSWITCH_TEAM_URL",
46
+ f"https://mcpswitch.lemonsqueezy.com/buy/{LEMONSQUEEZY_VARIANT_ID}",
47
+ )
48
+
49
+
50
+ # ── Base32 helpers (no padding) ───────────────────────────────────────────────
51
+
52
+ def _b32_encode(data: bytes) -> str:
53
+ return base64.b32encode(data).decode().rstrip("=")
54
+
55
+
56
+ def _b32_decode(s: str) -> bytes:
57
+ s = s.upper()
58
+ pad = (8 - len(s) % 8) % 8
59
+ return base64.b32decode(s + "=" * pad)
60
+
61
+
62
+ # ── License key generation ────────────────────────────────────────────────────
63
+
64
+ def generate_license_key(email: str, seats: int = 1) -> str:
65
+ """Generate a cryptographically signed Team license key.
66
+
67
+ Args:
68
+ email: Buyer's email address. Stored as a truncated SHA-256 hash —
69
+ never in plaintext.
70
+ seats: Number of seats included in the purchase.
71
+
72
+ Returns:
73
+ A key string like ``MCPS-TEAM-MNQWI...-ABCDE...``
74
+ """
75
+ email_hash = hashlib.sha256(email.lower().strip().encode()).hexdigest()[:16]
76
+ issued_at = int(time.time())
77
+ payload_json = json.dumps(
78
+ {"e": email_hash, "t": issued_at, "s": seats},
79
+ separators=(",", ":"),
80
+ )
81
+ payload = _b32_encode(payload_json.encode())
82
+ sig_raw = hmac.digest(_HMAC_SECRET, payload.encode(), "sha256")[:10]
83
+ sig = _b32_encode(sig_raw)
84
+ return f"MCPS-TEAM-{payload}-{sig}"
85
+
86
+
87
+ # ── License key validation ────────────────────────────────────────────────────
88
+
89
+ def validate_license_key(key: str) -> dict:
90
+ """Validate a Team license key offline using HMAC.
91
+
92
+ Returns on success::
93
+
94
+ {"valid": True, "tier": "team", "issued_at": int, "seats": int, "email_hash": str}
95
+
96
+ Returns on failure::
97
+
98
+ {"valid": False, "error": "<reason>"}
99
+ """
100
+ key = key.strip().upper()
101
+
102
+ parts = key.split("-", 3)
103
+ if len(parts) != 4 or parts[0] != "MCPS" or parts[1] != "TEAM":
104
+ return {"valid": False, "error": "Invalid key format — expected MCPS-TEAM-... key"}
105
+
106
+ _, _tier, payload, claimed_sig = parts
107
+
108
+ expected_raw = hmac.digest(_HMAC_SECRET, payload.encode(), "sha256")[:10]
109
+ expected_sig = _b32_encode(expected_raw)
110
+
111
+ if not hmac.compare_digest(expected_sig, claimed_sig):
112
+ return {"valid": False, "error": "Key signature is invalid"}
113
+
114
+ try:
115
+ meta = json.loads(_b32_decode(payload).decode())
116
+ except Exception:
117
+ return {"valid": False, "error": "Key payload is corrupted"}
118
+
119
+ return {
120
+ "valid": True,
121
+ "tier": "team",
122
+ "issued_at": meta.get("t", 0),
123
+ "seats": meta.get("s", 1),
124
+ "email_hash": meta.get("e", ""),
125
+ }
126
+
127
+
128
+ # ── Lemon Squeezy checkout ────────────────────────────────────────────────────
129
+
130
+ def open_checkout() -> str:
131
+ """Open the Lemon Squeezy checkout page for the Team tier in the browser.
132
+
133
+ Returns:
134
+ The URL that was opened.
135
+ """
136
+ webbrowser.open(TEAM_PAYMENT_URL)
137
+ return TEAM_PAYMENT_URL
138
+
139
+
140
+ # ── Lemon Squeezy webhook verification ───────────────────────────────────────
141
+
142
+ def verify_lemonsqueezy_webhook(payload: bytes, sig_header: str) -> dict:
143
+ """Verify a Lemon Squeezy webhook signature and return the parsed event.
144
+
145
+ Lemon Squeezy signs webhooks with HMAC-SHA256.
146
+ The signature is in the X-Signature header as a hex digest.
147
+
148
+ Args:
149
+ payload: Raw request body bytes.
150
+ sig_header: Value of the ``X-Signature`` HTTP header.
151
+
152
+ Returns:
153
+ Parsed event dict.
154
+
155
+ Raises:
156
+ ValueError: If the webhook secret is not set or signature is invalid.
157
+ """
158
+ if not LEMONSQUEEZY_WEBHOOK_SECRET:
159
+ raise ValueError(
160
+ "LEMONSQUEEZY_WEBHOOK_SECRET is not configured. "
161
+ "Set it to your webhook signing secret from the Lemon Squeezy Dashboard."
162
+ )
163
+
164
+ expected = hmac.digest(
165
+ LEMONSQUEEZY_WEBHOOK_SECRET.encode(),
166
+ payload,
167
+ "sha256",
168
+ ).hex()
169
+
170
+ if not hmac.compare_digest(expected, sig_header.lower()):
171
+ raise ValueError("Webhook signature is invalid")
172
+
173
+ return json.loads(payload.decode())