diff --git a/docs/data-sources/builtin-toolsets/github-mcp.md b/docs/data-sources/builtin-toolsets/github-mcp.md index 84899b0c0..03577d0be 100644 --- a/docs/data-sources/builtin-toolsets/github-mcp.md +++ b/docs/data-sources/builtin-toolsets/github-mcp.md @@ -4,9 +4,12 @@ The GitHub MCP server provides access to GitHub repositories, pull requests, iss ## Overview -The GitHub MCP server is deployed as a separate pod in your cluster when using the Holmes or Robusta Helm charts. For CLI users, you'll need to deploy the MCP server manually and configure Holmes to connect to it. +Holmes supports two authentication methods for GitHub. Both deploy a self-hosted MCP server pod in your cluster that wraps the [official GitHub MCP server](https://github.com/github/github-mcp-server): -The server supports both GitHub.com and GitHub Enterprise Server, making it suitable for both cloud and on-premises deployments. +- **Personal Access Token (PAT)**: Uses the standard `github-mcp` image. The PAT is passed directly to the MCP server. +- **GitHub App**: Uses the `github-app-mcp` image which automatically generates and refreshes short-lived installation tokens from GitHub App credentials. + +Both methods support GitHub.com and GitHub Enterprise Server. ## Prerequisites @@ -259,7 +262,7 @@ Before deploying the GitHub MCP server, you need a GitHub Personal Access Token ### Using a GitHub App -Instead of a Personal Access Token, you can authenticate using a [GitHub App](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/about-creating-github-apps). This method uses GitHub's remote MCP endpoint (`api.githubcopilot.com/mcp`) with automatically refreshed installation tokens. +Instead of a Personal Access Token, you can authenticate using a [GitHub App](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/about-creating-github-apps). This deploys the `github-app-mcp` image which wraps the official GitHub MCP server with automatic installation token generation and refresh. **Step 1: Create a GitHub App** @@ -296,52 +299,82 @@ Note the **Installation ID** from the URL after installation: `https://github.co Find the **App ID** on the App's settings page (under "About"). -**Step 5: Configure the GitHub MCP Server** +**Step 5: Configure Holmes** -Choose one of: +=== "Holmes CLI" -- **Self-hosted in cluster** — Deploy the MCP server using [Steps 1–2 from the PAT section above](#using-a-personal-access-token), then use its in-cluster URL. -- **GitHub's remote endpoint** — Use `https://api.githubcopilot.com/mcp` (no deployment needed). + For CLI usage, deploy the `github-app-mcp` server in your cluster and connect Holmes to it. -**Step 6: Configure Holmes** + **Create the Kubernetes secret:** -=== "Holmes CLI" + ```bash + kubectl create namespace holmes-mcp # if not already created - Set the following environment variables and add the MCP server to **~/.holmes/config.yaml**: + kubectl create secret generic holmes-github-app \ + --from-literal=GITHUB_APP_ID= \ + --from-literal=GITHUB_APP_INSTALLATION_ID= \ + --from-file=GITHUB_APP_PRIVATE_KEY=/path/to/private-key.pem \ + -n holmes-mcp + ``` - ```bash - export GITHUB_APP_ID="" - export GITHUB_APP_INSTALLATION_ID="" - export GITHUB_APP_PRIVATE_KEY="$(cat /path/to/private-key.pem)" + **Deploy the GitHub App MCP server:** + + ```yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: github-mcp-server + namespace: holmes-mcp + spec: + replicas: 1 + selector: + matchLabels: + app: github-mcp-server + template: + metadata: + labels: + app: github-mcp-server + spec: + containers: + - name: github-mcp + image: us-central1-docker.pkg.dev/genuine-flight-317411/mcp/github-app-mcp:1.0.0 + ports: + - containerPort: 8000 + args: + - "--stdio" + - "python3 /app/wrapper.py" + - "--port" + - "8000" + - "--outputTransport" + - "streamableHttp" + env: + - name: GITHUB_APP_ID + valueFrom: + secretKeyRef: + name: holmes-github-app + key: GITHUB_APP_ID + - name: GITHUB_APP_INSTALLATION_ID + valueFrom: + secretKeyRef: + name: holmes-github-app + key: GITHUB_APP_INSTALLATION_ID + - name: GITHUB_APP_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: holmes-github-app + key: GITHUB_APP_PRIVATE_KEY ``` - === "Self-hosted MCP Server" - - ```yaml - mcp_servers: - github: - description: "GitHub MCP Server" - config: - url: "http://github-mcp-server.holmes-mcp.svc.cluster.local:8000/sse" - mode: "sse" - extra_headers: - Authorization: "Bearer {{ env.AUTO_GENERATED_GITHUB_TOKEN }}" - ``` - - === "GitHub's Remote MCP" - - ```yaml - mcp_servers: - github: - description: "GitHub MCP Server" - config: - url: "https://api.githubcopilot.com/mcp" - mode: "streamable-http" - extra_headers: - Authorization: "Bearer {{ env.AUTO_GENERATED_GITHUB_TOKEN }}" - ``` - - Holmes will automatically generate an installation token at startup and refresh it in the background. + Then add the MCP server to **~/.holmes/config.yaml**: + + ```yaml + mcp_servers: + github: + description: "GitHub MCP Server" + config: + url: "http://github-mcp-server.holmes-mcp.svc.cluster.local:8000/mcp" + mode: "streamable-http" + ``` === "Holmes Helm Chart" @@ -357,65 +390,16 @@ Choose one of: **Add to your `values.yaml`:** - === "Self-hosted MCP Server" + ```yaml + mcpAddons: + github: + enabled: true + auth: + githubApp: + secretName: "holmes-github-app" + ``` - ```yaml - additionalEnvVars: - - name: GITHUB_APP_ID - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_ID - - name: GITHUB_APP_INSTALLATION_ID - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_INSTALLATION_ID - - name: GITHUB_APP_PRIVATE_KEY - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_PRIVATE_KEY - - mcp_servers: - github: - description: "GitHub MCP Server" - config: - url: "http://github-mcp-server.holmes-mcp.svc.cluster.local:8000/sse" - mode: "sse" - extra_headers: - Authorization: "Bearer {{ env.AUTO_GENERATED_GITHUB_TOKEN }}" - ``` - - === "GitHub's Remote MCP" - - ```yaml - additionalEnvVars: - - name: GITHUB_APP_ID - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_ID - - name: GITHUB_APP_INSTALLATION_ID - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_INSTALLATION_ID - - name: GITHUB_APP_PRIVATE_KEY - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_PRIVATE_KEY - - mcp_servers: - github: - description: "GitHub MCP Server" - config: - url: "https://api.githubcopilot.com/mcp" - mode: "streamable-http" - extra_headers: - Authorization: "Bearer {{ env.AUTO_GENERATED_GITHUB_TOKEN }}" - ``` + A self-hosted MCP server pod is deployed using the `github-app-mcp` image, which generates and auto-refreshes installation tokens internally. The token refresh interval defaults to 30 minutes. ```bash helm upgrade --install holmes robusta/holmes -f values.yaml @@ -435,81 +419,29 @@ Choose one of: **Add to your `generated_values.yaml`:** - === "Self-hosted MCP Server" - - ```yaml - holmes: - additionalEnvVars: - - name: GITHUB_APP_ID - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_ID - - name: GITHUB_APP_INSTALLATION_ID - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_INSTALLATION_ID - - name: GITHUB_APP_PRIVATE_KEY - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_PRIVATE_KEY - - mcp_servers: - github: - description: "GitHub MCP Server" - config: - url: "http://github-mcp-server.holmes-mcp.svc.cluster.local:8000/sse" - mode: "sse" - extra_headers: - Authorization: "Bearer {{ env.AUTO_GENERATED_GITHUB_TOKEN }}" - ``` - - === "GitHub's Remote MCP" - - ```yaml - holmes: - additionalEnvVars: - - name: GITHUB_APP_ID - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_ID - - name: GITHUB_APP_INSTALLATION_ID - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_INSTALLATION_ID - - name: GITHUB_APP_PRIVATE_KEY - valueFrom: - secretKeyRef: - name: holmes-github-app - key: GITHUB_APP_PRIVATE_KEY - - mcp_servers: - github: - description: "GitHub MCP Server" - config: - url: "https://api.githubcopilot.com/mcp" - mode: "streamable-http" - extra_headers: - Authorization: "Bearer {{ env.AUTO_GENERATED_GITHUB_TOKEN }}" - ``` + ```yaml + holmes: + mcpAddons: + github: + enabled: true + auth: + githubApp: + secretName: "holmes-github-app" + ``` + + A self-hosted MCP server pod is deployed using the `github-app-mcp` image, which generates and auto-refreshes installation tokens internally. ```bash helm upgrade --install robusta robusta/robusta -f generated_values.yaml --set clusterName=YOUR_CLUSTER_NAME ``` !!! info "How token refresh works" - When `GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, and `GITHUB_APP_PRIVATE_KEY` are set, Holmes automatically: + The `github-app-mcp` image handles token management internally: - 1. Generates a JWT signed with the private key + 1. At startup, generates a JWT signed with the private key 2. Exchanges it for a short-lived GitHub installation token - 3. Sets `AUTO_GENERATED_GITHUB_TOKEN` in the environment - 4. Runs a background thread that refreshes the token before it expires - - The refresh interval defaults to 30 minutes. Override with `GITHUB_APP_TOKEN_REFRESH_INTERVAL_SECONDS`. + 3. Sets the token as `GITHUB_PERSONAL_ACCESS_TOKEN` for the underlying MCP server + 4. A background thread refreshes the token every 30 minutes ## Available Tools diff --git a/helm/holmes/templates/mcp-servers/github/deployment.yaml b/helm/holmes/templates/mcp-servers/github/deployment.yaml index bd3683374..a90ff99c4 100644 --- a/helm/holmes/templates/mcp-servers/github/deployment.yaml +++ b/helm/holmes/templates/mcp-servers/github/deployment.yaml @@ -62,7 +62,11 @@ spec: {{- end }} containers: - name: github-mcp + {{- if .Values.mcpAddons.github.auth.githubApp.secretName }} + image: "{{ .Values.mcpAddons.github.auth.githubApp.registry }}/{{ .Values.mcpAddons.github.auth.githubApp.image }}" + {{- else }} image: "{{ .Values.mcpAddons.github.registry }}/{{ .Values.mcpAddons.github.image }}" + {{- end }} imagePullPolicy: {{ .Values.mcpAddons.github.imagePullPolicy | default "IfNotPresent" }} ports: - containerPort: 8000 @@ -70,15 +74,41 @@ spec: protocol: TCP args: - "--stdio" + {{- if .Values.mcpAddons.github.auth.githubApp.secretName }} + - "python3 /app/wrapper.py" + {{- else }} - "github-mcp-server stdio" + {{- end }} - "--port" - "8000" + {{- if .Values.mcpAddons.github.auth.githubApp.secretName }} + - "--outputTransport" + - "streamableHttp" + {{- end }} env: + {{- if .Values.mcpAddons.github.auth.githubApp.secretName }} + - name: GITHUB_APP_ID + valueFrom: + secretKeyRef: + name: {{ .Values.mcpAddons.github.auth.githubApp.secretName }} + key: GITHUB_APP_ID + - name: GITHUB_APP_INSTALLATION_ID + valueFrom: + secretKeyRef: + name: {{ .Values.mcpAddons.github.auth.githubApp.secretName }} + key: GITHUB_APP_INSTALLATION_ID + - name: GITHUB_APP_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.mcpAddons.github.auth.githubApp.secretName }} + key: GITHUB_APP_PRIVATE_KEY + {{- else }} - name: GITHUB_PERSONAL_ACCESS_TOKEN valueFrom: secretKeyRef: - name: {{ required "mcpAddons.github.auth.secretName is required" .Values.mcpAddons.github.auth.secretName }} + name: {{ required "mcpAddons.github.auth.secretName is required for PAT auth" .Values.mcpAddons.github.auth.secretName }} key: {{ .Values.mcpAddons.github.auth.secretKey | default "token" }} + {{- end }} {{- if .Values.mcpAddons.github.config.toolsets }} - name: GITHUB_TOOLSETS valueFrom: diff --git a/helm/holmes/templates/toolset-config.yaml b/helm/holmes/templates/toolset-config.yaml index c853c02a7..6f15f7023 100644 --- a/helm/holmes/templates/toolset-config.yaml +++ b/helm/holmes/templates/toolset-config.yaml @@ -88,18 +88,34 @@ data: {{- $mcpServers = merge $mcpServers $gcpMcpServers }} {{- end }} {{- if .Values.mcpAddons.github.enabled }} + {{- if .Values.mcpAddons.github.auth.githubApp.secretName }} + {{- $githubConfig := dict + "url" (printf "http://%s-github-mcp-server.%s.svc.cluster.local:8000/mcp" .Release.Name .Release.Namespace) + "mode" "streamable-http" + "icon_url" "https://cdn.simpleicons.org/github/181717" + }} {{- $githubMcpServers := dict "github" (dict "description" "GitHub MCP Server - access repositories, pull requests, issues, and GitHub Actions. Debug CI failures, search code, and delegate tasks to Copilot." - "config" (dict - "url" (printf "http://%s-github-mcp-server.%s.svc.cluster.local:8000/sse" .Release.Name .Release.Namespace) - "mode" "sse" - "icon_url" "https://cdn.simpleicons.org/github/181717" - ) + "config" $githubConfig + "llm_instructions" (include "holmes.githubMcp.llmInstructions" . | trim) + ) + }} + {{- $mcpServers = merge $mcpServers $githubMcpServers }} + {{- else }} + {{- $githubConfig := dict + "url" (printf "http://%s-github-mcp-server.%s.svc.cluster.local:8000/sse" .Release.Name .Release.Namespace) + "mode" "sse" + "icon_url" "https://cdn.simpleicons.org/github/181717" + }} + {{- $githubMcpServers := dict "github" (dict + "description" "GitHub MCP Server - access repositories, pull requests, issues, and GitHub Actions. Debug CI failures, search code, and delegate tasks to Copilot." + "config" $githubConfig "llm_instructions" (include "holmes.githubMcp.llmInstructions" . | trim) ) }} {{- $mcpServers = merge $mcpServers $githubMcpServers }} {{- end }} + {{- end }} {{- if .Values.mcpAddons.sentry.enabled }} {{- $sentryMcpServers := dict "sentry" (dict "description" "Sentry error tracking and monitoring - search issues, get stack traces, and analyze error patterns" diff --git a/helm/holmes/values.yaml b/helm/holmes/values.yaml index efb6febbf..34a2db45d 100644 --- a/helm/holmes/values.yaml +++ b/helm/holmes/values.yaml @@ -341,19 +341,18 @@ mcpAddons: # GitHub MCP Server Configuration # Provides access to GitHub repositories, pull requests, issues, and GitHub Actions - # Uses a prebuilt image that bundles GitHub MCP server with Supergateway for HTTP transport # - # AUTHENTICATION: - # Create a GitHub Personal Access Token (PAT) with appropriate permissions: - # - repo: Full control of private repositories (or public_repo for public only) - # - workflow: Update GitHub Action workflows (for CI/CD debugging) - # Then create a Kubernetes secret: + # AUTHENTICATION (choose one): + # Option 1 - PAT: Deploys a self-hosted MCP server using the standard github-mcp image # kubectl create secret generic github-mcp-token --from-literal=token= -n + # Option 2 - GitHub App: Deploys a self-hosted MCP server using the github-app-mcp image + # which generates and auto-refreshes installation tokens from GitHub App credentials + # kubectl create secret generic github-app-secret --from-literal=GITHUB_APP_ID=... -n # # GITHUB ENTERPRISE: # Set config.host to your GitHub Enterprise hostname (e.g., "github.mycompany.com") # - # CUSTOM IMAGE (optional): + # CUSTOM IMAGE (PAT only): # To build your own image, see: https://github.com/robusta-dev/holmes-mcp-integrations/tree/master/servers/github github: enabled: false @@ -362,11 +361,26 @@ mcpAddons: registry: "us-central1-docker.pkg.dev/genuine-flight-317411/mcp" imagePullPolicy: IfNotPresent - # Authentication - PAT from Kubernetes secret + # Authentication - choose ONE of the two methods below auth: - secretName: "" # Required: name of K8s secret containing the GitHub PAT + # Option 1: Personal Access Token (PAT) + # Create a secret: kubectl create secret generic github-mcp-token --from-literal=token= -n + secretName: "" # Name of K8s secret containing the GitHub PAT secretKey: "token" # Key in secret (default: "token") + # Option 2: GitHub App installation token + # Deploys a self-hosted MCP server that generates and refreshes installation tokens + # from GitHub App credentials. Uses a separate image with built-in token management. + # kubectl create secret generic github-app-secret \ + # --from-literal=GITHUB_APP_ID= \ + # --from-literal=GITHUB_APP_INSTALLATION_ID= \ + # --from-file=GITHUB_APP_PRIVATE_KEY=/path/to/private-key.pem \ + # -n + githubApp: + secretName: "" # Name of K8s secret containing GitHub App credentials + image: "github-app-mcp:1.0.0" # Image with GitHub App token management + registry: "us-central1-docker.pkg.dev/genuine-flight-317411/mcp" + config: # GitHub Enterprise hostname (leave empty for github.com) # Example: "https://github.mycompany.com" diff --git a/holmes/core/toolset_manager.py b/holmes/core/toolset_manager.py index e685ce898..699fe37bf 100644 --- a/holmes/core/toolset_manager.py +++ b/holmes/core/toolset_manager.py @@ -14,7 +14,6 @@ from holmes.plugins.toolsets import load_builtin_toolsets, load_toolsets_from_config from holmes.utils.config_hash import check_and_update_config_hashes from holmes.utils.definitions import CUSTOM_TOOLSET_LOCATION -from holmes.utils.github_app_token_manager import ensure_github_app_token_env if TYPE_CHECKING: pass @@ -115,10 +114,6 @@ def _list_all_toolsets( 2. Toolsets defined in self.toolsets can override both built-in and add new custom toolsets 3. custom toolset from config can override both built-in and add new custom toolsets # for backward compatibility """ - # Ensure GitHub App token is generated and background refresh is running - # before any toolset config resolution that may reference AUTO_GENERATED_GITHUB_TOKEN - ensure_github_app_token_env() - # Load built-in toolsets # Extract search paths from custom catalog files additional_search_paths = None diff --git a/holmes/utils/github_app_token_manager.py b/holmes/utils/github_app_token_manager.py deleted file mode 100644 index dd6bc0903..000000000 --- a/holmes/utils/github_app_token_manager.py +++ /dev/null @@ -1,178 +0,0 @@ -import logging -import os -import threading -import time -from typing import Optional - -import jwt -import requests - -logger = logging.getLogger(__name__) - -# How often to refresh the token (seconds). Default: 30 minutes. -# Configurable via GITHUB_APP_TOKEN_REFRESH_INTERVAL_SECONDS env var. -TOKEN_REFRESH_INTERVAL_SECONDS = int( - os.environ.get("GITHUB_APP_TOKEN_REFRESH_INTERVAL_SECONDS", "1800") -) - - -def _mask_token(token: str) -> str: - """Return first 4 and last 4 chars of a token for debug logging.""" - if len(token) <= 8: - return "***" - return f"{token[:4]}...{token[-4:]}" - - -class GitHubAppTokenManager: - """Manages GitHub App installation tokens with automatic refresh. - - Generates short-lived installation tokens from GitHub App credentials - (APP_ID, INSTALLATION_ID, PRIVATE_KEY). A background daemon thread - refreshes the token at a fixed interval and updates - os.environ["AUTO_GENERATED_GITHUB_TOKEN"] so that the relevant mcp servers - (e.g. extra_headers) always read a valid token. - """ - - _instance: Optional["GitHubAppTokenManager"] = None - _lock = threading.Lock() - - def __init__(self, app_id: str, installation_id: str, private_key: str): - self._app_id = app_id - self._installation_id = installation_id - self._private_key = private_key - self._refresh_thread_started = False - - @staticmethod - def has_github_app_env_vars() -> bool: - """Check if all required GitHub App environment variables are set.""" - return all( - os.environ.get(k) - for k in ("GITHUB_APP_ID", "GITHUB_APP_INSTALLATION_ID", "GITHUB_APP_PRIVATE_KEY") - ) - - @classmethod - def from_env(cls) -> Optional["GitHubAppTokenManager"]: - """Create a manager from environment variables, or return None if not configured.""" - app_id = os.environ.get("GITHUB_APP_ID") - installation_id = os.environ.get("GITHUB_APP_INSTALLATION_ID") - private_key = os.environ.get("GITHUB_APP_PRIVATE_KEY") - - if not all([app_id, installation_id, private_key]): - return None - - # CI/CD systems and secret stores may inject the private key with literal - # "\n" instead of actual newlines, which breaks JWT signing. - private_key = private_key.replace("\\n", "\n") # type: ignore[union-attr] - - return cls( - app_id=app_id, # type: ignore[arg-type] - installation_id=installation_id, # type: ignore[arg-type] - private_key=private_key, # type: ignore[arg-type] - ) - - @classmethod - def get_instance(cls) -> Optional["GitHubAppTokenManager"]: - """Get or create the singleton instance. Returns None if env vars are not set.""" - if cls._instance is not None: - return cls._instance - if not cls.has_github_app_env_vars(): - return None - with cls._lock: - if cls._instance is None: - cls._instance = cls.from_env() - return cls._instance - - def _generate_jwt(self) -> str: - """Generate a JWT signed with the GitHub App private key.""" - now = int(time.time()) - payload = { - "iat": now - 60, # Issued 60 seconds in the past for clock drift - "exp": now + 600, # Expires in 10 minutes (GitHub maximum) - "iss": self._app_id, - } - return jwt.encode(payload, self._private_key, algorithm="RS256") - - def refresh_token(self) -> str: - """Exchange a JWT for a GitHub installation access token.""" - encoded_jwt = self._generate_jwt() - - response = requests.post( - f"https://api.github.com/app/installations/{self._installation_id}/access_tokens", - headers={ - "Authorization": f"Bearer {encoded_jwt}", - "Accept": "application/vnd.github+json", - }, - timeout=30, - ) - response.raise_for_status() - - data = response.json() - token = data["token"] - - logger.info( - "GitHub App installation token refreshed (%s), expires at %s", - _mask_token(token), - data.get("expires_at", "unknown"), - ) - return token - - def start_background_refresh(self) -> None: - """Start a daemon thread that periodically refreshes the token and updates os.environ.""" - if self._refresh_thread_started: - return - self._refresh_thread_started = True - thread = threading.Thread(target=self._background_refresh_loop, daemon=True) - thread.start() - logger.info("Started GitHub App token background refresh thread") - - def _background_refresh_loop(self) -> None: - """Periodically refresh the token and update os.environ.""" - while True: - logger.debug( - "GitHub App token refresh thread sleeping for %ds", - TOKEN_REFRESH_INTERVAL_SECONDS, - ) - time.sleep(TOKEN_REFRESH_INTERVAL_SECONDS) - - try: - token = self.refresh_token() - os.environ["AUTO_GENERATED_GITHUB_TOKEN"] = token - except Exception: - logger.warning( - "Background refresh: failed to refresh GitHub App token", - exc_info=True, - ) - - -def ensure_github_app_token_env() -> None: - """If GitHub App credentials are configured, generate an installation token, - set it as AUTO_GENERATED_GITHUB_TOKEN in the environment, and start a - background thread to keep it fresh. - - This should be called early in the application lifecycle, before - environment variable substitution resolves MCP configs. - """ - # Skip if GitHub App env vars are not configured - if not GitHubAppTokenManager.has_github_app_env_vars(): - return - - # Don't override an existing token - if os.environ.get("AUTO_GENERATED_GITHUB_TOKEN"): - return - - manager = GitHubAppTokenManager.get_instance() - if manager is None: - return - - try: - token = manager.refresh_token() - os.environ["AUTO_GENERATED_GITHUB_TOKEN"] = token - logger.debug( - "Set AUTO_GENERATED_GITHUB_TOKEN (%s) from GitHub App installation token", - _mask_token(token), - ) - manager.start_background_refresh() - except Exception: - logger.warning( - "Failed to generate GitHub App installation token", exc_info=True - ) diff --git a/tests/test_mcp_toolset.py b/tests/test_mcp_toolset.py index 1694c7c46..da5721552 100644 --- a/tests/test_mcp_toolset.py +++ b/tests/test_mcp_toolset.py @@ -1799,7 +1799,7 @@ def capture_sse_client_call( class TestMCPExtraHeadersPreservedDuringEnvResolution: """Verify that load_toolsets_from_config does NOT resolve extra_headers templates. - extra_headers use Jinja2 templates like {{ env.AUTO_GENERATED_GITHUB_TOKEN }} + extra_headers use Jinja2 templates like {{ env.SOME_DYNAMIC_TOKEN }} that must be rendered at request time (so they pick up refreshed tokens). replace_env_vars_values uses the same {{ env.X }} syntax and would bake in stale values at config-load time if extra_headers were not excluded. @@ -1809,22 +1809,22 @@ class TestMCPExtraHeadersPreservedDuringEnvResolution: "os.environ", { "MY_STATIC_VAR": "resolved_value", - "AUTO_GENERATED_GITHUB_TOKEN": "ghs_initial", + "SOME_DYNAMIC_TOKEN": "initial_token", }, ) def test_extra_headers_templates_not_resolved(self): toolsets_config = { - "github": { + "my_mcp": { "type": "mcp", - "description": "GitHub MCP", + "description": "Test MCP", "config": { - "url": "https://api.githubcopilot.com/mcp", + "url": "https://example.com/mcp", "mode": "streamable-http", "headers": { "X-Static": "{{ env.MY_STATIC_VAR }}", }, "extra_headers": { - "Authorization": "Bearer {{ env.AUTO_GENERATED_GITHUB_TOKEN }}", + "Authorization": "Bearer {{ env.SOME_DYNAMIC_TOKEN }}", }, }, } @@ -1833,7 +1833,7 @@ def test_extra_headers_templates_not_resolved(self): # load_toolsets_from_config will fail to connect to the MCP server, # but we only care about the config resolution, not the connection. # Catch the validation error and inspect the config dict directly. - config = copy.deepcopy(toolsets_config["github"]) + config = copy.deepcopy(toolsets_config["my_mcp"]) # Simulate the pop/restore logic from load_toolsets_from_config saved_extra_headers = config["config"].pop("extra_headers", None) @@ -1845,7 +1845,7 @@ def test_extra_headers_templates_not_resolved(self): # extra_headers should still have the raw template (NOT resolved) assert ( config["config"]["extra_headers"]["Authorization"] - == "Bearer {{ env.AUTO_GENERATED_GITHUB_TOKEN }}" + == "Bearer {{ env.SOME_DYNAMIC_TOKEN }}" ) # regular headers SHOULD be resolved by replace_env_vars_values diff --git a/tests/utils/test_github_app_token_manager.py b/tests/utils/test_github_app_token_manager.py deleted file mode 100644 index eafdd64d7..000000000 --- a/tests/utils/test_github_app_token_manager.py +++ /dev/null @@ -1,167 +0,0 @@ -import os -import time -from unittest.mock import MagicMock, patch - -import jwt -import pytest -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.primitives.serialization import load_pem_private_key - -from holmes.utils.github_app_token_manager import ( - GitHubAppTokenManager, - ensure_github_app_token_env, -) - - -@pytest.fixture -def rsa_private_key(): - """Generate a real RSA private key for JWT signing.""" - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - return key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ).decode() - - -@pytest.fixture -def rsa_public_key(rsa_private_key): - """Derive the public key for JWT verification.""" - key = load_pem_private_key(rsa_private_key.encode(), password=None) - return key.public_key() - - -@pytest.fixture -def token_manager(rsa_private_key): - return GitHubAppTokenManager( - app_id="12345", - installation_id="67890", - private_key=rsa_private_key, - ) - - -@pytest.fixture(autouse=True) -def reset_singleton(): - """Reset the singleton between tests.""" - GitHubAppTokenManager._instance = None - yield - GitHubAppTokenManager._instance = None - - -@pytest.fixture -def env_without_token(): - """Provide an environment with AUTO_GENERATED_GITHUB_TOKEN removed but GitHub App env vars present.""" - env = {k: v for k, v in os.environ.items() if k != "AUTO_GENERATED_GITHUB_TOKEN"} - env.update({ - "GITHUB_APP_ID": "12345", - "GITHUB_APP_INSTALLATION_ID": "67890", - "GITHUB_APP_PRIVATE_KEY": "dummy-key", - }) - with patch.dict(os.environ, env, clear=True): - yield - - -class TestGitHubAppTokenManager: - def test_generate_jwt(self, token_manager, rsa_public_key): - encoded = token_manager._generate_jwt() - decoded = jwt.decode(encoded, rsa_public_key, algorithms=["RS256"]) - - assert decoded["iss"] == "12345" - now = int(time.time()) - assert decoded["iat"] <= now - assert decoded["exp"] > now - - @patch("holmes.utils.github_app_token_manager.requests.post") - def test_refresh_token(self, mock_post, token_manager): - mock_post.return_value = MagicMock( - status_code=200, - json=lambda: {"token": "ghs_testtoken123", "expires_at": "2099-01-01T00:00:00Z"}, - ) - - token = token_manager.refresh_token() - assert token == "ghs_testtoken123" - mock_post.assert_called_once() - - @patch("holmes.utils.github_app_token_manager.requests.post") - def test_refresh_token_always_fetches(self, mock_post, token_manager): - """Each call to refresh_token should make a new API call.""" - mock_post.return_value = MagicMock( - status_code=200, - json=lambda: {"token": "ghs_fresh", "expires_at": "2099-01-01T00:00:00Z"}, - ) - - token1 = token_manager.refresh_token() - token2 = token_manager.refresh_token() - - assert token1 == token2 == "ghs_fresh" - assert mock_post.call_count == 2 - - def test_from_env_returns_none_when_not_configured(self): - with patch.dict(os.environ, {}, clear=True): - manager = GitHubAppTokenManager.from_env() - assert manager is None - - def test_from_env_returns_manager_when_configured(self, rsa_private_key): - env = { - "GITHUB_APP_ID": "111", - "GITHUB_APP_INSTALLATION_ID": "222", - "GITHUB_APP_PRIVATE_KEY": rsa_private_key, - } - with patch.dict(os.environ, env, clear=False): - manager = GitHubAppTokenManager.from_env() - assert manager is not None - assert manager._app_id == "111" - assert manager._installation_id == "222" - - def test_from_env_returns_none_with_partial_config(self): - env = {"GITHUB_APP_ID": "111"} - with patch.dict(os.environ, env, clear=True): - manager = GitHubAppTokenManager.from_env() - assert manager is None - - -class TestEnsureGitHubAppTokenEnv: - def test_does_not_override_existing_token(self): - with patch.dict( - os.environ, {"AUTO_GENERATED_GITHUB_TOKEN": "existing_token"}, clear=False - ): - ensure_github_app_token_env() - assert os.environ["AUTO_GENERATED_GITHUB_TOKEN"] == "existing_token" - - @patch("holmes.utils.github_app_token_manager.GitHubAppTokenManager.get_instance") - def test_sets_token_from_github_app(self, mock_get_instance, env_without_token): - mock_manager = MagicMock() - mock_manager.refresh_token.return_value = "ghs_generated" - mock_get_instance.return_value = mock_manager - - ensure_github_app_token_env() - assert os.environ["AUTO_GENERATED_GITHUB_TOKEN"] == "ghs_generated" - - @patch("holmes.utils.github_app_token_manager.GitHubAppTokenManager.get_instance") - def test_handles_failure_gracefully(self, mock_get_instance, env_without_token): - mock_manager = MagicMock() - mock_manager.refresh_token.side_effect = Exception("API error") - mock_get_instance.return_value = mock_manager - - ensure_github_app_token_env() - assert "AUTO_GENERATED_GITHUB_TOKEN" not in os.environ - - @patch("holmes.utils.github_app_token_manager.GitHubAppTokenManager.get_instance") - def test_noop_when_no_github_app_configured(self, mock_get_instance, env_without_token): - mock_get_instance.return_value = None - - ensure_github_app_token_env() - assert "AUTO_GENERATED_GITHUB_TOKEN" not in os.environ - - -class TestBackgroundRefresh: - @patch("holmes.utils.github_app_token_manager.GitHubAppTokenManager.get_instance") - def test_ensure_starts_background_thread(self, mock_get_instance, env_without_token): - """ensure_github_app_token_env should start the background refresh thread.""" - mock_manager = MagicMock() - mock_manager.refresh_token.return_value = "ghs_generated" - mock_get_instance.return_value = mock_manager - - ensure_github_app_token_env() - mock_manager.start_background_refresh.assert_called_once()