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/hooks.py ADDED
@@ -0,0 +1,269 @@
1
+ """Claude Code hook handlers.
2
+
3
+ Hooks receive JSON on stdin, write response to stdout.
4
+
5
+ PreToolUse — fires before every tool call
6
+ → check if MCP is loaded, warn if not
7
+ PostToolUse — fires after every tool call
8
+ → log the call to usage.db (data moat)
9
+
10
+ Install with: mcpswitch setup
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from .usage import log_tool_call, _extract_server
21
+ from .profiles import get_active_profile, load_profiles
22
+ from .config import get_claude_code_config_path, get_all_mcp_servers
23
+
24
+
25
+ def _read_stdin() -> Optional[dict]:
26
+ try:
27
+ raw = sys.stdin.read()
28
+ return json.loads(raw) if raw.strip() else None
29
+ except Exception:
30
+ return None
31
+
32
+
33
+ def _write_response(data: dict) -> None:
34
+ sys.stdout.write(json.dumps(data) + "\n")
35
+ sys.stdout.flush()
36
+
37
+
38
+ def handle_post_tool_use() -> None:
39
+ """PostToolUse hook — log every MCP tool call.
40
+
41
+ stdin JSON:
42
+ {
43
+ "session_id": "abc123",
44
+ "tool_name": "mcp__github__create_pull_request",
45
+ "tool_input": {...},
46
+ "tool_result": {...}
47
+ }
48
+ """
49
+ data = _read_stdin()
50
+ if not data:
51
+ sys.exit(0)
52
+
53
+ tool_name = data.get("tool_name", "")
54
+ server = _extract_server(tool_name)
55
+
56
+ if not server:
57
+ sys.exit(0) # Not an MCP tool, skip
58
+
59
+ session_id = data.get("session_id")
60
+ project_dir = os.getcwd()
61
+ active_profile = get_active_profile()
62
+
63
+ # Determine success from tool result
64
+ result = data.get("tool_result", {})
65
+ success = True
66
+ if isinstance(result, dict):
67
+ success = not result.get("isError", False)
68
+
69
+ log_tool_call(
70
+ tool_name=tool_name,
71
+ session_id=session_id,
72
+ profile_name=active_profile,
73
+ project_dir=project_dir,
74
+ success=success,
75
+ )
76
+
77
+ sys.exit(0)
78
+
79
+
80
+ def handle_pre_tool_use() -> None:
81
+ """PreToolUse hook — check if MCP is loaded, warn user if not.
82
+
83
+ Does NOT block the tool call (exit 0 always).
84
+ Just warns the user so they know to switch profile next session.
85
+
86
+ stdin JSON:
87
+ {
88
+ "session_id": "abc123",
89
+ "tool_name": "mcp__github__create_pull_request",
90
+ "tool_input": {...}
91
+ }
92
+
93
+ Output (if warning):
94
+ {
95
+ "hookSpecificOutput": {
96
+ "permissionDecision": "allow",
97
+ "userFacingMessage": "MCPSwitch: github not in active profile 'lean'. Run: mcpswitch use full"
98
+ }
99
+ }
100
+ """
101
+ data = _read_stdin()
102
+ if not data:
103
+ sys.exit(0)
104
+
105
+ tool_name = data.get("tool_name", "")
106
+ server = _extract_server(tool_name)
107
+
108
+ if not server:
109
+ sys.exit(0)
110
+
111
+ # Check if this server is in the current Claude config
112
+ try:
113
+ config_path = get_claude_code_config_path()
114
+ loaded_servers = get_all_mcp_servers(config_path)
115
+ active_profile = get_active_profile()
116
+
117
+ if server not in loaded_servers:
118
+ # Find which profiles have this server
119
+ all_profiles = load_profiles()
120
+ profiles_with_server = [
121
+ name for name, servers in all_profiles.items()
122
+ if server in servers
123
+ ]
124
+
125
+ if profiles_with_server:
126
+ suggestion = f"mcpswitch use {profiles_with_server[0]}"
127
+ else:
128
+ suggestion = f"mcpswitch add <profile> {server}"
129
+
130
+ msg = (
131
+ f"[MCPSwitch] '{server}' not in active profile '{active_profile or 'none'}'. "
132
+ f"Tool may fail. Fix: {suggestion}"
133
+ )
134
+ _write_response({
135
+ "hookSpecificOutput": {
136
+ "permissionDecision": "allow",
137
+ "userFacingMessage": msg,
138
+ }
139
+ })
140
+ except Exception:
141
+ pass
142
+
143
+ sys.exit(0)
144
+
145
+
146
+ def get_hooks_config() -> dict:
147
+ """Return the hooks config block to inject into ~/.claude/settings.json.
148
+
149
+ Uses the mcpswitch module directly so no path issues.
150
+ Python executable from current venv or system python.
151
+ """
152
+ python = sys.executable
153
+
154
+ return {
155
+ "PreToolUse": [
156
+ {
157
+ "matcher": "mcp__.*",
158
+ "hooks": [
159
+ {
160
+ "type": "command",
161
+ "command": f'"{python}" -m mcpswitch.hooks pre_tool_use',
162
+ }
163
+ ],
164
+ }
165
+ ],
166
+ "PostToolUse": [
167
+ {
168
+ "matcher": "mcp__.*",
169
+ "hooks": [
170
+ {
171
+ "type": "command",
172
+ "command": f'"{python}" -m mcpswitch.hooks post_tool_use',
173
+ }
174
+ ],
175
+ }
176
+ ],
177
+ }
178
+
179
+
180
+ def install_hooks(dry_run: bool = False) -> dict:
181
+ """Install MCPSwitch hooks into ~/.claude/settings.json.
182
+
183
+ Returns {"added": [...], "already_existed": [...], "settings_path": str}
184
+ """
185
+ import json as _json
186
+ settings_path = Path.home() / ".claude" / "settings.json"
187
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
188
+
189
+ if settings_path.exists():
190
+ with open(settings_path, "r", encoding="utf-8") as f:
191
+ settings = _json.load(f)
192
+ else:
193
+ settings = {}
194
+
195
+ existing_hooks = settings.get("hooks", {})
196
+ new_hooks = get_hooks_config()
197
+
198
+ added = []
199
+ already_existed = []
200
+
201
+ for hook_type, hook_list in new_hooks.items():
202
+ existing = existing_hooks.get(hook_type, [])
203
+ # Check if mcpswitch hook already present
204
+ already = any("mcpswitch" in str(h) for h in existing)
205
+ if already:
206
+ already_existed.append(hook_type)
207
+ else:
208
+ if not dry_run:
209
+ existing_hooks[hook_type] = existing + hook_list
210
+ added.append(hook_type)
211
+
212
+ if not dry_run and added:
213
+ settings["hooks"] = existing_hooks
214
+ # Backup first
215
+ if settings_path.exists():
216
+ backup = settings_path.with_suffix(".json.bak")
217
+ import shutil
218
+ shutil.copy2(settings_path, backup)
219
+ with open(settings_path, "w", encoding="utf-8") as f:
220
+ _json.dump(settings, f, indent=2)
221
+
222
+ return {
223
+ "added": added,
224
+ "already_existed": already_existed,
225
+ "settings_path": str(settings_path),
226
+ }
227
+
228
+
229
+ def uninstall_hooks() -> dict:
230
+ """Remove MCPSwitch hooks from ~/.claude/settings.json."""
231
+ import json as _json
232
+ settings_path = Path.home() / ".claude" / "settings.json"
233
+
234
+ if not settings_path.exists():
235
+ return {"removed": [], "settings_path": str(settings_path)}
236
+
237
+ with open(settings_path, "r", encoding="utf-8") as f:
238
+ settings = _json.load(f)
239
+
240
+ hooks = settings.get("hooks", {})
241
+ removed = []
242
+
243
+ for hook_type in list(hooks.keys()):
244
+ original_count = len(hooks[hook_type])
245
+ hooks[hook_type] = [
246
+ h for h in hooks[hook_type]
247
+ if "mcpswitch" not in str(h)
248
+ ]
249
+ if len(hooks[hook_type]) < original_count:
250
+ removed.append(hook_type)
251
+ if not hooks[hook_type]:
252
+ del hooks[hook_type]
253
+
254
+ settings["hooks"] = hooks
255
+ with open(settings_path, "w", encoding="utf-8") as f:
256
+ _json.dump(settings, f, indent=2)
257
+
258
+ return {"removed": removed, "settings_path": str(settings_path)}
259
+
260
+
261
+ # Entry point when run as: python -m mcpswitch.hooks <command>
262
+ if __name__ == "__main__":
263
+ cmd = sys.argv[1] if len(sys.argv) > 1 else ""
264
+ if cmd == "pre_tool_use":
265
+ handle_pre_tool_use()
266
+ elif cmd == "post_tool_use":
267
+ handle_post_tool_use()
268
+ else:
269
+ sys.exit(0)
mcpswitch/profiles.py ADDED
@@ -0,0 +1,96 @@
1
+ """Profile management — create, read, update, delete MCP profiles."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ PROFILES_DIR = Path.home() / ".mcpswitch"
8
+ PROFILES_FILE = PROFILES_DIR / "profiles.json"
9
+ STATE_FILE = PROFILES_DIR / "state.json"
10
+
11
+
12
+ def _ensure_dir():
13
+ PROFILES_DIR.mkdir(parents=True, exist_ok=True)
14
+
15
+
16
+ def load_profiles() -> dict:
17
+ """Load all profiles from disk."""
18
+ _ensure_dir()
19
+ if not PROFILES_FILE.exists():
20
+ return {}
21
+ with open(PROFILES_FILE, "r", encoding="utf-8") as f:
22
+ return json.load(f)
23
+
24
+
25
+ def save_profiles(profiles: dict) -> None:
26
+ """Save all profiles to disk."""
27
+ _ensure_dir()
28
+ with open(PROFILES_FILE, "w", encoding="utf-8") as f:
29
+ json.dump(profiles, f, indent=2)
30
+
31
+
32
+ def get_active_profile() -> Optional[str]:
33
+ """Return name of currently active profile."""
34
+ _ensure_dir()
35
+ if not STATE_FILE.exists():
36
+ return None
37
+ with open(STATE_FILE, "r", encoding="utf-8") as f:
38
+ return json.load(f).get("active")
39
+
40
+
41
+ def set_active_profile(name: Optional[str]) -> None:
42
+ """Set the active profile name."""
43
+ _ensure_dir()
44
+ with open(STATE_FILE, "w", encoding="utf-8") as f:
45
+ json.dump({"active": name}, f, indent=2)
46
+
47
+
48
+ def create_profile(name: str, servers: dict) -> None:
49
+ """Create or overwrite a profile with the given servers."""
50
+ profiles = load_profiles()
51
+ profiles[name] = servers
52
+ save_profiles(profiles)
53
+
54
+
55
+ def get_profile(name: str) -> Optional[dict]:
56
+ """Get servers dict for a profile."""
57
+ return load_profiles().get(name)
58
+
59
+
60
+ def delete_profile(name: str) -> bool:
61
+ """Delete a profile. Returns False if not found."""
62
+ profiles = load_profiles()
63
+ if name not in profiles:
64
+ return False
65
+ del profiles[name]
66
+ save_profiles(profiles)
67
+ if get_active_profile() == name:
68
+ set_active_profile(None)
69
+ return True
70
+
71
+
72
+ def list_profiles() -> list[str]:
73
+ """Return sorted list of profile names."""
74
+ return sorted(load_profiles().keys())
75
+
76
+
77
+ def add_server_to_profile(profile_name: str, server_name: str, server_config: dict) -> bool:
78
+ """Add a server to an existing profile. Returns False if profile not found."""
79
+ profiles = load_profiles()
80
+ if profile_name not in profiles:
81
+ return False
82
+ profiles[profile_name][server_name] = server_config
83
+ save_profiles(profiles)
84
+ return True
85
+
86
+
87
+ def remove_server_from_profile(profile_name: str, server_name: str) -> bool:
88
+ """Remove a server from a profile. Returns False if not found."""
89
+ profiles = load_profiles()
90
+ if profile_name not in profiles:
91
+ return False
92
+ if server_name not in profiles[profile_name]:
93
+ return False
94
+ del profiles[profile_name][server_name]
95
+ save_profiles(profiles)
96
+ return True
mcpswitch/sync.py ADDED
@@ -0,0 +1,237 @@
1
+ """Multi-machine profile sync via GitHub Gists.
2
+
3
+ No backend server needed. Each user gets a private Gist as their sync target.
4
+
5
+ Pro feature:
6
+ mcpswitch sync --setup <github-token> → create private Gist, store token
7
+ mcpswitch sync --push → upload local profiles to Gist
8
+ mcpswitch sync --pull → download profiles from Gist
9
+ mcpswitch sync --status → show sync state
10
+
11
+ GitHub token needs: gist scope only (minimal permissions).
12
+ Create at: https://github.com/settings/tokens/new?scopes=gist
13
+ """
14
+
15
+ import json
16
+ import time
17
+ import urllib.request
18
+ import urllib.error
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ SYNC_CONFIG_FILE = Path.home() / ".mcpswitch" / "sync.json"
23
+ GIST_API = "https://api.github.com/gists"
24
+ GIST_FILENAME = "mcpswitch-profiles.json"
25
+
26
+
27
+ def _load_sync_config() -> dict:
28
+ if not SYNC_CONFIG_FILE.exists():
29
+ return {}
30
+ try:
31
+ with open(SYNC_CONFIG_FILE, "r", encoding="utf-8") as f:
32
+ return json.load(f)
33
+ except Exception:
34
+ return {}
35
+
36
+
37
+ def _save_sync_config(data: dict) -> None:
38
+ SYNC_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
39
+ with open(SYNC_CONFIG_FILE, "w", encoding="utf-8") as f:
40
+ json.dump(data, f, indent=2)
41
+
42
+
43
+ def _github_request(
44
+ url: str,
45
+ method: str = "GET",
46
+ token: str = "",
47
+ payload: Optional[dict] = None,
48
+ ) -> Optional[dict]:
49
+ try:
50
+ data = json.dumps(payload).encode("utf-8") if payload else None
51
+ req = urllib.request.Request(
52
+ url,
53
+ data=data,
54
+ method=method,
55
+ headers={
56
+ "Authorization": f"token {token}",
57
+ "Accept": "application/vnd.github.v3+json",
58
+ "Content-Type": "application/json",
59
+ "User-Agent": "mcpswitch/0.1",
60
+ },
61
+ )
62
+ with urllib.request.urlopen(req, timeout=10) as resp:
63
+ return json.loads(resp.read())
64
+ except urllib.error.HTTPError as e:
65
+ body = e.read().decode("utf-8", errors="ignore")
66
+ return {"error": e.code, "message": body}
67
+ except Exception as e:
68
+ return {"error": str(e)}
69
+
70
+
71
+ def setup_sync(github_token: str) -> dict:
72
+ """Create a private Gist and store the token for future syncs."""
73
+ from .profiles import load_profiles
74
+
75
+ profiles = load_profiles()
76
+ payload = {
77
+ "description": "MCPSwitch profile sync",
78
+ "public": False,
79
+ "files": {
80
+ GIST_FILENAME: {
81
+ "content": json.dumps({
82
+ "version": 1,
83
+ "profiles": profiles,
84
+ "synced_at": time.time(),
85
+ "machine": _get_machine_id(),
86
+ }, indent=2)
87
+ }
88
+ }
89
+ }
90
+
91
+ result = _github_request(GIST_API, method="POST", token=github_token, payload=payload)
92
+
93
+ if not result or "error" in result:
94
+ return {"success": False, "message": f"Failed to create Gist: {result}"}
95
+
96
+ gist_id = result.get("id")
97
+ gist_url = result.get("html_url")
98
+
99
+ _save_sync_config({
100
+ "github_token": github_token,
101
+ "gist_id": gist_id,
102
+ "gist_url": gist_url,
103
+ "last_push": time.time(),
104
+ "last_pull": None,
105
+ })
106
+
107
+ return {
108
+ "success": True,
109
+ "gist_id": gist_id,
110
+ "gist_url": gist_url,
111
+ "message": f"Sync configured. Gist: {gist_url}",
112
+ }
113
+
114
+
115
+ def push_profiles() -> dict:
116
+ """Upload local profiles to the sync Gist."""
117
+ config = _load_sync_config()
118
+ if not config.get("gist_id"):
119
+ return {"success": False, "message": "Sync not configured. Run: mcpswitch sync --setup"}
120
+
121
+ from .profiles import load_profiles
122
+ profiles = load_profiles()
123
+
124
+ payload = {
125
+ "files": {
126
+ GIST_FILENAME: {
127
+ "content": json.dumps({
128
+ "version": 1,
129
+ "profiles": profiles,
130
+ "synced_at": time.time(),
131
+ "machine": _get_machine_id(),
132
+ }, indent=2)
133
+ }
134
+ }
135
+ }
136
+
137
+ url = f"{GIST_API}/{config['gist_id']}"
138
+ result = _github_request(url, method="PATCH", token=config["github_token"], payload=payload)
139
+
140
+ if not result or "error" in result:
141
+ return {"success": False, "message": f"Push failed: {result}"}
142
+
143
+ config["last_push"] = time.time()
144
+ _save_sync_config(config)
145
+
146
+ return {
147
+ "success": True,
148
+ "profiles_pushed": len(profiles),
149
+ "message": f"Pushed {len(profiles)} profile(s) to Gist.",
150
+ }
151
+
152
+
153
+ def pull_profiles(merge: bool = True) -> dict:
154
+ """Download profiles from the sync Gist.
155
+
156
+ If merge=True: remote profiles are merged with local (remote wins on conflict).
157
+ If merge=False: remote profiles completely replace local.
158
+ """
159
+ config = _load_sync_config()
160
+ if not config.get("gist_id"):
161
+ return {"success": False, "message": "Sync not configured. Run: mcpswitch sync --setup"}
162
+
163
+ url = f"{GIST_API}/{config['gist_id']}"
164
+ result = _github_request(url, token=config["github_token"])
165
+
166
+ if not result or "error" in result:
167
+ return {"success": False, "message": f"Pull failed: {result}"}
168
+
169
+ try:
170
+ content = result["files"][GIST_FILENAME]["content"]
171
+ remote_data = json.loads(content)
172
+ remote_profiles = remote_data.get("profiles", {})
173
+ except (KeyError, json.JSONDecodeError) as e:
174
+ return {"success": False, "message": f"Invalid Gist format: {e}"}
175
+
176
+ from .profiles import load_profiles, save_profiles
177
+ local_profiles = load_profiles()
178
+
179
+ if merge:
180
+ merged = {**local_profiles, **remote_profiles}
181
+ else:
182
+ merged = remote_profiles
183
+
184
+ save_profiles(merged)
185
+
186
+ config["last_pull"] = time.time()
187
+ _save_sync_config(config)
188
+
189
+ new_count = len(set(remote_profiles) - set(local_profiles))
190
+ updated_count = len(set(remote_profiles) & set(local_profiles))
191
+
192
+ return {
193
+ "success": True,
194
+ "new_profiles": new_count,
195
+ "updated_profiles": updated_count,
196
+ "total_profiles": len(merged),
197
+ "message": f"Pulled: {new_count} new, {updated_count} updated. Total: {len(merged)} profiles.",
198
+ }
199
+
200
+
201
+ def get_sync_status() -> dict:
202
+ """Return current sync configuration and status."""
203
+ config = _load_sync_config()
204
+ if not config.get("gist_id"):
205
+ return {"configured": False}
206
+
207
+ last_push = config.get("last_push")
208
+ last_pull = config.get("last_pull")
209
+
210
+ return {
211
+ "configured": True,
212
+ "gist_url": config.get("gist_url"),
213
+ "gist_id": config.get("gist_id"),
214
+ "last_push": last_push,
215
+ "last_push_ago": _ago(last_push),
216
+ "last_pull": last_pull,
217
+ "last_pull_ago": _ago(last_pull),
218
+ }
219
+
220
+
221
+ def _ago(ts: Optional[float]) -> str:
222
+ if not ts:
223
+ return "never"
224
+ diff = time.time() - ts
225
+ if diff < 60:
226
+ return f"{int(diff)}s ago"
227
+ if diff < 3600:
228
+ return f"{int(diff/60)}m ago"
229
+ if diff < 86400:
230
+ return f"{int(diff/3600)}h ago"
231
+ return f"{int(diff/86400)}d ago"
232
+
233
+
234
+ def _get_machine_id() -> str:
235
+ import socket
236
+ import platform
237
+ return f"{socket.gethostname()}-{platform.system()}"