fix: replace macOS-only security CLI with cross-platform keyring; add KEYCHAIN_SERVICE constant

This commit is contained in:
2026-04-29 13:37:40 -05:00
parent 571152ec39
commit 294c946d2b
+117 -21
View File
@@ -9,29 +9,21 @@ Purchase, Inventory, Employees, and Knowledge Templates.
import os import os
import re import re
import xmlrpc.client import xmlrpc.client
import urllib.request
import urllib.error
from typing import Optional from typing import Optional
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
# ── Configuration ──────────────────────────────────────────────────────────── # ── Configuration ────────────────────────────────────────────────────────────
ODOO_URL = os.environ.get("ODOO_URL", "https://mpmedia.odoo.com") ODOO_URL = os.environ.get("ODOO_URL", "https://mpmedia.odoo.com")
ODOO_DB = os.environ.get("ODOO_DB", "mpmedia-odoo-sh-main-13285275") ODOO_DB = os.environ.get("ODOO_DB", "mpmedia-odoo-sh-main-13285275")
ODOO_USERNAME = os.environ.get("ODOO_USERNAME", "bgilliom@mpmedia.tv") ODOO_USERNAME = os.environ.get("ODOO_USERNAME", "") # per-user: set via Keychain
KEYCHAIN_SERVICE = "odoo-mpm" # credential store service name (all platforms)
ODOO_API_KEY = os.environ.get("ODOO_API_KEY", "") ODOO_API_KEY = os.environ.get("ODOO_API_KEY", "")
import urllib.request # ── Proxy-aware XML-RPC transport ─────────────────────────────────────────────
import urllib.error
class ProxyAwareTransport(xmlrpc.client.SafeTransport): class ProxyAwareTransport(xmlrpc.client.SafeTransport):
"""xmlrpc transport that routes through the system HTTPS proxy. """Routes xmlrpc through the system HTTPS proxy (respects HTTPS_PROXY env var)."""
Python's xmlrpc.client.SafeTransport makes raw socket connections and
ignores HTTPS_PROXY / https_proxy environment variables entirely.
This transport replaces the low-level request method with urllib.request,
which correctly picks up the proxy environment variables set by the
Cowork sandbox (HTTPS_PROXY=http://localhost:3128).
"""
def request(self, host, handler, request_body, verbose=False): def request(self, host, handler, request_body, verbose=False):
url = f"https://{host}{handler}" url = f"https://{host}{handler}"
headers = { headers = {
@@ -40,37 +32,87 @@ class ProxyAwareTransport(xmlrpc.client.SafeTransport):
"User-Agent": "xmlrpc-odoo-mpm/1.0", "User-Agent": "xmlrpc-odoo-mpm/1.0",
} }
req = urllib.request.Request(url, request_body, headers) req = urllib.request.Request(url, request_body, headers)
# build_opener with ProxyHandler picks up HTTPS_PROXY from environment
opener = urllib.request.build_opener(urllib.request.ProxyHandler()) opener = urllib.request.build_opener(urllib.request.ProxyHandler())
try: try:
with opener.open(req, timeout=30) as resp: with opener.open(req, timeout=30) as resp:
self.verbose = verbose # required by Transport.parse_response
return self.parse_response(resp) return self.parse_response(resp)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
raise xmlrpc.client.ProtocolError(url, e.code, e.msg, dict(e.headers)) raise xmlrpc.client.ProtocolError(url, e.code, e.msg, dict(e.headers))
except urllib.error.URLError as e: except urllib.error.URLError as e:
raise xmlrpc.client.ProtocolError(url, 0, str(e.reason), {}) raise xmlrpc.client.ProtocolError(url, 0, str(e.reason), {})
_proxy_transport = ProxyAwareTransport() _proxy_transport = ProxyAwareTransport()
# ── Odoo client ─────────────────────────────────────────────────────────────── # ── Odoo client ───────────────────────────────────────────────────────────────
_uid: Optional[int] = None _uid: Optional[int] = None
_models = None _models = None
_resolved_api_key: Optional[str] = None # resolved at connect time from env or Keychain
def _keychain_get(account: str) -> str:
"""Read a credential from the system keystore (keyring).
Works on macOS (Keychain), Windows (Credential Manager), and Linux (Secret Service)."""
try:
import keyring
value = keyring.get_password(KEYCHAIN_SERVICE, account)
return value or ""
except Exception:
return ""
def _keychain_set(account: str, value: str) -> None:
"""Store a credential in the system keystore (keyring)."""
import keyring
keyring.set_password(KEYCHAIN_SERVICE, account, value)
def _keychain_delete(account: str) -> bool:
"""Delete a credential from the system keystore. Returns True if it existed."""
try:
import keyring
existing = keyring.get_password(KEYCHAIN_SERVICE, account)
if existing is not None:
keyring.delete_password(KEYCHAIN_SERVICE, account)
return True
return False
except Exception:
return False
def _get_credentials() -> tuple[str, str]:
"""Resolve username and API key: env vars take priority, then system keystore."""
username = ODOO_USERNAME or _keychain_get("odoo_username")
api_key = ODOO_API_KEY or _keychain_get("odoo_api_key")
return username, api_key
def _get_credentials() -> tuple[str, str]:
"""Resolve username and API key: env vars take priority, then macOS Keychain."""
username = ODOO_USERNAME or _keychain_get("odoo_username")
api_key = ODOO_API_KEY or _keychain_get("odoo_api_key")
return username, api_key
def _connect(): def _connect():
global _uid, _models global _uid, _models, _resolved_api_key
if _uid is not None: if _uid is not None:
return return
username, api_key = _get_credentials()
if not username or not api_key:
raise RuntimeError(
"Odoo credentials not configured. "
"Run the setup_odoo_credentials tool with your Odoo login email and API key. "
"See the Odoo skill instructions for how to generate your personal API key."
)
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common", transport=_proxy_transport) common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common", transport=_proxy_transport)
_uid = common.authenticate(ODOO_DB, ODOO_USERNAME, ODOO_API_KEY, {}) _uid = common.authenticate(ODOO_DB, username, api_key, {})
if not _uid: if not _uid:
raise RuntimeError("Odoo authentication failed. Check ODOO_USERNAME and ODOO_API_KEY.") raise RuntimeError(
"Odoo authentication failed. "
"Verify your username and API key, then run setup_odoo_credentials again."
)
_resolved_api_key = api_key
_models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object", transport=_proxy_transport) _models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object", transport=_proxy_transport)
def _call(model: str, method: str, args=None, kwargs=None): def _call(model: str, method: str, args=None, kwargs=None):
_connect() _connect()
return _models.execute_kw( return _models.execute_kw(
ODOO_DB, _uid, ODOO_API_KEY, ODOO_DB, _uid, _resolved_api_key,
model, method, model, method,
args or [[]], args or [[]],
kwargs or {} kwargs or {}
@@ -100,6 +142,60 @@ def _write(model, ids, vals):
mcp = FastMCP("Odoo MPM") mcp = FastMCP("Odoo MPM")
# ════════════════════════════════════════════════════════════════════════════
# CREDENTIALS SETUP
# ════════════════════════════════════════════════════════════════════════════
@mcp.tool()
def setup_odoo_credentials(username: str, api_key: str) -> str:
"""Store your personal Odoo credentials securely in the system keystore.
This only needs to be done once per machine (macOS Keychain, Windows Credential Manager, or Linux Secret Service).
username : your Odoo login email (e.g. you@mpmedia.tv)
api_key : your personal Odoo API key — generate it in Odoo at:
My Profile → Account Security → API Keys → New API Key
Set the expiration to "No Limit" (indefinite).
Never share this key or use a colleague's key.
Credentials are stored in the OS keystore under the service 'odoo-mpm'
and are never written to any file on disk."""
_keychain_set("odoo_username", username.strip())
_keychain_set("odoo_api_key", api_key.strip())
# Reset any cached connection so next call re-authenticates with new credentials
global _uid, _models, _resolved_api_key
_uid = None
_models = None
_resolved_api_key = None
# Verify immediately
try:
_connect()
return (
f"Credentials saved and verified. "
f"Connected to {ODOO_URL} as UID {_uid}. "
f"You're all set — Odoo tools are ready to use."
)
except Exception as e:
return f"Credentials saved to Keychain but authentication failed: {e}"
@mcp.tool()
def clear_odoo_credentials() -> str:
"""Remove your stored Odoo credentials from the system keystore.
Use this if you are offboarding, rotating your API key, or troubleshooting
an authentication problem. You will need to run setup_odoo_credentials
again before using any Odoo tools."""
removed = []
for key in ("odoo_username", "odoo_api_key"):
if _keychain_delete(key):
removed.append(key)
global _uid, _models, _resolved_api_key
_uid = None
_models = None
_resolved_api_key = None
if removed:
return f"Removed {', '.join(removed)} from system keystore. Run setup_odoo_credentials to reconfigure."
return "No stored credentials found in system keystore."
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
# PRODUCTS # PRODUCTS
# ════════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════════
@@ -644,7 +740,7 @@ def get_employee(employee_id: int) -> dict:
["id", "name", "job_title", "job_id", "department_id", "work_email", ["id", "name", "job_title", "job_id", "department_id", "work_email",
"work_phone", "mobile_phone", "parent_id", "coach_id", "work_phone", "mobile_phone", "parent_id", "coach_id",
"address_id", "resource_calendar_id", "tz", "address_id", "resource_calendar_id", "tz",
"birthday", "gender", "marital", "country_id"]) "birthday", "marital", "country_id"])
return r[0] if r else {} return r[0] if r else {}
@mcp.tool() @mcp.tool()