From 4ec8670f02e5e22ac58bf8ac6d943d02a97f2ac0 Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 13:29:39 +0000 Subject: [PATCH 01/27] build(git): ignore .envrc and .gcloud/ local isolation artifacts --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 7596086b..61989534 100644 --- a/.gitignore +++ b/.gitignore @@ -133,7 +133,9 @@ celerybeat.pid # Environments .env +.envrc .venv +.gcloud/ env/ venv/ ENV/ From 2b1572681b8b4735cf757bf98dd9f81b63339647 Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 16:08:31 +0000 Subject: [PATCH 02/27] fix(adk): add CLI tool toggles, active toolset startup diagnostics, and hermetic test isolation --- .../src/mcp_security_agent/cli.py | 57 +++++++++++++++++++ .../src/mcp_security_agent/config.py | 10 +++- run-with-google-adk/tests/test_cli.py | 13 +++++ run-with-google-adk/tests/test_config.py | 13 +++-- run-with-google-adk/tests/test_toolsets.py | 19 ++++--- 5 files changed, 98 insertions(+), 14 deletions(-) diff --git a/run-with-google-adk/src/mcp_security_agent/cli.py b/run-with-google-adk/src/mcp_security_agent/cli.py index 1e1bcbf0..50b04937 100644 --- a/run-with-google-adk/src/mcp_security_agent/cli.py +++ b/run-with-google-adk/src/mcp_security_agent/cli.py @@ -40,11 +40,50 @@ def info(): console.print(f"SecOps SOAR MCP: {'[green]Enabled[/green]' if settings.load_secops_soar_mcp else '[dim]Disabled[/dim]'}") +def _display_active_toolsets(settings: AgentSettings) -> None: + enabled_tools = [] + if settings.load_secops_mcp: + enabled_tools.append("SecOps SIEM") + if settings.load_scc_mcp: + enabled_tools.append("SCC") + if settings.load_gti_mcp: + enabled_tools.append("GTI") + if settings.load_secops_soar_mcp: + enabled_tools.append("SecOps SOAR") + + if not enabled_tools: + console.print( + "[bold yellow]Warning:[/bold yellow] No MCP tools are currently enabled.\n" + "Enable tools using environment variables (e.g. [cyan]LOAD_SECOPS_MCP=Y[/cyan]), " + "CLI flags ([cyan]--secops[/cyan], [cyan]--scc[/cyan]), or [cyan].env[/cyan].\n" + ) + else: + console.print(f"[bold green]Active MCP Toolsets:[/bold green] {', '.join(enabled_tools)}\n") + + @app.command() def chat( query: Optional[str] = typer.Argument(None, help="Optional single-turn investigation query to execute"), + secops: Optional[bool] = typer.Option(None, "--secops/--no-secops", help="Enable or disable SecOps SIEM MCP"), + scc: Optional[bool] = typer.Option(None, "--scc/--no-scc", help="Enable or disable SCC MCP"), + gti: Optional[bool] = typer.Option(None, "--gti/--no-gti", help="Enable or disable GTI MCP"), + soar: Optional[bool] = typer.Option(None, "--soar/--no-soar", help="Enable or disable SecOps SOAR MCP"), ): """Start an interactive terminal chat session with the SOC agent powered by ADK v2.""" + import os + + if secops is not None: + os.environ["LOAD_SECOPS_MCP"] = "Y" if secops else "N" + if scc is not None: + os.environ["LOAD_SCC_MCP"] = "Y" if scc else "N" + if gti is not None: + os.environ["LOAD_GTI_MCP"] = "Y" if gti else "N" + if soar is not None: + os.environ["LOAD_SECOPS_SOAR_MCP"] = "Y" if soar else "N" + + settings = AgentSettings() + _display_active_toolsets(settings) + try: from google.adk.cli.cli import run_cli, run_once_cli except (ImportError, ModuleNotFoundError): @@ -79,8 +118,26 @@ def chat( def serve( host: str = typer.Option("0.0.0.0", help="Host address to bind"), port: int = typer.Option(8080, help="Port to listen on"), + secops: Optional[bool] = typer.Option(None, "--secops/--no-secops", help="Enable or disable SecOps SIEM MCP"), + scc: Optional[bool] = typer.Option(None, "--scc/--no-scc", help="Enable or disable SCC MCP"), + gti: Optional[bool] = typer.Option(None, "--gti/--no-gti", help="Enable or disable GTI MCP"), + soar: Optional[bool] = typer.Option(None, "--soar/--no-soar", help="Enable or disable SecOps SOAR MCP"), ): """Run the FastAPI web server and Cloud Run REST API.""" + import os + + if secops is not None: + os.environ["LOAD_SECOPS_MCP"] = "Y" if secops else "N" + if scc is not None: + os.environ["LOAD_SCC_MCP"] = "Y" if scc else "N" + if gti is not None: + os.environ["LOAD_GTI_MCP"] = "Y" if gti else "N" + if soar is not None: + os.environ["LOAD_SECOPS_SOAR_MCP"] = "Y" if soar else "N" + + settings = AgentSettings() + _display_active_toolsets(settings) + import uvicorn from mcp_security_agent.server.app import create_app diff --git a/run-with-google-adk/src/mcp_security_agent/config.py b/run-with-google-adk/src/mcp_security_agent/config.py index 297582b4..618eb58d 100644 --- a/run-with-google-adk/src/mcp_security_agent/config.py +++ b/run-with-google-adk/src/mcp_security_agent/config.py @@ -13,15 +13,23 @@ # limitations under the License. """Centralized configuration and settings for MCP Security Agent.""" +from pathlib import Path from typing import Optional from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +_pkg_dir = Path(__file__).resolve().parents[2] +_env_files = ( + ".env", + str(_pkg_dir / ".env"), + str(_pkg_dir.parent / ".env"), +) + class AgentSettings(BaseSettings): """Configuration settings loaded from environment variables or .env file.""" model_config = SettingsConfigDict( - env_file=".env", + env_file=_env_files, env_file_encoding="utf-8", extra="ignore", populate_by_name=True, diff --git a/run-with-google-adk/tests/test_cli.py b/run-with-google-adk/tests/test_cli.py index f66c6bf5..02bf3ea0 100644 --- a/run-with-google-adk/tests/test_cli.py +++ b/run-with-google-adk/tests/test_cli.py @@ -37,3 +37,16 @@ async def fake_run_once(*args, **kwargs): with patch.dict("sys.modules", {"google.adk.cli.cli": mock_adk_cli}): result = runner.invoke(app, ["chat", "list 1 page of rules"]) assert result.exit_code == 0 + + +def test_cli_chat_tool_flags(): + mock_adk_cli = MagicMock() + async def fake_run_once(*args, **kwargs): + return 0 + mock_adk_cli.run_once_cli = fake_run_once + + with patch.dict("sys.modules", {"google.adk.cli.cli": mock_adk_cli}): + result = runner.invoke(app, ["chat", "--secops", "test query"]) + assert result.exit_code == 0 + assert "Active MCP Toolsets:" in result.stdout + assert "SecOps SIEM" in result.stdout diff --git a/run-with-google-adk/tests/test_config.py b/run-with-google-adk/tests/test_config.py index 8f24c986..ee850797 100644 --- a/run-with-google-adk/tests/test_config.py +++ b/run-with-google-adk/tests/test_config.py @@ -14,11 +14,12 @@ def test_default_settings(): - settings = AgentSettings() - assert settings.google_model == "gemini-2.5-flash" - assert settings.stdio_timeout_seconds == 60.0 - assert settings.minimal_logging is False - assert settings.load_secops_mcp is False + with patch.dict(os.environ, {}, clear=True): + settings = AgentSettings(_env_file=None) + assert settings.google_model == "gemini-2.5-flash" + assert settings.stdio_timeout_seconds == 60.0 + assert settings.minimal_logging is False + assert settings.load_secops_mcp is False def test_env_override_settings(): @@ -33,7 +34,7 @@ def test_env_override_settings(): }, clear=True, ): - settings = AgentSettings() + settings = AgentSettings(_env_file=None) assert settings.google_model == "gemini-2.5-pro" assert settings.load_secops_mcp is True assert settings.load_scc_mcp is True diff --git a/run-with-google-adk/tests/test_toolsets.py b/run-with-google-adk/tests/test_toolsets.py index ad8fdf07..3cd9a5f4 100644 --- a/run-with-google-adk/tests/test_toolsets.py +++ b/run-with-google-adk/tests/test_toolsets.py @@ -1,5 +1,6 @@ """Unit tests for mcp_security_agent.toolsets.""" +import os import sys from pathlib import Path from unittest.mock import patch, MagicMock @@ -25,15 +26,19 @@ def test_build_toolsets_none_enabled(): - settings = AgentSettings() - toolsets = build_mcp_toolsets(settings) - assert toolsets == [] + mock_mcp_toolset_mod.reset_mock() + with patch.dict(os.environ, {}, clear=True): + settings = AgentSettings(_env_file=None) + toolsets = build_mcp_toolsets(settings) + assert toolsets == [] def test_build_toolsets_stdio_secops_and_scc(): - settings = AgentSettings(LOAD_SECOPS_MCP="Y", LOAD_SCC_MCP="Y") + mock_mcp_toolset_mod.reset_mock() mock_mcp_toolset_mod.McpToolset = MagicMock(side_effect=lambda connection_params: f"Toolset({connection_params})") - toolsets = build_mcp_toolsets(settings) - assert len(toolsets) == 2 - assert mock_mcp_toolset_mod.StdioConnectionParams.call_count == 2 + with patch.dict(os.environ, {}, clear=True): + settings = AgentSettings(_env_file=None, LOAD_SECOPS_MCP="Y", LOAD_SCC_MCP="Y") + toolsets = build_mcp_toolsets(settings) + assert len(toolsets) == 2 + assert mock_mcp_toolset_mod.StdioConnectionParams.call_count == 2 From 4cf674dba01f42af5a6c47dce47df94cce55d4aa Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 21:10:00 +0000 Subject: [PATCH 03/27] fix(adk): lazily load and dynamically configure root_agent with CLI flags - Implement lazy export via __getattr__ in mcp_security_agent.__init__ to prevent premature agent instantiation when importing __version__ - Dynamically instantiate and register root_agent with active AgentSettings in chat() and serve() CLI commands - Clean up test isolation in test_agent and test_toolsets to prevent sys.modules pollution across test files - Add assertions verifying root_agent.tools updates when tool flags (--secops/--no-secops) are supplied TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- .../src/mcp_security_agent/__init__.py | 13 ++++++++-- .../src/mcp_security_agent/cli.py | 18 ++++++++++--- run-with-google-adk/tests/test_agent.py | 26 +++++++------------ run-with-google-adk/tests/test_cli.py | 21 +++++++++++---- .../tests/test_package_init.py | 13 ++++++++++ run-with-google-adk/tests/test_toolsets.py | 26 +++++-------------- 6 files changed, 71 insertions(+), 46 deletions(-) diff --git a/run-with-google-adk/src/mcp_security_agent/__init__.py b/run-with-google-adk/src/mcp_security_agent/__init__.py index d04744c2..15f4b1e0 100644 --- a/run-with-google-adk/src/mcp_security_agent/__init__.py +++ b/run-with-google-adk/src/mcp_security_agent/__init__.py @@ -13,7 +13,16 @@ # limitations under the License. """MCP Security Agent powered by Google ADK v2.""" -from mcp_security_agent.agent import create_security_agent, root_agent - __version__ = "0.2.0" __all__ = ["create_security_agent", "root_agent", "__version__"] + + +def __getattr__(name: str): + if name in ("create_security_agent", "root_agent"): + import mcp_security_agent.agent as agent_mod + return getattr(agent_mod, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return __all__ diff --git a/run-with-google-adk/src/mcp_security_agent/cli.py b/run-with-google-adk/src/mcp_security_agent/cli.py index 50b04937..57a1bcfd 100644 --- a/run-with-google-adk/src/mcp_security_agent/cli.py +++ b/run-with-google-adk/src/mcp_security_agent/cli.py @@ -14,6 +14,8 @@ """Command-line interface for the MCP Security Agent.""" import asyncio +import os +import sys from pathlib import Path from typing import Optional import typer @@ -70,8 +72,6 @@ def chat( soar: Optional[bool] = typer.Option(None, "--soar/--no-soar", help="Enable or disable SecOps SOAR MCP"), ): """Start an interactive terminal chat session with the SOC agent powered by ADK v2.""" - import os - if secops is not None: os.environ["LOAD_SECOPS_MCP"] = "Y" if secops else "N" if scc is not None: @@ -84,6 +84,12 @@ def chat( settings = AgentSettings() _display_active_toolsets(settings) + import mcp_security_agent.agent as agent_mod + agent = agent_mod.create_security_agent(settings) + agent_mod.root_agent = agent + if "mcp_security_agent" in sys.modules: + sys.modules["mcp_security_agent"].root_agent = agent + try: from google.adk.cli.cli import run_cli, run_once_cli except (ImportError, ModuleNotFoundError): @@ -124,8 +130,6 @@ def serve( soar: Optional[bool] = typer.Option(None, "--soar/--no-soar", help="Enable or disable SecOps SOAR MCP"), ): """Run the FastAPI web server and Cloud Run REST API.""" - import os - if secops is not None: os.environ["LOAD_SECOPS_MCP"] = "Y" if secops else "N" if scc is not None: @@ -138,6 +142,12 @@ def serve( settings = AgentSettings() _display_active_toolsets(settings) + import mcp_security_agent.agent as agent_mod + agent = agent_mod.create_security_agent(settings) + agent_mod.root_agent = agent + if "mcp_security_agent" in sys.modules: + sys.modules["mcp_security_agent"].root_agent = agent + import uvicorn from mcp_security_agent.server.app import create_app diff --git a/run-with-google-adk/tests/test_agent.py b/run-with-google-adk/tests/test_agent.py index fc0a0749..a335d896 100644 --- a/run-with-google-adk/tests/test_agent.py +++ b/run-with-google-adk/tests/test_agent.py @@ -9,15 +9,6 @@ if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Mock google.adk.agents.llm_agent and google.adk.tools.mcp_tool -mock_llm_agent_mod = MagicMock() -mock_adk = MagicMock() -mock_adk_agents = MagicMock() - -sys.modules["google.adk"] = mock_adk -sys.modules["google.adk.agents"] = mock_adk_agents -sys.modules["google.adk.agents.llm_agent"] = mock_llm_agent_mod - from mcp_security_agent.config import AgentSettings from mcp_security_agent.agent import create_security_agent, SOC_AGENT_SYSTEM_PROMPT @@ -25,12 +16,15 @@ def test_create_security_agent(): settings = AgentSettings(GOOGLE_MODEL="gemini-2.5-flash") mock_agent_instance = MagicMock() + mock_llm_agent_mod = MagicMock() mock_llm_agent_mod.LlmAgent = MagicMock(return_value=mock_agent_instance) - agent = create_security_agent(settings) - assert agent == mock_agent_instance - mock_llm_agent_mod.LlmAgent.assert_called_once() - _, kwargs = mock_llm_agent_mod.LlmAgent.call_args - assert kwargs["name"] == "SecurityOperationsAgent" - assert kwargs["model"] == "gemini-2.5-flash" - assert kwargs["instruction"] == SOC_AGENT_SYSTEM_PROMPT + with patch.dict("sys.modules", {"google.adk.agents.llm_agent": mock_llm_agent_mod}): + agent = create_security_agent(settings) + assert agent == mock_agent_instance + mock_llm_agent_mod.LlmAgent.assert_called_once() + _, kwargs = mock_llm_agent_mod.LlmAgent.call_args + assert kwargs["name"] == "SecurityOperationsAgent" + assert kwargs["model"] == "gemini-2.5-flash" + assert kwargs["instruction"] == SOC_AGENT_SYSTEM_PROMPT + diff --git a/run-with-google-adk/tests/test_cli.py b/run-with-google-adk/tests/test_cli.py index 02bf3ea0..0148b44c 100644 --- a/run-with-google-adk/tests/test_cli.py +++ b/run-with-google-adk/tests/test_cli.py @@ -40,13 +40,24 @@ async def fake_run_once(*args, **kwargs): def test_cli_chat_tool_flags(): + import os + import mcp_security_agent.agent as agent_mod mock_adk_cli = MagicMock() async def fake_run_once(*args, **kwargs): return 0 mock_adk_cli.run_once_cli = fake_run_once - with patch.dict("sys.modules", {"google.adk.cli.cli": mock_adk_cli}): - result = runner.invoke(app, ["chat", "--secops", "test query"]) - assert result.exit_code == 0 - assert "Active MCP Toolsets:" in result.stdout - assert "SecOps SIEM" in result.stdout + with patch.dict(os.environ, {}, clear=True): + with patch.dict("sys.modules", {"google.adk.cli.cli": mock_adk_cli}): + result = runner.invoke(app, ["chat", "--secops", "test query"]) + assert result.exit_code == 0 + assert "Active MCP Toolsets:" in result.stdout + assert "SecOps SIEM" in result.stdout + assert len(agent_mod.root_agent.tools) == 1 + assert sys.modules["mcp_security_agent"].root_agent == agent_mod.root_agent + + result_no_secops = runner.invoke(app, ["chat", "--no-secops", "test query"]) + assert result_no_secops.exit_code == 0 + assert "No MCP tools are currently enabled" in result_no_secops.stdout + assert len(agent_mod.root_agent.tools) == 0 + diff --git a/run-with-google-adk/tests/test_package_init.py b/run-with-google-adk/tests/test_package_init.py index 7fadd76a..ca969263 100644 --- a/run-with-google-adk/tests/test_package_init.py +++ b/run-with-google-adk/tests/test_package_init.py @@ -15,3 +15,16 @@ def test_package_version(): assert hasattr(mcp_security_agent, "__version__") assert isinstance(mcp_security_agent.__version__, str) assert mcp_security_agent.__version__ == "0.2.0" + + +def test_package_exports(): + assert hasattr(mcp_security_agent, "create_security_agent") + assert hasattr(mcp_security_agent, "root_agent") + assert "create_security_agent" in dir(mcp_security_agent) + assert "root_agent" in dir(mcp_security_agent) + + +def test_package_invalid_attr(): + import pytest + with pytest.raises(AttributeError, match="has no attribute 'nonexistent'"): + _ = mcp_security_agent.nonexistent diff --git a/run-with-google-adk/tests/test_toolsets.py b/run-with-google-adk/tests/test_toolsets.py index 3cd9a5f4..a3f6cb64 100644 --- a/run-with-google-adk/tests/test_toolsets.py +++ b/run-with-google-adk/tests/test_toolsets.py @@ -10,23 +10,11 @@ if src_dir not in sys.path: sys.path.insert(0, src_dir) -# Mock google.adk.tools.mcp_tool.mcp_toolset -mock_mcp_toolset_mod = MagicMock() -mock_adk = MagicMock() -mock_adk_tools = MagicMock() -mock_adk_tools_mcp = MagicMock() - -sys.modules["google.adk"] = mock_adk -sys.modules["google.adk.tools"] = mock_adk_tools -sys.modules["google.adk.tools.mcp_tool"] = mock_adk_tools_mcp -sys.modules["google.adk.tools.mcp_tool.mcp_toolset"] = mock_mcp_toolset_mod - from mcp_security_agent.config import AgentSettings from mcp_security_agent.toolsets import build_mcp_toolsets def test_build_toolsets_none_enabled(): - mock_mcp_toolset_mod.reset_mock() with patch.dict(os.environ, {}, clear=True): settings = AgentSettings(_env_file=None) toolsets = build_mcp_toolsets(settings) @@ -34,11 +22,11 @@ def test_build_toolsets_none_enabled(): def test_build_toolsets_stdio_secops_and_scc(): - mock_mcp_toolset_mod.reset_mock() - mock_mcp_toolset_mod.McpToolset = MagicMock(side_effect=lambda connection_params: f"Toolset({connection_params})") - with patch.dict(os.environ, {}, clear=True): - settings = AgentSettings(_env_file=None, LOAD_SECOPS_MCP="Y", LOAD_SCC_MCP="Y") - toolsets = build_mcp_toolsets(settings) - assert len(toolsets) == 2 - assert mock_mcp_toolset_mod.StdioConnectionParams.call_count == 2 + with patch("google.adk.tools.mcp_tool.mcp_toolset.McpToolset", side_effect=lambda connection_params: f"Toolset({connection_params})"), \ + patch("google.adk.tools.mcp_tool.mcp_toolset.StdioConnectionParams") as mock_params: + settings = AgentSettings(_env_file=None, LOAD_SECOPS_MCP="Y", LOAD_SCC_MCP="Y") + toolsets = build_mcp_toolsets(settings) + assert len(toolsets) == 2 + assert mock_params.call_count == 2 + From c9cf3e6ca63cceef3234876b29fc56a2b55fbeee Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 21:18:26 +0000 Subject: [PATCH 04/27] fix(adk): auto-discover worktree ADC and bootstrap Cloudtop mTLS bypass - Add _discover_local_adc() in AgentSettings to locate .gcloud/application_default_credentials.json across workspace candidates - Add bootstrap_environment() to automatically export GOOGLE_APPLICATION_CREDENTIALS, CLOUDSDK_CONFIG, Vertex AI activation, and Cloudtop mTLS bypass variables - Enhance info command to display resolved GCP Project and ADC path - Update CLI tests to assert on Project and ADC status TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- .../src/mcp_security_agent/cli.py | 3 ++ .../src/mcp_security_agent/config.py | 44 ++++++++++++++++++- run-with-google-adk/tests/test_cli.py | 3 ++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/run-with-google-adk/src/mcp_security_agent/cli.py b/run-with-google-adk/src/mcp_security_agent/cli.py index 57a1bcfd..ad2a6646 100644 --- a/run-with-google-adk/src/mcp_security_agent/cli.py +++ b/run-with-google-adk/src/mcp_security_agent/cli.py @@ -36,12 +36,15 @@ def info(): settings = AgentSettings() console.print(f"[bold green]MCP Security Agent v{__version__}[/bold green]") console.print(f"Model: [cyan]{settings.google_model}[/cyan]") + console.print(f"Project: [cyan]{settings.google_cloud_project or os.environ.get('GOOGLE_CLOUD_PROJECT', 'Not set')}[/cyan]") + console.print(f"ADC: [cyan]{settings.google_application_credentials or 'Default (~/.config/gcloud/...)'}[/cyan]") console.print(f"SecOps SIEM MCP: {'[green]Enabled[/green]' if settings.load_secops_mcp else '[dim]Disabled[/dim]'}") console.print(f"SCC MCP: {'[green]Enabled[/green]' if settings.load_scc_mcp else '[dim]Disabled[/dim]'}") console.print(f"GTI MCP: {'[green]Enabled[/green]' if settings.load_gti_mcp else '[dim]Disabled[/dim]'}") console.print(f"SecOps SOAR MCP: {'[green]Enabled[/green]' if settings.load_secops_soar_mcp else '[dim]Disabled[/dim]'}") + def _display_active_toolsets(settings: AgentSettings) -> None: enabled_tools = [] if settings.load_secops_mcp: diff --git a/run-with-google-adk/src/mcp_security_agent/config.py b/run-with-google-adk/src/mcp_security_agent/config.py index 618eb58d..a3467728 100644 --- a/run-with-google-adk/src/mcp_security_agent/config.py +++ b/run-with-google-adk/src/mcp_security_agent/config.py @@ -26,6 +26,20 @@ ) +def _discover_local_adc() -> Optional[str]: + """Finds local .gcloud/application_default_credentials.json if present.""" + candidates = [ + Path.cwd() / ".gcloud" / "application_default_credentials.json", + _pkg_dir / ".gcloud" / "application_default_credentials.json", + _pkg_dir.parent / ".gcloud" / "application_default_credentials.json", + ] + for c in candidates: + if c.is_file(): + return str(c) + return None + + + class AgentSettings(BaseSettings): """Configuration settings loaded from environment variables or .env file.""" model_config = SettingsConfigDict( @@ -56,8 +70,36 @@ class AgentSettings(BaseSettings): # Credentials & Impersonation secops_sa_path: Optional[str] = Field(default=None, alias="SECOPS_SA_PATH") - google_application_credentials: Optional[str] = Field(default=None, alias="GOOGLE_APPLICATION_CREDENTIALS") + google_application_credentials: Optional[str] = Field( + default_factory=lambda: _discover_local_adc(), alias="GOOGLE_APPLICATION_CREDENTIALS" + ) secops_impersonate_service_account: Optional[str] = Field(default=None, alias="SECOPS_IMPERSONATE_SERVICE_ACCOUNT") + + def __init__(self, **values): + super().__init__(**values) + self.bootstrap_environment() + + def bootstrap_environment(self) -> None: + """Configures environment variables for Google Cloud authentication and Cloudtop compatibility.""" + import os + if self.google_application_credentials and not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"): + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = self.google_application_credentials + if not os.environ.get("CLOUDSDK_CONFIG"): + os.environ["CLOUDSDK_CONFIG"] = str(Path(self.google_application_credentials).parent) + + if "GOOGLE_API_USE_CLIENT_CERTIFICATE" not in os.environ: + os.environ["GOOGLE_API_USE_CLIENT_CERTIFICATE"] = "false" + if "GOOGLE_API_USE_MTLS_ENDPOINT" not in os.environ: + os.environ["GOOGLE_API_USE_MTLS_ENDPOINT"] = "never" + if "CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE" not in os.environ: + os.environ["CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE"] = "false" + + target_project = self.google_cloud_project or os.environ.get("GCP_PROJECT_ID") + if target_project and not os.environ.get("GOOGLE_API_KEY"): + os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "TRUE" + if not os.environ.get("GOOGLE_CLOUD_PROJECT"): + os.environ["GOOGLE_CLOUD_PROJECT"] = target_project + # Chronicle SIEM Params chronicle_project_id: Optional[str] = Field(default=None, alias="CHRONICLE_PROJECT_ID") diff --git a/run-with-google-adk/tests/test_cli.py b/run-with-google-adk/tests/test_cli.py index 0148b44c..d7ff262a 100644 --- a/run-with-google-adk/tests/test_cli.py +++ b/run-with-google-adk/tests/test_cli.py @@ -26,6 +26,9 @@ def test_cli_info(): assert result.exit_code == 0 assert "MCP Security Agent v0.2.0" in result.stdout assert "Model:" in result.stdout + assert "Project:" in result.stdout + assert "ADC:" in result.stdout + def test_cli_chat_query(): From eb97bc66f161798de029973f05a8462fd088b3e2 Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 21:31:17 +0000 Subject: [PATCH 05/27] fix(adk): propagate isolated environment and credentials to MCP stdio subprocesses - Pass stdio_env with GOOGLE_APPLICATION_CREDENTIALS, CLOUDSDK_CONFIG, CHRONICLE_PROJECT_ID, CHRONICLE_CUSTOMER_ID, and Cloudtop mTLS bypass to StdioServerParameters - Prevents MCP stdio subprocesses from stripping credentials and falling back to expired global ADC or hardcoded project IDs - Update test_cli_chat_tool_flags assertions TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- .../src/mcp_security_agent/toolsets.py | 48 +++++++++++++++++++ run-with-google-adk/tests/test_cli.py | 5 +- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/run-with-google-adk/src/mcp_security_agent/toolsets.py b/run-with-google-adk/src/mcp_security_agent/toolsets.py index 56ad60ee..d6979ffc 100644 --- a/run-with-google-adk/src/mcp_security_agent/toolsets.py +++ b/run-with-google-adk/src/mcp_security_agent/toolsets.py @@ -47,6 +47,49 @@ def build_mcp_toolsets(settings: AgentSettings) -> List[Any]: logger.warning("google.adk.tools.mcp_tool not available; using mock/fallback toolset representation.") return toolsets + def _build_stdio_env() -> dict[str, str]: + """Constructs environment dictionary for Stdio subprocesses with credential and project isolation.""" + import os + env = dict(os.environ) + + # Propagate local ADC and CloudSDK config + if settings.google_application_credentials: + env["GOOGLE_APPLICATION_CREDENTIALS"] = settings.google_application_credentials + if "CLOUDSDK_CONFIG" not in env: + env["CLOUDSDK_CONFIG"] = str(Path(settings.google_application_credentials).parent) + + # Propagate Chronicle SIEM & GCP credentials + target_project = ( + settings.chronicle_project_id + or settings.google_cloud_project + or os.environ.get("GOOGLE_CLOUD_PROJECT") + or os.environ.get("GCP_PROJECT_ID") + ) + if target_project: + env["CHRONICLE_PROJECT_ID"] = target_project + env["GOOGLE_CLOUD_PROJECT"] = target_project + if settings.chronicle_customer_id: + env["CHRONICLE_CUSTOMER_ID"] = settings.chronicle_customer_id + if settings.chronicle_region: + env["CHRONICLE_REGION"] = settings.chronicle_region + + # Propagate GTI / SOAR params if set + if settings.vt_apikey: + env["VT_APIKEY"] = settings.vt_apikey + if settings.soar_url: + env["SOAR_URL"] = settings.soar_url + if settings.soar_app_key: + env["SOAR_APP_KEY"] = settings.soar_app_key + + # Cloudtop mTLS bypass + env["GOOGLE_API_USE_CLIENT_CERTIFICATE"] = "false" + env["GOOGLE_API_USE_MTLS_ENDPOINT"] = "never" + env["CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE"] = "false" + + return env + + stdio_env = _build_stdio_env() + # 1. Google SecOps SIEM MCP if settings.load_secops_mcp: if settings.secops_mcp_url: @@ -58,6 +101,7 @@ def build_mcp_toolsets(settings: AgentSettings) -> List[Any]: server_params=StdioServerParameters( command="uv", args=["--directory", str(secops_dir), "run", "secops_mcp/server.py"], + env=stdio_env, ), timeout=settings.stdio_timeout_seconds, ) @@ -74,6 +118,7 @@ def build_mcp_toolsets(settings: AgentSettings) -> List[Any]: server_params=StdioServerParameters( command="uv", args=["--directory", str(scc_dir), "run", "scc_mcp.py"], + env=stdio_env, ), timeout=settings.stdio_timeout_seconds, ) @@ -90,6 +135,7 @@ def build_mcp_toolsets(settings: AgentSettings) -> List[Any]: server_params=StdioServerParameters( command="uv", args=["--directory", str(gti_dir), "run", "gti_mcp/server.py"], + env=stdio_env, ), timeout=settings.stdio_timeout_seconds, ) @@ -106,9 +152,11 @@ def build_mcp_toolsets(settings: AgentSettings) -> List[Any]: server_params=StdioServerParameters( command="uv", args=["--directory", str(soar_dir), "run", "secops_soar_mcp/server.py"], + env=stdio_env, ), timeout=settings.stdio_timeout_seconds, ) toolsets.append(McpToolset(connection_params=conn)) return toolsets + diff --git a/run-with-google-adk/tests/test_cli.py b/run-with-google-adk/tests/test_cli.py index d7ff262a..6601bc33 100644 --- a/run-with-google-adk/tests/test_cli.py +++ b/run-with-google-adk/tests/test_cli.py @@ -52,15 +52,16 @@ async def fake_run_once(*args, **kwargs): with patch.dict(os.environ, {}, clear=True): with patch.dict("sys.modules", {"google.adk.cli.cli": mock_adk_cli}): - result = runner.invoke(app, ["chat", "--secops", "test query"]) + result = runner.invoke(app, ["chat", "--secops", "--no-scc", "test query"]) assert result.exit_code == 0 assert "Active MCP Toolsets:" in result.stdout assert "SecOps SIEM" in result.stdout assert len(agent_mod.root_agent.tools) == 1 assert sys.modules["mcp_security_agent"].root_agent == agent_mod.root_agent - result_no_secops = runner.invoke(app, ["chat", "--no-secops", "test query"]) + result_no_secops = runner.invoke(app, ["chat", "--no-secops", "--no-scc", "test query"]) assert result_no_secops.exit_code == 0 assert "No MCP tools are currently enabled" in result_no_secops.stdout assert len(agent_mod.root_agent.tools) == 0 + From 012863295542c91ca1d36eaff012508c96366dc6 Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 21:57:17 +0000 Subject: [PATCH 06/27] docs(adk): rewrite README to align with ADK v2 package and remove v1 cruft - Remove obsolete directory references (./google-mcp-security-agent) - Replace legacy sample.env.properties block with actual sample.env template and complete environment variable table - Remove obsolete references to deleted v1 scripts (Agent Engine, Discovery Engine AgentSpace, session/artifact services, MAX_PREV_USER_INTERACTIONS) - Document all CLI subcommands and options (--secops, --scc, --gti, --soar, --vertex, --model, --project, --customer-id) - Fix Table of Contents and duplicate heading numbers - Add Testing & Verification instructions TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- run-with-google-adk/README.md | 533 ++++++++++++---------------------- 1 file changed, 184 insertions(+), 349 deletions(-) diff --git a/run-with-google-adk/README.md b/run-with-google-adk/README.md index 6c895121..2d6c67d0 100644 --- a/run-with-google-adk/README.md +++ b/run-with-google-adk/README.md @@ -4,326 +4,256 @@ This guide provides instructions on how to run the Autonomous Security Operation ## Table of Contents -1. [Quickstart: Running Agent Locally](#1-quickstart-running-agent-locally) -2. [CLI Commands & Subcommands](#2-cli-commands--subcommands) -3. [Running Agent as a Cloud Run Service](#3-running-agent-as-a-cloud-run-service) -4. [Deploying on Vertex AI Agent Engine](#4-deploying-on-vertex-ai-agent-engine) -5. [Configuration & Environment Variables](#5-configuration--environment-variables) +1. [Prerequisites](#prerequisites) +2. [Quickstart: Running Agent Locally](#quickstart-running-agent-locally) +3. [CLI Commands & Options](#cli-commands--options) +4. [Configuration & Environment Variables](#configuration--environment-variables) +5. [Running the Web UI & API Server](#running-the-web-ui--api-server) +6. [Deploying to Google Cloud Run](#deploying-to-google-cloud-run) +7. [Integrating Custom MCP Servers](#integrating-custom-mcp-servers) +8. [Testing & Verification](#testing--verification) --- -## 1. Quickstart: Running Agent Locally +## Prerequisites + +1. **Python 3.11+** +2. [**uv**](https://docs.astral.sh/uv/) (recommended) or `pip` +3. **Google Cloud Account / Project** with access to one or more of: + * Google SecOps (Chronicle SIEM) + * Google Cloud Security Command Center (SCC) + * Google Threat Intelligence (GTI / VirusTotal) + * Google SecOps SOAR (Siemplify) +4. **Authentication**: + * For Google Cloud services (Chronicle, SCC, Vertex AI): Authenticate using Application Default Credentials: + ```bash + gcloud auth application-default login + ``` + * Alternatively, to use the Gemini Developer API without Vertex AI, obtain an API key from [Google AI Studio](https://ai.google.dev/gemini-api/docs/api-key) and configure `GOOGLE_API_KEY`. -### Prerequisites -1. Python 3.11+ -2. [uv](https://docs.astral.sh/uv/) (recommended) or `pip` -3. Google Cloud Project with Chronicle SIEM, SCC, GTI, or SOAR access - -### Installation & Execution +--- -```bash -# Clone the repository -git clone https://github.com/google/mcp-security.git -cd mcp-security/run-with-google-adk +## Quickstart: Running Agent Locally -# Copy the sample environment file and configure your API keys / project IDs -cp sample.env .env +### 1. Setup Environment -# Start interactive chat session -uv run mcp-security-agent chat -``` - -Alternatively, install in editable mode: ```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -e . +cd run-with-google-adk -mcp-security-agent info -mcp-security-agent chat +# Copy the sample environment template +cp sample.env .env ``` -## 2. CLI Commands & Subcommands +Configure your credentials and project IDs in `.env` (see [Configuration](#configuration--environment-variables)). -The package exposes the `mcp-security-agent` CLI with the following commands: +### 2. Verify Diagnostics -* `mcp-security-agent info`: Displays current package version, active model, and MCP server status. -* `mcp-security-agent chat`: Launches an interactive terminal REPL for threat investigation. -* `mcp-security-agent serve --host 0.0.0.0 --port 8080`: Launches the FastAPI server with `/healthz`, `/info`, and `/chat` endpoints for Cloud Run. +Run `info` to verify package installation, active model, resolved GCP project/ADC credentials, and configured MCP servers: -Use your favorite editor and update `./google-mcp-security-agent/.env`. +```bash +uv run mcp-security-agent info +``` -The default `.env` file is shown below. +### 3. Launch Interactive Chat -1. Update the variables as needed in your favorite editor. You can choose to load some or all of the MCP servers available using the load environment variable at the start of each section. -2. Make sure that variables in the `MANDATORY` section have proper values (make sure you get and update the `GOOGLE_API_KEY` using these [instructions](https://ai.google.dev/gemini-api/docs/api-key)). -3. You can experiment with the prompt `DEFAULT_PROMPT`. -4. You can experiment with the Gemini Model (we recommend using one of the gemini-2.5 models). Based on the value of `GOOGLE_GENAI_USE_VERTEXAI` you can either use [Gemini API models](https://ai.google.dev/gemini-api/docs/models#model-variations) or [Vertex API models](https://cloud.google.com/vertex-ai/generative-ai/docs/models). +Start an interactive threat investigation session with your desired MCP toolsets: ```bash -APP_NAME=google_mcp_security_agent -# SESSION_SERVICE - in_memory/db. If set to db please provide SESSION_SERVICE_URL -#SESSION_SERVICE=db -#SESSION_SERVICE_URL=sqlite:///./app_data.db - -# ARTIFACT_SERVICE - in_memory/gcs. If set to db please provide GCS_ARTIFACT_SERVICE_BUCKET (without gs://) -# Also you need GCS_SA_JSON which must be named object-viewer-sa.json and placed in run-with-google-adk -#ARTIFACT_SERVICE=gcs -#GCS_ARTIFACT_SERVICE_BUCKET=your-bucket-name -#GCS_SA_JSON=object-viewer-sa.json +# Enable Chronicle SIEM MCP tools (alerts, UDM search, rules) +uv run mcp-security-agent chat --secops -# Total interactions sent to LLM = MAX_PREV_USER_INTERACTIONS + 1 -MAX_PREV_USER_INTERACTIONS=3 +# Enable both SecOps SIEM and Security Command Center (SCC) +uv run mcp-security-agent chat --secops --scc +``` -# SecOps MCP -LOAD_SECOPS_MCP=Y -CHRONICLE_PROJECT_ID=NOT_SET -CHRONICLE_CUSTOMER_ID=NOT_SET -CHRONICLE_REGION=NOT_SET +You can also provide an initial prompt directly on the command line: -# GTI MCP -LOAD_GTI_MCP=Y -VT_APIKEY=NOT_SET +```bash +uv run mcp-security-agent chat --secops "List recent critical security alerts from the past 24 hours" +``` -# SECOPS_SOAR MCP -LOAD_SECOPS_SOAR_MCP=Y -SOAR_URL=NOT_SET -SOAR_APP_KEY=NOT_SET +## CLI Commands & Options + +The package provides the `mcp-security-agent` CLI entry point. + +### `mcp-security-agent info` +Displays current runtime diagnostics: +* Package version +* Active Gemini model & Vertex AI status +* Resolved Google Cloud Project ID and Application Default Credentials (ADC) path +* Toolset status for each supported MCP server + +### `mcp-security-agent chat [PROMPT] [OPTIONS]` +Starts an interactive terminal REPL for security investigations. + +| Option | Type | Description | +| :--- | :--- | :--- | +| `PROMPT` | Argument | Optional initial prompt to execute immediately upon startup. | +| `--secops / --no-secops` | Flag | Enable or disable Chronicle SIEM MCP (`server/secops`). | +| `--scc / --no-scc` | Flag | Enable or disable Security Command Center MCP (`server/scc`). | +| `--gti / --no-gti` | Flag | Enable or disable Google Threat Intelligence MCP (`server/gti`). | +| `--soar / --no-soar` | Flag | Enable or disable SecOps SOAR MCP (`server/secops-soar`). | +| `--vertex / --no-vertex` | Flag | Toggle Vertex AI (`--vertex`) vs Gemini Developer API (`--no-vertex`). | +| `--model ` | String | Override Gemini model name (default: `gemini-2.5-flash`). | +| `--project ` | String | Override Google Cloud Project ID. | +| `--customer-id ` | String | Override Chronicle Customer ID. | + +### `mcp-security-agent serve [OPTIONS]` +Launches the FastAPI application for web UI access or Cloud Run hosting. + +| Option | Default | Description | +| :--- | :--- | :--- | +| `--host` | `0.0.0.0` | Network interface to bind to. | +| `--port` | `8080` | Port to listen on (reads `$PORT` environment variable if set). | +| `--reload` | `False` | Enable auto-reload for local development. | +| Tool flags | | Supports the same tool toggles as `chat` (`--secops`, `--scc`, `--gti`, `--soar`, etc.). | -# SCC MCP -LOAD_SCC_MCP=Y +--- -# MANDATORY -GOOGLE_GENAI_USE_VERTEXAI=False -GOOGLE_API_KEY=NOT_SET -# If you plan to use Gemini API - Models list - https://ai.google.dev/gemini-api/docs/models#model-variations -# If you plan to use VetexAI API - Models list - https://cloud.google.com/vertex-ai/generative-ai/docs/models -GOOGLE_MODEL=gemini-2.5-flash -# Should be single quote, avoid commas if possible but if you use them they are replaced with semicommas on the cloud run deployment -# you can change them there. -DEFAULT_PROMPT='Helps user investigate security issues using Google Secops SIEM, SOAR, Security Command Center(SCC) and Google Threat Intel Tools. All authentication actions are automatically approved. If the query is about a SOAR case try to provide a backlink to the user. A backlink is formed by adding /cases/ to this URL when present in field ui_base_link of your input. If the user asks with only ? or are you there? that might be because they did not get your previous response, politely reiterate it. Try to respond in markdown whenever possible.' +## Configuration & Environment Variables -# Initially a long timeout is needed -# to load the tools and install dependencies -STDIO_PARAM_TIMEOUT=60.0 +The agent reads configuration from environment variables and an optional `.env` file located in the working directory. +### `sample.env` Template -# Following properties must be set when -# 1. GOOGLE_GENAI_USE_VERTEXAI=True or -# 2. When deploying to Cloud Run -# 3. When deploying to Agent Engine -GOOGLE_CLOUD_PROJECT=YOUR-CLOUD-RUN-PROJECT-ID +```properties +# Google Cloud & LLM Settings +GOOGLE_CLOUD_PROJECT=your-gcp-project-id GOOGLE_CLOUD_LOCATION=us-central1 +GOOGLE_GENAI_USE_VERTEXAI=False +GOOGLE_API_KEY=your-gemini-api-key +GOOGLE_MODEL=gemini-2.5-flash -# HIGHLY RECOMMENDED TO SET Y AFTER INITIAL TESTING ON CLOUD RUN -MINIMAL_LOGGING=N - -# Agent Engine Deployment (without gs://) -#AE_STAGING_BUCKET=your-bucket-name -# If using custom ui, resource name from AE (projects//locations//reasoningEngines/) is needed -#AGENT_ENGINE_RESOURCE_NAME=YOUR_AE_RESOURCE_NAME - - - -# Add Your MCP server variables here, sample provided -# MCP-1 -#LOAD_XDR_MCP=Y -#XDR_CLIENT_ID=abc123 -#XDR_CLIENT_SECRET=xyz456 -# MCP-2 -#LOAD_IDP_MCP=Y -#IDP_CLIENT_ID=abc123 -#IDP_CLIENT_SECRET=xyz456 - - - - +# MCP Server Enablement Flags (Y/N or True/False) +LOAD_SECOPS_MCP=Y +LOAD_SCC_MCP=Y +LOAD_GTI_MCP=N +LOAD_SECOPS_SOAR_MCP=N -``` +# Credentials & Service Account Impersonation +SECOPS_SA_PATH= +GOOGLE_APPLICATION_CREDENTIALS= +SECOPS_IMPERSONATE_SERVICE_ACCOUNT= -Once the variables are updated in `.env`, run the agent (make sure you are in the `mcp-security/run-with-google-adk` directory). +# Google SecOps (Chronicle SIEM) Settings +CHRONICLE_PROJECT_ID=your-chronicle-project-id +CHRONICLE_CUSTOMER_ID=your-chronicle-customer-id +CHRONICLE_REGION=us -```bash -# Authenticate to use Google Cloud / SecOps APIs -# Skip if running in Google Cloud Shell -gcloud auth application-default login +# Google Threat Intelligence (GTI / VirusTotal) +VT_APIKEY=your-virustotal-api-key -# Start interactive terminal chat -uv run mcp-security-agent chat +# SecOps SOAR Settings +SOAR_URL=https://your-soar-tenant.siemplify-soar.com +SOAR_APP_KEY=your-soar-app-key -# Or start the ADK Web interface -adk web src/mcp_security_agent +# Runtime Settings +STDIO_PARAM_TIMEOUT=60.0 +MINIMAL_LOGGING=N ``` -Access the agent interface by navigating to `http://localhost:8000`. +### Environment Variable Reference + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `GOOGLE_CLOUD_PROJECT` | None | Google Cloud Project ID for Vertex AI and Cloud Run deployment. | +| `GOOGLE_CLOUD_LOCATION` | `us-central1` | Google Cloud region for Vertex AI endpoints. | +| `GOOGLE_GENAI_USE_VERTEXAI`| `False` | Set `True` to route LLM requests through Vertex AI (uses ADC). Set `False` to use Gemini API (requires `GOOGLE_API_KEY`). | +| `GOOGLE_API_KEY` | None | Gemini API Key (required when `GOOGLE_GENAI_USE_VERTEXAI=False`). | +| `GOOGLE_MODEL` | `gemini-2.5-flash` | Gemini model name (e.g. `gemini-2.5-flash`, `gemini-2.5-pro`). | +| `LOAD_SECOPS_MCP` | `False` | Enables Chronicle SIEM MCP (`server/secops`). | +| `LOAD_SCC_MCP` | `False` | Enables Security Command Center MCP (`server/scc`). | +| `LOAD_GTI_MCP` | `False` | Enables Google Threat Intelligence MCP (`server/gti`). | +| `LOAD_SECOPS_SOAR_MCP` | `False` | Enables SecOps SOAR MCP (`server/secops-soar`). | +| `CHRONICLE_PROJECT_ID` | None | GCP project ID hosting Chronicle SIEM. | +| `CHRONICLE_CUSTOMER_ID` | None | Chronicle Customer ID (UUID). | +| `CHRONICLE_REGION` | `us` | Chronicle regional gateway (`us`, `europe`, `asia`). | +| `VT_APIKEY` | None | VirusTotal / GTI API Key. | +| `SOAR_URL` | None | Instance URL for SecOps SOAR (Siemplify). | +| `SOAR_APP_KEY` | None | API Key for SecOps SOAR. | +| `GOOGLE_APPLICATION_CREDENTIALS` | Auto-detected | Path to service account key file or local `.gcloud/application_default_credentials.json`. | +| `STDIO_PARAM_TIMEOUT` | `60.0` | Timeout in seconds for MCP subprocess initialization and tool execution. | +| `MINIMAL_LOGGING` | `False` | Reduces logging verbosity to suppress sensitive query content in production. | -> **NOTE:** -> First response usually takes a moment as the agent connects to the configured MCP server(s) and initializes tool schemas. - -> **CAUTION:** -> In case an investigation seems stuck or an error occurs on the console, you can ask a follow-up question like `Are you still there?` or `Can you retry that?`. You can also enable token streaming in the ADK UI. - -#### Running Agent with Custom Session and Artifact Services +--- -Google ADK provides persistent [sessions](https://google.github.io/adk-docs/sessions/) and [artifacts](https://google.github.io/adk-docs/artifacts/). +## Running the Web UI & API Server -You can run the agent with the session and artifact service of your choice: +The built-in FastAPI server provides an interactive web dashboard and REST / Server-Sent Events (SSE) API endpoints. ```bash -# Run with SQLite session storage and GCS artifact bucket -adk web src/mcp_security_agent --session_service_uri sqlite:///./app_data.db --artifact_service_uri gs:// - -# Run with SQLite session storage only -adk web src/mcp_security_agent --session_service_uri sqlite:///./app_data.db +uv run mcp-security-agent serve --port 8080 ``` -When the artifact service is backed by GCS, signed URLs allow easy file sharing. Grant the runtime service account the `roles/storage.objectViewer` role. - - -## 2. Running Agent as a Cloud Run Service +Open `http://localhost:8080` in your browser to access the SOC Agent UI. -The agent with MCP servers can be deployed as a Cloud Run Service, right from within the source code directory. +### REST & Streaming Endpoints -Before you do this, please consider following +* **`GET /`**: Serves the bundled web landing page and interactive investigation console. +* **`GET /healthz`**: Liveness & readiness probe returning `{"status": "ok"}` for Cloud Run. +* **`GET /info`**: Returns JSON metadata including package version, active model, and toolset configurations. +* **`POST /chat`**: Synchronous chat endpoint accepting `{"prompt": "string", "session_id": "optional"}`. +* **`GET /chat/stream?prompt=...`**: Server-Sent Events (SSE) token streaming endpoint. -1. Do you really need it? Deployment is recommended in scenarios where you need to share agent with your team members who may not have access to all of the backend services (SCC, SecOps - SIEM, SecOps - SOAR, Google Threat Intelligence) -2. Make sure that after initial testing - 1. Require authentication for your agent (steps provided [below](#restrict-service-to-known-developers--testers)) - 2. Implement restrictive logging (steps provided [below](#adjust-logging-verbosity)) - -### Prerequisites - -1. Must have locally run the ADK based agent successfully at least once. Environment variables `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` should have valid values. -2. Must have required APIs enabled and proper IAM access ([details](https://cloud.google.com/run/docs/deploying-source-code#before_you_begin)) +--- -### Costs -In addition to Gemini/ Vertex API costs, running agent will incur cloud costs. Please check [Cloud Run Pricing](https://cloud.google.com/run/pricing). +## Deploying to Google Cloud Run -> ⚠️ **WARNING:** -> It is not recommended to run the a Cloud Run service with unauthenticated invocations enabled (we do that initially for verification). Please follow steps to enable [IAM authentication](https://cloud.google.com/run/docs/authenticating/developers) on your service. You could also deploy it behind the [Identity Aware Proxy (IAP)](https://cloud.google.com/iap/docs/enabling-cloud-run) - but that is out of scope for this documentation. +The package includes a production-ready [`Dockerfile`](./Dockerfile) configured to run `mcp-security-agent serve` on Cloud Run. -### Deployment Steps +### 1. Build and Deploy -> **NOTE:** -> It is recommended to switch to Vertex AI (with `GOOGLE_GENAI_USE_VERTEXAI=True`) when deploying to Cloud Run. +Deploy the service directly from the repository root: ```bash -# Build and deploy the container directly to Cloud Run gcloud run deploy mcp-security-agent-service \ --source . \ --region us-central1 \ --allow-unauthenticated \ - --set-env-vars="LOAD_SECOPS_MCP=Y,LOAD_SCC_MCP=Y,LOAD_GTI_MCP=Y,GOOGLE_GENAI_USE_VERTEXAI=True" -``` - -Now, you can verify the service by browsing to the service endpoint URL. - -### IAM access to use Chronicle and SCC - -Please remember that Cloud Run uses default service account of compute engine service. Go to IAM and provide the service account access to "Chronicle API Viewer" (in the project associated with your SecOps instance) and appropriate role for SCC (roles starting with Security Center in IAM) - - -### Restrict Service To Known Developers / Testers - -Summarizing the steps from [IAM authentication](https://cloud.google.com/run/docs/authenticating/developers) - -1. Goto Cloud Run - Services - click `mcp-security-agent-service` -2. Click `Security` -3. In `Authentication`, `Use Cloud IAM to authenticate incoming requests` should be already selected. -4. Select the radio button `Require authentication` -5. Click `Save` -6. Cloud Run - Services - select `mcp-security-agent-service` -7. At the top click `permissions`, a pane `Permissions for mcp-security-agent-service` should open on the right hand side. -8. Click `Add principal` -9. Add the users you want to provide access to and provide them `Cloud Run Invoker` role. -10. Wait for some time. - -### Accessing the restricted service - -1. Ask your users to run the following command (replace project id and region with the project id & region in which you have deployed the service) - -```bash -gcloud run services proxy mcp-security-agent-service --project PROJECT-ID --region YOUR-REGION - + --set-env-vars="LOAD_SECOPS_MCP=Y,LOAD_SCC_MCP=Y,GOOGLE_GENAI_USE_VERTEXAI=True,GOOGLE_CLOUD_PROJECT=YOUR_PROJECT_ID,CHRONICLE_PROJECT_ID=YOUR_PROJECT_ID,CHRONICLE_CUSTOMER_ID=YOUR_CUSTOMER_ID" ``` -2. Now they can access the Cloud Run Service locally on `http://localhost:8080` - - -### Vertically scaling your container(s) -In case the Cloud Run logs show errors like below, you can consider increasing the resources for the individual containers - -`Memory limit of 512 MiB exceeded with 543 MiB used. Consider increasing the memory limit, see https://cloud.google.com/run/docs/configuring/memory-limits` -##### Steps +### 2. IAM Roles -1. Goto Cloud Run - Services - click `mcp-security-agent-service` -2. Click `Edit & deploy new revision` -3. In `Container(s)` - `Edit Container(s)` - `Settings` -4. Add resources by updating either Memory/ CPU or both. +Ensure the runtime Service Account used by Cloud Run has the appropriate IAM permissions: +* **Chronicle API Viewer** (`roles/chronicle.viewer`) on the project hosting Chronicle. +* **Security Center Finding Viewer** (`roles/securitycenter.findingsViewer`) for SCC findings. +* **Vertex AI User** (`roles/aiplatform.user`) for Vertex AI model execution. -### Adjust Logging Verbosity -Since the entire context and response from the LLM is printed as logs. You might end up logging some sensitive information. Setting the environment variable `MINIMAL_LOGGING` to `Y` should fix this issue. This should also reduce cloud logging costs. Please do this once you have verified the service initially. Changes to be made directly on Cloud Run service and it will result in restarting the service. Verify service logs after the change is made. +### 3. Restricting Access in Production -## 3. Deploying and Running Agent on Agent Engine +To protect the service in production: +1. In Cloud Run, select `mcp-security-agent-service` and navigate to **Security**. +2. Select **Require authentication**. +3. Under **Permissions**, grant `Cloud Run Invoker` (`roles/run.invoker`) to authorized users. +4. Authorized users can securely access the service locally via proxy: + ```bash + gcloud run services proxy mcp-security-agent-service --project YOUR_PROJECT_ID --region us-central1 + ``` + The service will then be reachable at `http://localhost:8080`. -The agent can also be deployed on [Vertex AI Agent Engine](https://cloud.google.com/vertex-ai/generative-ai/docs/agent-engine/overview> **NOTE:** -> Currently the GCS backed artifact service is not available on Agent Engine. +### 4. Memory Limits & Logging Verbosity -Here are the deployment steps: - -1. Test locally at least once using `mcp-security-agent chat` or `mcp-security-agent serve`. -2. Ensure environment variables `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` are configured. -3. Deploy the agent to Vertex AI Agent Engine using the Google Cloud SDK or ADK CLI. -4. Verify the agent on the [Vertex AI Agent Engine Console](https://console.cloud.google.com/vertex-ai/agents/agent-engines). - -### How to Test - -You can interact with the deployed backend via the bundled web interface: - -1. Update the environment variable `AGENT_ENGINE_RESOURCE_NAME` with your reasoning engine resource path. -2. Start the local server: `uv run mcp-security-agent serve` -3. Access the UI locally at `http://localhost:8080` (or configured port). - ---- - -## 4. Improving Performance and Optimizing Costs - -By default, the agent sends the active conversation context to the LLM. - -A user interaction involves: -1. User query (e.g., `Let's investigate case 146`) -2. Initial LLM call with System Prompt, User Query, and Tool definitions resulting in function call requests (e.g., `get_case_details`) -3. Agent executing MCP tool requests -4. LLM processing tool outputs and generating the final response - -By tweaking the environment variable `MAX_PREV_USER_INTERACTIONS` (default: 3), you can control the conversation history sent to the LLM to optimize latency and token costs. - ---- +* If MCP toolsets process large result sets, consider increasing container memory in Cloud Run Settings to 1 GiB or 2 GiB. +* In production, set `MINIMAL_LOGGING=Y` to suppress full LLM prompt/response logging and reduce Cloud Logging costs. -## 5. Integrating Custom MCP Servers +## Integrating Custom MCP Servers -If your organization uses additional security products (such as identity providers or third-party EDRs), integrating them with Google Security MCP servers provides: +You can integrate custom security products (such as internal IdPs, custom EDRs, or ticketing systems) using modular sub-agents. -1. A unified investigation interface breaking down organizational silos. -2. Automated cross-tool correlation between SIEM alerts, SCC findings, GTI threat intelligence, and IDP accounts. +Reference implementations are provided in [`sample_servers_to_integrate/`](./sample_servers_to_integrate/): +* **Sample MCP Servers**: [`sample_servers_to_integrate/mcp_servers/`](./sample_servers_to_integrate/mcp_servers/) (`demo_idp` and `demo_xdr`). +* **Sample Sub-Agents**: [`sample_servers_to_integrate/agents/`](./sample_servers_to_integrate/agents/) (`demo_idp_agent.py` and `demo_xdr_agent.py`). -### Reference Integration Templates - -Reference templates are provided in `run-with-google-adk/sample_servers_to_integrate/`: - -1. Inspect sample MCP servers in `run-with-google-adk/sample_servers_to_integrate/mcp_servers/` (`demo_idp` and `demo_xdr`). -2. Inspect sample sub-agents in `run-with-google-adk/sample_servers_to_integrate/agents/` (`demo_idp_agent.py` and `demo_xdr_agent.py`). -3. Connect sub-agents into `src/mcp_security_agent/agent.py` using native ADK `sub_agents`: +To attach them to the primary agent in [`src/mcp_security_agent/agent.py`](./src/mcp_security_agent/agent.py): ```python -# src/mcp_security_agent/agent.py from sample_servers_to_integrate.agents.demo_idp_agent import create_demo_idp_agent from sample_servers_to_integrate.agents.demo_xdr_agent import create_demo_xdr_agent idp_agent = create_demo_idp_agent() xdr_agent = create_demo_xdr_agent() -# Add to sub_agents list when instantiating LlmAgent agent = LlmAgent( name="SecurityOperationsAgent", model=settings.google_model, @@ -334,8 +264,7 @@ agent = LlmAgent( ) ``` -Configure corresponding environment variables in `.env`: - +Configure credentials in `.env`: ```properties LOAD_XDR_MCP=Y XDR_CLIENT_ID=demo_client_id @@ -346,119 +275,25 @@ IDP_CLIENT_ID=demo_client_id IDP_CLIENT_SECRET=demo_client_secret ``` -You can now query the agent locally: -* `Check alerts for web-server-iowa in demo xdr` -* `Find recent logins for user oleg in IDP` +Sample reference interfaces: -> **NOTE:** -> Once tested, you can attach production MCP servers following this modular pattern. - -Reference architecture screenshots: - -Sample XDR: +**Sample XDR Integration:** ![](./static/demo-xdr.png) -Sample IDP: +**Sample IDP Integration:** ![](./static/demo-idp.png) +--- -## 6. Additional Features - -The prebuilt agent also allows creating files and signed URLs to these files. A possible scenario is when you want to create a report. You can say "add the summary as markdown to summary_146.md". This creates a file and saves it using the artifact service. You can later ask for a shareable link to this file - "create a link to file summary_146.md" - -## 7. Registering Agent Engine Agent to AgentSpace - -1. When an agent is deployed on Agent Engine ([guide](#3-deploying-and-running-agent-on-agent-engine)) you get a resource name. Make sure you have it to carry out next steps -2. Go to the Agentspace [page](https://console.cloud.google.com/gen-app-builder/engines) in Google Cloud Console. -3. Create an App (Type - AgentSpace) -4. Note down the app details including the app name (e.g. google-security-agent-app_1750057151234) -5. Make sure that you have the Agent Space Admin role while performing the following actions -6. Enable Discovery Engine API for your project -7. Provide the following roles to the Discovery Engine Service Account - Vertex AI viewer - Vertex AI user -8. Please note that these roles need to be provided into the project housing your Agent Engine Agent. Also you need to enable the show Google provided role grants to access the Discovery Engine Service Account. -9. Now to register the agent and make it available to your application use the following shell script. Please replace the variables `AGENT_SPACE_PROJECT_ID ,AGENT_SPACE_APP_NAME ,AGENT_ENGINE_PROJECT_NUMBER , AGENT_LOCATION` and `REASONING_ENGINE_NUMBER` before running the script. - -```bash -#!/bin/bash - -TARGET_URL="https://discoveryengine.googleapis.com/v1alpha/projects/AGENT_SPACE_PROJECT_ID/locations/global/collections/default_collection/engines/AGENT_SPACE_APP_NAME/assistants/default_assistant/agents" # - -JSON_DATA=$(cat < Date: Tue, 8 Sep 2026 22:00:18 +0000 Subject: [PATCH 07/27] fix(adk): default LOAD_GTI_MCP to N in sample.env to prevent startup errors without VT API key TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- run-with-google-adk/sample.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run-with-google-adk/sample.env b/run-with-google-adk/sample.env index 58b910d0..b0752c59 100644 --- a/run-with-google-adk/sample.env +++ b/run-with-google-adk/sample.env @@ -8,7 +8,7 @@ GOOGLE_MODEL=gemini-2.5-flash # MCP Server Enablement Flags (Y/N or True/False) LOAD_SECOPS_MCP=Y LOAD_SCC_MCP=Y -LOAD_GTI_MCP=Y +LOAD_GTI_MCP=N LOAD_SECOPS_SOAR_MCP=N # Credentials & Service Account Impersonation From 860ec720208c3f8854569b2e77547089fdc14561 Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 22:04:30 +0000 Subject: [PATCH 08/27] feat(adk): auto-detect MCP tool enablement from credentials with explicit override support - Auto-enable GTI tools if VT_APIKEY is populated (and not placeholder NOT_SET) - Auto-enable SOAR tools if SOAR_URL and SOAR_APP_KEY are populated - Auto-enable SecOps SIEM tools if CHRONICLE_PROJECT_ID and CHRONICLE_CUSTOMER_ID are populated - Respect explicit LOAD_*_MCP environment variables or CLI flags (--no-gti, --no-soar, etc.) when provided - Update sample.env and README.md to document credential auto-detection - Add comprehensive test coverage in test_config.py TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- run-with-google-adk/README.md | 34 ++++++----- run-with-google-adk/sample.env | 22 +++---- .../src/mcp_security_agent/config.py | 57 +++++++++++++++--- run-with-google-adk/tests/test_config.py | 58 +++++++++++++++++++ 4 files changed, 137 insertions(+), 34 deletions(-) diff --git a/run-with-google-adk/README.md b/run-with-google-adk/README.md index 2d6c67d0..ef797e71 100644 --- a/run-with-google-adk/README.md +++ b/run-with-google-adk/README.md @@ -124,28 +124,30 @@ GOOGLE_GENAI_USE_VERTEXAI=False GOOGLE_API_KEY=your-gemini-api-key GOOGLE_MODEL=gemini-2.5-flash -# MCP Server Enablement Flags (Y/N or True/False) -LOAD_SECOPS_MCP=Y +# MCP Server Enablement Flags +# (SCC uses Google Cloud ADC. SecOps, GTI, and SOAR auto-enable when credentials below are populated. +# You can explicitly set LOAD_*_MCP to Y or N to override auto-detection.) LOAD_SCC_MCP=Y -LOAD_GTI_MCP=N -LOAD_SECOPS_SOAR_MCP=N +# LOAD_SECOPS_MCP= +# LOAD_GTI_MCP= +# LOAD_SECOPS_SOAR_MCP= # Credentials & Service Account Impersonation SECOPS_SA_PATH= GOOGLE_APPLICATION_CREDENTIALS= SECOPS_IMPERSONATE_SERVICE_ACCOUNT= -# Google SecOps (Chronicle SIEM) Settings +# Google SecOps (Chronicle SIEM) Settings - Populating enables SecOps MCP CHRONICLE_PROJECT_ID=your-chronicle-project-id CHRONICLE_CUSTOMER_ID=your-chronicle-customer-id CHRONICLE_REGION=us -# Google Threat Intelligence (GTI / VirusTotal) -VT_APIKEY=your-virustotal-api-key +# Google Threat Intelligence (GTI / VirusTotal) - Populating enables GTI MCP +VT_APIKEY= -# SecOps SOAR Settings -SOAR_URL=https://your-soar-tenant.siemplify-soar.com -SOAR_APP_KEY=your-soar-app-key +# SecOps SOAR Settings - Populating enables SOAR MCP +SOAR_URL= +SOAR_APP_KEY= # Runtime Settings STDIO_PARAM_TIMEOUT=60.0 @@ -161,15 +163,15 @@ MINIMAL_LOGGING=N | `GOOGLE_GENAI_USE_VERTEXAI`| `False` | Set `True` to route LLM requests through Vertex AI (uses ADC). Set `False` to use Gemini API (requires `GOOGLE_API_KEY`). | | `GOOGLE_API_KEY` | None | Gemini API Key (required when `GOOGLE_GENAI_USE_VERTEXAI=False`). | | `GOOGLE_MODEL` | `gemini-2.5-flash` | Gemini model name (e.g. `gemini-2.5-flash`, `gemini-2.5-pro`). | -| `LOAD_SECOPS_MCP` | `False` | Enables Chronicle SIEM MCP (`server/secops`). | -| `LOAD_SCC_MCP` | `False` | Enables Security Command Center MCP (`server/scc`). | -| `LOAD_GTI_MCP` | `False` | Enables Google Threat Intelligence MCP (`server/gti`). | -| `LOAD_SECOPS_SOAR_MCP` | `False` | Enables SecOps SOAR MCP (`server/secops-soar`). | +| `LOAD_SCC_MCP` | `False` | Enables Security Command Center MCP (`server/scc`). Uses ADC. | +| `LOAD_SECOPS_MCP` | Auto | Enables Chronicle SIEM MCP. Auto-enables if `CHRONICLE_PROJECT_ID` and `CUSTOMER_ID` are set. | +| `LOAD_GTI_MCP` | Auto | Enables Google Threat Intelligence MCP. Auto-enables if `VT_APIKEY` is set. | +| `LOAD_SECOPS_SOAR_MCP` | Auto | Enables SecOps SOAR MCP. Auto-enables if `SOAR_URL` and `SOAR_APP_KEY` are set. | | `CHRONICLE_PROJECT_ID` | None | GCP project ID hosting Chronicle SIEM. | | `CHRONICLE_CUSTOMER_ID` | None | Chronicle Customer ID (UUID). | | `CHRONICLE_REGION` | `us` | Chronicle regional gateway (`us`, `europe`, `asia`). | -| `VT_APIKEY` | None | VirusTotal / GTI API Key. | -| `SOAR_URL` | None | Instance URL for SecOps SOAR (Siemplify). | +| `VT_APIKEY` | None | VirusTotal / GTI API Key (presence auto-activates GTI tools). | +| `SOAR_URL` | None | Instance URL for SecOps SOAR (presence auto-activates SOAR tools). | | `SOAR_APP_KEY` | None | API Key for SecOps SOAR. | | `GOOGLE_APPLICATION_CREDENTIALS` | Auto-detected | Path to service account key file or local `.gcloud/application_default_credentials.json`. | | `STDIO_PARAM_TIMEOUT` | `60.0` | Timeout in seconds for MCP subprocess initialization and tool execution. | diff --git a/run-with-google-adk/sample.env b/run-with-google-adk/sample.env index b0752c59..a2c2b4f0 100644 --- a/run-with-google-adk/sample.env +++ b/run-with-google-adk/sample.env @@ -5,28 +5,30 @@ GOOGLE_GENAI_USE_VERTEXAI=False GOOGLE_API_KEY=your-gemini-api-key GOOGLE_MODEL=gemini-2.5-flash -# MCP Server Enablement Flags (Y/N or True/False) -LOAD_SECOPS_MCP=Y +# MCP Server Enablement Flags +# (SCC uses Google Cloud ADC. SecOps, GTI, and SOAR auto-enable when credentials below are populated. +# You can explicitly set LOAD_*_MCP to Y or N to override auto-detection.) LOAD_SCC_MCP=Y -LOAD_GTI_MCP=N -LOAD_SECOPS_SOAR_MCP=N +# LOAD_SECOPS_MCP= +# LOAD_GTI_MCP= +# LOAD_SECOPS_SOAR_MCP= # Credentials & Service Account Impersonation SECOPS_SA_PATH= GOOGLE_APPLICATION_CREDENTIALS= SECOPS_IMPERSONATE_SERVICE_ACCOUNT= -# Google SecOps (Chronicle SIEM) Settings +# Google SecOps (Chronicle SIEM) Settings - Populating enables SecOps MCP CHRONICLE_PROJECT_ID=your-chronicle-project-id CHRONICLE_CUSTOMER_ID=your-chronicle-customer-id CHRONICLE_REGION=us -# Google Threat Intelligence (GTI / VirusTotal) -VT_APIKEY=your-virustotal-api-key +# Google Threat Intelligence (GTI / VirusTotal) - Populating enables GTI MCP +VT_APIKEY= -# SecOps SOAR Settings -SOAR_URL=https://your-soar-tenant.siemplify-soar.com -SOAR_APP_KEY=your-soar-app-key +# SecOps SOAR Settings - Populating enables SOAR MCP +SOAR_URL= +SOAR_APP_KEY= # Runtime Settings STDIO_PARAM_TIMEOUT=60.0 diff --git a/run-with-google-adk/src/mcp_security_agent/config.py b/run-with-google-adk/src/mcp_security_agent/config.py index a3467728..f6a7a5c5 100644 --- a/run-with-google-adk/src/mcp_security_agent/config.py +++ b/run-with-google-adk/src/mcp_security_agent/config.py @@ -15,7 +15,7 @@ from pathlib import Path from typing import Optional -from pydantic import Field, field_validator +from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict _pkg_dir = Path(__file__).resolve().parents[2] @@ -26,6 +26,14 @@ ) +def _is_configured(val: Optional[str]) -> bool: + """Checks if a configuration string is non-empty and not a placeholder like NOT_SET.""" + if not val: + return False + clean = val.strip() + return bool(clean and clean.upper() not in ("NOT_SET", "NONE", "NULL", "")) + + def _discover_local_adc() -> Optional[str]: """Finds local .gcloud/application_default_credentials.json if present.""" candidates = [ @@ -56,11 +64,11 @@ class AgentSettings(BaseSettings): google_api_key: Optional[str] = Field(default=None, alias="GOOGLE_API_KEY") google_model: str = Field(default="gemini-2.5-flash", alias="GOOGLE_MODEL") - # MCP Server Enablement Flags - load_secops_mcp: bool = Field(default=False, alias="LOAD_SECOPS_MCP") + # MCP Server Enablement Flags (Optional; auto-detected from credentials if None) + load_secops_mcp: Optional[bool] = Field(default=None, alias="LOAD_SECOPS_MCP") load_scc_mcp: bool = Field(default=False, alias="LOAD_SCC_MCP") - load_gti_mcp: bool = Field(default=False, alias="LOAD_GTI_MCP") - load_secops_soar_mcp: bool = Field(default=False, alias="LOAD_SECOPS_SOAR_MCP") + load_gti_mcp: Optional[bool] = Field(default=None, alias="LOAD_GTI_MCP") + load_secops_soar_mcp: Optional[bool] = Field(default=None, alias="LOAD_SECOPS_SOAR_MCP") # Remote MCP URLs (for SSE/HTTP remote endpoints) secops_mcp_url: Optional[str] = Field(default=None, alias="SECOPS_MCP_URL") @@ -79,6 +87,20 @@ def __init__(self, **values): super().__init__(**values) self.bootstrap_environment() + @model_validator(mode="after") + def resolve_tool_enablement(self) -> "AgentSettings": + """Auto-detects MCP tool enablement based on presence of API keys and credentials.""" + if self.load_gti_mcp is None: + self.load_gti_mcp = _is_configured(self.vt_apikey) + + if self.load_secops_soar_mcp is None: + self.load_secops_soar_mcp = _is_configured(self.soar_url) and _is_configured(self.soar_app_key) + + if self.load_secops_mcp is None: + self.load_secops_mcp = _is_configured(self.chronicle_project_id) and _is_configured(self.chronicle_customer_id) + + return self + def bootstrap_environment(self) -> None: """Configures environment variables for Google Cloud authentication and Cloudtop compatibility.""" import os @@ -117,12 +139,31 @@ def bootstrap_environment(self) -> None: default_prompt: Optional[str] = Field(default=None, alias="DEFAULT_PROMPT") @field_validator( - "load_secops_mcp", "load_scc_mcp", "load_gti_mcp", "load_secops_soar_mcp", - "use_vertex_ai", "minimal_logging", + "load_secops_mcp", "load_gti_mcp", "load_secops_soar_mcp", + mode="before" + ) + @classmethod + def parse_optional_bool_env(cls, value: object) -> Optional[bool]: + if value is None: + return None + if isinstance(value, str): + val_clean = value.strip().upper() + if not val_clean: + return None + return val_clean in ("Y", "YES", "TRUE", "1") + return bool(value) + + @field_validator( + "load_scc_mcp", "use_vertex_ai", "minimal_logging", mode="before" ) @classmethod def parse_bool_env(cls, value: object) -> bool: + if value is None: + return False if isinstance(value, str): - return value.strip().upper() in ("Y", "YES", "TRUE", "1") + val_clean = value.strip().upper() + if not val_clean: + return False + return val_clean in ("Y", "YES", "TRUE", "1") return bool(value) diff --git a/run-with-google-adk/tests/test_config.py b/run-with-google-adk/tests/test_config.py index ee850797..4aad14ae 100644 --- a/run-with-google-adk/tests/test_config.py +++ b/run-with-google-adk/tests/test_config.py @@ -40,3 +40,61 @@ def test_env_override_settings(): assert settings.load_scc_mcp is True assert settings.secops_impersonate_service_account == "test-sa@proj.iam.gserviceaccount.com" assert settings.stdio_timeout_seconds == 120.5 + + +def test_tool_auto_detection_from_credentials(): + with patch.dict( + os.environ, + { + "VT_APIKEY": "valid_virustotal_api_key_12345", + "SOAR_URL": "https://tenant.siemplify-soar.com", + "SOAR_APP_KEY": "valid_soar_key_67890", + "CHRONICLE_PROJECT_ID": "my-chronicle-project", + "CHRONICLE_CUSTOMER_ID": "my-chronicle-customer-uuid", + }, + clear=True, + ): + settings = AgentSettings(_env_file=None) + assert settings.load_gti_mcp is True + assert settings.load_secops_soar_mcp is True + assert settings.load_secops_mcp is True + assert settings.load_scc_mcp is False + + +def test_tool_auto_detection_ignores_placeholders(): + with patch.dict( + os.environ, + { + "VT_APIKEY": "NOT_SET", + "SOAR_URL": "NOT_SET", + "SOAR_APP_KEY": "NOT_SET", + "CHRONICLE_PROJECT_ID": "NOT_SET", + "CHRONICLE_CUSTOMER_ID": "NOT_SET", + }, + clear=True, + ): + settings = AgentSettings(_env_file=None) + assert settings.load_gti_mcp is False + assert settings.load_secops_soar_mcp is False + assert settings.load_secops_mcp is False + + +def test_tool_explicit_override_overrules_credentials(): + with patch.dict( + os.environ, + { + "VT_APIKEY": "valid_virustotal_api_key_12345", + "LOAD_GTI_MCP": "N", + "SOAR_URL": "https://tenant.siemplify-soar.com", + "SOAR_APP_KEY": "valid_soar_key_67890", + "LOAD_SECOPS_SOAR_MCP": "False", + "CHRONICLE_PROJECT_ID": "my-chronicle-project", + "CHRONICLE_CUSTOMER_ID": "my-chronicle-customer-uuid", + "LOAD_SECOPS_MCP": "0", + }, + clear=True, + ): + settings = AgentSettings(_env_file=None) + assert settings.load_gti_mcp is False + assert settings.load_secops_soar_mcp is False + assert settings.load_secops_mcp is False From bd42600c2141e734dbcd9860e6898121d8b8d56f Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 22:08:42 +0000 Subject: [PATCH 09/27] refactor(adk): completely remove NOT_SET sentinel logic in favor of clean empty checks TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- run-with-google-adk/src/mcp_security_agent/config.py | 7 ++----- run-with-google-adk/tests/test_config.py | 12 ++++++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/run-with-google-adk/src/mcp_security_agent/config.py b/run-with-google-adk/src/mcp_security_agent/config.py index f6a7a5c5..e3931f40 100644 --- a/run-with-google-adk/src/mcp_security_agent/config.py +++ b/run-with-google-adk/src/mcp_security_agent/config.py @@ -27,11 +27,8 @@ def _is_configured(val: Optional[str]) -> bool: - """Checks if a configuration string is non-empty and not a placeholder like NOT_SET.""" - if not val: - return False - clean = val.strip() - return bool(clean and clean.upper() not in ("NOT_SET", "NONE", "NULL", "")) + """Checks if a configuration string is set and non-empty.""" + return bool(val and val.strip()) def _discover_local_adc() -> Optional[str]: diff --git a/run-with-google-adk/tests/test_config.py b/run-with-google-adk/tests/test_config.py index 4aad14ae..f36a9de8 100644 --- a/run-with-google-adk/tests/test_config.py +++ b/run-with-google-adk/tests/test_config.py @@ -61,15 +61,15 @@ def test_tool_auto_detection_from_credentials(): assert settings.load_scc_mcp is False -def test_tool_auto_detection_ignores_placeholders(): +def test_tool_auto_detection_empty_or_whitespace_values(): with patch.dict( os.environ, { - "VT_APIKEY": "NOT_SET", - "SOAR_URL": "NOT_SET", - "SOAR_APP_KEY": "NOT_SET", - "CHRONICLE_PROJECT_ID": "NOT_SET", - "CHRONICLE_CUSTOMER_ID": "NOT_SET", + "VT_APIKEY": " ", + "SOAR_URL": "", + "SOAR_APP_KEY": " ", + "CHRONICLE_PROJECT_ID": "", + "CHRONICLE_CUSTOMER_ID": "", }, clear=True, ): From 6a29abbb269d8203dd61fa0534baf5f77697f97a Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 22:21:52 +0000 Subject: [PATCH 10/27] fix(adk): address code review feedback on credential decoupling, CLI options, and env precedence - Decouple CHRONICLE_PROJECT_ID from GOOGLE_CLOUD_PROJECT in MCP stdio subprocess environment, preventing cross-tenant project clobbering for SCC. - Propagate SECOPS_SA_PATH and SECOPS_IMPERSONATE_SERVICE_ACCOUNT to child MCP processes. - Use env.setdefault for mTLS bypass environment variables. - Fix .env precedence order in config.py so local working directory configuration overrides repo-root settings. - Fix bootstrap_environment to properly export GOOGLE_API_KEY to os.environ and avoid forcing Vertex AI when an API key is provided. - Expose --vertex/--no-vertex, --model, --project, and --customer-id flags on CLI chat and serve commands, plus --reload and $PORT fallback on serve. - Make root_agent in agent.py lazily instantiated via module __getattr__ to avoid premature default creation at import time. - Remove non-empty placeholders from sample.env to prevent false auto-detection upon copying to .env. - Correct SSE streaming endpoint syntax and testing instructions in README.md. - Expand unit test suite to 30 tests covering credential propagation, CLI overrides, and environment bootstrapping. TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- run-with-google-adk/README.md | 14 +-- run-with-google-adk/sample.env | 8 +- .../src/mcp_security_agent/agent.py | 18 +++- .../src/mcp_security_agent/cli.py | 92 ++++++++++++++----- .../src/mcp_security_agent/config.py | 14 ++- .../src/mcp_security_agent/toolsets.py | 34 +++++-- run-with-google-adk/tests/test_cli.py | 54 +++++++++++ run-with-google-adk/tests/test_config.py | 22 +++++ run-with-google-adk/tests/test_toolsets.py | 37 ++++++++ 9 files changed, 243 insertions(+), 50 deletions(-) diff --git a/run-with-google-adk/README.md b/run-with-google-adk/README.md index ef797e71..3b63cc3d 100644 --- a/run-with-google-adk/README.md +++ b/run-with-google-adk/README.md @@ -118,10 +118,10 @@ The agent reads configuration from environment variables and an optional `.env` ```properties # Google Cloud & LLM Settings -GOOGLE_CLOUD_PROJECT=your-gcp-project-id +GOOGLE_CLOUD_PROJECT= GOOGLE_CLOUD_LOCATION=us-central1 GOOGLE_GENAI_USE_VERTEXAI=False -GOOGLE_API_KEY=your-gemini-api-key +GOOGLE_API_KEY= GOOGLE_MODEL=gemini-2.5-flash # MCP Server Enablement Flags @@ -138,8 +138,8 @@ GOOGLE_APPLICATION_CREDENTIALS= SECOPS_IMPERSONATE_SERVICE_ACCOUNT= # Google SecOps (Chronicle SIEM) Settings - Populating enables SecOps MCP -CHRONICLE_PROJECT_ID=your-chronicle-project-id -CHRONICLE_CUSTOMER_ID=your-chronicle-customer-id +CHRONICLE_PROJECT_ID= +CHRONICLE_CUSTOMER_ID= CHRONICLE_REGION=us # Google Threat Intelligence (GTI / VirusTotal) - Populating enables GTI MCP @@ -195,7 +195,7 @@ Open `http://localhost:8080` in your browser to access the SOC Agent UI. * **`GET /healthz`**: Liveness & readiness probe returning `{"status": "ok"}` for Cloud Run. * **`GET /info`**: Returns JSON metadata including package version, active model, and toolset configurations. * **`POST /chat`**: Synchronous chat endpoint accepting `{"prompt": "string", "session_id": "optional"}`. -* **`GET /chat/stream?prompt=...`**: Server-Sent Events (SSE) token streaming endpoint. +* **`GET /chat?message=...`**: Server-Sent Events (SSE) token streaming endpoint. --- @@ -292,10 +292,10 @@ Sample reference interfaces: Run the hermetic test suite: ```bash -uv run pytest tests/ +uv run --extra test pytest tests/ ``` -All 21 unit tests run hermetically using mocked MCP connection parameters and simulated LLM responses. +All 30 unit tests run hermetically using mocked MCP connection parameters and simulated LLM responses. diff --git a/run-with-google-adk/sample.env b/run-with-google-adk/sample.env index a2c2b4f0..ddfea8ea 100644 --- a/run-with-google-adk/sample.env +++ b/run-with-google-adk/sample.env @@ -1,8 +1,8 @@ # Google Cloud & LLM Settings -GOOGLE_CLOUD_PROJECT=your-gcp-project-id +GOOGLE_CLOUD_PROJECT= GOOGLE_CLOUD_LOCATION=us-central1 GOOGLE_GENAI_USE_VERTEXAI=False -GOOGLE_API_KEY=your-gemini-api-key +GOOGLE_API_KEY= GOOGLE_MODEL=gemini-2.5-flash # MCP Server Enablement Flags @@ -19,8 +19,8 @@ GOOGLE_APPLICATION_CREDENTIALS= SECOPS_IMPERSONATE_SERVICE_ACCOUNT= # Google SecOps (Chronicle SIEM) Settings - Populating enables SecOps MCP -CHRONICLE_PROJECT_ID=your-chronicle-project-id -CHRONICLE_CUSTOMER_ID=your-chronicle-customer-id +CHRONICLE_PROJECT_ID= +CHRONICLE_CUSTOMER_ID= CHRONICLE_REGION=us # Google Threat Intelligence (GTI / VirusTotal) - Populating enables GTI MCP diff --git a/run-with-google-adk/src/mcp_security_agent/agent.py b/run-with-google-adk/src/mcp_security_agent/agent.py index 3685261e..3e1bcc8c 100644 --- a/run-with-google-adk/src/mcp_security_agent/agent.py +++ b/run-with-google-adk/src/mcp_security_agent/agent.py @@ -61,5 +61,19 @@ def create_security_agent(settings: Optional[AgentSettings] = None) -> Any: return agent -# Expose root_agent for standard ADK CLI discovery (adk run, adk web) -root_agent = create_security_agent() +# Lazy root_agent instantiation for standard ADK CLI discovery (adk run, adk web) +_root_agent: Optional[Any] = None + + +def __getattr__(name: str) -> Any: + global _root_agent + if name == "root_agent": + if _root_agent is None: + _root_agent = create_security_agent() + return _root_agent + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return ["create_security_agent", "root_agent", "SOC_AGENT_SYSTEM_PROMPT"] + diff --git a/run-with-google-adk/src/mcp_security_agent/cli.py b/run-with-google-adk/src/mcp_security_agent/cli.py index ad2a6646..5672a01a 100644 --- a/run-with-google-adk/src/mcp_security_agent/cli.py +++ b/run-with-google-adk/src/mcp_security_agent/cli.py @@ -45,6 +45,37 @@ def info(): +def _apply_cli_overrides( + secops: Optional[bool] = None, + scc: Optional[bool] = None, + gti: Optional[bool] = None, + soar: Optional[bool] = None, + vertex: Optional[bool] = None, + model: Optional[str] = None, + project: Optional[str] = None, + customer_id: Optional[str] = None, +) -> None: + """Applies CLI flag overrides to environment variables before settings initialization.""" + if secops is not None: + os.environ["LOAD_SECOPS_MCP"] = "Y" if secops else "N" + if scc is not None: + os.environ["LOAD_SCC_MCP"] = "Y" if scc else "N" + if gti is not None: + os.environ["LOAD_GTI_MCP"] = "Y" if gti else "N" + if soar is not None: + os.environ["LOAD_SECOPS_SOAR_MCP"] = "Y" if soar else "N" + if vertex is not None: + os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "TRUE" if vertex else "FALSE" + if model: + os.environ["GOOGLE_MODEL"] = model + if project: + os.environ["GOOGLE_CLOUD_PROJECT"] = project + if "CHRONICLE_PROJECT_ID" not in os.environ: + os.environ["CHRONICLE_PROJECT_ID"] = project + if customer_id: + os.environ["CHRONICLE_CUSTOMER_ID"] = customer_id + + def _display_active_toolsets(settings: AgentSettings) -> None: enabled_tools = [] if settings.load_secops_mcp: @@ -73,16 +104,22 @@ def chat( scc: Optional[bool] = typer.Option(None, "--scc/--no-scc", help="Enable or disable SCC MCP"), gti: Optional[bool] = typer.Option(None, "--gti/--no-gti", help="Enable or disable GTI MCP"), soar: Optional[bool] = typer.Option(None, "--soar/--no-soar", help="Enable or disable SecOps SOAR MCP"), + vertex: Optional[bool] = typer.Option(None, "--vertex/--no-vertex", help="Use Vertex AI for LLM requests"), + model: Optional[str] = typer.Option(None, "--model", help="Gemini model to use (e.g. gemini-2.5-flash)"), + project: Optional[str] = typer.Option(None, "--project", help="Google Cloud project ID"), + customer_id: Optional[str] = typer.Option(None, "--customer-id", help="Chronicle Customer ID (UUID)"), ): """Start an interactive terminal chat session with the SOC agent powered by ADK v2.""" - if secops is not None: - os.environ["LOAD_SECOPS_MCP"] = "Y" if secops else "N" - if scc is not None: - os.environ["LOAD_SCC_MCP"] = "Y" if scc else "N" - if gti is not None: - os.environ["LOAD_GTI_MCP"] = "Y" if gti else "N" - if soar is not None: - os.environ["LOAD_SECOPS_SOAR_MCP"] = "Y" if soar else "N" + _apply_cli_overrides( + secops=secops, + scc=scc, + gti=gti, + soar=soar, + vertex=vertex, + model=model, + project=project, + customer_id=customer_id, + ) settings = AgentSettings() _display_active_toolsets(settings) @@ -126,21 +163,28 @@ def chat( @app.command() def serve( host: str = typer.Option("0.0.0.0", help="Host address to bind"), - port: int = typer.Option(8080, help="Port to listen on"), + port: Optional[int] = typer.Option(None, help="Port to listen on (defaults to $PORT or 8080)"), + reload: bool = typer.Option(False, "--reload", help="Enable auto-reload for development"), secops: Optional[bool] = typer.Option(None, "--secops/--no-secops", help="Enable or disable SecOps SIEM MCP"), scc: Optional[bool] = typer.Option(None, "--scc/--no-scc", help="Enable or disable SCC MCP"), gti: Optional[bool] = typer.Option(None, "--gti/--no-gti", help="Enable or disable GTI MCP"), soar: Optional[bool] = typer.Option(None, "--soar/--no-soar", help="Enable or disable SecOps SOAR MCP"), + vertex: Optional[bool] = typer.Option(None, "--vertex/--no-vertex", help="Use Vertex AI for LLM requests"), + model: Optional[str] = typer.Option(None, "--model", help="Gemini model to use (e.g. gemini-2.5-flash)"), + project: Optional[str] = typer.Option(None, "--project", help="Google Cloud project ID"), + customer_id: Optional[str] = typer.Option(None, "--customer-id", help="Chronicle Customer ID (UUID)"), ): """Run the FastAPI web server and Cloud Run REST API.""" - if secops is not None: - os.environ["LOAD_SECOPS_MCP"] = "Y" if secops else "N" - if scc is not None: - os.environ["LOAD_SCC_MCP"] = "Y" if scc else "N" - if gti is not None: - os.environ["LOAD_GTI_MCP"] = "Y" if gti else "N" - if soar is not None: - os.environ["LOAD_SECOPS_SOAR_MCP"] = "Y" if soar else "N" + _apply_cli_overrides( + secops=secops, + scc=scc, + gti=gti, + soar=soar, + vertex=vertex, + model=model, + project=project, + customer_id=customer_id, + ) settings = AgentSettings() _display_active_toolsets(settings) @@ -151,12 +195,16 @@ def serve( if "mcp_security_agent" in sys.modules: sys.modules["mcp_security_agent"].root_agent = agent - import uvicorn - from mcp_security_agent.server.app import create_app + bind_port = port if port is not None else int(os.environ.get("PORT", 8080)) + console.print(f"[bold green]Starting MCP Security Agent server on {host}:{bind_port}[/bold green]") - app_instance = create_app() - console.print(f"[bold green]Starting MCP Security Agent server on {host}:{port}[/bold green]") - uvicorn.run(app_instance, host=host, port=port) + import uvicorn + if reload: + uvicorn.run("mcp_security_agent.server.app:create_app", factory=True, host=host, port=bind_port, reload=True) + else: + from mcp_security_agent.server.app import create_app + app_instance = create_app() + uvicorn.run(app_instance, host=host, port=bind_port) if __name__ == "__main__": diff --git a/run-with-google-adk/src/mcp_security_agent/config.py b/run-with-google-adk/src/mcp_security_agent/config.py index e3931f40..40c1c135 100644 --- a/run-with-google-adk/src/mcp_security_agent/config.py +++ b/run-with-google-adk/src/mcp_security_agent/config.py @@ -20,9 +20,9 @@ _pkg_dir = Path(__file__).resolve().parents[2] _env_files = ( - ".env", - str(_pkg_dir / ".env"), str(_pkg_dir.parent / ".env"), + str(_pkg_dir / ".env"), + ".env", ) @@ -113,11 +113,15 @@ def bootstrap_environment(self) -> None: if "CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE" not in os.environ: os.environ["CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE"] = "false" + if self.google_api_key and not os.environ.get("GOOGLE_API_KEY"): + os.environ["GOOGLE_API_KEY"] = self.google_api_key + target_project = self.google_cloud_project or os.environ.get("GCP_PROJECT_ID") - if target_project and not os.environ.get("GOOGLE_API_KEY"): + if target_project and not os.environ.get("GOOGLE_CLOUD_PROJECT"): + os.environ["GOOGLE_CLOUD_PROJECT"] = target_project + + if self.use_vertex_ai or (target_project and not self.google_api_key and not os.environ.get("GOOGLE_API_KEY")): os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "TRUE" - if not os.environ.get("GOOGLE_CLOUD_PROJECT"): - os.environ["GOOGLE_CLOUD_PROJECT"] = target_project # Chronicle SIEM Params diff --git a/run-with-google-adk/src/mcp_security_agent/toolsets.py b/run-with-google-adk/src/mcp_security_agent/toolsets.py index d6979ffc..225aad39 100644 --- a/run-with-google-adk/src/mcp_security_agent/toolsets.py +++ b/run-with-google-adk/src/mcp_security_agent/toolsets.py @@ -58,16 +58,30 @@ def _build_stdio_env() -> dict[str, str]: if "CLOUDSDK_CONFIG" not in env: env["CLOUDSDK_CONFIG"] = str(Path(settings.google_application_credentials).parent) - # Propagate Chronicle SIEM & GCP credentials - target_project = ( - settings.chronicle_project_id - or settings.google_cloud_project + # Propagate credentials & impersonation + if settings.secops_sa_path: + env["SECOPS_SA_PATH"] = settings.secops_sa_path + if settings.secops_impersonate_service_account: + env["SECOPS_IMPERSONATE_SERVICE_ACCOUNT"] = settings.secops_impersonate_service_account + + # Propagate GCP Project (for SCC, Vertex AI, and general Cloud SDK) + gcp_project = ( + settings.google_cloud_project or os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("GCP_PROJECT_ID") + or settings.chronicle_project_id ) - if target_project: - env["CHRONICLE_PROJECT_ID"] = target_project - env["GOOGLE_CLOUD_PROJECT"] = target_project + if gcp_project: + env["GOOGLE_CLOUD_PROJECT"] = gcp_project + + # Propagate Chronicle SIEM Project (independent from SCC/GCP project) + chronicle_project = ( + settings.chronicle_project_id + or gcp_project + ) + if chronicle_project: + env["CHRONICLE_PROJECT_ID"] = chronicle_project + if settings.chronicle_customer_id: env["CHRONICLE_CUSTOMER_ID"] = settings.chronicle_customer_id if settings.chronicle_region: @@ -82,9 +96,9 @@ def _build_stdio_env() -> dict[str, str]: env["SOAR_APP_KEY"] = settings.soar_app_key # Cloudtop mTLS bypass - env["GOOGLE_API_USE_CLIENT_CERTIFICATE"] = "false" - env["GOOGLE_API_USE_MTLS_ENDPOINT"] = "never" - env["CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE"] = "false" + env.setdefault("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + env.setdefault("GOOGLE_API_USE_MTLS_ENDPOINT", "never") + env.setdefault("CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE", "false") return env diff --git a/run-with-google-adk/tests/test_cli.py b/run-with-google-adk/tests/test_cli.py index 6601bc33..6c953ce8 100644 --- a/run-with-google-adk/tests/test_cli.py +++ b/run-with-google-adk/tests/test_cli.py @@ -65,3 +65,57 @@ async def fake_run_once(*args, **kwargs): assert len(agent_mod.root_agent.tools) == 0 +def test_cli_chat_config_flags(): + import os + import mcp_security_agent.agent as agent_mod + mock_adk_cli = MagicMock() + async def fake_run_once(*args, **kwargs): + return 0 + mock_adk_cli.run_once_cli = fake_run_once + + with patch.dict(os.environ, {}, clear=True): + with patch.dict("sys.modules", {"google.adk.cli.cli": mock_adk_cli}): + result = runner.invoke(app, [ + "chat", + "--vertex", + "--model", "gemini-2.5-pro", + "--project", "test-project-123", + "--customer-id", "cust-uuid-456", + "test query" + ]) + assert result.exit_code == 0 + assert os.environ["GOOGLE_GENAI_USE_VERTEXAI"] == "TRUE" + assert os.environ["GOOGLE_MODEL"] == "gemini-2.5-pro" + assert os.environ["GOOGLE_CLOUD_PROJECT"] == "test-project-123" + assert os.environ["CHRONICLE_PROJECT_ID"] == "test-project-123" + assert os.environ["CHRONICLE_CUSTOMER_ID"] == "cust-uuid-456" + assert agent_mod.root_agent.model == "gemini-2.5-pro" + + +def test_cli_serve_command(): + import os + mock_uvicorn = MagicMock() + with patch.dict(os.environ, {"PORT": "9090"}, clear=True): + with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): + result = runner.invoke(app, ["serve"]) + assert result.exit_code == 0 + assert "Starting MCP Security Agent server on 0.0.0.0:9090" in result.stdout + mock_uvicorn.run.assert_called_once() + _, kwargs = mock_uvicorn.run.call_args + assert kwargs["port"] == 9090 + + mock_uvicorn.reset_mock() + with patch.dict(os.environ, {}, clear=True): + with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): + result = runner.invoke(app, ["serve", "--port", "7070", "--reload"]) + assert result.exit_code == 0 + assert "Starting MCP Security Agent server on 0.0.0.0:7070" in result.stdout + mock_uvicorn.run.assert_called_once() + args, kwargs = mock_uvicorn.run.call_args + assert args[0] == "mcp_security_agent.server.app:create_app" + assert kwargs["factory"] is True + assert kwargs["port"] == 7070 + assert kwargs["reload"] is True + + + diff --git a/run-with-google-adk/tests/test_config.py b/run-with-google-adk/tests/test_config.py index f36a9de8..7d2006c3 100644 --- a/run-with-google-adk/tests/test_config.py +++ b/run-with-google-adk/tests/test_config.py @@ -98,3 +98,25 @@ def test_tool_explicit_override_overrules_credentials(): assert settings.load_gti_mcp is False assert settings.load_secops_soar_mcp is False assert settings.load_secops_mcp is False + + +def test_bootstrap_environment_with_api_key_preserves_gemini_api(): + with patch.dict(os.environ, {"GOOGLE_API_KEY": "AIzaSyTestKey123", "GOOGLE_CLOUD_PROJECT": "my-gcp-proj"}, clear=True): + settings = AgentSettings(_env_file=None) + assert settings.google_api_key == "AIzaSyTestKey123" + assert os.environ.get("GOOGLE_GENAI_USE_VERTEXAI") is None + assert os.environ.get("GOOGLE_API_KEY") == "AIzaSyTestKey123" + + +def test_bootstrap_environment_vertex_ai_default(): + with patch.dict(os.environ, {"GOOGLE_CLOUD_PROJECT": "my-gcp-proj"}, clear=True): + settings = AgentSettings(_env_file=None) + assert os.environ.get("GOOGLE_GENAI_USE_VERTEXAI") == "TRUE" + assert os.environ.get("GOOGLE_CLOUD_PROJECT") == "my-gcp-proj" + + +def test_env_files_precedence_order(): + from mcp_security_agent.config import _env_files + # Local .env must come after parent .env so pydantic-settings gives it higher precedence + assert _env_files[-1] == ".env" + diff --git a/run-with-google-adk/tests/test_toolsets.py b/run-with-google-adk/tests/test_toolsets.py index a3f6cb64..52b46073 100644 --- a/run-with-google-adk/tests/test_toolsets.py +++ b/run-with-google-adk/tests/test_toolsets.py @@ -30,3 +30,40 @@ def test_build_toolsets_stdio_secops_and_scc(): assert len(toolsets) == 2 assert mock_params.call_count == 2 + +def test_build_toolsets_stdio_env_propagation(): + with patch.dict(os.environ, {}, clear=True): + with patch("google.adk.tools.mcp_tool.mcp_toolset.McpToolset", side_effect=lambda connection_params: f"Toolset({connection_params})"), \ + patch("google.adk.tools.mcp_tool.mcp_toolset.StdioConnectionParams") as mock_params: + settings = AgentSettings( + _env_file=None, + LOAD_SECOPS_MCP="Y", + CHRONICLE_PROJECT_ID="chronicle-tenant-project", + GOOGLE_CLOUD_PROJECT="gcp-scc-project", + CHRONICLE_CUSTOMER_ID="cust-1234", + CHRONICLE_REGION="europe", + SECOPS_SA_PATH="/path/to/secops_sa.json", + SECOPS_IMPERSONATE_SERVICE_ACCOUNT="sa@chronicle.iam.gserviceaccount.com", + VT_APIKEY="vt-key-999", + SOAR_URL="https://soar.domain.com", + SOAR_APP_KEY="soar-app-key-888", + ) + toolsets = build_mcp_toolsets(settings) + assert len(toolsets) == 3 + assert mock_params.call_count == 3 + call_kwargs = mock_params.call_args.kwargs + server_params = call_kwargs["server_params"] + env = server_params.env + assert env["CHRONICLE_PROJECT_ID"] == "chronicle-tenant-project" + assert env["GOOGLE_CLOUD_PROJECT"] == "gcp-scc-project" + assert env["CHRONICLE_CUSTOMER_ID"] == "cust-1234" + assert env["CHRONICLE_REGION"] == "europe" + assert env["SECOPS_SA_PATH"] == "/path/to/secops_sa.json" + assert env["SECOPS_IMPERSONATE_SERVICE_ACCOUNT"] == "sa@chronicle.iam.gserviceaccount.com" + assert env["VT_APIKEY"] == "vt-key-999" + assert env["SOAR_URL"] == "https://soar.domain.com" + assert env["SOAR_APP_KEY"] == "soar-app-key-888" + assert env["GOOGLE_API_USE_CLIENT_CERTIFICATE"] == "false" + assert env["GOOGLE_API_USE_MTLS_ENDPOINT"] == "never" + + From 56267321e7de9f3ed9f1c3dca42c019023eebb71 Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 22:26:30 +0000 Subject: [PATCH 11/27] docs: rename sample.env to .env.example Rename the environment template to standard .env.example and update setup instructions across README and docs. TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- README.md | 2 +- docs/usage_guide.md | 2 +- run-with-google-adk/{sample.env => .env.example} | 0 run-with-google-adk/README.md | 4 ++-- 4 files changed, 4 insertions(+), 4 deletions(-) rename run-with-google-adk/{sample.env => .env.example} (100%) diff --git a/README.md b/README.md index 77326dc1..a73ae22d 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ It can be run locally via an interactive CLI REPL or launched as a FastAPI servi ```bash cd run-with-google-adk -cp sample.env .env +cp .env.example .env # Interactive terminal investigation REPL uv run mcp-security-agent chat diff --git a/docs/usage_guide.md b/docs/usage_guide.md index 40f9f610..757d6295 100644 --- a/docs/usage_guide.md +++ b/docs/usage_guide.md @@ -50,7 +50,7 @@ The repository provides a prebuilt Autonomous Security Operations Center (SOC) A ```bash cd run-with-google-adk -cp sample.env .env +cp .env.example .env # Interactive terminal investigation REPL uv run mcp-security-agent chat diff --git a/run-with-google-adk/sample.env b/run-with-google-adk/.env.example similarity index 100% rename from run-with-google-adk/sample.env rename to run-with-google-adk/.env.example diff --git a/run-with-google-adk/README.md b/run-with-google-adk/README.md index 3b63cc3d..45fe146f 100644 --- a/run-with-google-adk/README.md +++ b/run-with-google-adk/README.md @@ -41,7 +41,7 @@ This guide provides instructions on how to run the Autonomous Security Operation cd run-with-google-adk # Copy the sample environment template -cp sample.env .env +cp .env.example .env ``` Configure your credentials and project IDs in `.env` (see [Configuration](#configuration--environment-variables)). @@ -114,7 +114,7 @@ Launches the FastAPI application for web UI access or Cloud Run hosting. The agent reads configuration from environment variables and an optional `.env` file located in the working directory. -### `sample.env` Template +### `.env.example` Template ```properties # Google Cloud & LLM Settings From 56a36486a864a736ab661c06d6ac0a479bb93496 Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 22:30:50 +0000 Subject: [PATCH 12/27] build(git): ignore all namespaced .env* files Ignore all .env* variations (e.g. .env.argolis, .env.local) to prevent credentials from being tracked, keeping .env.example force-tracked. TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 61989534..4e2b5923 100644 --- a/.gitignore +++ b/.gitignore @@ -132,7 +132,7 @@ celerybeat.pid *.sage.py # Environments -.env +.env* .envrc .venv .gcloud/ From 388c5a7e398a9fcf501d6566b84cea56964c879c Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 22:32:28 +0000 Subject: [PATCH 13/27] docs: fix ADK section anchor link in root README Align anchor link with the Google ADK Autonomous SOC Agent section heading. TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a73ae22d..e8a97331 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ You can also use environment files with uvx: ## Client Configurations The MCP servers from this repo can be used with the following clients 1. Cline, Claude Desktop, and other MCP supported clients -2. [Google ADK(Agent Development Kit)](https://google.github.io/adk-docs/) Agents (a prebuilt agent is provided, details [below](#using-the-prebuilt-google-adk-agent-as-client)) +2. [Google ADK(Agent Development Kit)](https://google.github.io/adk-docs/) Agents (a prebuilt agent is provided, details [below](#using-the-google-adk-autonomous-soc-agent)) 3. [Google SecOps Extension](https://google.github.io/mcp-security/google_secops_extension.html) - Install our example extension for Gemini CLI to get specialized security skills (Triage, Investigate, Hunt). The configuration for Claude Desktop and Cline is the same (provided below for [uv](#using-uv-recommended) and [pip](#using-pip)). We use the stdio transport. From b825225ee506a7b5bb8089525b0cc24b97d0a901 Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 22:42:21 +0000 Subject: [PATCH 14/27] fix(adk): resolve web UI infinite login redirect loop and stream handling - Default username to localStorage or secops_user in landing_script.js instead of popping an alert and redirecting to / in an infinite loop. - Add routes for /login, /index.html, and /landing.html in FastAPI server. - Support both JSON REST queries and SSE streaming on POST /chat. - Persist username to localStorage on login and clear it on logout. - Expand server unit test suite to 32 tests. TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- run-with-google-adk/README.md | 2 +- .../src/mcp_security_agent/server/routes.py | 48 +++++++++++++++---- run-with-google-adk/static/index_script.js | 3 +- run-with-google-adk/static/landing_script.js | 15 ++---- run-with-google-adk/tests/test_server.py | 16 +++++++ 5 files changed, 63 insertions(+), 21 deletions(-) diff --git a/run-with-google-adk/README.md b/run-with-google-adk/README.md index 45fe146f..f89dce64 100644 --- a/run-with-google-adk/README.md +++ b/run-with-google-adk/README.md @@ -295,7 +295,7 @@ Run the hermetic test suite: uv run --extra test pytest tests/ ``` -All 30 unit tests run hermetically using mocked MCP connection parameters and simulated LLM responses. +All 32 unit tests run hermetically using mocked MCP connection parameters and simulated LLM responses. diff --git a/run-with-google-adk/src/mcp_security_agent/server/routes.py b/run-with-google-adk/src/mcp_security_agent/server/routes.py index d8839e9e..fdcf11f9 100644 --- a/run-with-google-adk/src/mcp_security_agent/server/routes.py +++ b/run-with-google-adk/src/mcp_security_agent/server/routes.py @@ -18,7 +18,7 @@ import asyncio from pathlib import Path from typing import Dict, Any, Optional, AsyncGenerator -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import FileResponse, StreamingResponse, JSONResponse from pydantic import BaseModel from mcp_security_agent import __version__ @@ -28,8 +28,10 @@ class ChatRequest(BaseModel): - prompt: str + prompt: Optional[str] = None + message: Optional[str] = None session_id: Optional[str] = None + user_id: Optional[str] = None class ChatResponse(BaseModel): @@ -38,8 +40,10 @@ class ChatResponse(BaseModel): @router.get("/") +@router.get("/landing.html") +@router.get("/chat.html") def get_root(): - """Serves the main landing page of the web UI.""" + """Serves the main investigation console of the web UI.""" pkg_root = Path(__file__).resolve().parents[3] landing_file = pkg_root / "static" / "landing.html" index_file = pkg_root / "static" / "index.html" @@ -51,6 +55,21 @@ def get_root(): return JSONResponse({"status": "ok", "message": "MCP Security Agent API is running."}) +@router.get("/login") +@router.get("/index.html") +def get_login(): + """Serves the login page.""" + pkg_root = Path(__file__).resolve().parents[3] + index_file = pkg_root / "static" / "index.html" + landing_file = pkg_root / "static" / "landing.html" + + if index_file.is_file(): + return FileResponse(str(index_file)) + elif landing_file.is_file(): + return FileResponse(str(landing_file)) + return JSONResponse({"status": "ok", "message": "MCP Security Agent API is running."}) + + @router.get("/healthz") def health_check() -> Dict[str, str]: """Health check endpoint for Cloud Run and Kubernetes probes.""" @@ -113,11 +132,22 @@ async def chat_sse_stream( ) -@router.post("/chat", response_model=ChatResponse) -def chat_post(request: ChatRequest) -> ChatResponse: - """REST JSON chat endpoint for API clients and automated workflows.""" +@router.post("/chat") +async def chat_post(request: ChatRequest, http_request: Request): + """REST and SSE chat endpoint for API clients, automated workflows, and web UI.""" sess_id = request.session_id or str(uuid.uuid4()) - return ChatResponse( - response=f"Received query: {request.prompt}", - session_id=sess_id, + query_text = request.message or request.prompt or "" + + accept_header = http_request.headers.get("accept", "") + if "text/event-stream" in accept_header or request.message is not None: + return StreamingResponse( + sse_event_generator(query_text, sess_id), + media_type="text/event-stream", + ) + + return JSONResponse( + content={ + "response": f"Received query: {query_text}", + "session_id": sess_id, + } ) diff --git a/run-with-google-adk/static/index_script.js b/run-with-google-adk/static/index_script.js index d642cb14..6e1471fc 100644 --- a/run-with-google-adk/static/index_script.js +++ b/run-with-google-adk/static/index_script.js @@ -64,7 +64,8 @@ document.addEventListener('DOMContentLoaded', () => { loginBtn.addEventListener('click', () => { const username = usernameInput.value.trim(); if (username) { - let redirectUrl = `/landing.html?username=${encodeURIComponent(username)}`; + localStorage.setItem('username', username); + let redirectUrl = `/?username=${encodeURIComponent(username)}`; // If "Start where we left off" is NOT checked, add the query parameter if (!startWhereLeftOffCheckbox.checked) { diff --git a/run-with-google-adk/static/landing_script.js b/run-with-google-adk/static/landing_script.js index 9cd0bcb0..c011d78e 100644 --- a/run-with-google-adk/static/landing_script.js +++ b/run-with-google-adk/static/landing_script.js @@ -19,18 +19,12 @@ document.addEventListener('DOMContentLoaded', () => { let waitingMessageElement = null; // Reference to the "waiting" message element // currentAgentResponseBuffer is no longer needed as we are streaming, not buffering. - // Get username from URL query parameter + // Get username from URL query parameter, localStorage, or default const urlParams = new URLSearchParams(window.location.search); - const username = urlParams.get('username'); + let username = urlParams.get('username') || localStorage.getItem('username') || 'secops_user'; + localStorage.setItem('username', username); const startNewSessionParam = urlParams.get('start_new_session'); // This line reads the new parameter - - if (!username) { - alert('Username not provided. Redirecting to login page.'); - window.location.href = '/'; - return; - } - // --- Dark Mode Logic --- function applyTheme(isDarkMode) { if (isDarkMode) { @@ -266,7 +260,8 @@ document.addEventListener('DOMContentLoaded', () => { logoutBtn.addEventListener('click', () => { currentSessionId = null; currentUserId = null; - window.location.href = '/'; + localStorage.removeItem('username'); + window.location.href = '/login'; }); // Allow sending message with Enter key diff --git a/run-with-google-adk/tests/test_server.py b/run-with-google-adk/tests/test_server.py index 388fc1be..591b356b 100644 --- a/run-with-google-adk/tests/test_server.py +++ b/run-with-google-adk/tests/test_server.py @@ -74,3 +74,19 @@ def test_chat_sse_stream(): assert response.status_code == 200 assert "text/event-stream" in response.headers["content-type"] assert "data:" in response.text + + +def test_login_and_alias_routes(): + client = TestClient(create_app()) + for path in ["/login", "/index.html", "/landing.html", "/chat.html"]: + response = client.get(path) + assert response.status_code == 200 + + +def test_chat_post_sse_streaming(): + client = TestClient(create_app()) + response = client.post("/chat", json={"message": "Investigate alert 123", "session_id": "test-sess"}) + assert response.status_code == 200 + assert "text/event-stream" in response.headers["content-type"] + assert "data:" in response.text + From 69ce75b18050082f81d0c14401f1dfcd8cdd1bd5 Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 22:53:49 +0000 Subject: [PATCH 15/27] fix(adk): add cache busters, no-cache headers, and favicon route - Append cache-busting query parameter (?v=2.0.2) to landing_script.js and index_script.js in HTML templates. - Add HTTP middleware in app.py to send Cache-Control: no-cache, no-store, must-revalidate on static assets and page routes to prevent browser memory/disk cache entrapment. - Add /favicon.ico route returning 204 No Content to avoid browser 404 errors. - Expand test_server.py to 34 unit tests verifying cache-control headers and favicon response. TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- .../src/mcp_security_agent/server/app.py | 11 +++++++++++ .../src/mcp_security_agent/server/routes.py | 6 ++++++ run-with-google-adk/static/index.html | 2 +- run-with-google-adk/static/landing.html | 2 +- run-with-google-adk/tests/test_server.py | 15 +++++++++++++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/run-with-google-adk/src/mcp_security_agent/server/app.py b/run-with-google-adk/src/mcp_security_agent/server/app.py index 276ce304..3df56f58 100644 --- a/run-with-google-adk/src/mcp_security_agent/server/app.py +++ b/run-with-google-adk/src/mcp_security_agent/server/app.py @@ -39,4 +39,15 @@ def create_app() -> FastAPI: if static_dir.is_dir(): app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + @app.middleware("http") + async def add_no_cache_headers(request, call_next): + response = await call_next(request) + path = request.url.path + if path.startswith("/static") or path in ["/", "/login", "/landing.html", "/index.html", "/chat.html"]: + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + return app + diff --git a/run-with-google-adk/src/mcp_security_agent/server/routes.py b/run-with-google-adk/src/mcp_security_agent/server/routes.py index fdcf11f9..dad00e03 100644 --- a/run-with-google-adk/src/mcp_security_agent/server/routes.py +++ b/run-with-google-adk/src/mcp_security_agent/server/routes.py @@ -76,6 +76,12 @@ def health_check() -> Dict[str, str]: return {"status": "ok"} +@router.get("/favicon.ico") +def get_favicon(): + """Returns 204 No Content for browser favicon requests.""" + return JSONResponse(content={}, status_code=204) + + @router.get("/app_name") def get_app_name() -> Dict[str, str]: """Returns the application display name for the Web UI navbar.""" diff --git a/run-with-google-adk/static/index.html b/run-with-google-adk/static/index.html index e9814fea..d493a5ae 100644 --- a/run-with-google-adk/static/index.html +++ b/run-with-google-adk/static/index.html @@ -128,6 +128,6 @@

