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.
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/.gitignore +1 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/PKG-INFO +20 -1
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/README.md +19 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/pyproject.toml +1 -1
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/src/second_opinion_mcp/__init__.py +1 -1
- second_opinion_mcp-0.3.2/src/second_opinion_mcp/cli.py +465 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/src/second_opinion_mcp/server.py +108 -0
- second_opinion_mcp-0.3.0/src/second_opinion_mcp/cli.py +0 -272
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/LICENSE +0 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/src/second_opinion_mcp/__main__.py +0 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/__init__.py +0 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/conftest.py +0 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_configuration.py +0 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_connection_pool.py +0 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_error_isolation.py +0 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_input_validation.py +0 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_keyring_security.py +0 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_path_security.py +0 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_rate_limiting.py +0 -0
- {second_opinion_mcp-0.3.0 → second_opinion_mcp-0.3.2}/tests/test_streaming_limits.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: second-opinion-mcp
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|