Skip to main content
Glama
openbao_secrets.py19.3 kB
""" OpenBao Secrets Client for MCP Servers. This module provides a simple interface for MCP servers to read secrets from a local OpenBao Agent using Arc Forge's secret path pattern. Secret Path Pattern: secret/{namespace}/{environment}-{type}-{service}-{identifier} Examples: - secret/client0/prod-mcp-vikunja-samuel - secret/client0/prod-mcp-joplin-desktop SECURITY: Environment variable fallback is DISABLED by default. Only enable for local development environments, never in production. Usage: from src.utils.openbao_secrets import get_mcp_token, get_mcp_config # Production: Agent only (no fallback) token = get_mcp_token("vikunja") # Development only: Allow env var fallback (explicit opt-in) token = get_mcp_token("vikunja", dev_fallback="VIKUNJA_TOKEN") # Get full MCP config config = get_mcp_config("vikunja") """ import os import socket import subprocess import warnings from typing import Optional, Dict, Any from functools import lru_cache try: import httpx HTTPX_AVAILABLE = True except ImportError: HTTPX_AVAILABLE = False # Configuration # Default port 18200 is the local agent listener (distinct from VPS tunnel on 8200) AGENT_ADDR = os.getenv("OPENBAO_AGENT_ADDR", "http://127.0.0.1:18200") AGENT_TIMEOUT = float(os.getenv("OPENBAO_AGENT_TIMEOUT", "5.0")) # Arc Forge secret path configuration ARC_CLIENT = os.getenv("ARC_CLIENT", "client0") ARC_ENVIRONMENT = os.getenv("ARC_ENVIRONMENT", "prod") # Development mode detection # Set OPENBAO_DEV_MODE=1 to allow env var fallbacks (local dev only) DEV_MODE = os.getenv("OPENBAO_DEV_MODE", "").lower() in ("1", "true", "yes") class OpenBaoError(Exception): """Base exception for OpenBao errors.""" pass class AgentNotRunningError(OpenBaoError): """Raised when the OpenBao Agent is not running.""" pass class SecretNotFoundError(OpenBaoError): """Raised when a secret path doesn't exist.""" pass def _get_client(): """Get HTTP client for agent communication.""" if not HTTPX_AVAILABLE: raise OpenBaoError("httpx is required for OpenBao agent support") return httpx.Client( base_url=AGENT_ADDR, timeout=AGENT_TIMEOUT, headers={"X-Vault-Request": "true"} ) def _get_git_email() -> Optional[str]: """Get user email from git config.""" try: result = subprocess.run( ["git", "config", "user.email"], capture_output=True, text=True, timeout=2 ) if result.returncode == 0: return result.stdout.strip() except Exception: pass return None def _detect_identifier(service: str) -> str: """ Auto-detect identifier for service credential. Args: service: Service name (e.g., "vikunja", "joplin") Returns: Identifier string (username or hostname) Detection strategy: - vikunja: User-scoped → git email username - joplin: Machine-scoped → hostname - default: hostname """ if service == "joplin": # Machine-scoped: use hostname return socket.gethostname().split('.')[0] elif service == "vikunja": # User-scoped: use git email username email = _get_git_email() if email: return email.split('@')[0] # "samuel@arcforge.au" → "samuel" # Fallback to system user return os.getenv("USER", "default") # Default: user-scoped (git email username) email = _get_git_email() if email: return email.split('@')[0] # "samuel@arcforge.au" → "samuel" # Fallback to system user return os.getenv("USER", "default") def build_mcp_secret_path(service: str, identifier: Optional[str] = None) -> str: """ Build secret path using Arc Forge pattern. Pattern: secret/{client}/{environment}-mcp-{service}-{identifier} Args: service: Service name (e.g., "vikunja", "joplin") identifier: Optional explicit identifier (auto-detected if not provided) Returns: Secret path string (e.g., "client0/prod-mcp-vikunja-samuel") Examples: >>> build_mcp_secret_path("vikunja") "client0/prod-mcp-vikunja-samuel" >>> build_mcp_secret_path("joplin", "laptop") "client0/prod-mcp-joplin-laptop" """ if identifier is None: identifier = _detect_identifier(service) return f"{ARC_CLIENT}/{ARC_ENVIRONMENT}-mcp-{service}-{identifier}" def check_agent_health() -> bool: """ Check if the OpenBao Agent is running and healthy. Returns: True if agent is healthy, False otherwise. """ if not HTTPX_AVAILABLE: return False try: with _get_client() as client: response = client.get("/v1/sys/health") return response.status_code in (200, 429, 472, 473, 501, 503) except Exception: return False def get_secret(path: str, key: Optional[str] = None) -> Any: """ Read a secret from the OpenBao Agent. Args: path: Secret path (e.g., "mcp/vikunja") key: Optional specific key within the secret data Returns: The secret data dict, or specific key value if key provided. Raises: AgentNotRunningError: If agent is not running SecretNotFoundError: If secret path doesn't exist OpenBaoError: For other OpenBao errors """ if not HTTPX_AVAILABLE: raise AgentNotRunningError("httpx not installed - cannot connect to agent") # Ensure path doesn't start with / path = path.lstrip("/") # Build the full path for KV v2 full_path = f"/v1/secret/data/{path}" try: with _get_client() as client: response = client.get(full_path) if response.status_code == 404: raise SecretNotFoundError(f"Secret not found: {path}") if response.status_code != 200: raise OpenBaoError( f"Failed to read secret: {response.status_code} - {response.text}" ) data = response.json() secret_data = data.get("data", {}).get("data", {}) if key: if key not in secret_data: raise SecretNotFoundError(f"Key '{key}' not found in {path}") return secret_data[key] return secret_data except httpx.ConnectError: raise AgentNotRunningError( f"Cannot connect to OpenBao Agent at {AGENT_ADDR}. " "Start the agent with:\n" " export BW_SESSION=$(bw unlock --raw)\n" " start-openbao-mcp" ) def get_mcp_token( service: str, dev_fallback: Optional[str] = None, required: bool = True, identifier: Optional[str] = None ) -> Optional[str]: """ Get an MCP service token from the OpenBao agent. Uses Arc Forge secret path pattern: secret/{client}/{environment}-mcp-{service}-{identifier} SECURITY: Environment variable fallback is DISABLED by default. Fallback only works when OPENBAO_DEV_MODE=1 is set AND dev_fallback is specified. This ensures production environments always use the agent. Args: service: Service name (e.g., "vikunja", "joplin") dev_fallback: Environment variable name for dev-only fallback. Only used when OPENBAO_DEV_MODE=1 is set. required: If True, raise error when token not found identifier: Optional explicit identifier (auto-detected if not provided) Returns: The token string, or None if not found and not required. Raises: AgentNotRunningError: If agent not running (production) SecretNotFoundError: If secret not found (production) ValueError: If required=True and token not found Example: # Production: Agent only (will fail if agent not running) token = get_mcp_token("vikunja") # Development: Allow env fallback (requires OPENBAO_DEV_MODE=1) token = get_mcp_token("vikunja", dev_fallback="VIKUNJA_TOKEN") # Explicit identifier override token = get_mcp_token("vikunja", identifier="kayla") """ # Build secret path using Arc Forge pattern secret_path = build_mcp_secret_path(service, identifier) # Try agent first try: return get_secret(secret_path, "token") except OpenBaoError as e: # Catch all OpenBao errors (agent not running, secret not found, permission denied, etc.) # Only allow fallback in dev mode with explicit fallback specified if DEV_MODE and dev_fallback: token = os.getenv(dev_fallback) if token: warnings.warn( f"[DEV MODE] Using {dev_fallback} env var (agent error: {type(e).__name__}). " "This fallback is disabled in production.", UserWarning ) return token # No fallback available - raise appropriate error if isinstance(e, AgentNotRunningError): if required: raise AgentNotRunningError( f"OpenBao Agent not running at {AGENT_ADDR}. " "Start the agent with:\n" " export BW_SESSION=$(bw unlock --raw)\n" " start-openbao-mcp" ) from e return None elif isinstance(e, SecretNotFoundError): if required: raise ValueError( f"Token for '{service}' not found in agent at secret/{secret_path}. " "Ensure the secret exists in OpenBao with the correct path pattern: " f"secret/{ARC_CLIENT}/{ARC_ENVIRONMENT}-mcp-{{service}}-{{identifier}}" ) from e return None else: # Other OpenBaoError (like permission denied) if required: identifier = _detect_identifier(service) raise ValueError( f"Failed to retrieve token for '{service}' from agent: {e}\n" f"Expected path: secret/{secret_path}\n\n" f"To create this secret, connect to your OpenBao server and run:\n" f" bao kv put secret/{secret_path} token=\"your-{service}-token\" url=\"https://your-{service}-url\"\n\n" f"Or for development, enable dev mode:\n" f" export OPENBAO_DEV_MODE=1\n" f" export VIKUNJA_TOKEN=your-token\n" f" export VIKUNJA_URL=https://your-url" ) from e return None def get_mcp_config( service: str, dev_fallbacks: Optional[Dict[str, str]] = None, identifier: Optional[str] = None ) -> Dict[str, Any]: """ Get full configuration for an MCP service from the OpenBao agent. Uses Arc Forge secret path pattern: secret/{client}/{environment}-mcp-{service}-{identifier} SECURITY: Environment variable fallback is DISABLED by default. Fallback only works when OPENBAO_DEV_MODE=1 is set AND dev_fallbacks is specified. This ensures production environments always use the agent. Args: service: Service name (e.g., "vikunja") dev_fallbacks: Dict mapping config keys to env var names for dev-only fallback. Only used when OPENBAO_DEV_MODE=1 is set. e.g., {"token": "VIKUNJA_TOKEN", "url": "VIKUNJA_URL"} identifier: Optional explicit identifier (auto-detected if not provided) Returns: Dict with all config keys for the service. Example: # Production: Agent only config = get_mcp_config("vikunja") # Development: Allow env fallback (requires OPENBAO_DEV_MODE=1) config = get_mcp_config("vikunja", dev_fallbacks={ "token": "VIKUNJA_TOKEN", "url": "VIKUNJA_URL" }) # Explicit identifier override config = get_mcp_config("vikunja", identifier="kayla") """ # Build secret path using Arc Forge pattern secret_path = build_mcp_secret_path(service, identifier) try: return get_secret(secret_path) except OpenBaoError as e: # Catch all OpenBao errors (agent not running, secret not found, permission denied, etc.) # Only allow fallback in dev mode with explicit fallbacks specified if DEV_MODE and dev_fallbacks: config = {} for key, env_var in dev_fallbacks.items(): value = os.getenv(env_var) if value: config[key] = value if config: warnings.warn( f"[DEV MODE] Using environment variables for {service} (agent error: {type(e).__name__}). " "This fallback is disabled in production.", UserWarning ) return config raise @lru_cache(maxsize=1) def is_agent_available() -> bool: """ Check if agent is available (cached for performance). Use this at startup to decide between agent and fallback mode. Result is cached for the lifetime of the process. Returns: True if agent is available. """ return check_agent_health() # Convenience aliases read_secret = get_secret # ============================================================================= # Deferred Credential Loading (for FastMCP lifespan pattern) # ============================================================================= class DeferredCredentialLoader: """ Deferred credential loader for MCP servers. This class delays credential loading until the lifespan context is entered, preventing import-time failures when the OpenBao agent isn't running. Usage: # At module level (no I/O) _stripe_loader = DeferredCredentialLoader( "stripe", "api_key", dev_fallback="STRIPE_API_KEY" ) @asynccontextmanager async def lifespan(app): # Load credentials here (fails fast if unavailable) api_key = _stripe_loader.load() stripe.api_key = api_key print("MCP_SERVER_READY", file=sys.stderr) yield Error codes printed to stderr for lazy-mcp detection: - OPENBAO_AGENT_NOT_RUNNING: Agent not responding - SECRET_NOT_FOUND: Secret path doesn't exist - SECRET_PERMISSION_DENIED: No access to secret - SECRET_INVALID_TOKEN: Token expired or invalid - SOURCE_ENV: Using environment variable fallback """ def __init__( self, service: str, key: str = "token", dev_fallback: Optional[str] = None, identifier: Optional[str] = None ): """ Initialize deferred loader. Args: service: Service name (e.g., "stripe", "vikunja") key: Key within the secret data (default: "token") dev_fallback: Environment variable name for dev-only fallback identifier: Optional explicit identifier (auto-detected if not provided) """ self.service = service self.key = key self.dev_fallback = dev_fallback self.identifier = identifier self._cached_value: Optional[str] = None self._source: Optional[str] = None self._loaded = False def is_available(self) -> bool: """ Check if credential can be loaded (either from agent or dev fallback). Returns: True if credential is available from either source. """ try: self.load() return True except (OpenBaoError, ValueError): return False def get_source(self) -> Optional[str]: """Get the source of the credential after loading.""" return self._source def load(self) -> str: """ Load the credential from OpenBao or dev fallback. Returns: The credential value. Raises: AgentNotRunningError: If agent not running and no fallback SecretNotFoundError: If secret not found and no fallback ValueError: If credential cannot be retrieved """ if self._loaded and self._cached_value: return self._cached_value secret_path = build_mcp_secret_path(self.service, self.identifier) try: self._cached_value = get_secret(secret_path, self.key) self._source = "SOURCE_OPENBAO" self._loaded = True return self._cached_value except OpenBaoError as e: # Try dev fallback if available if DEV_MODE and self.dev_fallback: value = os.getenv(self.dev_fallback) if value: self._cached_value = value self._source = "SOURCE_ENV" self._loaded = True import sys print( f"[DEV MODE] Using {self.dev_fallback} env var " f"(agent error: {type(e).__name__}). " "This fallback is disabled in production.", file=sys.stderr ) return self._cached_value # Output structured error for lazy-mcp detection import sys error_code = self._map_error_code(e) error_json = { "error_code": error_code, "error_message": str(e), "service": self.service, "secret_path": secret_path } import json print(f"OPENBAO_ERROR:{json.dumps(error_json)}", file=sys.stderr) raise def _map_error_code(self, error: OpenBaoError) -> str: """Map OpenBaoError to error code string.""" if isinstance(error, AgentNotRunningError): return "OPENBAO_AGENT_NOT_RUNNING" elif isinstance(error, SecretNotFoundError): return "SECRET_NOT_FOUND" else: error_str = str(error).lower() if "permission denied" in error_str or "403" in error_str: return "SECRET_PERMISSION_DENIED" elif "invalid token" in error_str or "token expired" in error_str: return "SECRET_INVALID_TOKEN" return "OPENBAO_AGENT_NOT_RUNNING" def check_agent_status() -> Dict[str, Any]: """ Check OpenBao agent status and return structured result. Returns: Dict with 'available', 'error_code', 'error_message' keys. This is useful for lifespan context to check availability before attempting to load credentials. """ result = { "available": False, "error_code": None, "error_message": None } try: if check_agent_health(): result["available"] = True else: result["error_code"] = "OPENBAO_AGENT_NOT_RUNNING" result["error_message"] = f"Agent not healthy at {AGENT_ADDR}" except Exception as e: result["error_code"] = "OPENBAO_AGENT_NOT_RUNNING" result["error_message"] = str(e) return result

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/IAMSamuelRodda/gmail-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server