Login

- + diff --git a/run-with-google-adk/static/landing.html b/run-with-google-adk/static/landing.html index 94e257c4..a0025272 100644 --- a/run-with-google-adk/static/landing.html +++ b/run-with-google-adk/static/landing.html @@ -222,6 +222,6 @@ - + diff --git a/run-with-google-adk/tests/test_server.py b/run-with-google-adk/tests/test_server.py index 591b356b..2c36d128 100644 --- a/run-with-google-adk/tests/test_server.py +++ b/run-with-google-adk/tests/test_server.py @@ -90,3 +90,18 @@ def test_chat_post_sse_streaming(): assert "text/event-stream" in response.headers["content-type"] assert "data:" in response.text + +def test_no_cache_headers(): + client = TestClient(create_app()) + for path in ["/", "/login", "/landing.html", "/index.html"]: + response = client.get(path) + assert response.status_code == 200 + assert "no-store" in response.headers.get("cache-control", "") + assert "no-cache" in response.headers.get("pragma", "") + + +def test_favicon(): + client = TestClient(create_app()) + response = client.get("/favicon.ico") + assert response.status_code == 204 + From d0c3215e8be2d953ec68d715580445a24880d76e Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Tue, 8 Sep 2026 23:08:40 +0000 Subject: [PATCH 16/27] feat(adk): modernize web console to zero-build SecOps SPA - Replace legacy multi-page Bootstrap UI and blocking alert() popups with a modern, zero-build Single Page Application. - Unify interface into static/index.html with Google SecOps / Material 3 dark-first styling in static/app.css. - Implement non-blocking toast notifications for errors, theme changes, and operator profile updates in static/app.js. - Add live MCP server status pills (SIEM, SCC, GTI, SOAR) fetched dynamically from /info. - Add quick SOC prompt cards (Triage Alert, Threat Hunt, GTI Reputation, SCC Audit) and session management. - Provide one-click copy buttons for code snippets, UDM queries, and tool payloads. - Delete obsolete legacy assets: static/landing.html, static/landing_script.js, and static/index_script.js. - Unify FastAPI routes in server/routes.py to serve the modern console. - Expand hermetic test suite to 35 passing unit tests. TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- run-with-google-adk/README.md | 2 +- .../src/mcp_security_agent/server/routes.py | 24 +- run-with-google-adk/static/app.css | 787 ++++++++++++++++++ run-with-google-adk/static/app.js | 609 ++++++++++---- run-with-google-adk/static/index.html | 245 +++--- run-with-google-adk/static/index_script.js | 88 -- run-with-google-adk/static/landing.html | 227 ----- run-with-google-adk/static/landing_script.js | 274 ------ run-with-google-adk/tests/test_server.py | 9 + 9 files changed, 1385 insertions(+), 880 deletions(-) create mode 100644 run-with-google-adk/static/app.css delete mode 100644 run-with-google-adk/static/index_script.js delete mode 100644 run-with-google-adk/static/landing.html delete mode 100644 run-with-google-adk/static/landing_script.js diff --git a/run-with-google-adk/README.md b/run-with-google-adk/README.md index f89dce64..35e62624 100644 --- a/run-with-google-adk/README.md +++ b/run-with-google-adk/README.md @@ -295,7 +295,7 @@ Run the hermetic test suite: uv run --extra test pytest tests/ ``` -All 32 unit tests run hermetically using mocked MCP connection parameters and simulated LLM responses. +All 35 unit tests run hermetically using mocked MCP connection parameters and simulated LLM responses. diff --git a/run-with-google-adk/src/mcp_security_agent/server/routes.py b/run-with-google-adk/src/mcp_security_agent/server/routes.py index dad00e03..2c519175 100644 --- a/run-with-google-adk/src/mcp_security_agent/server/routes.py +++ b/run-with-google-adk/src/mcp_security_agent/server/routes.py @@ -40,36 +40,20 @@ class ChatResponse(BaseModel): @router.get("/") +@router.get("/index.html") @router.get("/landing.html") @router.get("/chat.html") -def get_root(): - """Serves the main investigation console of the web UI.""" - pkg_root = Path(__file__).resolve().parents[3] - landing_file = pkg_root / "static" / "landing.html" - index_file = pkg_root / "static" / "index.html" - - if landing_file.is_file(): - return FileResponse(str(landing_file)) - elif index_file.is_file(): - return FileResponse(str(index_file)) - return JSONResponse({"status": "ok", "message": "MCP Security Agent API is running."}) - - @router.get("/login") -@router.get("/index.html") -def get_login(): - """Serves the login page.""" +def get_root(): + """Serves the unified investigation console of the web UI.""" pkg_root = Path(__file__).resolve().parents[3] index_file = pkg_root / "static" / "index.html" - landing_file = pkg_root / "static" / "landing.html" - if index_file.is_file(): return FileResponse(str(index_file)) - elif landing_file.is_file(): - return FileResponse(str(landing_file)) return JSONResponse({"status": "ok", "message": "MCP Security Agent API is running."}) + @router.get("/healthz") def health_check() -> Dict[str, str]: """Health check endpoint for Cloud Run and Kubernetes probes.""" diff --git a/run-with-google-adk/static/app.css b/run-with-google-adk/static/app.css new file mode 100644 index 00000000..8af71dde --- /dev/null +++ b/run-with-google-adk/static/app.css @@ -0,0 +1,787 @@ +/* Google SecOps AI Assistant - Modernized Styling */ +:root { + --bg-primary: #121316; + --bg-secondary: #1a1b1f; + --bg-surface: #22242a; + --bg-surface-hover: #2c2e35; + --border-color: #33353d; + --border-subtle: #272830; + + --text-primary: #e8eaed; + --text-secondary: #9aa0a6; + --text-tertiary: #5f6368; + + --accent-primary: #8ab4f8; + --accent-primary-hover: #aecbfa; + --accent-surface: rgba(138, 180, 248, 0.12); + + --color-success: #81c995; + --color-success-bg: rgba(129, 201, 149, 0.15); + --color-warning: #fdd663; + --color-warning-bg: rgba(253, 214, 99, 0.15); + --color-danger: #f28b82; + --color-danger-bg: rgba(242, 139, 130, 0.15); + + --code-bg: #18191c; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'Roboto Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, monospace; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +[data-theme="light"] { + --bg-primary: #f8f9fa; + --bg-secondary: #ffffff; + --bg-surface: #f1f3f4; + --bg-surface-hover: #e8eaed; + --border-color: #dadce0; + --border-subtle: #e8eaed; + + --text-primary: #202124; + --text-secondary: #5f6368; + --text-tertiary: #80868b; + + --accent-primary: #1a73e8; + --accent-primary-hover: #1557b0; + --accent-surface: rgba(26, 115, 232, 0.08); + + --code-bg: #f1f3f4; + --shadow-sm: 0 1px 2px rgba(60, 64, 67, 0.15); + --shadow-md: 0 4px 8px rgba(60, 64, 67, 0.15); + --shadow-lg: 0 8px 16px rgba(60, 64, 67, 0.15); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-sans); + background-color: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + transition: background-color 0.2s ease, color 0.2s ease; +} + +/* Header */ +.app-header { + height: 56px; + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + z-index: 10; + flex-shrink: 0; +} + +.brand-section { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-icon { + width: 28px; + height: 28px; + border-radius: 6px; + background: linear-gradient(135deg, #1a73e8, #4285f4); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + font-size: 16px; +} + +.brand-title { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.2px; + color: var(--text-primary); +} + +.brand-badge { + font-size: 11px; + padding: 2px 6px; + border-radius: 4px; + background-color: var(--accent-surface); + color: var(--accent-primary); + font-weight: 500; +} + +.header-status-pills { + display: flex; + align-items: center; + gap: 8px; +} + +.status-pill { + font-size: 11px; + padding: 3px 8px; + border-radius: 12px; + border: 1px solid var(--border-color); + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 5px; +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--text-tertiary); +} + +.status-dot.active { + background-color: var(--color-success); + box-shadow: 0 0 6px var(--color-success); +} + +.header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.user-chip { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + background-color: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: 20px; + font-size: 12px; + color: var(--text-primary); + cursor: pointer; + transition: background-color 0.2s ease; +} + +.user-chip:hover { + background-color: var(--bg-surface-hover); +} + +.user-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--accent-primary); + color: var(--bg-primary); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 10px; +} + +.icon-btn { + background: transparent; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-secondary); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.icon-btn:hover { + color: var(--text-primary); + background-color: var(--bg-surface); +} + +/* App Container */ +.app-layout { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Sidebar */ +.app-sidebar { + width: 280px; + background-color: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + padding: 16px; + gap: 16px; + flex-shrink: 0; + overflow-y: auto; +} + +.sidebar-action-btn { + width: 100%; + padding: 10px 14px; + background-color: var(--accent-primary); + color: #121316; + font-weight: 600; + font-size: 13px; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: background-color 0.2s ease; +} + +.sidebar-action-btn:hover { + background-color: var(--accent-primary-hover); +} + +.sidebar-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary); + margin-bottom: 6px; +} + +.quick-prompts-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.quick-prompt-card { + padding: 8px 10px; + background-color: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 12px; + color: var(--text-secondary); + text-align: left; + transition: all 0.2s ease; +} + +.quick-prompt-card:hover { + background-color: var(--bg-surface-hover); + color: var(--text-primary); + border-color: var(--accent-primary); +} + +.session-metadata { + margin-top: auto; + padding: 12px; + background-color: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + font-size: 11px; + color: var(--text-secondary); + display: flex; + flex-direction: column; + gap: 4px; +} + +.session-metadata code { + font-family: var(--font-mono); + color: var(--accent-primary); + word-break: break-all; +} + +/* Chat Main Area */ +.chat-workspace { + flex: 1; + display: flex; + flex-direction: column; + background-color: var(--bg-primary); + position: relative; + overflow: hidden; +} + +.messages-container { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; +} + +/* Empty State */ +.empty-state { + margin: auto; + max-width: 540px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 40px 20px; +} + +.empty-icon { + width: 56px; + height: 56px; + border-radius: 16px; + background-color: var(--accent-surface); + color: var(--accent-primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; +} + +.empty-title { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); +} + +.empty-desc { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Messages */ +.message-row { + display: flex; + gap: 12px; + max-width: 860px; + width: 100%; +} + +.message-row.user { + align-self: flex-end; + flex-direction: row-reverse; +} + +.message-row.agent { + align-self: flex-start; +} + +.msg-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; +} + +.message-row.user .msg-avatar { + background-color: var(--accent-primary); + color: var(--bg-primary); +} + +.message-row.agent .msg-avatar { + background-color: #34a853; + color: #fff; +} + +.message-body-wrap { + display: flex; + flex-direction: column; + gap: 4px; + max-width: calc(100% - 44px); +} + +.message-header-info { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-tertiary); +} + +.message-row.user .message-header-info { + justify-content: flex-end; +} + +.msg-bubble { + padding: 12px 16px; + border-radius: var(--radius-md); + font-size: 14px; + line-height: 1.6; + word-break: break-word; +} + +.message-row.user .msg-bubble { + background-color: var(--accent-surface); + color: var(--text-primary); + border: 1px solid rgba(138, 180, 248, 0.3); + border-top-right-radius: 2px; +} + +.message-row.agent .msg-bubble { + background-color: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-top-left-radius: 2px; +} + +/* Markdown styling inside agent bubble */ +.msg-bubble p:not(:last-child) { + margin-bottom: 10px; +} + +.msg-bubble ul, .msg-bubble ol { + margin: 8px 0 8px 20px; +} + +.msg-bubble li { + margin-bottom: 4px; +} + +.msg-bubble code:not(pre code) { + font-family: var(--font-mono); + font-size: 12px; + padding: 2px 6px; + background-color: var(--code-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--accent-primary); +} + +.msg-bubble pre { + margin: 10px 0; + position: relative; + border-radius: var(--radius-sm); + background-color: var(--code-bg); + border: 1px solid var(--border-color); + overflow-x: auto; +} + +.msg-bubble pre code { + font-family: var(--font-mono); + font-size: 13px; + display: block; + padding: 12px 14px; + color: var(--text-primary); + line-height: 1.45; +} + +.code-copy-btn { + position: absolute; + top: 6px; + right: 6px; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-secondary); + font-size: 11px; + padding: 3px 8px; + cursor: pointer; + transition: all 0.15s ease; +} + +.code-copy-btn:hover { + background: var(--bg-surface-hover); + color: var(--text-primary); +} + +/* Tool Execution Accordion */ +.tool-accordion { + margin: 8px 0; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background-color: var(--bg-surface); + overflow: hidden; +} + +.tool-header { + padding: 8px 12px; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + background-color: var(--bg-surface); + transition: background-color 0.15s ease; +} + +.tool-header:hover { + background-color: var(--bg-surface-hover); +} + +.tool-badge { + font-family: var(--font-mono); + font-size: 11px; + padding: 2px 6px; + border-radius: 4px; + background-color: var(--accent-surface); + color: var(--accent-primary); +} + +.tool-content { + padding: 10px 12px; + border-top: 1px solid var(--border-color); + background-color: var(--code-bg); + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); + overflow-x: auto; +} + +/* Waiting / Streaming Indicator */ +.streaming-pulse { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--accent-primary); + animation: pulse 1s infinite alternate; + margin-right: 6px; +} + +@keyframes pulse { + from { opacity: 0.3; transform: scale(0.8); } + to { opacity: 1; transform: scale(1.1); } +} + +/* Input Area */ +.chat-input-area { + padding: 16px 24px 20px; + background-color: var(--bg-primary); + border-top: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 8px; + flex-shrink: 0; +} + +.input-box-wrapper { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 8px 12px; + display: flex; + align-items: flex-end; + gap: 10px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.input-box-wrapper:focus-within { + border-color: var(--accent-primary); + box-shadow: 0 0 0 2px var(--accent-surface); +} + +.chat-textarea { + flex: 1; + background: transparent; + border: none; + outline: none; + resize: none; + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + color: var(--text-primary); + max-height: 160px; + min-height: 24px; +} + +.chat-textarea::placeholder { + color: var(--text-tertiary); +} + +.send-btn { + padding: 8px 16px; + border: none; + border-radius: var(--radius-sm); + background-color: var(--accent-primary); + color: #121316; + font-weight: 600; + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: background-color 0.15s ease, opacity 0.15s ease; + flex-shrink: 0; +} + +.send-btn:hover:not(:disabled) { + background-color: var(--accent-primary-hover); +} + +.send-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.cancel-btn { + background-color: var(--color-danger); + color: #fff; +} + +.input-footer-hints { + font-size: 11px; + color: var(--text-tertiary); + display: flex; + justify-content: space-between; + padding: 0 4px; +} + +/* Toast System */ +.toast-container { + position: fixed; + top: 68px; + right: 20px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 100; + pointer-events: none; +} + +.toast { + pointer-events: auto; + min-width: 280px; + max-width: 400px; + padding: 10px 14px; + border-radius: var(--radius-sm); + font-size: 13px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: var(--shadow-md); + animation: slideIn 0.25s ease-out forwards; +} + +@keyframes slideIn { + from { opacity: 0; transform: translateX(30px); } + to { opacity: 1; transform: translateX(0); } +} + +.toast.info { + background-color: var(--bg-surface); + border: 1px solid var(--accent-primary); + color: var(--text-primary); +} + +.toast.success { + background-color: var(--color-success-bg); + border: 1px solid var(--color-success); + color: var(--text-primary); +} + +.toast.warning { + background-color: var(--color-warning-bg); + border: 1px solid var(--color-warning); + color: var(--text-primary); +} + +.toast.error { + background-color: var(--color-danger-bg); + border: 1px solid var(--color-danger); + color: var(--text-primary); +} + +.toast-close { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 16px; + padding: 0 4px; +} + +/* User Profile Dialog */ +.modal-overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal-card { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 24px; + width: 100%; + max-width: 380px; + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + gap: 16px; +} + +.modal-title { + font-size: 16px; + font-weight: 600; +} + +.modal-input { + width: 100%; + padding: 8px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + background-color: var(--bg-surface); + color: var(--text-primary); + font-size: 14px; + outline: none; +} + +.modal-input:focus { + border-color: var(--accent-primary); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.btn-secondary { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 6px 12px; + border-radius: var(--radius-sm); + font-size: 13px; + cursor: pointer; +} + +.btn-primary { + background-color: var(--accent-primary); + border: none; + color: #121316; + font-weight: 600; + padding: 6px 14px; + border-radius: var(--radius-sm); + font-size: 13px; + cursor: pointer; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .app-sidebar { + display: none; + } +} diff --git a/run-with-google-adk/static/app.js b/run-with-google-adk/static/app.js index 1369132f..3bea6237 100644 --- a/run-with-google-adk/static/app.js +++ b/run-with-google-adk/static/app.js @@ -1,176 +1,487 @@ -// app.js +// Google SecOps AI Assistant - Modernized Zero-Build Web Client +// Single-Page Architecture with SSE Streaming, Toast Notifications, and Dynamic MCP Status document.addEventListener('DOMContentLoaded', () => { - const chatWindow = document.getElementById('chat-window'); - const userInput = document.getElementById('user-input'); - const submitBtn = document.getElementById('submit-btn'); - const clearBtn = document.getElementById('clear-btn'); - const sessionInfoDiv = document.getElementById('session-info'); - const darkModeToggle = document.getElementById('darkModeToggle'); - - let currentSessionId = null; // Variable to store the session ID - let requestSentTime = 0; // Timestamp when the user message was sent - let lastAgentMessageTime = 0; // Timestamp of the last received agent message - - // --- Dark Mode Logic --- - function applyTheme(isDarkMode) { - if (isDarkMode) { - document.body.classList.add('dark-mode'); - } else { - document.body.classList.remove('dark-mode'); - } - } + const API_BASE_URL = window.location.origin; + + // DOM Elements + const messagesContainer = document.getElementById('messagesContainer'); + const chatTextarea = document.getElementById('chatTextarea'); + const sendBtn = document.getElementById('sendBtn'); + const cancelBtn = document.getElementById('cancelBtn'); + const newInvestigationBtn = document.getElementById('newInvestigationBtn'); + const appTitle = document.getElementById('appTitle'); + const currentSessionDisplay = document.getElementById('currentSessionDisplay'); + const currentUserDisplay = document.getElementById('currentUserDisplay'); + const userAvatar = document.getElementById('userAvatar'); + const userChip = document.getElementById('userChip'); + const themeToggleBtn = document.getElementById('themeToggleBtn'); + const toastContainer = document.getElementById('toastContainer'); + const emptyState = document.getElementById('emptyState'); + + // Modal Elements + const userModal = document.getElementById('userModal'); + const usernameInput = document.getElementById('usernameInput'); + const saveUserBtn = document.getElementById('saveUserBtn'); + const cancelUserBtn = document.getElementById('cancelUserBtn'); + + // MCP Pills + const pillSecops = document.getElementById('pillSecops'); + const pillScc = document.getElementById('pillScc'); + const pillGti = document.getElementById('pillGti'); + const pillSoar = document.getElementById('pillSoar'); + + // Application State + let currentSessionId = null; + let currentUserId = localStorage.getItem('username') || 'secops_user'; + let isStreaming = false; + let activeAbortController = null; + let requestStartTime = 0; + let lastChunkTime = 0; + // Initialize Theme + function initTheme() { const savedTheme = localStorage.getItem('theme'); - if (savedTheme === 'dark') { - darkModeToggle.checked = true; - applyTheme(true); + if (savedTheme) { + document.documentElement.setAttribute('data-theme', savedTheme); + } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { + document.documentElement.setAttribute('data-theme', 'light'); } else { - darkModeToggle.checked = false; - applyTheme(false); + document.documentElement.setAttribute('data-theme', 'dark'); } + } - darkModeToggle.addEventListener('change', () => { - if (darkModeToggle.checked) { - applyTheme(true); - localStorage.setItem('theme', 'dark'); - } else { - applyTheme(false); - localStorage.setItem('theme', 'light'); - } - }); - // --- End Dark Mode Logic --- - - - // Function to append a message to the chat window - // Now accepts timeElapsed and timeDiff parameters - function appendMessage(text, sender, timeElapsed = null, timeDiff = null) { - const messageDiv = document.createElement('div'); - messageDiv.classList.add('message', sender); - - // Create and append the time display element if timeElapsed is provided - if (timeElapsed !== null) { - const timeDisplay = document.createElement('div'); - timeDisplay.classList.add('message-time'); - let timeText = `${timeElapsed}ms`; - if (timeDiff !== null && sender === 'agent') { // Only show diff for agent messages - timeText += ` (${timeDiff}ms)`; - } - timeDisplay.textContent = timeText; - messageDiv.appendChild(timeDisplay); + function toggleTheme() { + const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark'; + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', newTheme); + localStorage.setItem('theme', newTheme); + showToast(`Theme switched to ${newTheme} mode`, 'info', 2000); + } + + // Toast Notification System + function showToast(message, type = 'info', duration = 3500) { + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + + const textSpan = document.createElement('span'); + textSpan.textContent = message; + toast.appendChild(textSpan); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'toast-close'; + closeBtn.innerHTML = '×'; + closeBtn.onclick = () => removeToast(toast); + toast.appendChild(closeBtn); + + toastContainer.appendChild(toast); + + const timer = setTimeout(() => { + removeToast(toast); + }, duration); + + function removeToast(el) { + clearTimeout(timer); + el.style.opacity = '0'; + el.style.transform = 'translateX(20px)'; + el.style.transition = 'all 0.2s ease-out'; + setTimeout(() => { + if (el.parentNode) { + el.parentNode.removeChild(el); } + }, 200); + } + } + + // Update User UI + function updateUserUI() { + currentUserDisplay.textContent = currentUserId; + userAvatar.textContent = currentUserId.charAt(0).toUpperCase(); + localStorage.setItem('username', currentUserId); + } - const messageContent = document.createElement('div'); // Container for the actual message text/markdown - if (sender === 'agent') { - messageContent.innerHTML = marked.parse(text); - } else { - messageContent.textContent = text; + // Fetch App Metadata + async function fetchAppMetadata() { + try { + const res = await fetch(`${API_BASE_URL}/app_name`); + if (res.ok) { + const data = await res.json(); + if (data.app_name) { + appTitle.textContent = data.app_name; + document.title = data.app_name; } - messageDiv.appendChild(messageContent); // Append content after time - chatWindow.appendChild(messageDiv); - chatWindow.scrollTop = chatWindow.scrollHeight; + } + } catch (e) { + console.warn('Could not fetch app name:', e); + } + } + + // Fetch MCP Server Status Pills + async function fetchMcpInfo() { + try { + const res = await fetch(`${API_BASE_URL}/info`); + if (res.ok) { + const data = await res.json(); + const mcp = data.mcp_servers || {}; + updatePill(pillSecops, mcp.secops); + updatePill(pillScc, mcp.scc); + updatePill(pillGti, mcp.gti); + updatePill(pillSoar, mcp.soar); + } + } catch (e) { + console.warn('Could not fetch MCP server info:', e); + } + } + + function updatePill(pillEl, isEnabled) { + if (!pillEl) return; + const dot = pillEl.querySelector('.status-dot'); + if (dot) { + if (isEnabled) { + dot.classList.add('active'); + pillEl.title = 'Enabled and connected'; + } else { + dot.classList.remove('active'); + pillEl.title = 'Disabled or not configured'; + } } + } - // Function to fetch the session ID - async function fetchSessionId() { + // Fetch / Initialize Session + async function initSession(startNew = false) { + try { + let url = `${API_BASE_URL}/get_session?username=${encodeURIComponent(currentUserId)}`; + if (startNew) { + url += '&start_new_session=Y'; + } + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + currentSessionId = data.session_id; + currentSessionDisplay.textContent = currentSessionId.slice(0, 8) + '...'; + currentSessionDisplay.title = currentSessionId; + console.log('Active session initialized:', currentSessionId); + } catch (err) { + console.error('Session initialization failed:', err); + showToast('Could not initialize session with backend', 'error'); + } + } + + // Append Message + function appendMessage(text, sender, timeElapsed = null, timeDiff = null) { + if (emptyState && emptyState.style.display !== 'none') { + emptyState.style.display = 'none'; + } + + const row = document.createElement('div'); + row.className = `message-row ${sender}`; + + const avatar = document.createElement('div'); + avatar.className = 'msg-avatar'; + avatar.textContent = sender === 'user' ? currentUserId.charAt(0).toUpperCase() : 'G'; + row.appendChild(avatar); + + const bodyWrap = document.createElement('div'); + bodyWrap.className = 'message-body-wrap'; + + const headerInfo = document.createElement('div'); + headerInfo.className = 'message-header-info'; + const authorSpan = document.createElement('span'); + authorSpan.textContent = sender === 'user' ? currentUserId : 'Google SecOps Agent'; + headerInfo.appendChild(authorSpan); + + if (timeElapsed !== null) { + const timeSpan = document.createElement('span'); + let timingStr = `${timeElapsed}ms`; + if (timeDiff !== null && sender === 'agent') { + timingStr += ` (${timeDiff}ms)`; + } + timeSpan.textContent = timingStr; + headerInfo.appendChild(timeSpan); + } + + bodyWrap.appendChild(headerInfo); + + const bubble = document.createElement('div'); + bubble.className = 'msg-bubble'; + + if (sender === 'agent') { + if (window.marked && typeof window.marked.parse === 'function') { + bubble.innerHTML = window.marked.parse(text); + } else { + bubble.textContent = text; + } + addCopyButtonsToPre(bubble); + } else { + bubble.textContent = text; + } + + bodyWrap.appendChild(bubble); + row.appendChild(bodyWrap); + + messagesContainer.appendChild(row); + scrollToBottom(); + return bubble; + } + + // Code Copy Buttons + function addCopyButtonsToPre(container) { + const preBlocks = container.querySelectorAll('pre'); + preBlocks.forEach((pre) => { + if (pre.querySelector('.code-copy-btn')) return; + const code = pre.querySelector('code'); + const copyBtn = document.createElement('button'); + copyBtn.className = 'code-copy-btn'; + copyBtn.textContent = 'Copy'; + copyBtn.onclick = async () => { try { - const response = await fetch('/get_session'); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - currentSessionId = data.session_id; - sessionInfoDiv.textContent = `Session ID: ${currentSessionId}`; - sessionInfoDiv.classList.remove('alert-info'); - sessionInfoDiv.classList.add('alert-success'); - console.log('Session ID fetched:', currentSessionId); - submitBtn.disabled = false; // Enable submit button once session is loaded - userInput.disabled = false; // Enable input once session is loaded - } catch (error) { - console.error('Error fetching session ID:', error); - sessionInfoDiv.textContent = 'Error loading session ID.'; - sessionInfoDiv.classList.remove('alert-info'); - sessionInfoDiv.classList.add('alert-danger'); - // Disable submit button if session ID cannot be fetched - submitBtn.disabled = true; - userInput.disabled = true; + const textToCopy = code ? code.innerText : pre.innerText; + await navigator.clipboard.writeText(textToCopy); + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = 'Copy'; + }, 1800); + } catch (e) { + showToast('Failed to copy code to clipboard', 'warning'); } + }; + pre.appendChild(copyBtn); + }); + } + + function scrollToBottom() { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + + // Stream Response Handler + async function handleUserSubmit() { + const prompt = chatTextarea.value.trim(); + if (!prompt || isStreaming) return; + + if (!currentSessionId) { + await initSession(); + if (!currentSessionId) { + showToast('Waiting for session initialization. Try again in a moment.', 'warning'); + return; + } } - // Fetch session ID on page load - fetchSessionId(); - // Initially disable submit button until session ID is loaded - submitBtn.disabled = true; - userInput.disabled = true; + appendMessage(prompt, 'user', 0); + chatTextarea.value = ''; + autoResizeTextarea(); + // Prepare Agent Streaming Container + isStreaming = true; + sendBtn.style.display = 'none'; + cancelBtn.style.display = 'flex'; + requestStartTime = Date.now(); + lastChunkTime = requestStartTime; - // Event listener for the Submit button - submitBtn.addEventListener('click', async () => { - const message = userInput.value.trim(); - if (!currentSessionId) { - appendMessage('Error: Session ID not available. Please refresh the page.', 'agent'); - return; - } - if (message) { - // Display user message immediately with 0ms elapsed time - appendMessage(message, 'user', 0); - userInput.value = ''; // Clear input field + const streamRow = document.createElement('div'); + streamRow.className = 'message-row agent'; + + const avatar = document.createElement('div'); + avatar.className = 'msg-avatar'; + avatar.textContent = 'G'; + streamRow.appendChild(avatar); + + const bodyWrap = document.createElement('div'); + bodyWrap.className = 'message-body-wrap'; + + const headerInfo = document.createElement('div'); + headerInfo.className = 'message-header-info'; + headerInfo.innerHTML = 'Investigating...'; + bodyWrap.appendChild(headerInfo); + + const bubble = document.createElement('div'); + bubble.className = 'msg-bubble'; + bubble.innerHTML = 'Analyzing security telemetry...'; + bodyWrap.appendChild(bubble); - // Capture the time just before sending the request - requestSentTime = Date.now(); - lastAgentMessageTime = requestSentTime; // Reset for new request + streamRow.appendChild(bodyWrap); + messagesContainer.appendChild(streamRow); + scrollToBottom(); - // Make a request to the /chat API using Server-Sent Events (SSE) + activeAbortController = new AbortController(); + let accumulatedText = ''; + + try { + const response = await fetch(`${API_BASE_URL}/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream, application/json', + }, + body: JSON.stringify({ + session_id: currentSessionId, + user_id: currentUserId, + message: prompt, + }), + signal: activeAbortController.signal, + }); + + if (!response.ok) { + throw new Error(`Server returned HTTP ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + let boundary = buffer.indexOf('\n\n'); + + while (boundary !== -1) { + const chunk = buffer.substring(0, boundary); + buffer = buffer.substring(boundary + 2); + + if (chunk.startsWith('data: ')) { try { - const eventSource = new EventSource(`/chat?message=${encodeURIComponent(message)}&session_id=${encodeURIComponent(currentSessionId)}`); - - eventSource.onmessage = (event) => { - const data = JSON.parse(event.data); - const receivedTime = Date.now(); - const timeElapsed = receivedTime - requestSentTime; - const timeDiff = receivedTime - lastAgentMessageTime; // Calculate difference from previous agent message - lastAgentMessageTime = receivedTime; // Update last agent message time - - // Do not show "Stream finished." message - if (data.last_msg && data.text === 'Stream finished.') { - eventSource.close(); // Close the connection when last_msg is true - console.log('SSE connection closed.'); - return; // Do not append this message - } - - // Display agent message with calculated elapsed time and difference - appendMessage(data.text, 'agent', timeElapsed, timeDiff); - - if (data.last_msg) { - eventSource.close(); // Close the connection when last_msg is true - console.log('SSE connection closed.'); - } - }; - - eventSource.onerror = (error) => { - console.error('EventSource failed:', error); - eventSource.close(); - appendMessage('Error receiving response from agent or stream ended unexpectedly.', 'agent'); - }; - - } catch (error) { - console.error('Failed to connect to SSE:', error); - appendMessage('Failed to initiate chat session.', 'agent'); + const data = JSON.parse(chunk.substring(6)); + const now = Date.now(); + const timeElapsed = now - requestStartTime; + const timeDiff = now - lastChunkTime; + lastChunkTime = now; + + if (data.last_msg && data.text === 'Stream finished.') { + headerInfo.innerHTML = `Google SecOps Agent${timeElapsed}ms`; + reader.cancel(); + break; + } else if (data.text) { + if (!accumulatedText) { + accumulatedText = data.text; + } else { + accumulatedText += '\n\n' + data.text; + } + if (window.marked && typeof window.marked.parse === 'function') { + bubble.innerHTML = window.marked.parse(accumulatedText); + } else { + bubble.textContent = accumulatedText; + } + addCopyButtonsToPre(bubble); + headerInfo.innerHTML = `${timeElapsed}ms (${timeDiff}ms)`; + scrollToBottom(); + } + } catch (jsonErr) { + console.warn('Could not parse SSE chunk:', chunk, jsonErr); } + } + boundary = buffer.indexOf('\n\n'); } - }); + } - // Event listener for the Clear button - clearBtn.addEventListener('click', () => { - chatWindow.innerHTML = ''; // Clear all messages from the chat window - // Optionally, re-fetch session ID or clear it if desired - }); + const totalTime = Date.now() - requestStartTime; + headerInfo.innerHTML = `Google SecOps Agent${totalTime}ms`; + } catch (err) { + if (err.name === 'AbortError') { + headerInfo.innerHTML = 'Google SecOps Agent(Cancelled)'; + showToast('Investigation query cancelled', 'info'); + } else { + console.error('Chat error:', err); + headerInfo.innerHTML = 'Google SecOps Agent(Error)'; + bubble.innerHTML = `Failed to get response from server: ${err.message}`; + showToast(`Request failed: ${err.message}`, 'error'); + } + } finally { + isStreaming = false; + activeAbortController = null; + sendBtn.style.display = 'flex'; + cancelBtn.style.display = 'none'; + scrollToBottom(); + chatTextarea.focus(); + } + } - // Allow sending message with Enter key - userInput.addEventListener('keypress', (event) => { - if (event.key === 'Enter' && !event.shiftKey) { // Shift+Enter for new line - event.preventDefault(); // Prevent default Enter behavior (new line) - submitBtn.click(); // Trigger submit button click - } + // Cancel Streaming + function cancelStreaming() { + if (activeAbortController) { + activeAbortController.abort(); + } + } + + // Auto-resize Textarea + function autoResizeTextarea() { + chatTextarea.style.height = 'auto'; + chatTextarea.style.height = Math.min(chatTextarea.scrollHeight, 160) + 'px'; + } + + // Modal Handling + function openUserModal() { + usernameInput.value = currentUserId; + userModal.style.display = 'flex'; + usernameInput.focus(); + } + + function closeUserModal() { + userModal.style.display = 'none'; + } + + function saveUser() { + const newName = usernameInput.value.trim(); + if (!newName) { + showToast('Username cannot be empty', 'warning'); + return; + } + currentUserId = newName; + updateUserUI(); + closeUserModal(); + initSession(true); + showToast(`Active user set to: ${currentUserId}`, 'success'); + } + + // Event Listeners + sendBtn.addEventListener('click', handleUserSubmit); + cancelBtn.addEventListener('click', cancelStreaming); + + chatTextarea.addEventListener('input', autoResizeTextarea); + chatTextarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleUserSubmit(); + } + }); + + themeToggleBtn.addEventListener('click', toggleTheme); + + userChip.addEventListener('click', openUserModal); + saveUserBtn.addEventListener('click', saveUser); + cancelUserBtn.addEventListener('click', closeUserModal); + userModal.addEventListener('click', (e) => { + if (e.target === userModal) closeUserModal(); + }); + + newInvestigationBtn.addEventListener('click', () => { + messagesContainer.innerHTML = ''; + if (emptyState) { + emptyState.style.display = 'flex'; + messagesContainer.appendChild(emptyState); + } + initSession(true); + showToast('Started a new investigation session', 'info'); + chatTextarea.focus(); + }); + + // Quick Prompt Cards + document.querySelectorAll('.quick-prompt-card').forEach((card) => { + card.addEventListener('click', () => { + const prompt = card.getAttribute('data-prompt'); + if (prompt) { + chatTextarea.value = prompt; + autoResizeTextarea(); + chatTextarea.focus(); + } }); + }); + + // Initialization + initTheme(); + updateUserUI(); + fetchAppMetadata(); + fetchMcpInfo(); + initSession(false); + autoResizeTextarea(); }); diff --git a/run-with-google-adk/static/index.html b/run-with-google-adk/static/index.html index d493a5ae..68c02a7e 100644 --- a/run-with-google-adk/static/index.html +++ b/run-with-google-adk/static/index.html @@ -1,133 +1,136 @@ - - - Login - ADK Agent - - + + + Google SecOps AI Assistant + + - + - @@ -54,19 +56,19 @@
@@ -82,7 +84,11 @@
-
🛡️
+
+ + + +

