second-opinion-mcp 0.3.0__tar.gz → 0.3.2__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.
Files changed (20) hide show
  1. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/.gitignore +1 -0
  2. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/PKG-INFO +20 -1
  3. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/README.md +19 -0
  4. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/pyproject.toml +1 -1
  5. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/src/second_opinion_mcp/__init__.py +1 -1
  6. second_opinion_mcp-0.3.2/src/second_opinion_mcp/cli.py +465 -0
  7. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/src/second_opinion_mcp/server.py +108 -0
  8. second_opinion_mcp-0.3.0/src/second_opinion_mcp/cli.py +0 -272
  9. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/LICENSE +0 -0
  10. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/src/second_opinion_mcp/__main__.py +0 -0
  11. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/__init__.py +0 -0
  12. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/conftest.py +0 -0
  13. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_configuration.py +0 -0
  14. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_connection_pool.py +0 -0
  15. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_error_isolation.py +0 -0
  16. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_input_validation.py +0 -0
  17. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_keyring_security.py +0 -0
  18. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_path_security.py +0 -0
  19. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_rate_limiting.py +0 -0
  20. {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_streaming_limits.py +0 -0
@@ -50,3 +50,4 @@ Thumbs.db
50
50
 
51
51
  # Distribution / packaging
52
52
  MANIFEST
53
+ second_opinion.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: second-opinion-mcp
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Multi-model AI analysis for Claude - get second opinions from DeepSeek, Kimi, and OpenRouter
5
5
  Project-URL: Homepage, https://github.com/MarvinFS/second-opinion-mcp
6
6
  Project-URL: Repository, https://github.com/MarvinFS/second-opinion-mcp
@@ -279,6 +279,25 @@ Use code_review with focus_areas=["security", "error-handling", "performance"]
279
279
  Use code_review on directory="./src" with save_to="./docs/reviews"
280
280
  ```
281
281
 
282
+ ## Updating
283
+
284
+ The server checks for updates on startup and notifies you if a new version is available.
285
+
286
+ To update manually:
287
+
288
+ ```bash
289
+ pipx upgrade second-opinion-mcp
290
+ ```
291
+
292
+ Or reinstall:
293
+
294
+ ```bash
295
+ pipx uninstall second-opinion-mcp
296
+ pipx install second-opinion-mcp
297
+ ```
298
+
299
+ Your API keys are preserved in the system keyring across updates.
300
+
282
301
  ## Uninstalling
283
302
 
284
303
  ```bash
@@ -241,6 +241,25 @@ Use code_review with focus_areas=["security", "error-handling", "performance"]
241
241
  Use code_review on directory="./src" with save_to="./docs/reviews"
242
242
  ```
243
243
 
244
+ ## Updating
245
+
246
+ The server checks for updates on startup and notifies you if a new version is available.
247
+
248
+ To update manually:
249
+
250
+ ```bash
251
+ pipx upgrade second-opinion-mcp
252
+ ```
253
+
254
+ Or reinstall:
255
+
256
+ ```bash
257
+ pipx uninstall second-opinion-mcp
258
+ pipx install second-opinion-mcp
259
+ ```
260
+
261
+ Your API keys are preserved in the system keyring across updates.
262
+
244
263
  ## Uninstalling
245
264
 
246
265
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "second-opinion-mcp"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "Multi-model AI analysis for Claude - get second opinions from DeepSeek, Kimi, and OpenRouter"
5
5
  requires-python = ">=3.10"
6
6
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  """Second Opinion MCP Server - Multi-model AI analysis for Claude."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
4
4
  __author__ = "MarvinFS"
5
5
 
6
6
  from second_opinion_mcp.server import mcp, second_opinion, challenge, code_review, consensus, review_synthesis
@@ -0,0 +1,465 @@
1
+ #!/usr/bin/env python3
2
+ """Interactive setup wizard for second-opinion-mcp."""
3
+ import sys
4
+ import platform
5
+
6
+ try:
7
+ import questionary
8
+ from questionary import Style
9
+ except ImportError:
10
+ print("Error: questionary not installed. Run: pip install questionary")
11
+ sys.exit(1)
12
+
13
+ import keyring
14
+ from keyring.errors import NoKeyringError
15
+
16
+ # Provider configurations
17
+ PROVIDERS = {
18
+ "deepseek": {
19
+ "name": "DeepSeek V3.2",
20
+ "description": "DeepSeek Reasoner with chain-of-thought reasoning",
21
+ "keyring_service": "second-opinion",
22
+ "keyring_name": "deepseek",
23
+ "key_hint": "https://platform.deepseek.com",
24
+ "key_prefix": "sk-",
25
+ },
26
+ "moonshot": {
27
+ "name": "Kimi K2.5 (Moonshot)",
28
+ "description": "Moonshot AI's Kimi with thinking mode",
29
+ "keyring_service": "second-opinion",
30
+ "keyring_name": "moonshot",
31
+ "key_hint": "https://platform.moonshot.cn",
32
+ "key_prefix": None,
33
+ },
34
+ "openrouter": {
35
+ "name": "OpenRouter",
36
+ "description": "Access 300+ models via unified API",
37
+ "keyring_service": "second-opinion",
38
+ "keyring_name": "openrouter",
39
+ "key_hint": "https://openrouter.ai/keys",
40
+ "key_prefix": "sk-or-",
41
+ },
42
+ }
43
+
44
+ # Custom style for questionary
45
+ custom_style = Style([
46
+ ("qmark", "fg:cyan bold"),
47
+ ("question", "bold"),
48
+ ("answer", "fg:cyan"),
49
+ ("pointer", "fg:cyan bold"),
50
+ ("highlighted", "fg:cyan bold"),
51
+ ("selected", "fg:green"),
52
+ ])
53
+
54
+
55
+ def check_keyring_available() -> tuple[bool, str]:
56
+ """Check if keyring backend is available and working."""
57
+ try:
58
+ # Try to access keyring - this will fail if no backend
59
+ backend = keyring.get_keyring()
60
+ backend_name = str(type(backend).__name__)
61
+
62
+ # Check for null/fail backends
63
+ if "Null" in backend_name or "Fail" in backend_name:
64
+ return False, backend_name
65
+
66
+ # Try a test write/read/delete to verify it works
67
+ test_service = "second-opinion-test"
68
+ test_key = "connectivity-check"
69
+ try:
70
+ keyring.set_password(test_service, test_key, "test")
71
+ result = keyring.get_password(test_service, test_key)
72
+ keyring.delete_password(test_service, test_key)
73
+ if result != "test":
74
+ return False, backend_name
75
+ except Exception:
76
+ return False, backend_name
77
+
78
+ return True, backend_name
79
+ except NoKeyringError:
80
+ return False, "NoKeyringError"
81
+ except Exception as e:
82
+ return False, str(e)
83
+
84
+
85
+ def print_keyring_setup_instructions():
86
+ """Print instructions for setting up keyring on different platforms."""
87
+ print()
88
+ print("=" * 60)
89
+ print(" Keyring Backend Required")
90
+ print("=" * 60)
91
+ print()
92
+ print("second-opinion-mcp stores API keys securely in your system keyring.")
93
+ print("A keyring backend must be installed and configured first.")
94
+ print()
95
+
96
+ system = platform.system()
97
+
98
+ if system == "Linux":
99
+ print("For Linux, install one of these backends:")
100
+ print()
101
+ print(" Option 1 - Secret Service (GNOME/KDE desktop):")
102
+ print(" sudo apt install gnome-keyring # or kde-wallet")
103
+ print(" pip install secretstorage")
104
+ print()
105
+ print(" Option 2 - Encrypted file (headless/server):")
106
+ print(" pip install keyrings.alt")
107
+ print(" mkdir -p ~/.local/share/python_keyring")
108
+ print(" cat > ~/.local/share/python_keyring/keyringrc.cfg << 'EOF'")
109
+ print(" [backend]")
110
+ print(" default-keyring=keyrings.alt.file.EncryptedKeyring")
111
+ print(" EOF")
112
+ print()
113
+ print(" Then run 'second-opinion-mcp setup' again.")
114
+
115
+ elif system == "Darwin":
116
+ print("For macOS, keyring should work automatically with Keychain.")
117
+ print("If you see this error, try:")
118
+ print()
119
+ print(" pip install keyring --upgrade")
120
+ print()
121
+ print("Then run 'second-opinion-mcp setup' again.")
122
+
123
+ else:
124
+ print("For Windows, keyring should work automatically with Credential Manager.")
125
+ print("If you see this error, try:")
126
+ print()
127
+ print(" pip install keyring --upgrade")
128
+ print()
129
+ print("Then run 'second-opinion-mcp setup' again.")
130
+
131
+ print()
132
+
133
+
134
+ def print_manual_setup_instructions(providers: list[str] | None = None):
135
+ """Print instructions for manual keyring setup."""
136
+ print()
137
+ print("=" * 60)
138
+ print(" Manual API Key Setup")
139
+ print("=" * 60)
140
+ print()
141
+ print("IMPORTANT: You must configure at least 2 providers for the")
142
+ print("'consensus' tool to work. Single-provider mode is limited.")
143
+ print()
144
+ print("Run these commands to store your API keys:")
145
+ print()
146
+
147
+ target_providers = providers if providers else list(PROVIDERS.keys())
148
+
149
+ for provider in target_providers:
150
+ config = PROVIDERS[provider]
151
+ print(f" # {config['name']} - Get key at {config['key_hint']}")
152
+ print(f" python -c \"import keyring; keyring.set_password('{config['keyring_service']}', '{config['keyring_name']}', 'YOUR_API_KEY')\"")
153
+ print()
154
+
155
+ print("To verify your keys are stored:")
156
+ print()
157
+ print(" python -c \"import keyring; print('deepseek:', bool(keyring.get_password('second-opinion', 'deepseek'))); print('moonshot:', bool(keyring.get_password('second-opinion', 'moonshot')))\"")
158
+ print()
159
+ print("After configuring keys, register with Claude Code:")
160
+ print()
161
+
162
+ if providers and len(providers) >= 1:
163
+ providers_str = ",".join(providers)
164
+ else:
165
+ providers_str = "deepseek,moonshot"
166
+
167
+ print(f" claude mcp add -s user second-opinion -e SECOND_OPINION_PROVIDERS=\"{providers_str}\" -- second-opinion-mcp")
168
+ print()
169
+
170
+
171
+ def check_existing_keys() -> dict[str, bool]:
172
+ """Check which providers already have API keys configured."""
173
+ existing = {}
174
+ for provider, config in PROVIDERS.items():
175
+ try:
176
+ key = keyring.get_password(config["keyring_service"], config["keyring_name"])
177
+ existing[provider] = bool(key)
178
+ except Exception:
179
+ existing[provider] = False
180
+ return existing
181
+
182
+
183
+ def count_configured_providers() -> int:
184
+ """Count how many providers have API keys configured."""
185
+ return sum(1 for v in check_existing_keys().values() if v)
186
+
187
+
188
+ def store_api_key(provider: str, key: str) -> tuple[bool, str]:
189
+ """Store API key in system keyring. Returns (success, error_message)."""
190
+ config = PROVIDERS[provider]
191
+ try:
192
+ keyring.set_password(config["keyring_service"], config["keyring_name"], key)
193
+ return True, ""
194
+ except Exception as e:
195
+ return False, str(e)
196
+
197
+
198
+ def validate_api_key(provider: str, key: str) -> tuple[bool, str]:
199
+ """Basic validation of API key format."""
200
+ if not key or not key.strip():
201
+ return False, "API key cannot be empty"
202
+
203
+ config = PROVIDERS[provider]
204
+ key = key.strip()
205
+
206
+ if config["key_prefix"] and not key.startswith(config["key_prefix"]):
207
+ return False, f"Key should start with '{config['key_prefix']}'"
208
+
209
+ if len(key) < 20:
210
+ return False, "Key seems too short"
211
+
212
+ return True, ""
213
+
214
+
215
+ def get_registration_command(selected_providers: list[str]) -> str:
216
+ """Generate the registration command for Claude Code CLI."""
217
+ providers_env = ",".join(selected_providers)
218
+ return f'claude mcp add -s user second-opinion -e SECOND_OPINION_PROVIDERS="{providers_env}" -- second-opinion-mcp'
219
+
220
+
221
+ def get_desktop_config(selected_providers: list[str]) -> str:
222
+ """Generate Claude Desktop configuration JSON."""
223
+ providers_env = ",".join(selected_providers)
224
+
225
+ return f'''{{
226
+ "mcpServers": {{
227
+ "second-opinion": {{
228
+ "command": "second-opinion-mcp",
229
+ "env": {{
230
+ "SECOND_OPINION_PROVIDERS": "{providers_env}"
231
+ }}
232
+ }}
233
+ }}
234
+ }}'''
235
+
236
+
237
+ def setup_wizard():
238
+ """Interactive setup wizard for second-opinion-mcp."""
239
+ print()
240
+ print("=" * 60)
241
+ print(" Second Opinion MCP - Setup Wizard")
242
+ print("=" * 60)
243
+ print()
244
+
245
+ # Check keyring availability first
246
+ keyring_ok, backend_info = check_keyring_available()
247
+
248
+ if not keyring_ok:
249
+ print(f"Keyring backend not available: {backend_info}")
250
+ print_keyring_setup_instructions()
251
+ print()
252
+ print("Alternatively, choose 'Manual setup' to get CLI commands.")
253
+ print()
254
+
255
+ choice = questionary.select(
256
+ "How would you like to proceed?",
257
+ choices=[
258
+ questionary.Choice("Manual setup (show CLI commands)", value="manual"),
259
+ questionary.Choice("Exit and fix keyring first", value="exit"),
260
+ ],
261
+ style=custom_style,
262
+ ).ask()
263
+
264
+ if choice == "manual" or choice is None:
265
+ print_manual_setup_instructions()
266
+ return
267
+
268
+ print("This wizard will help you configure API keys for AI providers.")
269
+ print("Keys are stored securely in your system keyring.")
270
+ print()
271
+
272
+ # Check existing configuration
273
+ existing = check_existing_keys()
274
+ configured_count = sum(1 for v in existing.values() if v)
275
+
276
+ if configured_count > 0:
277
+ configured = [p for p, has_key in existing.items() if has_key]
278
+ print(f"Already configured: {', '.join(configured)}")
279
+ if configured_count < 2:
280
+ print("Note: At least 2 providers required for 'consensus' tool.")
281
+ print()
282
+
283
+ # Setup method selection
284
+ setup_method = questionary.select(
285
+ "Setup method:",
286
+ choices=[
287
+ questionary.Choice("Interactive (enter keys now)", value="interactive"),
288
+ questionary.Choice("Manual (show CLI commands)", value="manual"),
289
+ ],
290
+ style=custom_style,
291
+ ).ask()
292
+
293
+ if setup_method is None:
294
+ print("Setup cancelled.")
295
+ return
296
+
297
+ if setup_method == "manual":
298
+ # Ask which providers they want to configure
299
+ choices = [
300
+ questionary.Choice(
301
+ f"{config['name']}" + (" (configured)" if existing.get(provider) else ""),
302
+ value=provider,
303
+ checked=not existing.get(provider) # Pre-check unconfigured ones
304
+ )
305
+ for provider, config in PROVIDERS.items()
306
+ ]
307
+
308
+ selected = questionary.checkbox(
309
+ "Select providers to configure:",
310
+ choices=choices,
311
+ style=custom_style,
312
+ ).ask()
313
+
314
+ if not selected:
315
+ selected = list(PROVIDERS.keys())
316
+
317
+ print_manual_setup_instructions(selected)
318
+ return
319
+
320
+ # Interactive setup
321
+ choices = []
322
+ for provider, config in PROVIDERS.items():
323
+ label = config["name"]
324
+ if existing.get(provider):
325
+ label += " (configured)"
326
+ choices.append(questionary.Choice(label, value=provider, checked=not existing.get(provider)))
327
+
328
+ selected = questionary.checkbox(
329
+ "Select providers to configure (at least 2 required for consensus):",
330
+ choices=choices,
331
+ style=custom_style,
332
+ validate=lambda x: len(x) >= 1 or "Select at least one provider",
333
+ ).ask()
334
+
335
+ if not selected:
336
+ print("Setup cancelled.")
337
+ return
338
+
339
+ # Warn about single provider
340
+ total_will_have = len([p for p in selected if not existing.get(p)]) + configured_count
341
+ if total_will_have < 2 and len(selected) < 2:
342
+ print()
343
+ print("WARNING: You need at least 2 providers for the 'consensus' tool.")
344
+ print("With only 1 provider, only basic tools will be available.")
345
+ proceed = questionary.confirm(
346
+ "Continue anyway?",
347
+ default=False,
348
+ style=custom_style,
349
+ ).ask()
350
+ if not proceed:
351
+ return setup_wizard()
352
+
353
+ print()
354
+
355
+ # Configure API keys
356
+ keyring_error_shown = False
357
+ for provider in selected:
358
+ config = PROVIDERS[provider]
359
+
360
+ if existing.get(provider):
361
+ update = questionary.confirm(
362
+ f"{config['name']} already configured. Update API key?",
363
+ default=False,
364
+ style=custom_style,
365
+ ).ask()
366
+ if not update:
367
+ continue
368
+
369
+ print(f"\n{config['name']}")
370
+ print(f" Get your key at: {config['key_hint']}")
371
+
372
+ while True:
373
+ key = questionary.password(
374
+ " Enter API key:",
375
+ style=custom_style,
376
+ ).ask()
377
+
378
+ if key is None:
379
+ print(" Skipped.")
380
+ break
381
+
382
+ valid, error = validate_api_key(provider, key)
383
+ if not valid:
384
+ print(f" {error}")
385
+ retry = questionary.confirm("Try again?", default=True, style=custom_style).ask()
386
+ if not retry:
387
+ break
388
+ continue
389
+
390
+ success, store_error = store_api_key(provider, key.strip())
391
+ if success:
392
+ print(" Key stored successfully.")
393
+ break
394
+ else:
395
+ print(f" Error storing key: {store_error}")
396
+ if not keyring_error_shown:
397
+ keyring_error_shown = True
398
+ print()
399
+ print(" Keyring storage failed. You may need to configure a backend.")
400
+ show_manual = questionary.confirm(
401
+ " Show manual setup instructions?",
402
+ default=True,
403
+ style=custom_style,
404
+ ).ask()
405
+ if show_manual:
406
+ print_manual_setup_instructions(selected)
407
+ return
408
+ break
409
+
410
+ # Check final configuration
411
+ final_existing = check_existing_keys()
412
+ configured = [p for p, has_key in final_existing.items() if has_key]
413
+
414
+ print()
415
+ print("=" * 60)
416
+ print(" Setup Complete!")
417
+ print("=" * 60)
418
+ print()
419
+
420
+ if not configured:
421
+ print("No API keys were configured.")
422
+ print("Run 'second-opinion-mcp setup' to try again.")
423
+ return
424
+
425
+ print(f"Configured providers: {', '.join(configured)}")
426
+
427
+ if len(configured) < 2:
428
+ print()
429
+ print("WARNING: Only 1 provider configured. The 'consensus' tool requires 2+.")
430
+ print("Run 'second-opinion-mcp setup' to add more providers.")
431
+
432
+ print()
433
+ print("Register with Claude Code CLI:")
434
+ print()
435
+ print(f" {get_registration_command(configured)}")
436
+ print()
437
+
438
+ # Show desktop config option
439
+ show_desktop = questionary.confirm(
440
+ "Show Claude Desktop configuration?",
441
+ default=False,
442
+ style=custom_style,
443
+ ).ask()
444
+
445
+ if show_desktop:
446
+ print()
447
+ print("Add this to your claude_desktop_config.json:")
448
+ print()
449
+ print(get_desktop_config(configured))
450
+ print()
451
+ if platform.system() == "Windows":
452
+ print("Location: %APPDATA%\\Claude\\claude_desktop_config.json")
453
+ elif platform.system() == "Darwin":
454
+ print("Location: ~/Library/Application Support/Claude/claude_desktop_config.json")
455
+ else:
456
+ print("Location: ~/.config/Claude/claude_desktop_config.json")
457
+
458
+ print()
459
+ print("Test the server with:")
460
+ print(" second-opinion-mcp")
461
+ print()
462
+
463
+
464
+ if __name__ == "__main__":
465
+ setup_wizard()
@@ -1523,13 +1523,121 @@ def _sync_cleanup():
1523
1523
  atexit.register(_sync_cleanup)
1524
1524
 
1525
1525
 
1526
+ def _check_provider_keys() -> tuple[list[str], list[str]]:
1527
+ """Check which enabled providers have API keys configured.
1528
+
1529
+ Returns:
1530
+ (configured_providers, missing_providers) tuple
1531
+ """
1532
+ configured = []
1533
+ missing = []
1534
+
1535
+ for provider, config in MODELS.items():
1536
+ if not config.get("enabled"):
1537
+ continue
1538
+
1539
+ try:
1540
+ key = keyring.get_password(config["keyring_service"], config["keyring_name"])
1541
+ if key:
1542
+ configured.append(provider)
1543
+ else:
1544
+ missing.append(provider)
1545
+ except Exception:
1546
+ missing.append(provider)
1547
+
1548
+ return configured, missing
1549
+
1550
+
1551
+ def _validate_startup_requirements() -> tuple[bool, str]:
1552
+ """Validate that minimum requirements are met to run the server.
1553
+
1554
+ Returns:
1555
+ (ok, error_message) tuple. If ok is False, server should not start.
1556
+ """
1557
+ configured, missing = _check_provider_keys()
1558
+
1559
+ if len(configured) == 0:
1560
+ return False, (
1561
+ "No API keys configured. Run 'second-opinion-mcp setup' to configure providers.\n\n"
1562
+ "Or manually configure keys:\n"
1563
+ " python -c \"import keyring; keyring.set_password('second-opinion', 'deepseek', 'YOUR_KEY')\"\n"
1564
+ " python -c \"import keyring; keyring.set_password('second-opinion', 'moonshot', 'YOUR_KEY')\"\n"
1565
+ )
1566
+
1567
+ if len(configured) == 1:
1568
+ return False, (
1569
+ f"Only 1 provider configured ({configured[0]}). At least 2 providers required.\n\n"
1570
+ "The 'consensus' tool requires multiple models to debate.\n"
1571
+ "Run 'second-opinion-mcp setup' to add another provider.\n\n"
1572
+ f"Missing: {', '.join(missing) if missing else 'none enabled'}\n"
1573
+ )
1574
+
1575
+ return True, ""
1576
+
1577
+
1578
+ def _check_for_updates() -> tuple[str | None, str | None]:
1579
+ """Check PyPI for newer version of second-opinion-mcp.
1580
+
1581
+ Returns:
1582
+ (latest_version, current_version) if update available, (None, None) otherwise.
1583
+ """
1584
+ import urllib.request
1585
+ import json
1586
+
1587
+ try:
1588
+ from second_opinion_mcp import __version__ as current_version
1589
+ except ImportError:
1590
+ current_version = "0.0.0"
1591
+
1592
+ try:
1593
+ # Quick timeout to not delay startup
1594
+ url = "https://pypi.org/pypi/second-opinion-mcp/json"
1595
+ req = urllib.request.Request(url, headers={"User-Agent": "second-opinion-mcp"})
1596
+ with urllib.request.urlopen(req, timeout=3) as response:
1597
+ data = json.loads(response.read().decode())
1598
+ latest_version = data.get("info", {}).get("version", "0.0.0")
1599
+
1600
+ # Simple version comparison (works for semver)
1601
+ def parse_version(v: str) -> tuple[int, ...]:
1602
+ return tuple(int(x) for x in v.split(".")[:3] if x.isdigit())
1603
+
1604
+ if parse_version(latest_version) > parse_version(current_version):
1605
+ return latest_version, current_version
1606
+
1607
+ except Exception:
1608
+ # Don't fail startup on update check errors
1609
+ pass
1610
+
1611
+ return None, None
1612
+
1613
+
1526
1614
  def main():
1527
1615
  """Run the MCP server with stdio transport."""
1616
+ import sys
1617
+
1528
1618
  # Configure logging when run directly (not when imported as library)
1529
1619
  logging.basicConfig(
1530
1620
  level=logging.INFO,
1531
1621
  format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
1532
1622
  )
1623
+
1624
+ # Check for updates (non-blocking, quick timeout)
1625
+ latest, current = _check_for_updates()
1626
+ if latest:
1627
+ print(f"Update available: {current} -> {latest}", file=sys.stderr)
1628
+ print("Run: pipx upgrade second-opinion-mcp", file=sys.stderr)
1629
+ print(file=sys.stderr)
1630
+
1631
+ # Validate minimum requirements
1632
+ ok, error = _validate_startup_requirements()
1633
+ if not ok:
1634
+ print(f"Error: {error}", file=sys.stderr)
1635
+ sys.exit(1)
1636
+
1637
+ # Log configured providers
1638
+ configured, _ = _check_provider_keys()
1639
+ logger.info("Starting with providers: %s", ", ".join(configured))
1640
+
1533
1641
  mcp.run(transport="stdio")
1534
1642
 
1535
1643
 
@@ -1,272 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Interactive setup wizard for second-opinion-mcp."""
3
- import sys
4
- import platform
5
-
6
- try:
7
- import questionary
8
- from questionary import Style
9
- except ImportError:
10
- print("Error: questionary not installed. Run: pip install questionary")
11
- sys.exit(1)
12
-
13
- import keyring
14
-
15
- # Provider configurations
16
- PROVIDERS = {
17
- "deepseek": {
18
- "name": "DeepSeek V3.2",
19
- "description": "DeepSeek Reasoner with chain-of-thought reasoning",
20
- "keyring_service": "second-opinion",
21
- "keyring_name": "deepseek",
22
- "key_hint": "Get your key at https://platform.deepseek.com",
23
- "key_prefix": "sk-",
24
- },
25
- "moonshot": {
26
- "name": "Kimi K2.5 (Moonshot)",
27
- "description": "Moonshot AI's Kimi with thinking mode",
28
- "keyring_service": "second-opinion",
29
- "keyring_name": "moonshot",
30
- "key_hint": "Get your key at https://platform.moonshot.cn",
31
- "key_prefix": None,
32
- },
33
- "openrouter": {
34
- "name": "OpenRouter",
35
- "description": "Access 300+ models via unified API",
36
- "keyring_service": "second-opinion",
37
- "keyring_name": "openrouter",
38
- "key_hint": "Get your key at https://openrouter.ai/keys",
39
- "key_prefix": "sk-or-",
40
- },
41
- }
42
-
43
- # Custom style for questionary
44
- custom_style = Style([
45
- ("qmark", "fg:cyan bold"),
46
- ("question", "bold"),
47
- ("answer", "fg:cyan"),
48
- ("pointer", "fg:cyan bold"),
49
- ("highlighted", "fg:cyan bold"),
50
- ("selected", "fg:green"),
51
- ])
52
-
53
-
54
- def check_existing_keys() -> dict[str, bool]:
55
- """Check which providers already have API keys configured."""
56
- existing = {}
57
- for provider, config in PROVIDERS.items():
58
- try:
59
- key = keyring.get_password(config["keyring_service"], config["keyring_name"])
60
- existing[provider] = bool(key)
61
- except Exception:
62
- existing[provider] = False
63
- return existing
64
-
65
-
66
- def store_api_key(provider: str, key: str) -> bool:
67
- """Store API key in system keyring."""
68
- config = PROVIDERS[provider]
69
- try:
70
- keyring.set_password(config["keyring_service"], config["keyring_name"], key)
71
- return True
72
- except Exception as e:
73
- print(f"Error storing key: {e}")
74
- return False
75
-
76
-
77
- def validate_api_key(provider: str, key: str) -> tuple[bool, str]:
78
- """Basic validation of API key format."""
79
- if not key or not key.strip():
80
- return False, "API key cannot be empty"
81
-
82
- config = PROVIDERS[provider]
83
- key = key.strip()
84
-
85
- if config["key_prefix"] and not key.startswith(config["key_prefix"]):
86
- return False, f"Key should start with '{config['key_prefix']}'"
87
-
88
- if len(key) < 20:
89
- return False, "Key seems too short"
90
-
91
- return True, ""
92
-
93
-
94
- def get_registration_command(selected_providers: list[str]) -> str:
95
- """Generate the registration command for Claude Code CLI."""
96
- providers_env = ",".join(selected_providers)
97
-
98
- if platform.system() == "Windows":
99
- return f'''claude mcp add -s user second-opinion -e SECOND_OPINION_PROVIDERS="{providers_env}" -- second-opinion-mcp'''
100
- else:
101
- return f'''claude mcp add -s user second-opinion -e SECOND_OPINION_PROVIDERS="{providers_env}" -- second-opinion-mcp'''
102
-
103
-
104
- def get_desktop_config(selected_providers: list[str]) -> str:
105
- """Generate Claude Desktop configuration JSON."""
106
- providers_env = ",".join(selected_providers)
107
-
108
- return f'''{{
109
- "mcpServers": {{
110
- "second-opinion": {{
111
- "command": "second-opinion-mcp",
112
- "env": {{
113
- "SECOND_OPINION_PROVIDERS": "{providers_env}"
114
- }}
115
- }}
116
- }}
117
- }}'''
118
-
119
-
120
- def setup_wizard():
121
- """Interactive setup wizard for second-opinion-mcp."""
122
- print()
123
- print("=" * 60)
124
- print(" Second Opinion MCP - Setup Wizard")
125
- print("=" * 60)
126
- print()
127
- print("This wizard will help you configure API keys for AI providers.")
128
- print("Keys are stored securely in your system keyring.")
129
- print()
130
-
131
- # Check existing configuration
132
- existing = check_existing_keys()
133
- has_existing = any(existing.values())
134
-
135
- if has_existing:
136
- configured = [p for p, has_key in existing.items() if has_key]
137
- print(f"Already configured: {', '.join(configured)}")
138
- print()
139
-
140
- # Provider selection
141
- choices = []
142
- for provider, config in PROVIDERS.items():
143
- label = config["name"]
144
- if existing.get(provider):
145
- label += " (configured)"
146
- choices.append(questionary.Choice(label, value=provider, checked=existing.get(provider, False)))
147
-
148
- selected = questionary.checkbox(
149
- "Select providers to configure (at least 2 recommended):",
150
- choices=choices,
151
- style=custom_style,
152
- validate=lambda x: len(x) >= 1 or "Select at least one provider",
153
- ).ask()
154
-
155
- if not selected:
156
- print("Setup cancelled.")
157
- return
158
-
159
- if len(selected) < 2:
160
- print()
161
- print("Note: Using 2+ providers enables the 'consensus' tool for multi-model debates.")
162
- proceed = questionary.confirm(
163
- "Continue with single provider?",
164
- default=True,
165
- style=custom_style,
166
- ).ask()
167
- if not proceed:
168
- return setup_wizard() # Restart
169
-
170
- print()
171
-
172
- # Configure API keys
173
- for provider in selected:
174
- config = PROVIDERS[provider]
175
-
176
- if existing.get(provider):
177
- update = questionary.confirm(
178
- f"{config['name']} already configured. Update API key?",
179
- default=False,
180
- style=custom_style,
181
- ).ask()
182
- if not update:
183
- continue
184
-
185
- print(f"\n{config['name']}")
186
- print(f" {config['key_hint']}")
187
-
188
- while True:
189
- key = questionary.password(
190
- f" Enter API key:",
191
- style=custom_style,
192
- ).ask()
193
-
194
- if key is None: # User cancelled
195
- print(" Skipped.")
196
- break
197
-
198
- valid, error = validate_api_key(provider, key)
199
- if not valid:
200
- print(f" {error}")
201
- retry = questionary.confirm("Try again?", default=True, style=custom_style).ask()
202
- if not retry:
203
- break
204
- continue
205
-
206
- if store_api_key(provider, key.strip()):
207
- print(" Key stored successfully.")
208
- break
209
-
210
- # Generate registration commands
211
- print()
212
- print("=" * 60)
213
- print(" Setup Complete!")
214
- print("=" * 60)
215
- print()
216
-
217
- # Filter to providers that now have keys
218
- configured = []
219
- for provider in selected:
220
- try:
221
- key = keyring.get_password(
222
- PROVIDERS[provider]["keyring_service"],
223
- PROVIDERS[provider]["keyring_name"]
224
- )
225
- if key:
226
- configured.append(provider)
227
- except Exception:
228
- pass
229
-
230
- if not configured:
231
- print("No API keys were configured. Run 'second-opinion-mcp setup' to try again.")
232
- return
233
-
234
- print(f"Configured providers: {', '.join(configured)}")
235
- print()
236
-
237
- # Show registration command
238
- print("For Claude Code CLI, run:")
239
- print()
240
- print(f" {get_registration_command(configured)}")
241
- print()
242
-
243
- # Show desktop config
244
- show_desktop = questionary.confirm(
245
- "Show Claude Desktop configuration?",
246
- default=False,
247
- style=custom_style,
248
- ).ask()
249
-
250
- if show_desktop:
251
- print()
252
- print("Add this to your claude_desktop_config.json:")
253
- print()
254
- print(get_desktop_config(configured))
255
- print()
256
- if platform.system() == "Windows":
257
- print("Location: %APPDATA%\\Claude\\claude_desktop_config.json")
258
- elif platform.system() == "Darwin":
259
- print("Location: ~/Library/Application Support/Claude/claude_desktop_config.json")
260
- else:
261
- print("Location: ~/.config/Claude/claude_desktop_config.json")
262
-
263
- print()
264
- print("Test the server with:")
265
- print(" second-opinion-mcp")
266
- print()
267
- print("The server will start and wait for input. Press Ctrl+C to stop.")
268
- print()
269
-
270
-
271
- if __name__ == "__main__":
272
- setup_wizard()