second-opinion-mcp 0.3.6__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.6 → second_opinion_mcp-0.3.8}/PKG-INFO +3 -1
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/pyproject.toml +3 -1
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/src/second_opinion_mcp/__init__.py +1 -1
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/src/second_opinion_mcp/cli.py +47 -15
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/src/second_opinion_mcp/server.py +34 -10
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/.gitignore +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/LICENSE +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/README.md +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/src/second_opinion_mcp/__main__.py +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/tests/__init__.py +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/tests/conftest.py +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/tests/test_configuration.py +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/tests/test_connection_pool.py +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/tests/test_error_isolation.py +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/tests/test_input_validation.py +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/tests/test_keyring_security.py +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/tests/test_path_security.py +0 -0
- {second_opinion_mcp-0.3.6 → second_opinion_mcp-0.3.8}/tests/test_rate_limiting.py +0 -0
- {second_opinion_mcp-0.3.6 → 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")
|
|
@@ -326,22 +344,30 @@ def validate_api_key(provider: str, key: str) -> tuple[bool, str]:
|
|
|
326
344
|
return True, ""
|
|
327
345
|
|
|
328
346
|
|
|
329
|
-
def get_registration_command(selected_providers: list[str]) -> str:
|
|
347
|
+
def get_registration_command(selected_providers: list[str], include_keyring_password: bool = False) -> str:
|
|
330
348
|
"""Generate the registration command for Claude Code CLI."""
|
|
331
349
|
providers_env = ",".join(selected_providers)
|
|
332
|
-
|
|
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
|
|
333
355
|
|
|
334
356
|
|
|
335
|
-
def get_desktop_config(selected_providers: list[str]) -> str:
|
|
357
|
+
def get_desktop_config(selected_providers: list[str], include_keyring_password: bool = False) -> str:
|
|
336
358
|
"""Generate Claude Desktop configuration JSON."""
|
|
337
359
|
providers_env = ",".join(selected_providers)
|
|
338
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
|
+
|
|
339
365
|
return f'''{{
|
|
340
366
|
"mcpServers": {{
|
|
341
367
|
"second-opinion": {{
|
|
342
368
|
"command": "second-opinion-mcp",
|
|
343
369
|
"env": {{
|
|
344
|
-
|
|
370
|
+
{env_section}
|
|
345
371
|
}}
|
|
346
372
|
}}
|
|
347
373
|
}}
|
|
@@ -351,16 +377,22 @@ def get_desktop_config(selected_providers: list[str]) -> str:
|
|
|
351
377
|
def print_client_instructions(configured_providers: list[str]):
|
|
352
378
|
"""Print setup instructions for all supported MCP clients."""
|
|
353
379
|
providers_env = ",".join(configured_providers)
|
|
380
|
+
is_linux = platform.system() == "Linux"
|
|
354
381
|
|
|
355
382
|
print()
|
|
356
383
|
print("=" * 60)
|
|
357
384
|
print(" Client Setup Instructions")
|
|
358
385
|
print("=" * 60)
|
|
359
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
|
+
|
|
360
392
|
# Claude Code CLI
|
|
361
393
|
print()
|
|
362
394
|
print("CLAUDE CODE CLI:")
|
|
363
|
-
print(f"
|
|
395
|
+
print(f" {get_registration_command(configured_providers, include_keyring_password=is_linux)}")
|
|
364
396
|
|
|
365
397
|
# Claude Desktop
|
|
366
398
|
print()
|
|
@@ -373,7 +405,7 @@ def print_client_instructions(configured_providers: list[str]):
|
|
|
373
405
|
else:
|
|
374
406
|
print(" Location: ~/.config/Claude/claude_desktop_config.json")
|
|
375
407
|
print()
|
|
376
|
-
print(get_desktop_config(configured_providers))
|
|
408
|
+
print(get_desktop_config(configured_providers, include_keyring_password=is_linux))
|
|
377
409
|
|
|
378
410
|
# OpenClaw / Other MCP clients
|
|
379
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
|