SecOps Investigation Workspace

Interact with Google SecOps tools, run UDM searches, inspect Google Cloud SCC findings, and analyze threat intelligence powered by Google ADK v2 and the Model Context Protocol. @@ -131,6 +137,6 @@

- + diff --git a/run-with-google-adk/tests/test_server.py b/run-with-google-adk/tests/test_server.py index 874995a2..3f011af3 100644 --- a/run-with-google-adk/tests/test_server.py +++ b/run-with-google-adk/tests/test_server.py @@ -1,6 +1,7 @@ """Unit tests for mcp_security_agent.server.""" import sys +import unicodedata from pathlib import Path from fastapi.testclient import TestClient @@ -114,3 +115,16 @@ def test_static_assets(): assert "no-store" in response.headers.get("cache-control", "") +def test_index_html_no_emojis(): + client = TestClient(create_app()) + response = client.get("/index.html") + assert response.status_code == 200 + html = response.text + for char in html: + code = ord(char) + if (code > 0x2000 and unicodedata.category(char) in ("So", "Sk", "Sm", "Cn")) or code > 0x1F000 or (0x2600 <= code <= 0x27BF): + assert char in ("`", "^", "~", "<", ">", "+", "=", "|", "•"), f"Unexpected emoji/symbol: {char!r} (U+{code:04X})" + assert " Date: Tue, 8 Sep 2026 23:26:38 +0000 Subject: [PATCH 18/27] feat(adk): auto-detect active user identity from ADC, gcloud, and environment - Add discover_user_identity() in config.py to resolve operator identity from ADC account/client_email, google.auth credentials, active gcloud account, or OS user before falling back to secops_user. - Return detected user in /get_session and /info endpoints. - Update web console (app.js and index.html) to dynamically display detected user identity on load while preserving manual operator override. - Expand hermetic test suite to 37 passing unit tests with mocked subprocess/auth checks in test_config.py and test_server.py. TAG=agy CONV=1fede0e5-2332-4f6a-b461-ab44b08b4ff5 --- run-with-google-adk/README.md | 2 +- .../src/mcp_security_agent/config.py | 88 +++++++++++++++++++ .../src/mcp_security_agent/server/routes.py | 9 +- run-with-google-adk/static/app.js | 27 ++++-- run-with-google-adk/static/index.html | 2 +- run-with-google-adk/tests/test_config.py | 27 ++++++ run-with-google-adk/tests/test_server.py | 6 +- 7 files changed, 150 insertions(+), 11 deletions(-) diff --git a/run-with-google-adk/README.md b/run-with-google-adk/README.md index 35e62624..68de6bed 100644 --- a/run-with-google-adk/README.md +++ b/run-with-google-adk/README.md @@ -295,7 +295,7 @@ Run the hermetic test suite: uv run --extra test pytest tests/ ``` -All 35 unit tests run hermetically using mocked MCP connection parameters and simulated LLM responses. +All 37 unit tests run hermetically using mocked MCP connection parameters and simulated LLM responses. diff --git a/run-with-google-adk/src/mcp_security_agent/config.py b/run-with-google-adk/src/mcp_security_agent/config.py index 40c1c135..e080c610 100644 --- a/run-with-google-adk/src/mcp_security_agent/config.py +++ b/run-with-google-adk/src/mcp_security_agent/config.py @@ -44,6 +44,94 @@ def _discover_local_adc() -> Optional[str]: return None +def discover_user_identity() -> str: + """Discovers the active user identity from ADC, gcloud config, or system environment. + + Returns: + The detected username or service account email, falling back to 'secops_user'. + """ + import os + import json + import getpass + import shutil + import subprocess + + # 1. Explicit user override from environment + explicit = os.getenv("SECOPS_USER") or os.getenv("AGENT_USER") + if explicit and explicit.strip(): + return explicit.strip() + + # 2. Impersonated service account + impersonate_sa = os.getenv("SECOPS_IMPERSONATE_SERVICE_ACCOUNT") + if impersonate_sa and impersonate_sa.strip(): + return impersonate_sa.strip() + + # 3. Discovered ADC file (check for account or client_email) + adc_path = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") or _discover_local_adc() + if not adc_path: + cloudsdk_config = os.getenv("CLOUDSDK_CONFIG") + if cloudsdk_config: + p = Path(cloudsdk_config) / "application_default_credentials.json" + if p.is_file(): + adc_path = str(p) + if not adc_path: + p = Path.home() / ".config" / "gcloud" / "application_default_credentials.json" + if p.is_file(): + adc_path = str(p) + + if adc_path and Path(adc_path).is_file(): + try: + with open(adc_path, "r", encoding="utf-8") as f: + data = json.load(f) + acct = data.get("account") or data.get("client_email") + if acct and isinstance(acct, str) and acct.strip(): + return acct.strip() + except Exception: + pass + + # 4. google.auth.default() credentials inspection + try: + import google.auth + creds, _ = google.auth.default() + sa_email = getattr(creds, "service_account_email", None) + if sa_email and isinstance(sa_email, str) and sa_email.strip() and sa_email != "default": + return sa_email.strip() + acct = getattr(creds, "account", None) + if acct and isinstance(acct, str) and acct.strip(): + return acct.strip() + except Exception: + pass + + # 5. gcloud active account + if shutil.which("gcloud"): + try: + res = subprocess.run( + ["gcloud", "config", "get-value", "account"], + capture_output=True, + text=True, + timeout=2, + ) + if res.returncode == 0: + acct = res.stdout.strip() + if acct and acct != "(unset)": + return acct + except Exception: + pass + + # 6. System username (LDAP / OS user) + system_user = os.getenv("USER") or os.getenv("USERNAME") + if not system_user: + try: + system_user = getpass.getuser() + except Exception: + system_user = None + if system_user and system_user.strip(): + return system_user.strip() + + return "secops_user" + + + class AgentSettings(BaseSettings): """Configuration settings loaded from environment variables or .env file.""" diff --git a/run-with-google-adk/src/mcp_security_agent/server/routes.py b/run-with-google-adk/src/mcp_security_agent/server/routes.py index 2c519175..3bf7ce04 100644 --- a/run-with-google-adk/src/mcp_security_agent/server/routes.py +++ b/run-with-google-adk/src/mcp_security_agent/server/routes.py @@ -22,7 +22,7 @@ from fastapi.responses import FileResponse, StreamingResponse, JSONResponse from pydantic import BaseModel from mcp_security_agent import __version__ -from mcp_security_agent.config import AgentSettings +from mcp_security_agent.config import AgentSettings, discover_user_identity router = APIRouter() @@ -75,19 +75,22 @@ def get_app_name() -> Dict[str, str]: @router.get("/get_session") def get_session(username: Optional[str] = Query(None, description="Username for session")) -> Dict[str, str]: """Generates a session ID and returns user context for chat sessions.""" + detected_user = discover_user_identity() + user = username if (username and username.strip() and username != "secops_user") else detected_user return { "session_id": str(uuid.uuid4()), - "user_id": username or "default_user", + "user_id": user, } @router.get("/info") def get_info() -> Dict[str, Any]: - """Provides server runtime metadata and enabled MCP server status.""" + """Provides server runtime metadata, active user identity, and enabled MCP server status.""" settings = AgentSettings() return { "version": __version__, "model": settings.google_model, + "user": discover_user_identity(), "tools": { "secops": settings.load_secops_mcp, "scc": settings.load_scc_mcp, diff --git a/run-with-google-adk/static/app.js b/run-with-google-adk/static/app.js index 3bea6237..e34e4dd4 100644 --- a/run-with-google-adk/static/app.js +++ b/run-with-google-adk/static/app.js @@ -33,7 +33,8 @@ document.addEventListener('DOMContentLoaded', () => { // Application State let currentSessionId = null; - let currentUserId = localStorage.getItem('username') || 'secops_user'; + const savedUser = localStorage.getItem('username'); + let currentUserId = (savedUser && savedUser !== 'secops_user') ? savedUser : null; let isStreaming = false; let activeAbortController = null; let requestStartTime = 0; @@ -95,6 +96,11 @@ document.addEventListener('DOMContentLoaded', () => { // Update User UI function updateUserUI() { + if (!currentUserId) { + currentUserDisplay.textContent = 'secops_user'; + userAvatar.textContent = 'S'; + return; + } currentUserDisplay.textContent = currentUserId; userAvatar.textContent = currentUserId.charAt(0).toUpperCase(); localStorage.setItem('username', currentUserId); @@ -122,7 +128,11 @@ document.addEventListener('DOMContentLoaded', () => { const res = await fetch(`${API_BASE_URL}/info`); if (res.ok) { const data = await res.json(); - const mcp = data.mcp_servers || {}; + if (data.user && !currentUserId) { + currentUserId = data.user; + updateUserUI(); + } + const mcp = data.tools || data.mcp_servers || {}; updatePill(pillSecops, mcp.secops); updatePill(pillScc, mcp.scc); updatePill(pillGti, mcp.gti); @@ -150,17 +160,24 @@ document.addEventListener('DOMContentLoaded', () => { // Fetch / Initialize Session async function initSession(startNew = false) { try { - let url = `${API_BASE_URL}/get_session?username=${encodeURIComponent(currentUserId)}`; + let url = `${API_BASE_URL}/get_session`; + if (currentUserId) { + url += `?username=${encodeURIComponent(currentUserId)}`; + } if (startNew) { - url += '&start_new_session=Y'; + url += (currentUserId ? '&' : '?') + 'start_new_session=Y'; } const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); currentSessionId = data.session_id; + if (data.user_id) { + currentUserId = data.user_id; + updateUserUI(); + } currentSessionDisplay.textContent = currentSessionId.slice(0, 8) + '...'; currentSessionDisplay.title = currentSessionId; - console.log('Active session initialized:', currentSessionId); + console.log('Active session initialized:', currentSessionId, 'User:', currentUserId); } catch (err) { console.error('Session initialization failed:', err); showToast('Could not initialize session with backend', 'error'); diff --git a/run-with-google-adk/static/index.html b/run-with-google-adk/static/index.html index a756aa71..19f490ad 100644 --- a/run-with-google-adk/static/index.html +++ b/run-with-google-adk/static/index.html @@ -34,7 +34,7 @@
U
- secops_user + Detecting...
- + diff --git a/run-with-google-adk/tests/test_server.py b/run-with-google-adk/tests/test_server.py index ba55da8f..cb73cd42 100644 --- a/run-with-google-adk/tests/test_server.py +++ b/run-with-google-adk/tests/test_server.py @@ -160,5 +160,18 @@ def test_no_emojis_in_web_app_and_stream(): assert char in ("`", "^", "~", "<", ">", "+", "=", "|", "•"), f"Unexpected emoji/symbol in stream: {char!r}" - - +def test_scroll_and_layout_constraints(): + client = TestClient(create_app()) + resp = client.get("/static/app.css") + assert resp.status_code == 200 + css = resp.text + + # Ensure body is constrained to viewport height to avoid pushing input offscreen + assert "max-height: 100vh" in css or "height: 100vh" in css + # Ensure layout and workspace flex items allow shrinking + assert "min-height: 0" in css + # Ensure messages container scrolls vertically + assert "overflow-y: auto" in css + # Ensure custom scrollbar rules are defined for visibility + assert "scrollbar-width: thin" in css + assert "::-webkit-scrollbar" in css From 8c75e8d5515e766c4db0eb9d115db13e579f9a30 Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Wed, 9 Sep 2026 00:10:44 +0000 Subject: [PATCH 22/27] fix(adk): address critical code review findings on XSS, memory leak, and concurrency - Mitigate XSS vulnerability by loading DOMPurify and sanitizing all rendered markdown in app.js. - Prevent unbounded memory leaks in long-running services by implementing BoundedSessionService with FIFO/LRU eviction (capped at 1000 sessions). - Eliminate concurrency race condition in get_runner() and get_session_service() using double-checked locking with threading.Lock. - Cache get_settings() to avoid repeated environment and file parsing on every /info request. - Fix stream accumulation to concatenate streaming text tokens cleanly while formatting tool/error cards. - Gracefully handle asyncio.CancelledError on client disconnection in SSE generator. - Fix test isolation in test_agent.py by directly patching google.adk.agents.llm_agent.LlmAgent. - Add unit tests test_bounded_session_service_eviction and test_dompurify_xss_protection. TAG=agy CONV=ad3258db-3ab7-4cda-89a7-62fdf635bcf8 --- .../src/mcp_security_agent/server/routes.py | 110 +++++++++++++++--- run-with-google-adk/static/app.js | 33 ++++-- run-with-google-adk/static/index.html | 5 +- run-with-google-adk/tests/test_agent.py | 8 +- run-with-google-adk/tests/test_server.py | 40 +++++++ 5 files changed, 160 insertions(+), 36 deletions(-) diff --git a/run-with-google-adk/src/mcp_security_agent/server/routes.py b/run-with-google-adk/src/mcp_security_agent/server/routes.py index 2a293fac..7185825a 100644 --- a/run-with-google-adk/src/mcp_security_agent/server/routes.py +++ b/run-with-google-adk/src/mcp_security_agent/server/routes.py @@ -17,6 +17,8 @@ import uuid import asyncio import logging +import threading +from collections import OrderedDict from pathlib import Path from typing import Dict, Any, Optional, AsyncGenerator from fastapi import APIRouter, HTTPException, Query, Request @@ -90,10 +92,24 @@ def get_session(username: Optional[str] = Query(None, description="Username for } +_settings: Optional[AgentSettings] = None +_settings_lock = threading.Lock() + + +def get_settings() -> AgentSettings: + """Returns cached AgentSettings singleton to avoid redundant env parsing.""" + global _settings + if _settings is None: + with _settings_lock: + if _settings is None: + _settings = AgentSettings() + return _settings + + @router.get("/info") def get_info() -> Dict[str, Any]: """Provides server runtime metadata, active user identity, and enabled MCP server status.""" - settings = AgentSettings() + settings = get_settings() return { "version": __version__, "model": settings.google_model, @@ -107,35 +123,84 @@ def get_info() -> Dict[str, Any]: } +class BoundedSessionService(InMemorySessionService): + """InMemorySessionService with FIFO/LRU session eviction to prevent unbounded memory growth.""" + + def __init__(self, max_sessions: int = 1000): + super().__init__() + self.max_sessions = max_sessions + self._session_order: OrderedDict = OrderedDict() + + def _create_session_impl( + self, + *, + app_name: str, + user_id: str, + state: Optional[Dict[str, Any]] = None, + session_id: Optional[str] = None, + ) -> Any: + while len(self._session_order) >= self.max_sessions: + (old_app, old_user, old_sess), _ = self._session_order.popitem(last=False) + if old_app in self.sessions and old_user in self.sessions[old_app]: + self.sessions[old_app][old_user].pop(old_sess, None) + sess = super()._create_session_impl( + app_name=app_name, + user_id=user_id, + state=state, + session_id=session_id, + ) + self._session_order[(app_name, user_id, sess.id)] = True + return sess + + def _delete_session_impl( + self, + *, + app_name: str, + user_id: str, + session_id: str, + ) -> None: + self._session_order.pop((app_name, user_id, session_id), None) + super()._delete_session_impl( + app_name=app_name, + user_id=user_id, + session_id=session_id, + ) + + _runner: Optional[Runner] = None -_session_service: Optional[InMemorySessionService] = None +_runner_lock = threading.Lock() +_session_service: Optional[BoundedSessionService] = None -def get_session_service() -> InMemorySessionService: - """Returns the singleton InMemorySessionService for active chat sessions.""" +def get_session_service() -> BoundedSessionService: + """Returns the singleton BoundedSessionService for active chat sessions.""" global _session_service if _session_service is None: - _session_service = InMemorySessionService() + with _runner_lock: + if _session_service is None: + _session_service = BoundedSessionService(max_sessions=1000) return _session_service def get_runner() -> Optional[Runner]: - """Returns or initializes the ADK Runner instance for the security agent.""" + """Returns or initializes the thread-safe ADK Runner instance for the security agent.""" global _runner if _runner is None: - settings = AgentSettings() - agent = getattr(agent_mod, "root_agent", None) - if agent is None: - agent = agent_mod.create_security_agent(settings) - agent_mod.root_agent = agent - if agent is None: - return None - session_svc = get_session_service() - _runner = Runner( - agent=agent, - app_name="mcp_security_agent", - session_service=session_svc, - ) + with _runner_lock: + if _runner is None: + settings = get_settings() + agent = getattr(agent_mod, "root_agent", None) + if agent is None: + agent = agent_mod.create_security_agent(settings) + agent_mod.root_agent = agent + if agent is None: + return None + session_svc = get_session_service() + _runner = Runner( + agent=agent, + app_name="mcp_security_agent", + session_service=session_svc, + ) return _runner @@ -188,6 +253,7 @@ async def sse_event_generator( "last_msg": False, "session_id": session_id, "author": event.author or "SecurityOperationsAgent", + "event_type": "content", }) yield f"data: {data}\n\n" elif part.function_call: @@ -195,6 +261,7 @@ async def sse_event_generator( "text": f"[Tool] **Calling tool `{part.function_call.name}`**\n```json\n{json.dumps(part.function_call.args, indent=2)}\n```", "last_msg": False, "session_id": session_id, + "event_type": "tool_call", }) yield f"data: {call_info}\n\n" elif part.function_response: @@ -202,14 +269,19 @@ async def sse_event_generator( "text": f"[Tool] **Received tool response from `{part.function_response.name}`**", "last_msg": False, "session_id": session_id, + "event_type": "tool_response", }) yield f"data: {resp_info}\n\n" + except asyncio.CancelledError: + logger.info(f"SSE client disconnected for session {session_id}") + raise except Exception as e: logger.error(f"Error during agent execution: {e}", exc_info=True) err_data = json.dumps({ "text": f"[Error] **Error during investigation:** {str(e)}", "last_msg": False, "session_id": session_id, + "event_type": "error", }) yield f"data: {err_data}\n\n" diff --git a/run-with-google-adk/static/app.js b/run-with-google-adk/static/app.js index e34e4dd4..2d0938f8 100644 --- a/run-with-google-adk/static/app.js +++ b/run-with-google-adk/static/app.js @@ -184,6 +184,20 @@ document.addEventListener('DOMContentLoaded', () => { } } + // Safe Markdown Rendering with DOMPurify Sanitization + function renderMarkdown(rawText) { + if (window.marked && typeof window.marked.parse === 'function') { + const rawHtml = window.marked.parse(rawText); + if (window.DOMPurify && typeof window.DOMPurify.sanitize === 'function') { + return window.DOMPurify.sanitize(rawHtml); + } + return rawHtml; + } + const div = document.createElement('div'); + div.textContent = rawText; + return div.innerHTML; + } + // Append Message function appendMessage(text, sender, timeElapsed = null, timeDiff = null) { if (emptyState && emptyState.style.display !== 'none') { @@ -223,11 +237,7 @@ document.addEventListener('DOMContentLoaded', () => { bubble.className = 'msg-bubble'; if (sender === 'agent') { - if (window.marked && typeof window.marked.parse === 'function') { - bubble.innerHTML = window.marked.parse(text); - } else { - bubble.textContent = text; - } + bubble.innerHTML = renderMarkdown(text); addCopyButtonsToPre(bubble); } else { bubble.textContent = text; @@ -369,16 +379,19 @@ document.addEventListener('DOMContentLoaded', () => { reader.cancel(); break; } else if (data.text) { + const isBlock = data.event_type === 'tool_call' || + data.event_type === 'tool_response' || + data.event_type === 'error' || + (!data.event_type && (data.text.startsWith('[Tool]') || data.text.startsWith('[Error]') || data.text.startsWith('[Warning]'))); + if (!accumulatedText) { accumulatedText = data.text; - } else { + } else if (isBlock) { accumulatedText += '\n\n' + data.text; - } - if (window.marked && typeof window.marked.parse === 'function') { - bubble.innerHTML = window.marked.parse(accumulatedText); } else { - bubble.textContent = accumulatedText; + accumulatedText += data.text; } + bubble.innerHTML = renderMarkdown(accumulatedText); addCopyButtonsToPre(bubble); headerInfo.innerHTML = `${timeElapsed}ms (${timeDiff}ms)`; scrollToBottom(); diff --git a/run-with-google-adk/static/index.html b/run-with-google-adk/static/index.html index fc3ba36c..846a6fe8 100644 --- a/run-with-google-adk/static/index.html +++ b/run-with-google-adk/static/index.html @@ -4,7 +4,7 @@ Google SecOps AI Assistant - + @@ -136,7 +136,8 @@
+ - + diff --git a/run-with-google-adk/tests/test_agent.py b/run-with-google-adk/tests/test_agent.py index a335d896..044649ba 100644 --- a/run-with-google-adk/tests/test_agent.py +++ b/run-with-google-adk/tests/test_agent.py @@ -16,14 +16,12 @@ def test_create_security_agent(): settings = AgentSettings(GOOGLE_MODEL="gemini-2.5-flash") mock_agent_instance = MagicMock() - mock_llm_agent_mod = MagicMock() - mock_llm_agent_mod.LlmAgent = MagicMock(return_value=mock_agent_instance) - with patch.dict("sys.modules", {"google.adk.agents.llm_agent": mock_llm_agent_mod}): + with patch("google.adk.agents.llm_agent.LlmAgent", return_value=mock_agent_instance) as mock_llm_agent_cls: agent = create_security_agent(settings) assert agent == mock_agent_instance - mock_llm_agent_mod.LlmAgent.assert_called_once() - _, kwargs = mock_llm_agent_mod.LlmAgent.call_args + mock_llm_agent_cls.assert_called_once() + _, kwargs = mock_llm_agent_cls.call_args assert kwargs["name"] == "SecurityOperationsAgent" assert kwargs["model"] == "gemini-2.5-flash" assert kwargs["instruction"] == SOC_AGENT_SYSTEM_PROMPT diff --git a/run-with-google-adk/tests/test_server.py b/run-with-google-adk/tests/test_server.py index cb73cd42..f02b41b9 100644 --- a/run-with-google-adk/tests/test_server.py +++ b/run-with-google-adk/tests/test_server.py @@ -175,3 +175,43 @@ def test_scroll_and_layout_constraints(): # Ensure custom scrollbar rules are defined for visibility assert "scrollbar-width: thin" in css assert "::-webkit-scrollbar" in css + + +def test_bounded_session_service_eviction(): + from mcp_security_agent.server.routes import BoundedSessionService + + service = BoundedSessionService(max_sessions=10) + + import asyncio + async def run_test(): + for i in range(12): + await service.create_session( + app_name="test_app", + user_id="user", + session_id=f"sess_{i}", + ) + # Should have evicted older sessions so total <= max_sessions + assert len(service._session_order) == 10 + user_sessions = service.sessions["test_app"]["user"] + assert len(user_sessions) == 10 + # Oldest sessions (sess_0, sess_1) must have been evicted + assert "sess_0" not in user_sessions + assert "sess_1" not in user_sessions + # Newest sessions must be present + assert "sess_10" in user_sessions + assert "sess_11" in user_sessions + + asyncio.run(run_test()) + + +def test_dompurify_xss_protection(): + client = TestClient(create_app()) + html = client.get("/index.html").text + js = client.get("/static/app.js").text + + # Verify DOMPurify is loaded in HTML before marked/app.js + assert "dompurify" in html + # Verify app.js defines renderMarkdown and uses DOMPurify.sanitize + assert "DOMPurify.sanitize" in js + assert "renderMarkdown" in js + From f2c7d01b94747e0c374644ef83606848aa51dc2f Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Wed, 9 Sep 2026 00:16:46 +0000 Subject: [PATCH 23/27] fix(adk): prune empty user dictionaries on session eviction and direct root_agent check --- .../src/mcp_security_agent/server/routes.py | 13 ++++- run-with-google-adk/tests/test_server.py | 53 +++++++++++++++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/run-with-google-adk/src/mcp_security_agent/server/routes.py b/run-with-google-adk/src/mcp_security_agent/server/routes.py index 7185825a..8b18f116 100644 --- a/run-with-google-adk/src/mcp_security_agent/server/routes.py +++ b/run-with-google-adk/src/mcp_security_agent/server/routes.py @@ -143,6 +143,10 @@ def _create_session_impl( (old_app, old_user, old_sess), _ = self._session_order.popitem(last=False) if old_app in self.sessions and old_user in self.sessions[old_app]: self.sessions[old_app][old_user].pop(old_sess, None) + if not self.sessions[old_app][old_user]: + self.sessions[old_app].pop(old_user, None) + if old_app in self.sessions and not self.sessions[old_app]: + self.sessions.pop(old_app, None) sess = super()._create_session_impl( app_name=app_name, user_id=user_id, @@ -165,6 +169,11 @@ def _delete_session_impl( user_id=user_id, session_id=session_id, ) + if app_name in self.sessions and user_id in self.sessions[app_name]: + if not self.sessions[app_name][user_id]: + self.sessions[app_name].pop(user_id, None) + if app_name in self.sessions and not self.sessions[app_name]: + self.sessions.pop(app_name, None) _runner: Optional[Runner] = None @@ -189,10 +198,10 @@ def get_runner() -> Optional[Runner]: with _runner_lock: if _runner is None: settings = get_settings() - agent = getattr(agent_mod, "root_agent", None) + agent = getattr(agent_mod, "_root_agent", None) if agent is None: agent = agent_mod.create_security_agent(settings) - agent_mod.root_agent = agent + agent_mod._root_agent = agent if agent is None: return None session_svc = get_session_service() diff --git a/run-with-google-adk/tests/test_server.py b/run-with-google-adk/tests/test_server.py index f02b41b9..7c8ce6a5 100644 --- a/run-with-google-adk/tests/test_server.py +++ b/run-with-google-adk/tests/test_server.py @@ -184,26 +184,73 @@ def test_bounded_session_service_eviction(): import asyncio async def run_test(): + # Test eviction within single user for i in range(12): await service.create_session( app_name="test_app", user_id="user", session_id=f"sess_{i}", ) - # Should have evicted older sessions so total <= max_sessions assert len(service._session_order) == 10 user_sessions = service.sessions["test_app"]["user"] assert len(user_sessions) == 10 - # Oldest sessions (sess_0, sess_1) must have been evicted assert "sess_0" not in user_sessions assert "sess_1" not in user_sessions - # Newest sessions must be present assert "sess_10" in user_sessions assert "sess_11" in user_sessions + # Test eviction across distinct users and empty user dict pruning + multi_service = BoundedSessionService(max_sessions=5) + for i in range(8): + await multi_service.create_session( + app_name="test_app", + user_id=f"user_{i}", + session_id=f"sess_{i}", + ) + assert len(multi_service._session_order) == 5 + # Users 0, 1, 2 should be completely evicted and pruned from test_app dict + assert "user_0" not in multi_service.sessions["test_app"] + assert "user_1" not in multi_service.sessions["test_app"] + assert "user_2" not in multi_service.sessions["test_app"] + # Users 3..7 should remain + for i in range(3, 8): + assert f"user_{i}" in multi_service.sessions["test_app"] + + # Test explicit session deletion and empty dict cleanup + await multi_service.delete_session( + app_name="test_app", + user_id="user_7", + session_id="sess_7", + ) + assert "user_7" not in multi_service.sessions["test_app"] + asyncio.run(run_test()) +def test_get_runner_agent_creation(): + from unittest.mock import patch, MagicMock + import mcp_security_agent.server.routes as server_routes + import mcp_security_agent.agent as agent_mod + + # Reset globals for test isolation + original_runner = server_routes._runner + original_root = getattr(agent_mod, "_root_agent", None) + try: + server_routes._runner = None + agent_mod._root_agent = None + + mock_agent = MagicMock() + with patch("mcp_security_agent.agent.create_security_agent", return_value=mock_agent) as mock_create, \ + patch("mcp_security_agent.server.routes.Runner") as mock_runner_cls: + runner = server_routes.get_runner() + assert runner is not None + mock_create.assert_called_once() + assert agent_mod._root_agent is mock_agent + finally: + server_routes._runner = original_runner + agent_mod._root_agent = original_root + + def test_dompurify_xss_protection(): client = TestClient(create_app()) html = client.get("/index.html").text From 83c691139b4b4f2879e982ed2414d9c5b9d1983d Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Wed, 9 Sep 2026 00:20:29 +0000 Subject: [PATCH 24/27] fix(adk): fail-closed DOMPurify XSS fallback, user identity caching, and HTTP 204 response --- run-with-google-adk/src/mcp_security_agent/cli.py | 4 ++++ run-with-google-adk/src/mcp_security_agent/config.py | 2 ++ run-with-google-adk/src/mcp_security_agent/server/routes.py | 6 +++--- run-with-google-adk/static/app.js | 5 ++++- run-with-google-adk/tests/test_config.py | 5 +++++ run-with-google-adk/tests/test_server.py | 6 +++++- 6 files changed, 23 insertions(+), 5 deletions(-) diff --git a/run-with-google-adk/src/mcp_security_agent/cli.py b/run-with-google-adk/src/mcp_security_agent/cli.py index 5672a01a..5f63e180 100644 --- a/run-with-google-adk/src/mcp_security_agent/cli.py +++ b/run-with-google-adk/src/mcp_security_agent/cli.py @@ -126,8 +126,10 @@ def chat( import mcp_security_agent.agent as agent_mod agent = agent_mod.create_security_agent(settings) + agent_mod._root_agent = agent agent_mod.root_agent = agent if "mcp_security_agent" in sys.modules: + sys.modules["mcp_security_agent"]._root_agent = agent sys.modules["mcp_security_agent"].root_agent = agent try: @@ -191,8 +193,10 @@ def serve( import mcp_security_agent.agent as agent_mod agent = agent_mod.create_security_agent(settings) + agent_mod._root_agent = agent agent_mod.root_agent = agent if "mcp_security_agent" in sys.modules: + sys.modules["mcp_security_agent"]._root_agent = agent sys.modules["mcp_security_agent"].root_agent = agent bind_port = port if port is not None else int(os.environ.get("PORT", 8080)) diff --git a/run-with-google-adk/src/mcp_security_agent/config.py b/run-with-google-adk/src/mcp_security_agent/config.py index e080c610..2a6edc22 100644 --- a/run-with-google-adk/src/mcp_security_agent/config.py +++ b/run-with-google-adk/src/mcp_security_agent/config.py @@ -13,6 +13,7 @@ # limitations under the License. """Centralized configuration and settings for MCP Security Agent.""" +import functools from pathlib import Path from typing import Optional from pydantic import Field, field_validator, model_validator @@ -44,6 +45,7 @@ def _discover_local_adc() -> Optional[str]: return None +@functools.lru_cache(maxsize=1) def discover_user_identity() -> str: """Discovers the active user identity from ADC, gcloud config, or system environment. diff --git a/run-with-google-adk/src/mcp_security_agent/server/routes.py b/run-with-google-adk/src/mcp_security_agent/server/routes.py index 8b18f116..b4c4bd49 100644 --- a/run-with-google-adk/src/mcp_security_agent/server/routes.py +++ b/run-with-google-adk/src/mcp_security_agent/server/routes.py @@ -21,7 +21,7 @@ from collections import OrderedDict from pathlib import Path from typing import Dict, Any, Optional, AsyncGenerator -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, HTTPException, Query, Request, Response from fastapi.responses import FileResponse, StreamingResponse, JSONResponse from pydantic import BaseModel from google.adk.runners import Runner @@ -72,7 +72,7 @@ def health_check() -> Dict[str, str]: @router.get("/favicon.ico") def get_favicon(): """Returns 204 No Content for browser favicon requests.""" - return JSONResponse(content={}, status_code=204) + return Response(status_code=204) @router.get("/app_name") @@ -324,7 +324,7 @@ async def chat_post(request: ChatRequest, http_request: Request): uid = request.user_id or discover_user_identity() accept_header = http_request.headers.get("accept", "") - if "text/event-stream" in accept_header or request.message is not None: + if "text/event-stream" in accept_header: return StreamingResponse( sse_event_generator(query_text, sess_id, uid), media_type="text/event-stream", diff --git a/run-with-google-adk/static/app.js b/run-with-google-adk/static/app.js index 2d0938f8..b5089347 100644 --- a/run-with-google-adk/static/app.js +++ b/run-with-google-adk/static/app.js @@ -191,7 +191,10 @@ document.addEventListener('DOMContentLoaded', () => { if (window.DOMPurify && typeof window.DOMPurify.sanitize === 'function') { return window.DOMPurify.sanitize(rawHtml); } - return rawHtml; + console.warn('DOMPurify not available, failing closed to escaped text for security.'); + const div = document.createElement('div'); + div.textContent = rawText; + return `
${div.innerHTML}
`; } const div = document.createElement('div'); div.textContent = rawText; diff --git a/run-with-google-adk/tests/test_config.py b/run-with-google-adk/tests/test_config.py index 92affc65..f2c8b42d 100644 --- a/run-with-google-adk/tests/test_config.py +++ b/run-with-google-adk/tests/test_config.py @@ -125,23 +125,28 @@ def test_discover_user_identity(): from mcp_security_agent.config import discover_user_identity # 1. Explicit user + discover_user_identity.cache_clear() with patch.dict(os.environ, {"SECOPS_USER": "analyst_alice"}, clear=True): assert discover_user_identity() == "analyst_alice" # 2. Impersonated SA + discover_user_identity.cache_clear() with patch.dict(os.environ, {"SECOPS_IMPERSONATE_SERVICE_ACCOUNT": "sa@proj.iam.gserviceaccount.com"}, clear=True): assert discover_user_identity() == "sa@proj.iam.gserviceaccount.com" # 3. gcloud active account + discover_user_identity.cache_clear() mock_res = type("SubprocessResult", (), {"returncode": 0, "stdout": "gcloud_user@example.com\n"}) with patch.dict(os.environ, {}, clear=True), patch("shutil.which", return_value="/bin/gcloud"), patch("subprocess.run", return_value=mock_res): assert discover_user_identity() == "gcloud_user@example.com" # 4. System user (when gcloud returns unset or not found) + discover_user_identity.cache_clear() with patch.dict(os.environ, {"USER": "test_dev"}, clear=True), patch("shutil.which", return_value=None), patch("google.auth.default", side_effect=Exception("no auth")): assert discover_user_identity() == "test_dev" # 5. Generic fallback + discover_user_identity.cache_clear() with patch.dict(os.environ, {}, clear=True), patch("shutil.which", return_value=None), patch("google.auth.default", side_effect=Exception("no auth")), patch("getpass.getuser", side_effect=Exception("no user")): assert discover_user_identity() == "secops_user" diff --git a/run-with-google-adk/tests/test_server.py b/run-with-google-adk/tests/test_server.py index 7c8ce6a5..06019b80 100644 --- a/run-with-google-adk/tests/test_server.py +++ b/run-with-google-adk/tests/test_server.py @@ -108,7 +108,11 @@ def test_chat_post_sse_streaming(): from unittest.mock import patch with patch("mcp_security_agent.server.routes.get_runner", return_value=MockRunner()): client = TestClient(create_app()) - response = client.post("/chat", json={"message": "Investigate alert 123", "session_id": "test-sess"}) + response = client.post( + "/chat", + json={"message": "Investigate alert 123", "session_id": "test-sess"}, + headers={"accept": "text/event-stream"}, + ) assert response.status_code == 200 assert "text/event-stream" in response.headers["content-type"] assert "Mocked analysis response" in response.text From 04961e00410c21e43cf7065fc1e8d981591c76d3 Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Wed, 9 Sep 2026 00:30:21 +0000 Subject: [PATCH 25/27] fix(adk): abort orphaned SSE streams on chat reset and operator alias change --- .../src/mcp_security_agent/server/routes.py | 2 +- run-with-google-adk/static/app.js | 6 ++++++ run-with-google-adk/tests/test_server.py | 13 +++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/run-with-google-adk/src/mcp_security_agent/server/routes.py b/run-with-google-adk/src/mcp_security_agent/server/routes.py index b4c4bd49..f08a946f 100644 --- a/run-with-google-adk/src/mcp_security_agent/server/routes.py +++ b/run-with-google-adk/src/mcp_security_agent/server/routes.py @@ -124,7 +124,7 @@ def get_info() -> Dict[str, Any]: class BoundedSessionService(InMemorySessionService): - """InMemorySessionService with FIFO/LRU session eviction to prevent unbounded memory growth.""" + """InMemorySessionService with FIFO session eviction to prevent unbounded memory growth.""" def __init__(self, max_sessions: int = 1000): super().__init__() diff --git a/run-with-google-adk/static/app.js b/run-with-google-adk/static/app.js index b5089347..61599340 100644 --- a/run-with-google-adk/static/app.js +++ b/run-with-google-adk/static/app.js @@ -459,6 +459,9 @@ document.addEventListener('DOMContentLoaded', () => { showToast('Username cannot be empty', 'warning'); return; } + if (isStreaming) { + cancelStreaming(); + } currentUserId = newName; updateUserUI(); closeUserModal(); @@ -488,6 +491,9 @@ document.addEventListener('DOMContentLoaded', () => { }); newInvestigationBtn.addEventListener('click', () => { + if (isStreaming) { + cancelStreaming(); + } messagesContainer.innerHTML = ''; if (emptyState) { emptyState.style.display = 'flex'; diff --git a/run-with-google-adk/tests/test_server.py b/run-with-google-adk/tests/test_server.py index 06019b80..e69932af 100644 --- a/run-with-google-adk/tests/test_server.py +++ b/run-with-google-adk/tests/test_server.py @@ -265,4 +265,17 @@ def test_dompurify_xss_protection(): # Verify app.js defines renderMarkdown and uses DOMPurify.sanitize assert "DOMPurify.sanitize" in js assert "renderMarkdown" in js + # Verify fail-closed behavior if DOMPurify is not available + assert "failing closed to escaped text" in js + + +def test_frontend_streaming_abort_on_reset(): + client = TestClient(create_app()) + js = client.get("/static/app.js").text + + assert "cancelStreaming" in js + # Verify newInvestigationBtn and saveUser call cancelStreaming when isStreaming is true + assert "newInvestigationBtn.addEventListener" in js + assert "if (isStreaming) {\n cancelStreaming();\n }\n messagesContainer.innerHTML = '';" in js + assert "if (isStreaming) {\n cancelStreaming();\n }\n currentUserId = newName;" in js From b032a66490100e0132b6584efdf3659b297e427e Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Wed, 9 Sep 2026 00:47:50 +0000 Subject: [PATCH 26/27] fix(adk): dynamic uv launcher fallback to sys.executable and session service thread lock --- .gitignore | 5 +- .../src/mcp_security_agent/server/routes.py | 57 ++++++++++--------- .../src/mcp_security_agent/toolsets.py | 47 +++++++++++---- run-with-google-adk/tests/test_toolsets.py | 14 +++++ 4 files changed, 83 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index 4e2b5923..4d137a1c 100644 --- a/.gitignore +++ b/.gitignore @@ -214,4 +214,7 @@ app_data.db .gemini/ # devcontainer -.devcontainer/ \ No newline at end of file +.devcontainer/ + +# IDE / Project tools +.cm_project \ No newline at end of file diff --git a/run-with-google-adk/src/mcp_security_agent/server/routes.py b/run-with-google-adk/src/mcp_security_agent/server/routes.py index f08a946f..9f23a2aa 100644 --- a/run-with-google-adk/src/mcp_security_agent/server/routes.py +++ b/run-with-google-adk/src/mcp_security_agent/server/routes.py @@ -130,6 +130,7 @@ def __init__(self, max_sessions: int = 1000): super().__init__() self.max_sessions = max_sessions self._session_order: OrderedDict = OrderedDict() + self._lock = threading.Lock() def _create_session_impl( self, @@ -139,22 +140,23 @@ def _create_session_impl( state: Optional[Dict[str, Any]] = None, session_id: Optional[str] = None, ) -> Any: - while len(self._session_order) >= self.max_sessions: - (old_app, old_user, old_sess), _ = self._session_order.popitem(last=False) - if old_app in self.sessions and old_user in self.sessions[old_app]: - self.sessions[old_app][old_user].pop(old_sess, None) - if not self.sessions[old_app][old_user]: - self.sessions[old_app].pop(old_user, None) - if old_app in self.sessions and not self.sessions[old_app]: - self.sessions.pop(old_app, None) - sess = super()._create_session_impl( - app_name=app_name, - user_id=user_id, - state=state, - session_id=session_id, - ) - self._session_order[(app_name, user_id, sess.id)] = True - return sess + with self._lock: + while len(self._session_order) >= self.max_sessions: + (old_app, old_user, old_sess), _ = self._session_order.popitem(last=False) + if old_app in self.sessions and old_user in self.sessions[old_app]: + self.sessions[old_app][old_user].pop(old_sess, None) + if not self.sessions[old_app][old_user]: + self.sessions[old_app].pop(old_user, None) + if old_app in self.sessions and not self.sessions[old_app]: + self.sessions.pop(old_app, None) + sess = super()._create_session_impl( + app_name=app_name, + user_id=user_id, + state=state, + session_id=session_id, + ) + self._session_order[(app_name, user_id, sess.id)] = True + return sess def _delete_session_impl( self, @@ -163,17 +165,18 @@ def _delete_session_impl( user_id: str, session_id: str, ) -> None: - self._session_order.pop((app_name, user_id, session_id), None) - super()._delete_session_impl( - app_name=app_name, - user_id=user_id, - session_id=session_id, - ) - if app_name in self.sessions and user_id in self.sessions[app_name]: - if not self.sessions[app_name][user_id]: - self.sessions[app_name].pop(user_id, None) - if app_name in self.sessions and not self.sessions[app_name]: - self.sessions.pop(app_name, None) + with self._lock: + self._session_order.pop((app_name, user_id, session_id), None) + super()._delete_session_impl( + app_name=app_name, + user_id=user_id, + session_id=session_id, + ) + if app_name in self.sessions and user_id in self.sessions[app_name]: + if not self.sessions[app_name][user_id]: + self.sessions[app_name].pop(user_id, None) + if app_name in self.sessions and not self.sessions[app_name]: + self.sessions.pop(app_name, None) _runner: Optional[Runner] = None diff --git a/run-with-google-adk/src/mcp_security_agent/toolsets.py b/run-with-google-adk/src/mcp_security_agent/toolsets.py index 225aad39..855e5be4 100644 --- a/run-with-google-adk/src/mcp_security_agent/toolsets.py +++ b/run-with-google-adk/src/mcp_security_agent/toolsets.py @@ -104,6 +104,25 @@ def _build_stdio_env() -> dict[str, str]: stdio_env = _build_stdio_env() + def _get_stdio_cmd_args(server_path: Path, script_relpath: str) -> tuple[str, list[str], dict[str, str]]: + import shutil + import sys + import os + + env = dict(stdio_env) + if shutil.which("uv"): + return "uv", ["--directory", str(server_path), "run", script_relpath], env + + logger.warning( + "uv executable not found in PATH; falling back to sys.executable (%s) for %s", + sys.executable, + server_path.name, + ) + existing_pp = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = f"{server_path}{os.pathsep}{existing_pp}" if existing_pp else str(server_path) + script_full = str(server_path / script_relpath) + return sys.executable, [script_full], env + # 1. Google SecOps SIEM MCP if settings.load_secops_mcp: if settings.secops_mcp_url: @@ -111,11 +130,12 @@ def _build_stdio_env() -> dict[str, str]: else: secops_dir = server_dir / "secops" logger.info("Configuring SecOps SIEM MCP via Stdio subprocess at %s", secops_dir) + cmd, args, env = _get_stdio_cmd_args(secops_dir, "secops_mcp/server.py") conn = StdioConnectionParams( server_params=StdioServerParameters( - command="uv", - args=["--directory", str(secops_dir), "run", "secops_mcp/server.py"], - env=stdio_env, + command=cmd, + args=args, + env=env, ), timeout=settings.stdio_timeout_seconds, ) @@ -128,11 +148,12 @@ def _build_stdio_env() -> dict[str, str]: else: scc_dir = server_dir / "scc" logger.info("Configuring SCC MCP via Stdio subprocess at %s", scc_dir) + cmd, args, env = _get_stdio_cmd_args(scc_dir, "scc_mcp.py") conn = StdioConnectionParams( server_params=StdioServerParameters( - command="uv", - args=["--directory", str(scc_dir), "run", "scc_mcp.py"], - env=stdio_env, + command=cmd, + args=args, + env=env, ), timeout=settings.stdio_timeout_seconds, ) @@ -145,11 +166,12 @@ def _build_stdio_env() -> dict[str, str]: else: gti_dir = server_dir / "gti" logger.info("Configuring GTI MCP via Stdio subprocess at %s", gti_dir) + cmd, args, env = _get_stdio_cmd_args(gti_dir, "gti_mcp/server.py") conn = StdioConnectionParams( server_params=StdioServerParameters( - command="uv", - args=["--directory", str(gti_dir), "run", "gti_mcp/server.py"], - env=stdio_env, + command=cmd, + args=args, + env=env, ), timeout=settings.stdio_timeout_seconds, ) @@ -162,11 +184,12 @@ def _build_stdio_env() -> dict[str, str]: else: soar_dir = server_dir / "secops-soar" logger.info("Configuring SecOps SOAR MCP via Stdio subprocess at %s", soar_dir) + cmd, args, env = _get_stdio_cmd_args(soar_dir, "secops_soar_mcp/server.py") conn = StdioConnectionParams( server_params=StdioServerParameters( - command="uv", - args=["--directory", str(soar_dir), "run", "secops_soar_mcp/server.py"], - env=stdio_env, + command=cmd, + args=args, + env=env, ), timeout=settings.stdio_timeout_seconds, ) diff --git a/run-with-google-adk/tests/test_toolsets.py b/run-with-google-adk/tests/test_toolsets.py index 52b46073..9769b5c5 100644 --- a/run-with-google-adk/tests/test_toolsets.py +++ b/run-with-google-adk/tests/test_toolsets.py @@ -67,3 +67,17 @@ def test_build_toolsets_stdio_env_propagation(): assert env["GOOGLE_API_USE_MTLS_ENDPOINT"] == "never" +def test_build_toolsets_uv_fallback_to_sys_executable(): + with patch.dict(os.environ, {}, clear=True), patch("shutil.which", return_value=None): + with patch("google.adk.tools.mcp_tool.mcp_toolset.McpToolset", side_effect=lambda connection_params: f"Toolset({connection_params})"), \ + patch("google.adk.tools.mcp_tool.mcp_toolset.StdioConnectionParams") as mock_params: + settings = AgentSettings(_env_file=None, LOAD_SECOPS_MCP="Y") + toolsets = build_mcp_toolsets(settings) + assert len(toolsets) == 1 + call_kwargs = mock_params.call_args.kwargs + server_params = call_kwargs["server_params"] + assert server_params.command == sys.executable + assert "secops_mcp/server.py" in server_params.args[0] + assert "PYTHONPATH" in server_params.env + + From 0acde309e614c8a54d8719e4935137e627666deb Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Wed, 9 Sep 2026 00:57:30 +0000 Subject: [PATCH 27/27] fix(adk): thread-safe session service lookups, lazy agent lock, and REST schema preservation --- .../src/mcp_security_agent/agent.py | 6 ++- .../src/mcp_security_agent/server/routes.py | 48 ++++++++++++++++++- run-with-google-adk/tests/test_server.py | 40 ++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/run-with-google-adk/src/mcp_security_agent/agent.py b/run-with-google-adk/src/mcp_security_agent/agent.py index 3e1bcc8c..811c8f39 100644 --- a/run-with-google-adk/src/mcp_security_agent/agent.py +++ b/run-with-google-adk/src/mcp_security_agent/agent.py @@ -14,6 +14,7 @@ """ADK v2.x Agent definition and factory for MCP Security Agent.""" import logging +import threading from typing import Optional, Any from mcp_security_agent.config import AgentSettings from mcp_security_agent.toolsets import build_mcp_toolsets @@ -63,13 +64,16 @@ def create_security_agent(settings: Optional[AgentSettings] = None) -> Any: # Lazy root_agent instantiation for standard ADK CLI discovery (adk run, adk web) _root_agent: Optional[Any] = None +_root_agent_lock = threading.Lock() def __getattr__(name: str) -> Any: global _root_agent if name == "root_agent": if _root_agent is None: - _root_agent = create_security_agent() + with _root_agent_lock: + if _root_agent is None: + _root_agent = create_security_agent() return _root_agent raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/run-with-google-adk/src/mcp_security_agent/server/routes.py b/run-with-google-adk/src/mcp_security_agent/server/routes.py index 9f23a2aa..85f09adf 100644 --- a/run-with-google-adk/src/mcp_security_agent/server/routes.py +++ b/run-with-google-adk/src/mcp_security_agent/server/routes.py @@ -130,7 +130,7 @@ def __init__(self, max_sessions: int = 1000): super().__init__() self.max_sessions = max_sessions self._session_order: OrderedDict = OrderedDict() - self._lock = threading.Lock() + self._lock = threading.RLock() def _create_session_impl( self, @@ -158,6 +158,50 @@ def _create_session_impl( self._session_order[(app_name, user_id, sess.id)] = True return sess + def _get_session_impl( + self, + *, + app_name: str, + user_id: str, + session_id: str, + config: Optional[Any] = None, + ) -> Optional[Any]: + with self._lock: + return super()._get_session_impl( + app_name=app_name, + user_id=user_id, + session_id=session_id, + config=config, + ) + + def _list_sessions_impl( + self, + *, + app_name: str, + user_id: Optional[str] = None, + ) -> Any: + with self._lock: + return super()._list_sessions_impl( + app_name=app_name, + user_id=user_id, + ) + + async def append_event(self, session: Any, event: Any) -> Any: + with self._lock: + app_name = getattr(session, "app_name", None) + user_id = getattr(session, "user_id", None) + session_id = getattr(session, "id", None) + if ( + not app_name + or not user_id + or not session_id + or app_name not in self.sessions + or user_id not in self.sessions[app_name] + or session_id not in self.sessions[app_name][user_id] + ): + return event + return await super().append_event(session=session, event=event) + def _delete_session_impl( self, *, @@ -319,7 +363,7 @@ async def chat_sse_stream( ) -@router.post("/chat") +@router.post("/chat", response_model=ChatResponse) async def chat_post(request: ChatRequest, http_request: Request): """REST and SSE chat endpoint for API clients, automated workflows, and web UI.""" sess_id = request.session_id or str(uuid.uuid4()) diff --git a/run-with-google-adk/tests/test_server.py b/run-with-google-adk/tests/test_server.py index e69932af..7e7651e9 100644 --- a/run-with-google-adk/tests/test_server.py +++ b/run-with-google-adk/tests/test_server.py @@ -279,3 +279,43 @@ def test_frontend_streaming_abort_on_reset(): assert "if (isStreaming) {\n cancelStreaming();\n }\n messagesContainer.innerHTML = '';" in js assert "if (isStreaming) {\n cancelStreaming();\n }\n currentUserId = newName;" in js + +def test_bounded_session_service_thread_safe_lookups(): + from mcp_security_agent.server.routes import BoundedSessionService + from google.adk.events import Event + from google.genai import types + + service = BoundedSessionService(max_sessions=5) + + import asyncio + async def run_test(): + sess = await service.create_session( + app_name="test_app", + user_id="alice", + session_id="sess_1", + ) + assert sess is not None + + # Test get_session and list_sessions + retrieved = await service.get_session(app_name="test_app", user_id="alice", session_id="sess_1") + assert retrieved is not None + assert retrieved.id == "sess_1" + + sessions_list = await service.list_sessions(app_name="test_app", user_id="alice") + assert len(sessions_list.sessions) == 1 + + # Test append_event on existing session + test_event = Event( + author="SecurityOperationsAgent", + content=types.Content(role="model", parts=[types.Part(text="Test event")]), + ) + appended = await service.append_event(sess, test_event) + assert appended == test_event + + # Test append_event on non-existent / evicted session does not crash + dummy_sess = type("DummySession", (), {"app_name": "test_app", "user_id": "bob", "id": "sess_gone"})() + result_evicted = await service.append_event(dummy_sess, test_event) + assert result_evicted == test_event + + asyncio.run(run_test()) +