second-opinion-mcp 0.3.5__tar.gz → 0.3.8__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.5 → second_opinion_mcp-0.3.8}/PKG-INFO +3 -1
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/pyproject.toml +3 -1
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/src/second_opinion_mcp/__init__.py +1 -1
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/src/second_opinion_mcp/cli.py +47 -16
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/src/second_opinion_mcp/server.py +34 -10
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/.gitignore +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/LICENSE +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/README.md +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/src/second_opinion_mcp/__main__.py +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/tests/__init__.py +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/tests/conftest.py +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/tests/test_configuration.py +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/tests/test_connection_pool.py +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/tests/test_error_isolation.py +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/tests/test_input_validation.py +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/tests/test_keyring_security.py +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/tests/test_path_security.py +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/tests/test_rate_limiting.py +0 -0
- {second_opinion_mcp-0.3.5 → second_opinion_mcp-0.3.8}/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.8
|
|
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
|
|
@@ -31,8 +31,10 @@ Requires-Dist: questionary>=2.0.0
|
|
|
31
31
|
Requires-Dist: tenacity>=8.2.0
|
|
32
32
|
Provides-Extra: dev
|
|
33
33
|
Requires-Dist: build; extra == 'dev'
|
|
34
|
+
Requires-Dist: pre-commit>=3.6.0; extra == 'dev'
|
|
34
35
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
35
36
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: ruff>=0.3.0; extra == 'dev'
|
|
36
38
|
Requires-Dist: twine; extra == 'dev'
|
|
37
39
|
Description-Content-Type: text/markdown
|
|
38
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "second-opinion-mcp"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.8"
|
|
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"
|
|
@@ -36,6 +36,8 @@ dev = [
|
|
|
36
36
|
"pytest-asyncio>=0.23",
|
|
37
37
|
"build",
|
|
38
38
|
"twine",
|
|
39
|
+
"pre-commit>=3.6.0",
|
|
40
|
+
"ruff>=0.3.0",
|
|
39
41
|
]
|
|
40
42
|
|
|
41
43
|
[project.urls]
|
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
import sys
|
|
4
4
|
import platform
|
|
5
5
|
import os
|
|
6
|
+
import getpass
|
|
7
|
+
|
|
8
|
+
# Patch getpass to support KEYRING_CRYPTFILE_PASSWORD env var for non-interactive use.
|
|
9
|
+
_original_getpass = getpass.getpass
|
|
10
|
+
def _patched_getpass(prompt="", stream=None):
|
|
11
|
+
"""Return password from env var if set, otherwise prompt interactively."""
|
|
12
|
+
pwd = os.environ.get('KEYRING_CRYPTFILE_PASSWORD', '')
|
|
13
|
+
if pwd:
|
|
14
|
+
return pwd
|
|
15
|
+
return _original_getpass(prompt, stream)
|
|
16
|
+
getpass.getpass = _patched_getpass
|
|
6
17
|
|
|
7
18
|
try:
|
|
8
19
|
import questionary
|
|
@@ -11,8 +22,8 @@ except ImportError:
|
|
|
11
22
|
print("Error: questionary not installed. Run: pip install questionary")
|
|
12
23
|
sys.exit(1)
|
|
13
24
|
|
|
14
|
-
import keyring
|
|
15
|
-
from keyring.errors import NoKeyringError
|
|
25
|
+
import keyring # noqa: E402
|
|
26
|
+
from keyring.errors import NoKeyringError # noqa: E402
|
|
16
27
|
|
|
17
28
|
# Provider configurations
|
|
18
29
|
PROVIDERS = {
|
|
@@ -145,12 +156,12 @@ def print_keyring_setup_instructions():
|
|
|
145
156
|
if install_method == "pipx":
|
|
146
157
|
print("For Linux (pipx installation detected), run:")
|
|
147
158
|
print()
|
|
148
|
-
print(" # Install
|
|
149
|
-
print(" pipx inject second-opinion-mcp keyrings.alt")
|
|
159
|
+
print(" # Install encrypted keyring backend with crypto support")
|
|
160
|
+
print(" pipx inject second-opinion-mcp keyrings.alt pycryptodome")
|
|
150
161
|
else:
|
|
151
162
|
print("For Linux, install the encrypted file backend:")
|
|
152
163
|
print()
|
|
153
|
-
print(" pip install keyrings.alt")
|
|
164
|
+
print(" pip install keyrings.alt pycryptodome")
|
|
154
165
|
print()
|
|
155
166
|
print(" # Create the keyring config")
|
|
156
167
|
print(" mkdir -p ~/.local/share/python_keyring")
|
|
@@ -159,6 +170,9 @@ def print_keyring_setup_instructions():
|
|
|
159
170
|
print(" default-keyring=keyrings.alt.file.EncryptedKeyring")
|
|
160
171
|
print(" EOF")
|
|
161
172
|
print()
|
|
173
|
+
print(" # Set encryption password (add to .bashrc or .profile for persistence)")
|
|
174
|
+
print(" export KEYRING_CRYPTFILE_PASSWORD='your-secure-password-here'")
|
|
175
|
+
print()
|
|
162
176
|
print("Alternative for desktop Linux (GNOME/KDE):")
|
|
163
177
|
print(" sudo apt install gnome-keyring # or kde-wallet")
|
|
164
178
|
if install_method == "pipx":
|
|
@@ -206,19 +220,23 @@ def print_manual_setup_instructions(providers: list[str] | None = None, existing
|
|
|
206
220
|
system = platform.system()
|
|
207
221
|
|
|
208
222
|
if system == "Linux":
|
|
209
|
-
print("Step 1: Install
|
|
223
|
+
print("Step 1: Install encrypted keyring backend (if not done):")
|
|
210
224
|
print()
|
|
211
225
|
if install_method == "pipx":
|
|
212
|
-
print(" pipx inject second-opinion-mcp keyrings.alt")
|
|
226
|
+
print(" pipx inject second-opinion-mcp keyrings.alt pycryptodome")
|
|
213
227
|
else:
|
|
214
|
-
print(" pip install keyrings.alt")
|
|
228
|
+
print(" pip install keyrings.alt pycryptodome")
|
|
215
229
|
print(" mkdir -p ~/.local/share/python_keyring")
|
|
216
230
|
print(" cat > ~/.local/share/python_keyring/keyringrc.cfg << 'EOF'")
|
|
217
231
|
print(" [backend]")
|
|
218
232
|
print(" default-keyring=keyrings.alt.file.EncryptedKeyring")
|
|
219
233
|
print(" EOF")
|
|
220
234
|
print()
|
|
221
|
-
print("Step 2:
|
|
235
|
+
print("Step 2: Set encryption password:")
|
|
236
|
+
print()
|
|
237
|
+
print(" export KEYRING_CRYPTFILE_PASSWORD='your-secure-password-here'")
|
|
238
|
+
print()
|
|
239
|
+
print("Step 3: Store API keys (at least 2 required):")
|
|
222
240
|
print()
|
|
223
241
|
else:
|
|
224
242
|
print("IMPORTANT: You must configure at least 2 providers for the")
|
|
@@ -268,7 +286,6 @@ def print_manual_setup_instructions(providers: list[str] | None = None, existing
|
|
|
268
286
|
|
|
269
287
|
print("To verify your keys are stored:")
|
|
270
288
|
print()
|
|
271
|
-
verify_providers = "', '".join([f"{p}:", "bool(keyring.get_password('second-opinion', '{p}'))".replace("{p}", p) for p in ["deepseek", "moonshot", "openrouter"]])
|
|
272
289
|
print(f" {python_cmd} -c \"import keyring; print('deepseek:', bool(keyring.get_password('second-opinion', 'deepseek'))); print('moonshot:', bool(keyring.get_password('second-opinion', 'moonshot'))); print('openrouter:', bool(keyring.get_password('second-opinion', 'openrouter')))\"")
|
|
273
290
|
print()
|
|
274
291
|
print("After configuring at least 2 keys, register with Claude Code:")
|
|
@@ -327,22 +344,30 @@ def validate_api_key(provider: str, key: str) -> tuple[bool, str]:
|
|
|
327
344
|
return True, ""
|
|
328
345
|
|
|
329
346
|
|
|
330
|
-
def get_registration_command(selected_providers: list[str]) -> str:
|
|
347
|
+
def get_registration_command(selected_providers: list[str], include_keyring_password: bool = False) -> str:
|
|
331
348
|
"""Generate the registration command for Claude Code CLI."""
|
|
332
349
|
providers_env = ",".join(selected_providers)
|
|
333
|
-
|
|
350
|
+
cmd = f'claude mcp add -s user second-opinion -e SECOND_OPINION_PROVIDERS="{providers_env}"'
|
|
351
|
+
if include_keyring_password:
|
|
352
|
+
cmd += ' -e KEYRING_CRYPTFILE_PASSWORD="your-keyring-password"'
|
|
353
|
+
cmd += ' -- second-opinion-mcp'
|
|
354
|
+
return cmd
|
|
334
355
|
|
|
335
356
|
|
|
336
|
-
def get_desktop_config(selected_providers: list[str]) -> str:
|
|
357
|
+
def get_desktop_config(selected_providers: list[str], include_keyring_password: bool = False) -> str:
|
|
337
358
|
"""Generate Claude Desktop configuration JSON."""
|
|
338
359
|
providers_env = ",".join(selected_providers)
|
|
339
360
|
|
|
361
|
+
env_section = f'"SECOND_OPINION_PROVIDERS": "{providers_env}"'
|
|
362
|
+
if include_keyring_password:
|
|
363
|
+
env_section += ',\n "KEYRING_CRYPTFILE_PASSWORD": "your-keyring-password"'
|
|
364
|
+
|
|
340
365
|
return f'''{{
|
|
341
366
|
"mcpServers": {{
|
|
342
367
|
"second-opinion": {{
|
|
343
368
|
"command": "second-opinion-mcp",
|
|
344
369
|
"env": {{
|
|
345
|
-
|
|
370
|
+
{env_section}
|
|
346
371
|
}}
|
|
347
372
|
}}
|
|
348
373
|
}}
|
|
@@ -352,16 +377,22 @@ def get_desktop_config(selected_providers: list[str]) -> str:
|
|
|
352
377
|
def print_client_instructions(configured_providers: list[str]):
|
|
353
378
|
"""Print setup instructions for all supported MCP clients."""
|
|
354
379
|
providers_env = ",".join(configured_providers)
|
|
380
|
+
is_linux = platform.system() == "Linux"
|
|
355
381
|
|
|
356
382
|
print()
|
|
357
383
|
print("=" * 60)
|
|
358
384
|
print(" Client Setup Instructions")
|
|
359
385
|
print("=" * 60)
|
|
360
386
|
|
|
387
|
+
if is_linux:
|
|
388
|
+
print()
|
|
389
|
+
print("NOTE: On Linux, include KEYRING_CRYPTFILE_PASSWORD in environment")
|
|
390
|
+
print(" to enable encrypted keyring access.")
|
|
391
|
+
|
|
361
392
|
# Claude Code CLI
|
|
362
393
|
print()
|
|
363
394
|
print("CLAUDE CODE CLI:")
|
|
364
|
-
print(f"
|
|
395
|
+
print(f" {get_registration_command(configured_providers, include_keyring_password=is_linux)}")
|
|
365
396
|
|
|
366
397
|
# Claude Desktop
|
|
367
398
|
print()
|
|
@@ -374,7 +405,7 @@ def print_client_instructions(configured_providers: list[str]):
|
|
|
374
405
|
else:
|
|
375
406
|
print(" Location: ~/.config/Claude/claude_desktop_config.json")
|
|
376
407
|
print()
|
|
377
|
-
print(get_desktop_config(configured_providers))
|
|
408
|
+
print(get_desktop_config(configured_providers, include_keyring_password=is_linux))
|
|
378
409
|
|
|
379
410
|
# OpenClaw / Other MCP clients
|
|
380
411
|
print()
|
|
@@ -6,6 +6,7 @@ Secrets via keyring (no env var exposure).
|
|
|
6
6
|
"""
|
|
7
7
|
import asyncio
|
|
8
8
|
import atexit
|
|
9
|
+
import getpass
|
|
9
10
|
import os
|
|
10
11
|
import re
|
|
11
12
|
import time
|
|
@@ -14,12 +15,23 @@ from datetime import datetime
|
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
from typing import Literal
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
from
|
|
22
|
-
|
|
18
|
+
# Patch getpass to support KEYRING_CRYPTFILE_PASSWORD env var for non-interactive use.
|
|
19
|
+
# This enables EncryptedKeyring to work in server/headless environments.
|
|
20
|
+
_original_getpass = getpass.getpass
|
|
21
|
+
def _patched_getpass(prompt="", stream=None):
|
|
22
|
+
"""Return password from env var if set, otherwise prompt interactively."""
|
|
23
|
+
pwd = os.environ.get('KEYRING_CRYPTFILE_PASSWORD', '')
|
|
24
|
+
if pwd:
|
|
25
|
+
return pwd
|
|
26
|
+
return _original_getpass(prompt, stream)
|
|
27
|
+
getpass.getpass = _patched_getpass
|
|
28
|
+
|
|
29
|
+
import httpx # noqa: E402
|
|
30
|
+
import keyring # noqa: E402
|
|
31
|
+
from openai import AsyncOpenAI, APIError, RateLimitError, APITimeoutError, APIConnectionError # noqa: E402
|
|
32
|
+
from mcp.server.fastmcp import FastMCP # noqa: E402
|
|
33
|
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type # noqa: E402
|
|
34
|
+
import logging # noqa: E402
|
|
23
35
|
|
|
24
36
|
# Library logging pattern: NullHandler by default, host app configures
|
|
25
37
|
logger = logging.getLogger("second-opinion")
|
|
@@ -37,15 +49,27 @@ def _check_keyring_security() -> None:
|
|
|
37
49
|
|
|
38
50
|
Detects PlaintextKeyring and NullKeyring which store credentials insecurely.
|
|
39
51
|
Set SECOND_OPINION_STRICT_KEYRING=1 to fail on insecure backends in production.
|
|
52
|
+
|
|
53
|
+
For Linux servers without a desktop keyring, use EncryptedKeyring with
|
|
54
|
+
KEYRING_CRYPTFILE_PASSWORD environment variable for non-interactive decryption.
|
|
40
55
|
"""
|
|
41
56
|
backend_name = str(type(keyring.get_keyring()))
|
|
42
57
|
insecure_backends = ('PlaintextKeyring', 'NullKeyring', 'Null')
|
|
58
|
+
|
|
43
59
|
if any(name in backend_name for name in insecure_backends):
|
|
60
|
+
# Check if user can switch to EncryptedKeyring
|
|
61
|
+
hint = ""
|
|
62
|
+
if 'PlaintextKeyring' in backend_name:
|
|
63
|
+
hint = (
|
|
64
|
+
" Hint: Set KEYRING_CRYPTFILE_PASSWORD env var to enable "
|
|
65
|
+
"EncryptedKeyring for non-interactive use."
|
|
66
|
+
)
|
|
67
|
+
|
|
44
68
|
logger.warning(
|
|
45
69
|
"SECURITY WARNING: Using insecure keyring backend (%s). "
|
|
46
70
|
"API keys may be stored in plaintext. For production, use "
|
|
47
|
-
"a secure backend or set SECOND_OPINION_STRICT_KEYRING=1 to fail
|
|
48
|
-
backend_name
|
|
71
|
+
"a secure backend or set SECOND_OPINION_STRICT_KEYRING=1 to fail.%s",
|
|
72
|
+
backend_name, hint
|
|
49
73
|
)
|
|
50
74
|
if os.environ.get("SECOND_OPINION_STRICT_KEYRING"):
|
|
51
75
|
raise RuntimeError(
|
|
@@ -1210,7 +1234,7 @@ async def consensus(
|
|
|
1210
1234
|
_, err_a = _validate_provider(provider_a)
|
|
1211
1235
|
_, err_b = _validate_provider(provider_b)
|
|
1212
1236
|
if err_a and err_b:
|
|
1213
|
-
return
|
|
1237
|
+
return "Error: No providers available for consensus debate. Enable deepseek or moonshot."
|
|
1214
1238
|
|
|
1215
1239
|
output_parts = [f"## Debate: {topic}\n"]
|
|
1216
1240
|
|
|
@@ -1411,7 +1435,7 @@ async def review_synthesis(
|
|
|
1411
1435
|
# Check for critical failures
|
|
1412
1436
|
errors = [p for p, r in reviews.items() if r.startswith("Error:")]
|
|
1413
1437
|
if len(errors) == len(providers_used):
|
|
1414
|
-
return
|
|
1438
|
+
return "Error: All code reviews failed.\n\n" + "\n\n".join(
|
|
1415
1439
|
f"**{p}:** {reviews[p]}" for p in providers_used
|
|
1416
1440
|
)
|
|
1417
1441
|
|
|
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
|
|
File without changes
|
|
File without changes
|