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/community.py ADDED
@@ -0,0 +1,209 @@
1
+ """Community profiles — discover and install curated MCP stacks.
2
+
3
+ Backend: GitHub repo (mcpswitch/community-profiles) — no server needed.
4
+ Anyone can submit a profile via PR. MCPSwitch fetches the index from GitHub.
5
+
6
+ Pro feature:
7
+ mcpswitch community list → browse available stacks
8
+ mcpswitch community install react → install a community profile locally
9
+ mcpswitch community publish my-stack → submit your profile (opens GitHub PR)
10
+ mcpswitch community info react → show details about a profile
11
+ """
12
+
13
+ import json
14
+ import urllib.request
15
+ import urllib.error
16
+ from typing import Optional
17
+
18
+ # GitHub repo that hosts community profiles
19
+ COMMUNITY_REPO = "mcpswitch-dev/community-profiles"
20
+ COMMUNITY_BRANCH = "main"
21
+ INDEX_URL = (
22
+ f"https://raw.githubusercontent.com/{COMMUNITY_REPO}/"
23
+ f"{COMMUNITY_BRANCH}/index.json"
24
+ )
25
+ PROFILE_BASE_URL = (
26
+ f"https://raw.githubusercontent.com/{COMMUNITY_REPO}/"
27
+ f"{COMMUNITY_BRANCH}/profiles"
28
+ )
29
+
30
+ # Fallback built-in profiles (used when GitHub is unreachable)
31
+ BUILTIN_PROFILES = {
32
+ "react-fullstack": {
33
+ "name": "react-fullstack",
34
+ "description": "React + TypeScript + Postgres + GitHub",
35
+ "author": "mcpswitch",
36
+ "stars": 0,
37
+ "tags": ["react", "typescript", "postgres", "frontend"],
38
+ "servers": ["github", "playwright", "postgres", "context7"],
39
+ "builtin": True,
40
+ },
41
+ "python-api": {
42
+ "name": "python-api",
43
+ "description": "FastAPI + Postgres + GitHub — Python backend development",
44
+ "author": "mcpswitch",
45
+ "stars": 0,
46
+ "tags": ["python", "fastapi", "postgres", "backend"],
47
+ "servers": ["github", "postgres", "context7", "sequential-thinking"],
48
+ "builtin": True,
49
+ },
50
+ "go-backend": {
51
+ "name": "go-backend",
52
+ "description": "Go API development — GitHub + context7 only",
53
+ "author": "mcpswitch",
54
+ "stars": 0,
55
+ "tags": ["go", "golang", "backend", "api"],
56
+ "servers": ["github", "context7"],
57
+ "builtin": True,
58
+ },
59
+ "writing-docs": {
60
+ "name": "writing-docs",
61
+ "description": "Documentation and writing — filesystem + fetch, no code tools",
62
+ "author": "mcpswitch",
63
+ "stars": 0,
64
+ "tags": ["writing", "docs", "markdown"],
65
+ "servers": ["filesystem", "fetch"],
66
+ "builtin": True,
67
+ },
68
+ "data-analysis": {
69
+ "name": "data-analysis",
70
+ "description": "Data analysis — SQLite + Postgres + sequential thinking",
71
+ "author": "mcpswitch",
72
+ "stars": 0,
73
+ "tags": ["data", "sql", "analysis", "sqlite"],
74
+ "servers": ["sqlite", "postgres", "sequential-thinking", "filesystem"],
75
+ "builtin": True,
76
+ },
77
+ "scraping": {
78
+ "name": "scraping",
79
+ "description": "Web scraping and automation — Playwright + Brave Search",
80
+ "author": "mcpswitch",
81
+ "stars": 0,
82
+ "tags": ["scraping", "automation", "browser", "playwright"],
83
+ "servers": ["playwright", "brave-search", "fetch"],
84
+ "builtin": True,
85
+ },
86
+ "devops": {
87
+ "name": "devops",
88
+ "description": "DevOps and infra — GitHub + filesystem + fetch",
89
+ "author": "mcpswitch",
90
+ "stars": 0,
91
+ "tags": ["devops", "ci", "github-actions", "infra"],
92
+ "servers": ["github", "filesystem", "fetch"],
93
+ "builtin": True,
94
+ },
95
+ "minimal": {
96
+ "name": "minimal",
97
+ "description": "Absolute minimum — sequential thinking only. Max context space.",
98
+ "author": "mcpswitch",
99
+ "stars": 0,
100
+ "tags": ["minimal", "lean", "context"],
101
+ "servers": ["sequential-thinking"],
102
+ "builtin": True,
103
+ },
104
+ }
105
+
106
+
107
+ def _fetch_url(url: str, timeout: int = 5) -> Optional[dict]:
108
+ try:
109
+ req = urllib.request.Request(url, headers={"User-Agent": "mcpswitch/0.1"})
110
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
111
+ return json.loads(resp.read())
112
+ except Exception:
113
+ return None
114
+
115
+
116
+ def list_community_profiles(tag: Optional[str] = None) -> list[dict]:
117
+ """Fetch community profile index. Falls back to built-ins if offline."""
118
+ remote = _fetch_url(INDEX_URL)
119
+
120
+ if remote and isinstance(remote, list):
121
+ profiles = remote
122
+ else:
123
+ profiles = list(BUILTIN_PROFILES.values())
124
+
125
+ if tag:
126
+ profiles = [p for p in profiles if tag.lower() in [t.lower() for t in p.get("tags", [])]]
127
+
128
+ return sorted(profiles, key=lambda p: p.get("stars", 0), reverse=True)
129
+
130
+
131
+ def get_profile_detail(name: str) -> Optional[dict]:
132
+ """Fetch full profile detail including exact server configs."""
133
+ # Try built-in first
134
+ if name in BUILTIN_PROFILES:
135
+ profile = dict(BUILTIN_PROFILES[name])
136
+ # Convert server list to minimal configs (user still needs real configs)
137
+ profile["server_configs"] = {
138
+ srv: {"command": "npx", "args": [f"@modelcontextprotocol/server-{srv}"]}
139
+ for srv in profile.get("servers", [])
140
+ }
141
+ return profile
142
+
143
+ # Try remote
144
+ url = f"{PROFILE_BASE_URL}/{name}.json"
145
+ return _fetch_url(url)
146
+
147
+
148
+ def install_community_profile(name: str) -> dict:
149
+ """Install a community profile locally.
150
+
151
+ For built-in profiles: creates the profile with server name stubs.
152
+ User configures actual server commands afterward.
153
+
154
+ Returns {"success": bool, "profile_name": str, "servers": [...], "message": str}
155
+ """
156
+ from .profiles import create_profile
157
+
158
+ detail = get_profile_detail(name)
159
+ if not detail:
160
+ return {"success": False, "profile_name": name,
161
+ "servers": [], "message": f"Profile '{name}' not found."}
162
+
163
+ # Build server config stubs from server list
164
+ server_configs = detail.get("server_configs", {})
165
+ if not server_configs and "servers" in detail:
166
+ server_configs = {
167
+ srv: {"command": "npx", "args": [f"@modelcontextprotocol/server-{srv}"]}
168
+ for srv in detail["servers"]
169
+ }
170
+
171
+ create_profile(name, server_configs)
172
+
173
+ return {
174
+ "success": True,
175
+ "profile_name": name,
176
+ "servers": list(server_configs.keys()),
177
+ "description": detail.get("description", ""),
178
+ "message": (
179
+ f"Installed '{name}' with {len(server_configs)} server stub(s).\n"
180
+ f"Edit server commands: mcpswitch edit {name}"
181
+ ),
182
+ }
183
+
184
+
185
+ def publish_profile_url(profile_name: str, author: str = "") -> str:
186
+ """Return the GitHub URL to submit a community profile PR."""
187
+ import urllib.parse
188
+ title = urllib.parse.quote(f"Add community profile: {profile_name}")
189
+ body = urllib.parse.quote(
190
+ f"**Profile name:** {profile_name}\n"
191
+ f"**Author:** {author or 'anonymous'}\n\n"
192
+ f"<!-- Add your profile JSON below -->\n"
193
+ )
194
+ return (
195
+ f"https://github.com/{COMMUNITY_REPO}/issues/new"
196
+ f"?title={title}&body={body}&labels=new-profile"
197
+ )
198
+
199
+
200
+ def search_profiles(query: str) -> list[dict]:
201
+ """Search profiles by name, description, or tags."""
202
+ q = query.lower()
203
+ all_profiles = list_community_profiles()
204
+ return [
205
+ p for p in all_profiles
206
+ if q in p.get("name", "").lower()
207
+ or q in p.get("description", "").lower()
208
+ or any(q in t.lower() for t in p.get("tags", []))
209
+ ]
mcpswitch/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """Read and write Claude MCP config files."""
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ def get_claude_code_config_path() -> Path:
12
+ """~/.claude/claude_desktop_config.json — used by Claude Code CLI."""
13
+ return Path.home() / ".claude" / "claude_desktop_config.json"
14
+
15
+
16
+ def get_claude_desktop_config_path() -> Optional[Path]:
17
+ """AppData/Roaming/Claude/claude_desktop_config.json — Claude Desktop app."""
18
+ if platform.system() == "Windows":
19
+ appdata = os.environ.get("APPDATA", "")
20
+ if appdata:
21
+ return Path(appdata) / "Claude" / "claude_desktop_config.json"
22
+ elif platform.system() == "Darwin":
23
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
24
+ elif platform.system() == "Linux":
25
+ return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
26
+ return None
27
+
28
+
29
+ def read_config(path: Path) -> dict:
30
+ """Read a Claude config file. Returns empty dict if not found."""
31
+ if not path.exists():
32
+ return {}
33
+ with open(path, "r", encoding="utf-8") as f:
34
+ return json.load(f)
35
+
36
+
37
+ def write_config(path: Path, data: dict) -> None:
38
+ """Write config atomically with backup."""
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+
41
+ # Backup before overwrite
42
+ if path.exists():
43
+ backup = path.with_suffix(".json.bak")
44
+ shutil.copy2(path, backup)
45
+
46
+ tmp = path.with_suffix(".json.tmp")
47
+ with open(tmp, "w", encoding="utf-8") as f:
48
+ json.dump(data, f, indent=2)
49
+ tmp.replace(path)
50
+
51
+
52
+ def get_all_mcp_servers(path: Path) -> dict:
53
+ """Return the mcpServers dict from a config file."""
54
+ config = read_config(path)
55
+ return config.get("mcpServers", {})
56
+
57
+
58
+ def set_mcp_servers(path: Path, servers: dict) -> None:
59
+ """Replace mcpServers in config, preserving all other keys."""
60
+ config = read_config(path)
61
+ config["mcpServers"] = servers
62
+ write_config(path, config)
mcpswitch/email.py ADDED
@@ -0,0 +1,204 @@
1
+ """Weekly token savings digest email via Resend.
2
+
3
+ Pro feature: sends a weekly summary of:
4
+ - Which MCPs were called most
5
+ - How many tokens were saved vs loading everything
6
+ - Waste alerts
7
+ - Top recommendation for next week
8
+
9
+ Setup: mcpswitch digest --setup your@email.com --api-key re_xxx
10
+ Send: mcpswitch digest --send
11
+ Auto: add to cron/task scheduler — mcpswitch digest --send --quiet
12
+ """
13
+
14
+ import json
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ DIGEST_CONFIG_FILE = Path.home() / ".mcpswitch" / "digest.json"
20
+
21
+
22
+ def _load_config() -> dict:
23
+ if not DIGEST_CONFIG_FILE.exists():
24
+ return {}
25
+ try:
26
+ with open(DIGEST_CONFIG_FILE, "r", encoding="utf-8") as f:
27
+ return json.load(f)
28
+ except Exception:
29
+ return {}
30
+
31
+
32
+ def _save_config(data: dict) -> None:
33
+ DIGEST_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
34
+ with open(DIGEST_CONFIG_FILE, "w", encoding="utf-8") as f:
35
+ json.dump(data, f, indent=2)
36
+
37
+
38
+ def setup_digest(email: str, api_key: str) -> dict:
39
+ """Save Resend API key and recipient email."""
40
+ _save_config({
41
+ "email": email,
42
+ "resend_api_key": api_key,
43
+ "setup_at": time.time(),
44
+ "last_sent": None,
45
+ })
46
+ return {"success": True, "email": email}
47
+
48
+
49
+ def get_digest_config() -> dict:
50
+ return _load_config()
51
+
52
+
53
+ def _build_html(summary: dict, waste: list, savings_tokens: int) -> str:
54
+ waste_rows = ""
55
+ for w in waste[:5]:
56
+ waste_rows += f"""
57
+ <tr>
58
+ <td style="padding:6px 12px;border-bottom:1px solid #eee">{w['server']}</td>
59
+ <td style="padding:6px 12px;border-bottom:1px solid #eee;text-align:center">
60
+ {w['total_calls']} calls</td>
61
+ <td style="padding:6px 12px;border-bottom:1px solid #eee;color:#e53e3e">
62
+ {w['waste_level']}</td>
63
+ </tr>"""
64
+
65
+ top_servers = ""
66
+ for s in summary.get("top_servers", []):
67
+ top_servers += f"<li><strong>{s['server']}</strong> — {s['calls']} calls</li>"
68
+
69
+ savings_kb = round(savings_tokens / 1000, 1)
70
+
71
+ return f"""
72
+ <!DOCTYPE html>
73
+ <html>
74
+ <head><meta charset="utf-8"><title>MCPSwitch Weekly Digest</title></head>
75
+ <body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
76
+ max-width:600px;margin:0 auto;padding:24px;color:#1a1a1a">
77
+
78
+ <h2 style="color:#2563eb;margin-bottom:4px">MCPSwitch Weekly Digest</h2>
79
+ <p style="color:#666;margin-top:0">Last 7 days</p>
80
+
81
+ <div style="background:#f0fdf4;border-left:4px solid #22c55e;padding:16px;
82
+ border-radius:4px;margin:20px 0">
83
+ <strong>Tokens saved this week:</strong>
84
+ <span style="font-size:1.4em;color:#16a34a;margin-left:8px">
85
+ ~{savings_kb}k tokens
86
+ </span>
87
+ <span style="color:#666;font-size:0.9em"> by using profiles instead of loading everything</span>
88
+ </div>
89
+
90
+ <h3>Most Used Servers</h3>
91
+ <ul style="line-height:1.8">{top_servers or '<li>No usage data yet — run mcpswitch setup</li>'}</ul>
92
+
93
+ {'<h3>Waste Alerts</h3><table style="width:100%;border-collapse:collapse"><thead><tr><th style="text-align:left;padding:6px 12px;background:#f9fafb">Server</th><th style="padding:6px 12px;background:#f9fafb">Usage</th><th style="padding:6px 12px;background:#f9fafb">Level</th></tr></thead><tbody>' + waste_rows + '</tbody></table><p style="font-size:0.9em;color:#666">Fix with: <code>mcpswitch waste --fix</code></p>' if waste else '<p style="color:#16a34a">No waste detected this week.</p>'}
94
+
95
+ <hr style="border:none;border-top:1px solid #eee;margin:24px 0">
96
+ <p style="color:#999;font-size:0.85em">
97
+ MCPSwitch Pro | <a href="https://mcpswitch.dev" style="color:#2563eb">mcpswitch.dev</a> |
98
+ <a href="https://mcpswitch.dev/unsubscribe" style="color:#999">Unsubscribe</a>
99
+ </p>
100
+ </body>
101
+ </html>"""
102
+
103
+
104
+ def _build_text(summary: dict, waste: list, savings_tokens: int) -> str:
105
+ savings_kb = round(savings_tokens / 1000, 1)
106
+ lines = [
107
+ "MCPSwitch Weekly Digest",
108
+ "=" * 40,
109
+ f"Tokens saved this week: ~{savings_kb}k",
110
+ "",
111
+ "Most Used Servers:",
112
+ ]
113
+ for s in summary.get("top_servers", []):
114
+ lines.append(f" {s['server']}: {s['calls']} calls")
115
+
116
+ if waste:
117
+ lines += ["", "Waste Alerts:"]
118
+ for w in waste[:5]:
119
+ lines.append(f" {w['server']} — {w['waste_level']} ({w['total_calls']} calls)")
120
+ lines.append("Fix with: mcpswitch waste --fix")
121
+ else:
122
+ lines.append("\nNo waste detected this week.")
123
+
124
+ lines += ["", "---", "mcpswitch.dev"]
125
+ return "\n".join(lines)
126
+
127
+
128
+ def send_digest(force: bool = False) -> dict:
129
+ """Build and send the weekly digest.
130
+
131
+ Returns {"success": bool, "message": str, "skipped": bool}
132
+ """
133
+ config = _load_config()
134
+
135
+ if not config.get("resend_api_key"):
136
+ return {"success": False, "skipped": False,
137
+ "message": "Not configured. Run: mcpswitch digest --setup"}
138
+
139
+ # Rate limit: only send once per 6 days unless forced
140
+ last_sent = config.get("last_sent")
141
+ if last_sent and not force:
142
+ days_since = (time.time() - last_sent) / 86400
143
+ if days_since < 6:
144
+ return {"success": True, "skipped": True,
145
+ "message": f"Already sent {days_since:.1f} days ago. Use --force to resend."}
146
+
147
+ # Build digest content
148
+ from .usage import get_usage_summary, get_waste_report, get_server_usage_stats
149
+ from .config import get_claude_code_config_path, get_all_mcp_servers
150
+ from .tokens import estimate_total_tokens
151
+
152
+ summary = get_usage_summary(days=7)
153
+ servers = get_all_mcp_servers(get_claude_code_config_path())
154
+ waste = get_waste_report(list(servers.keys()), days=7) if servers else []
155
+
156
+ # Estimate token savings: current profile vs loading all known servers
157
+ current_tokens = estimate_total_tokens(servers)["total"]
158
+ # Approximate "all servers" as 2x current (conservative)
159
+ savings_tokens = max(0, current_tokens)
160
+
161
+ html = _build_html(summary, waste, savings_tokens)
162
+ text = _build_text(summary, waste, savings_tokens)
163
+
164
+ # Send via Resend API
165
+ try:
166
+ import urllib.request
167
+ payload = json.dumps({
168
+ "from": "MCPSwitch <digest@mcpswitch.dev>",
169
+ "to": [config["email"]],
170
+ "subject": f"MCPSwitch Weekly: {summary['total_calls']} tool calls, "
171
+ f"{len(waste)} waste alerts",
172
+ "html": html,
173
+ "text": text,
174
+ }).encode("utf-8")
175
+
176
+ req = urllib.request.Request(
177
+ "https://api.resend.com/emails",
178
+ data=payload,
179
+ headers={
180
+ "Authorization": f"Bearer {config['resend_api_key']}",
181
+ "Content-Type": "application/json",
182
+ },
183
+ method="POST",
184
+ )
185
+ with urllib.request.urlopen(req, timeout=10) as resp:
186
+ result = json.loads(resp.read())
187
+ config["last_sent"] = time.time()
188
+ _save_config(config)
189
+ return {"success": True, "skipped": False,
190
+ "message": f"Sent to {config['email']}", "id": result.get("id")}
191
+
192
+ except Exception as e:
193
+ return {"success": False, "skipped": False, "message": f"Send failed: {e}"}
194
+
195
+
196
+ def preview_digest() -> str:
197
+ """Return plain text preview of the digest without sending."""
198
+ from .usage import get_usage_summary, get_waste_report
199
+ from .config import get_claude_code_config_path, get_all_mcp_servers
200
+
201
+ summary = get_usage_summary(days=7)
202
+ servers = get_all_mcp_servers(get_claude_code_config_path())
203
+ waste = get_waste_report(list(servers.keys()), days=7) if servers else []
204
+ return _build_text(summary, waste, 0)