second-opinion-mcp 0.3.6__py3-none-any.whl → 0.3.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  """Second Opinion MCP Server - Multi-model AI analysis for Claude."""
2
2
 
3
- __version__ = "0.3.6"
3
+ __version__ = "0.3.8"
4
4
  __author__ = "MarvinFS"
5
5
 
6
6
  from second_opinion_mcp.server import mcp, second_opinion, challenge, code_review, consensus, review_synthesis
second_opinion_mcp/cli.py CHANGED
@@ -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 the encrypted file backend into the pipx environment")
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 keyrings.alt backend (if not done):")
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: Store API keys (at least 2 required):")
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
- return f'claude mcp add -s user second-opinion -e SECOND_OPINION_PROVIDERS="{providers_env}" -- second-opinion-mcp'
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
- "SECOND_OPINION_PROVIDERS": "{providers_env}"
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" claude mcp add -s user second-opinion -e SECOND_OPINION_PROVIDERS=\"{providers_env}\" -- second-opinion-mcp")
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
- import httpx
18
- import keyring
19
- from openai import AsyncOpenAI, APIError, RateLimitError, APITimeoutError, APIConnectionError
20
- from mcp.server.fastmcp import FastMCP
21
- from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
22
- import logging
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 f"Error: No providers available for consensus debate. Enable deepseek or moonshot."
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 f"Error: All code reviews failed.\n\n" + "\n\n".join(
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: second-opinion-mcp
3
- Version: 0.3.6
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
 
@@ -0,0 +1,9 @@
1
+ second_opinion_mcp/__init__.py,sha256=5E-gvd4FKOinUkkz2EdInMNsVHNXRhYxXTBtjVY5R8k,373
2
+ second_opinion_mcp/__main__.py,sha256=WDv19O6nvIH69GR_DUfXqDt-wm8bgsHNqvPm1LEIKzs,509
3
+ second_opinion_mcp/cli.py,sha256=CzxW5LFo2P7SSuyNHecObSh15LVuRsM3MiW9aT1bRRU,28073
4
+ second_opinion_mcp/server.py,sha256=0OQskL83WFTl5ObX0uExDvZsuN_X-l8o9Vs22a8dOjc,63286
5
+ second_opinion_mcp-0.3.8.dist-info/METADATA,sha256=WDyIRH3ISS4GSQGeSSRtx-9alj9bctjutUbM92Y5DTo,11680
6
+ second_opinion_mcp-0.3.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
7
+ second_opinion_mcp-0.3.8.dist-info/entry_points.txt,sha256=-E8BA2gFyU4qW-kJL8SV9Pg1Cc7glOCmraJoZH0PZP8,72
8
+ second_opinion_mcp-0.3.8.dist-info/licenses/LICENSE,sha256=dPx2Jy-Ejearvfh6IlF2PN4Srt-nZW8M4bW5EW7RPAg,1065
9
+ second_opinion_mcp-0.3.8.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- second_opinion_mcp/__init__.py,sha256=BJBLikFUnc016tLagTu4T11GSPMyBTqWG7uRXkbu8XU,373
2
- second_opinion_mcp/__main__.py,sha256=WDv19O6nvIH69GR_DUfXqDt-wm8bgsHNqvPm1LEIKzs,509
3
- second_opinion_mcp/cli.py,sha256=CzUKTAhKOUcEYoECaybtsQoz8crzZRBRMjSVZNMGNQo,26608
4
- second_opinion_mcp/server.py,sha256=46cHbK0GgL0VBCGI3gmTA1-vQ1jRDdQ6MsCCqWw3LVk,62237
5
- second_opinion_mcp-0.3.6.dist-info/METADATA,sha256=aT0t3kPUBCFj0dUJkvnOU83_thavyggHfDr-1KA6-P8,11588
6
- second_opinion_mcp-0.3.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
7
- second_opinion_mcp-0.3.6.dist-info/entry_points.txt,sha256=-E8BA2gFyU4qW-kJL8SV9Pg1Cc7glOCmraJoZH0PZP8,72
8
- second_opinion_mcp-0.3.6.dist-info/licenses/LICENSE,sha256=dPx2Jy-Ejearvfh6IlF2PN4Srt-nZW8M4bW5EW7RPAg,1065
9
- second_opinion_mcp-0.3.6.dist-info/RECORD,,