From e3b716bcb9dbe254989f7d2f8134ea102944b622 Mon Sep 17 00:00:00 2001 From: Kyle Squizzato Date: Mon, 15 Jun 2026 13:29:37 -0700 Subject: [PATCH 1/3] fix(runner): make integrations behave more consistently in session `build_credential_mcp_servers()` only registers the Jira MCP subprocess when `jira` is present in `CREDENTIAL_IDS` (set via RoleBinding resolution). However, `populate_runtime_credentials()` fetches Jira credentials via the session-scoped fallback endpoint and sets JIRA_URL/JIRA_API_TOKEN in the environment regardless of binding state. Add an env-var fallback in `build_mcp_servers()`: if JIRA_URL and JIRA_API_TOKEN are set but no Jira MCP server was added by the credential binding path, register mcp-atlassian from the registry (same pattern Gerrit already uses via GERRIT_CONFIG_PATH). Previously the system prompt always told Claude to ask users to configure Jira/Google when those integrations were needed, even when MCP servers were already loaded and credentials were present in the environment. Claude followed the static instruction and reported integrations as unavailable, requiring a user follow-up to remind it the MCP was loaded. Root cause: MCP_INTEGRATIONS_PROMPT was a static string evaluated at import time, and the system prompt was not rebuilt after the first-run credential refresh that populates env vars like JIRA_URL and JIRA_API_TOKEN. Fixes: - Replace static MCP_INTEGRATIONS_PROMPT with a data-driven _INTEGRATION_REGISTRY covering GitHub, GitLab, Jira, and Google Workspace. Each entry has a detect() callable and mode-keyed prompts. When an integration is configured, Claude is told to use its MCP server/tools; when not, it's told to ask the user to set it up. New integrations can be added by appending a single _Integration entry. - Add _rebuild_system_prompt() to ClaudeBridge and call it alongside _rebuild_mcp_servers() on first run, after credential refresh, so the prompt accurately reflects actual integration state even when the backend was slow on the initial _setup_platform() credential fetch. - Update the Gemini CLI bridge to use _build_integrations_prompt() for the same conditional behavior instead of the old static constant + separate blocks. - Add TestBuildIntegrationsPrompt with parameterized tests for all 4 integrations covering configured/not-configured states and both sidecar and token modes. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Kyle Squizzato --- .../ambient_runner/bridges/claude/bridge.py | 29 ++- .../ambient_runner/bridges/claude/mcp.py | 16 ++ .../bridges/gemini_cli/system_prompt.py | 14 +- .../ambient_runner/platform/prompts.py | 181 ++++++++++++-- .../tests/test_bridge_claude.py | 231 ++++++++++++++++++ 5 files changed, 433 insertions(+), 38 deletions(-) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py index 893e2348c..c9f9af6fe 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py @@ -218,12 +218,16 @@ async def _initialize_run( await populate_mcp_server_credentials(self._context) self._last_creds_refresh = time.monotonic() - # If the caller changed, destroy the worker and rebuild MCP servers + - # adapter so the new ClaudeSDKClient gets fresh mcp_servers config. - # The session ID is preserved — --resume works because each SDK client - # is a new CLI subprocess that spawns fresh MCP servers from os.environ. + # Rebuild MCP servers when credentials may have changed. + # On first run: always rebuild after credential fetch so credential-based servers + # (e.g. Jira via session endpoint) that were missed during _setup_platform(). + # On user change: destroy worker so the new SDK client picks up fresh creds. user_changed = current_user_id != prev_user - if user_changed and self._session_manager.get_existing(thread_id): + if self._first_run: + self._rebuild_mcp_servers() + self._rebuild_system_prompt() + self._adapter = None + elif user_changed and self._session_manager.get_existing(thread_id): logger.info( f"User changed for thread={thread_id}, " "rebuilding MCP servers and adapter with new credentials" @@ -719,6 +723,21 @@ def _rebuild_mcp_servers(self) -> None: self._allowed_tools = build_allowed_tools(self._mcp_servers) logger.info("Rebuilt MCP servers with updated credentials") + def _rebuild_system_prompt(self) -> None: + """Rebuild the system prompt with current env vars. + + Called on first run after credential refresh so the prompt accurately + reflects which integrations are configured (e.g. Jira via session + endpoint). The initial build in _setup_platform() may have run before + credentials were fully available. + """ + from ambient_runner.bridges.claude.prompts import build_sdk_system_prompt + + self._system_prompt = build_sdk_system_prompt( + self._context.workspace_path, self._cwd_path + ) + logger.info("Rebuilt system prompt with updated credentials") + # ------------------------------------------------------------------ # Private: adapter lifecycle # ------------------------------------------------------------------ diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py index 7929f548e..6b2ca980f 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py @@ -140,6 +140,22 @@ def build_mcp_servers( "Added credential MCP servers: %s", list(credential_mcp_servers.keys()) ) + # Fallback: add Jira MCP server from env vars when credentials are available but + # no credential binding exists + jira_server_name = _CREDENTIAL_MCP_REGISTRY["jira"]["server_name"] + if ( + jira_server_name not in mcp_servers + and os.getenv("JIRA_URL", "").strip() + and os.getenv("JIRA_API_TOKEN", "").strip() + ): + jira_entry = _CREDENTIAL_MCP_REGISTRY["jira"] + mcp_servers[jira_server_name] = { + "command": jira_entry["command"], + "args": list(jira_entry["args"]), + "env": {k: _expand_env_vars(v) for k, v in jira_entry["env"].items()}, + } + logger.info("Added Jira MCP server (credentials available via session endpoint)") + # Gerrit MCP server (only if credentials are configured) gerrit_config = os.environ.get("GERRIT_CONFIG_PATH", "") if gerrit_config and Path(gerrit_config).exists(): diff --git a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py index e696f1a7f..4c7847f91 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py @@ -117,13 +117,11 @@ def _build_system_prompt(cwd_path: str) -> str: """Build the full system.md content string.""" from ambient_runner.platform.config import get_repos_config, load_ambient_config from ambient_runner.platform.prompts import ( - GITHUB_TOKEN_PROMPT, - GITLAB_TOKEN_PROMPT, GIT_PUSH_INSTRUCTIONS_BODY, GIT_PUSH_INSTRUCTIONS_HEADER, GIT_PUSH_STEPS, - MCP_INTEGRATIONS_PROMPT, WORKSPACE_FIXED_PATHS_PROMPT, + _build_integrations_prompt, ) from ambient_runner.platform.utils import derive_workflow_name @@ -200,14 +198,8 @@ def _build_system_prompt(cwd_path: str) -> str: except Exception as exc: logger.warning("Could not list uploaded files in %s: %s", uploads, exc) - # ---- MCP integration hints ---- - sections.append(MCP_INTEGRATIONS_PROMPT) - - # ---- Token visibility ---- - if os.getenv("GITHUB_TOKEN"): - sections.append(GITHUB_TOKEN_PROMPT) - if os.getenv("GITLAB_TOKEN"): - sections.append(GITLAB_TOKEN_PROMPT) + # ---- Integration status — conditional on actual credential state ---- + sections.append(_build_integrations_prompt()) # ---- Workflow custom instructions ---- ambient_config: dict = {} diff --git a/components/runners/ambient-runner/ambient_runner/platform/prompts.py b/components/runners/ambient-runner/ambient_runner/platform/prompts.py index 05cfa4c72..418eae5fa 100644 --- a/components/runners/ambient-runner/ambient_runner/platform/prompts.py +++ b/components/runners/ambient-runner/ambient_runner/platform/prompts.py @@ -32,13 +32,13 @@ "- `/workspace/artifacts/` AI writes all output here\n\n" ) -MCP_INTEGRATIONS_PROMPT = ( - "## MCP Integrations\n" - "If you need Google Drive access: Ask user to go to Integrations page " - "in Ambient and authenticate with Google Drive.\n" - "If you need Jira access: Ask user to go to Workspace Settings in Ambient " - "and configure Jira credentials there.\n\n" -) +# --------------------------------------------------------------------------- +# Integration prompt constants +# Each integration has an "available" prompt and a "missing" prompt. +# Add new prompts here when adding a new integration to _INTEGRATION_REGISTRY. +# --------------------------------------------------------------------------- + +_PLACEHOLDER_EMAIL = "user@example.com" GITHUB_TOKEN_PROMPT = ( "## GitHub Access\n" @@ -60,6 +60,12 @@ "All GitHub write operations must go through MCP tools.\n\n" ) +GITHUB_MISSING_PROMPT = ( + "## GitHub Access\n" + "GitHub is not connected. If you need to push code, create PRs, or access " + "GitHub APIs, ask the user to connect GitHub on the Integrations page in Ambient.\n\n" +) + GITLAB_TOKEN_PROMPT = ( "## GitLab Access\n" "A `GITLAB_TOKEN` environment variable is set in this session. " @@ -67,6 +73,150 @@ "The token is automatically used for git operations.\n\n" ) +GITLAB_MISSING_PROMPT = ( + "## GitLab Access\n" + "GitLab is not connected. If you need to push code or access GitLab APIs, " + "ask the user to connect GitLab on the Integrations page in Ambient.\n\n" +) + +JIRA_MCP_PROMPT = ( + "## Jira Access\n" + "Jira is configured and available via the **mcp-atlassian** MCP server. " + "Use `mcp__mcp-atlassian__*` tools to read issues, add comments, update " + "status, and more. Do NOT tell the user to configure Jira — it is already set up.\n\n" +) + +JIRA_MISSING_PROMPT = ( + "## Jira Access\n" + "Jira is not configured. If you need Jira access, ask the user to go to " + "Workspace Settings in Ambient and configure Jira credentials there.\n\n" +) + +GOOGLE_MCP_PROMPT = ( + "## Google Workspace Access\n" + "Google Workspace is configured and available via the **google-workspace** MCP server. " + "Use `mcp__google-workspace__*` tools to interact with Google Drive, Gmail, etc. " + "Do NOT tell the user to set up Google integration — it is already set up.\n\n" +) + +GOOGLE_MISSING_PROMPT = ( + "## Google Workspace Access\n" + "Google Workspace is not connected. If you need Google Drive, Gmail, or other " + "Google services, ask the user to go to the Integrations page in Ambient and " + "authenticate with Google.\n\n" +) + +# Legacy alias kept for backward compatibility — use _build_integrations_prompt() instead. +MCP_INTEGRATIONS_PROMPT = JIRA_MISSING_PROMPT + GOOGLE_MISSING_PROMPT + + +# --------------------------------------------------------------------------- +# Integration registry +# --------------------------------------------------------------------------- + +from dataclasses import dataclass +from typing import Callable + + +@dataclass(frozen=True) +class _Integration: + """Registry entry for a platform integration. + + To add a new integration: + 1. Add prompt constants above (available + missing states). + 2. Implement a detect callable: (cmu: dict) -> str | None. + Return a mode string ("mcp", "token") when configured, None when not. + 3. Add an _Integration entry to _INTEGRATION_REGISTRY below. + """ + + label: str + detect: Callable[[dict], str | None] + prompts: dict[str, str] # mode -> prompt text; "missing" for unconfigured state + + +def _detect_github(cmu: dict) -> str | None: + if "github" in cmu: + return "mcp" + if os.getenv("GITHUB_TOKEN"): + return "token" + return None + + +def _detect_gitlab(cmu: dict) -> str | None: + if os.getenv("GITLAB_TOKEN"): + return "token" + return None + + +def _detect_jira(cmu: dict) -> str | None: + if os.getenv("JIRA_URL", "").strip() and os.getenv("JIRA_API_TOKEN", "").strip(): + return "mcp" + return None + + +def _detect_google(cmu: dict) -> str | None: + if "google" in cmu: + return "mcp" + email = os.getenv("USER_GOOGLE_EMAIL", "").strip() + if email and email != _PLACEHOLDER_EMAIL: + return "mcp" + return None + + +_INTEGRATION_REGISTRY: list[_Integration] = [ + _Integration( + label="GitHub", + detect=_detect_github, + prompts={"mcp": GITHUB_MCP_PROMPT, "token": GITHUB_TOKEN_PROMPT, "missing": GITHUB_MISSING_PROMPT}, + ), + _Integration( + label="GitLab", + detect=_detect_gitlab, + prompts={"token": GITLAB_TOKEN_PROMPT, "missing": GITLAB_MISSING_PROMPT}, + ), + _Integration( + label="Jira", + detect=_detect_jira, + prompts={"mcp": JIRA_MCP_PROMPT, "missing": JIRA_MISSING_PROMPT}, + ), + _Integration( + label="Google Workspace", + detect=_detect_google, + prompts={"mcp": GOOGLE_MCP_PROMPT, "missing": GOOGLE_MISSING_PROMPT}, + ), +] + + +def _build_integrations_prompt() -> str: + """Build the integrations status section for the system prompt. + + Iterates _INTEGRATION_REGISTRY and emits the appropriate prompt for each + integration based on current credential state (env vars + CREDENTIAL_MCP_URLS). + When an integration is configured, Claude is told to use its MCP server/tools. + When not configured, Claude is told to ask the user to set it up. + """ + credential_mcp_urls_raw = os.getenv("CREDENTIAL_MCP_URLS", "").strip() + cmu: dict = {} + if credential_mcp_urls_raw: + try: + parsed = json.loads(credential_mcp_urls_raw) + if isinstance(parsed, dict): + cmu = parsed + except (ValueError, TypeError): + pass + + return "".join( + integration.prompts.get( + integration.detect(cmu) or "missing", + integration.prompts.get("missing", ""), + ) + for integration in _INTEGRATION_REGISTRY + ) + + +# Keep the old name as an alias for any call sites that haven't migrated. +_build_mcp_integrations_prompt = _build_integrations_prompt + GIT_PUSH_INSTRUCTIONS_HEADER = "## Git Push Instructions\n\n" GIT_PUSH_INSTRUCTIONS_BODY = ( @@ -256,21 +406,8 @@ def build_workspace_context_prompt( # Human-in-the-loop instructions prompt += HUMAN_INPUT_INSTRUCTIONS - # MCP integration setup instructions - prompt += MCP_INTEGRATIONS_PROMPT - - # Token visibility — tell Claude what credentials are available - if credential_mcp_urls: - try: - urls = json.loads(credential_mcp_urls) - if "github" in urls: - prompt += GITHUB_MCP_PROMPT - except (ValueError, TypeError): - pass - elif os.getenv("GITHUB_TOKEN"): - prompt += GITHUB_TOKEN_PROMPT - if not credential_mcp_urls and os.getenv("GITLAB_TOKEN"): - prompt += GITLAB_TOKEN_PROMPT + # Integration status — conditional on actual credential state for all providers + prompt += _build_integrations_prompt() # Workflow instructions if ambient_config.get("systemPrompt"): diff --git a/components/runners/ambient-runner/tests/test_bridge_claude.py b/components/runners/ambient-runner/tests/test_bridge_claude.py index 5f9377ff6..2b2d5c92d 100644 --- a/components/runners/ambient-runner/tests/test_bridge_claude.py +++ b/components/runners/ambient-runner/tests/test_bridge_claude.py @@ -370,6 +370,237 @@ def test_get_error_context_with_stderr(self): assert "line 42" in ctx +@pytest.mark.asyncio +class TestClaudeBridgeFirstRunMCPRebuild: + """Verify MCP servers and system prompt are rebuilt on first run after credential refresh.""" + + async def test_first_run_rebuilds_mcp_servers_and_clears_adapter(self): + """On first run, MCP servers and system prompt must be rebuilt after + populate_runtime_credentials so credential-based servers (e.g. Jira via + session endpoint) that were absent during _setup_platform() due to transient + backend latency are captured with the now-populated env vars.""" + bridge = ClaudeBridge() + ctx = RunnerContext(session_id="s1", workspace_path="/w") + bridge.set_context(ctx) + bridge._ready = True + bridge._first_run = True + bridge._cwd_path = "/w" + bridge._session_manager = MagicMock() + bridge._session_manager.get_existing.return_value = None + bridge._adapter = MagicMock() + + with ( + patch( + "ambient_runner.platform.auth.populate_runtime_credentials", + new_callable=AsyncMock, + ), + patch( + "ambient_runner.platform.auth.populate_mcp_server_credentials", + new_callable=AsyncMock, + ), + patch( + "ambient_runner.platform.auth.clear_runtime_credentials", + ), + patch.object(bridge, "_ensure_ready", new_callable=AsyncMock), + patch.object(bridge, "_rebuild_mcp_servers") as mock_rebuild, + patch.object(bridge, "_rebuild_system_prompt") as mock_rebuild_prompt, + patch.object(bridge, "_ensure_adapter"), + ): + await bridge._initialize_run( + thread_id="t1", + current_user_id="user1", + current_user_name="User One", + caller_token="tok", + ) + + mock_rebuild.assert_called_once() + mock_rebuild_prompt.assert_called_once() + assert bridge._adapter is None + + async def test_subsequent_run_does_not_rebuild_when_same_user(self): + """After first run, MCP servers and system prompt must NOT be rebuilt when + the user is unchanged.""" + bridge = ClaudeBridge() + ctx = RunnerContext(session_id="s1", workspace_path="/w") + ctx.set_current_user("user1", "User One", "tok") + bridge.set_context(ctx) + bridge._ready = True + bridge._first_run = False + bridge._cwd_path = "/w" + bridge._session_manager = MagicMock() + bridge._session_manager.get_existing.return_value = None + original_adapter = MagicMock() + bridge._adapter = original_adapter + + with ( + patch( + "ambient_runner.platform.auth.populate_runtime_credentials", + new_callable=AsyncMock, + ), + patch( + "ambient_runner.platform.auth.populate_mcp_server_credentials", + new_callable=AsyncMock, + ), + patch( + "ambient_runner.platform.auth.clear_runtime_credentials", + ), + patch.object(bridge, "_ensure_ready", new_callable=AsyncMock), + patch.object(bridge, "_rebuild_mcp_servers") as mock_rebuild, + patch.object(bridge, "_rebuild_system_prompt") as mock_rebuild_prompt, + patch.object(bridge, "_ensure_adapter"), + ): + await bridge._initialize_run( + thread_id="t1", + current_user_id="user1", + current_user_name="User One", + caller_token="tok", + ) + + mock_rebuild.assert_not_called() + mock_rebuild_prompt.assert_not_called() + assert bridge._adapter is original_adapter + + +class TestBuildIntegrationsPrompt: + """Verify _build_integrations_prompt is conditional on credential state for all integrations.""" + + _CLEAN_VARS = ( + "JIRA_URL", "JIRA_API_TOKEN", + "USER_GOOGLE_EMAIL", "CREDENTIAL_MCP_URLS", + "GITHUB_TOKEN", "GITLAB_TOKEN", + ) + + def _clean_env(self, monkeypatch): + for var in self._CLEAN_VARS: + monkeypatch.delenv(var, raising=False) + + def _prompt(self): + from ambient_runner.platform.prompts import _build_integrations_prompt + return _build_integrations_prompt() + + # ------------------------------------------------------------------ + # No credentials — all integrations show setup instructions + # ------------------------------------------------------------------ + + def test_no_creds_shows_missing_prompts_for_all(self, monkeypatch): + self._clean_env(monkeypatch) + result = self._prompt() + assert "configure Jira credentials" in result + assert "mcp-atlassian" not in result + assert "Google Workspace is not connected" in result + assert "google-workspace" not in result + assert "GitHub is not connected" in result + assert "GitLab is not connected" in result + + # ------------------------------------------------------------------ + # GitHub + # ------------------------------------------------------------------ + + @pytest.mark.parametrize("use_sidecar", [True, False]) + def test_github_configured_tells_claude_to_use_mcp_or_token(self, monkeypatch, use_sidecar): + self._clean_env(monkeypatch) + if use_sidecar: + monkeypatch.setenv("CREDENTIAL_MCP_URLS", '{"github": "http://sidecar:8080"}') + else: + monkeypatch.setenv("GITHUB_TOKEN", "ghp_token123") + + result = self._prompt() + assert "GitHub is not connected" not in result + # Sidecar uses MCP tools; token uses git/gh CLI + if use_sidecar: + assert "mcp__github__" in result + else: + assert "GITHUB_TOKEN" in result + + def test_github_not_configured_shows_missing_prompt(self, monkeypatch): + self._clean_env(monkeypatch) + result = self._prompt() + assert "GitHub is not connected" in result + + # ------------------------------------------------------------------ + # GitLab + # ------------------------------------------------------------------ + + def test_gitlab_token_configured_tells_claude_to_use_token(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GITLAB_TOKEN", "glpat_token123") + result = self._prompt() + assert "GITLAB_TOKEN" in result + assert "GitLab is not connected" not in result + + def test_gitlab_not_configured_shows_missing_prompt(self, monkeypatch): + self._clean_env(monkeypatch) + result = self._prompt() + assert "GitLab is not connected" in result + + # ------------------------------------------------------------------ + # Jira + # ------------------------------------------------------------------ + + def test_jira_creds_present_tells_claude_to_use_mcp(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("JIRA_URL", "https://jira.example.com") + monkeypatch.setenv("JIRA_API_TOKEN", "tok123") + result = self._prompt() + assert "mcp-atlassian" in result + assert "Do NOT tell the user to configure Jira" in result + assert "configure Jira credentials" not in result + + def test_jira_url_without_token_shows_missing_prompt(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("JIRA_URL", "https://jira.example.com") + result = self._prompt() + assert "configure Jira credentials" in result + + # ------------------------------------------------------------------ + # Google Workspace + # ------------------------------------------------------------------ + + def test_google_oauth_email_tells_claude_to_use_mcp(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("USER_GOOGLE_EMAIL", "alice@example.com") + result = self._prompt() + assert "google-workspace" in result + assert "Google Workspace is not connected" not in result + assert "Do NOT tell the user to set up Google" in result + + def test_google_placeholder_email_shows_missing_prompt(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("USER_GOOGLE_EMAIL", "user@example.com") + result = self._prompt() + assert "Google Workspace is not connected" in result + + def test_google_sidecar_via_credential_mcp_urls(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("CREDENTIAL_MCP_URLS", '{"google": "http://sidecar:8080"}') + result = self._prompt() + assert "google-workspace" in result + assert "Google Workspace is not connected" not in result + + # ------------------------------------------------------------------ + # All integrations configured + # ------------------------------------------------------------------ + + def test_all_configured_shows_available_prompts_for_all(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GITHUB_TOKEN", "ghp_token123") + monkeypatch.setenv("GITLAB_TOKEN", "glpat_token123") + monkeypatch.setenv("JIRA_URL", "https://jira.example.com") + monkeypatch.setenv("JIRA_API_TOKEN", "tok123") + monkeypatch.setenv("USER_GOOGLE_EMAIL", "alice@example.com") + result = self._prompt() + # Each integration shows its available prompt + assert "GITHUB_TOKEN" in result # GitHub token mode + assert "GITLAB_TOKEN" in result # GitLab token mode + assert "mcp-atlassian" in result # Jira MCP + assert "google-workspace" in result # Google MCP + # No missing prompts + assert "GitHub is not connected" not in result + assert "GitLab is not connected" not in result + assert "configure Jira credentials" not in result + assert "Google Workspace is not connected" not in result + + @pytest.mark.asyncio class TestClaudeBridgeRunGuards: """Test run() and interrupt() guard conditions.""" From 75cea0a92c7f19b90d151054a83130f924ac7400 Mon Sep 17 00:00:00 2001 From: Kyle Squizzato Date: Mon, 15 Jun 2026 13:51:05 -0700 Subject: [PATCH 2/3] fix: ruff format Signed-off-by: Kyle Squizzato --- .../ambient_runner/bridges/claude/mcp.py | 26 ++++++++++++++----- .../ambient_runner/platform/prompts.py | 11 +++++--- .../tests/test_bridge_claude.py | 26 ++++++++++++------- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py index 6b2ca980f..d003a12c9 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py @@ -154,7 +154,9 @@ def build_mcp_servers( "args": list(jira_entry["args"]), "env": {k: _expand_env_vars(v) for k, v in jira_entry["env"].items()}, } - logger.info("Added Jira MCP server (credentials available via session endpoint)") + logger.info( + "Added Jira MCP server (credentials available via session endpoint)" + ) # Gerrit MCP server (only if credentials are configured) gerrit_config = os.environ.get("GERRIT_CONFIG_PATH", "") @@ -259,11 +261,15 @@ def _build_sidecar_mcp_servers(credential_mcp_urls_raw: str) -> dict: try: credential_mcp_urls = json.loads(credential_mcp_urls_raw) except (json.JSONDecodeError, TypeError): - logger.warning("Failed to parse CREDENTIAL_MCP_URLS — skipping credential MCP servers") + logger.warning( + "Failed to parse CREDENTIAL_MCP_URLS — skipping credential MCP servers" + ) return {} if not isinstance(credential_mcp_urls, dict): - logger.warning("CREDENTIAL_MCP_URLS is not a JSON object — skipping credential MCP servers") + logger.warning( + "CREDENTIAL_MCP_URLS is not a JSON object — skipping credential MCP servers" + ) return {} servers: dict = {} @@ -314,7 +320,11 @@ def _wait_for_sidecar_readiness( if not endpoints: return - logger.info("Waiting for %d credential sidecar(s) to become ready (timeout=%ds)", len(endpoints), int(timeout)) + logger.info( + "Waiting for %d credential sidecar(s) to become ready (timeout=%ds)", + len(endpoints), + int(timeout), + ) deadline = time.monotonic() + timeout pending = list(endpoints) @@ -323,7 +333,9 @@ def _wait_for_sidecar_readiness( for name, host, port in pending: try: with socket.create_connection((host, port), timeout=1.0): - logger.info("Credential sidecar %s ready at %s:%d", name, host, port) + logger.info( + "Credential sidecar %s ready at %s:%d", name, host, port + ) except (ConnectionRefusedError, OSError, socket.timeout): still_pending.append((name, host, port)) pending = still_pending @@ -332,7 +344,9 @@ def _wait_for_sidecar_readiness( if pending: names = [p[0] for p in pending] - logger.warning("Credential sidecar(s) not ready after %ds: %s", int(timeout), names) + logger.warning( + "Credential sidecar(s) not ready after %ds: %s", int(timeout), names + ) def _build_subprocess_mcp_servers() -> dict: diff --git a/components/runners/ambient-runner/ambient_runner/platform/prompts.py b/components/runners/ambient-runner/ambient_runner/platform/prompts.py index 418eae5fa..6236672c3 100644 --- a/components/runners/ambient-runner/ambient_runner/platform/prompts.py +++ b/components/runners/ambient-runner/ambient_runner/platform/prompts.py @@ -10,7 +10,9 @@ import json import logging import os +from dataclasses import dataclass from pathlib import Path +from typing import Callable logger = logging.getLogger(__name__) @@ -114,9 +116,6 @@ # Integration registry # --------------------------------------------------------------------------- -from dataclasses import dataclass -from typing import Callable - @dataclass(frozen=True) class _Integration: @@ -167,7 +166,11 @@ def _detect_google(cmu: dict) -> str | None: _Integration( label="GitHub", detect=_detect_github, - prompts={"mcp": GITHUB_MCP_PROMPT, "token": GITHUB_TOKEN_PROMPT, "missing": GITHUB_MISSING_PROMPT}, + prompts={ + "mcp": GITHUB_MCP_PROMPT, + "token": GITHUB_TOKEN_PROMPT, + "missing": GITHUB_MISSING_PROMPT, + }, ), _Integration( label="GitLab", diff --git a/components/runners/ambient-runner/tests/test_bridge_claude.py b/components/runners/ambient-runner/tests/test_bridge_claude.py index 2b2d5c92d..08fb7772e 100644 --- a/components/runners/ambient-runner/tests/test_bridge_claude.py +++ b/components/runners/ambient-runner/tests/test_bridge_claude.py @@ -465,9 +465,12 @@ class TestBuildIntegrationsPrompt: """Verify _build_integrations_prompt is conditional on credential state for all integrations.""" _CLEAN_VARS = ( - "JIRA_URL", "JIRA_API_TOKEN", - "USER_GOOGLE_EMAIL", "CREDENTIAL_MCP_URLS", - "GITHUB_TOKEN", "GITLAB_TOKEN", + "JIRA_URL", + "JIRA_API_TOKEN", + "USER_GOOGLE_EMAIL", + "CREDENTIAL_MCP_URLS", + "GITHUB_TOKEN", + "GITLAB_TOKEN", ) def _clean_env(self, monkeypatch): @@ -476,6 +479,7 @@ def _clean_env(self, monkeypatch): def _prompt(self): from ambient_runner.platform.prompts import _build_integrations_prompt + return _build_integrations_prompt() # ------------------------------------------------------------------ @@ -497,10 +501,14 @@ def test_no_creds_shows_missing_prompts_for_all(self, monkeypatch): # ------------------------------------------------------------------ @pytest.mark.parametrize("use_sidecar", [True, False]) - def test_github_configured_tells_claude_to_use_mcp_or_token(self, monkeypatch, use_sidecar): + def test_github_configured_tells_claude_to_use_mcp_or_token( + self, monkeypatch, use_sidecar + ): self._clean_env(monkeypatch) if use_sidecar: - monkeypatch.setenv("CREDENTIAL_MCP_URLS", '{"github": "http://sidecar:8080"}') + monkeypatch.setenv( + "CREDENTIAL_MCP_URLS", '{"github": "http://sidecar:8080"}' + ) else: monkeypatch.setenv("GITHUB_TOKEN", "ghp_token123") @@ -590,10 +598,10 @@ def test_all_configured_shows_available_prompts_for_all(self, monkeypatch): monkeypatch.setenv("USER_GOOGLE_EMAIL", "alice@example.com") result = self._prompt() # Each integration shows its available prompt - assert "GITHUB_TOKEN" in result # GitHub token mode - assert "GITLAB_TOKEN" in result # GitLab token mode - assert "mcp-atlassian" in result # Jira MCP - assert "google-workspace" in result # Google MCP + assert "GITHUB_TOKEN" in result # GitHub token mode + assert "GITLAB_TOKEN" in result # GitLab token mode + assert "mcp-atlassian" in result # Jira MCP + assert "google-workspace" in result # Google MCP # No missing prompts assert "GitHub is not connected" not in result assert "GitLab is not connected" not in result From 1d53202892ee9a67bce182035e2452ff8d57a11d Mon Sep 17 00:00:00 2001 From: Kyle Squizzato Date: Mon, 15 Jun 2026 14:00:34 -0700 Subject: [PATCH 3/3] fix: jira sidecar detection, remove contradictory prompts Signed-off-by: Kyle Squizzato --- .../bridges/gemini_cli/system_prompt.py | 20 ++++++++++++++++++- .../ambient_runner/platform/prompts.py | 2 ++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py index 4c7847f91..755f549cc 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/gemini_cli/system_prompt.py @@ -119,12 +119,29 @@ def _build_system_prompt(cwd_path: str) -> str: from ambient_runner.platform.prompts import ( GIT_PUSH_INSTRUCTIONS_BODY, GIT_PUSH_INSTRUCTIONS_HEADER, + GIT_PUSH_MCP_STEPS, GIT_PUSH_STEPS, WORKSPACE_FIXED_PATHS_PROMPT, _build_integrations_prompt, + _detect_github, ) from ambient_runner.platform.utils import derive_workflow_name + # Detect GitHub mode once so the git-push step selection is consistent + # with what _build_integrations_prompt() will tell the model to use. + _cmu: dict = {} + _cmu_raw = os.getenv("CREDENTIAL_MCP_URLS", "").strip() + if _cmu_raw: + try: + import json as _json + + _parsed = _json.loads(_cmu_raw) + if isinstance(_parsed, dict): + _cmu = _parsed + except (ValueError, TypeError): + pass + github_mode = _detect_github(_cmu) + # Pull in Gemini's dynamically-built default sections via variable substitution. # These are expanded at runtime by the CLI — no static text to maintain. sections = [ @@ -171,7 +188,8 @@ def _build_system_prompt(cwd_path: str) -> str: sections.append(GIT_PUSH_INSTRUCTIONS_BODY) for r in auto_push: sections.append(f"- **repos/{r.get('name', 'unknown')}/**") - sections.append(GIT_PUSH_STEPS.format(branch=branch)) + push_steps = GIT_PUSH_MCP_STEPS if github_mode == "mcp" else GIT_PUSH_STEPS + sections.append(push_steps.format(branch=branch)) # ---- Workflow directory ---- if active_workflow_url: diff --git a/components/runners/ambient-runner/ambient_runner/platform/prompts.py b/components/runners/ambient-runner/ambient_runner/platform/prompts.py index 6236672c3..7cd5ca5e5 100644 --- a/components/runners/ambient-runner/ambient_runner/platform/prompts.py +++ b/components/runners/ambient-runner/ambient_runner/platform/prompts.py @@ -148,6 +148,8 @@ def _detect_gitlab(cmu: dict) -> str | None: def _detect_jira(cmu: dict) -> str | None: + if "jira" in cmu: + return "mcp" if os.getenv("JIRA_URL", "").strip() and os.getenv("JIRA_API_TOKEN", "").strip(): return "mcp" return None