Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions .github/workflows/build-installers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,45 @@ jobs:
wait "$APP_PID" 2>/dev/null || true
echo "userns-restricted PASS (--no-sandbox fallback + /api/health 200)"

custom-agent-mcp-harness:
name: Custom agent MCP harness (${{ matrix.os }})
needs: build
if: always() && needs.build.result == 'success'
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'

- name: Install GAIA with MCP test dependencies
run: python -m pip install -e ".[dev,mcp]"

- name: Run custom-agent MCP harness
env:
GAIA_INSTALLER_HARNESS_ARTIFACT_DIR: ${{ runner.temp }}/gaia-installer-agent-harness
run: >
python -m pytest
tests/fixtures/mcp/dummy_server/test_dummy_server.py
tests/installer/test_custom_agent_mcp_harness.py
-v --tb=short

- name: Upload custom-agent MCP harness logs
if: failure()
uses: actions/upload-artifact@v6
with:
name: custom-agent-mcp-harness-${{ matrix.os }}
path: ${{ runner.temp }}/gaia-installer-agent-harness
if-no-files-found: ignore

# ─── Wayland visibility (DEFERRED — issue #782 follow-up) ───────────
# The plan calls for a headless Wayland compositor (cage -s or weston
# --backend=headless-backend.so) plus pixel-diff via grim, to verify
Expand Down Expand Up @@ -1076,7 +1115,8 @@ jobs:
# publish.yml (or any caller) should wait on.
#
# The smoke jobs (appimage-structural-smoke, appimage-distro-matrix,
# appimage-userns-restricted) are issue #782 regression guards — a
# appimage-userns-restricted) are issue #782 regression guards. The
# custom-agent MCP harness is issue #993's installed-runtime guard. A
# broken AppImage that still builds but does not launch would slip past
# if we gated only on `build`.
build-complete:
Expand All @@ -1087,6 +1127,7 @@ jobs:
- appimage-structural-smoke
- appimage-distro-matrix
- appimage-userns-restricted
- custom-agent-mcp-harness
- dmg-structural-smoke
if: always()
steps:
Expand All @@ -1097,18 +1138,20 @@ jobs:
structural_result="${{ needs.appimage-structural-smoke.result }}"
distro_result="${{ needs.appimage-distro-matrix.result }}"
userns_result="${{ needs.appimage-userns-restricted.result }}"
mcp_harness_result="${{ needs.custom-agent-mcp-harness.result }}"
dmg_result="${{ needs.dmg-structural-smoke.result }}"
echo "build: $build_result"
echo "appimage-structural-smoke: $structural_result"
echo "appimage-distro-matrix: $distro_result"
echo "appimage-userns-restricted: $userns_result"
echo "custom-agent-mcp-harness: $mcp_harness_result"
echo "dmg-structural-smoke: $dmg_result"
fail=0
if [ "$build_result" != "success" ]; then
echo "::error::One or more platform installer builds failed"
fail=1
fi
for r in "$structural_result" "$distro_result" "$userns_result" "$dmg_result"; do
for r in "$structural_result" "$distro_result" "$userns_result" "$mcp_harness_result" "$dmg_result"; do
if [ "$r" != "success" ] && [ "$r" != "skipped" ]; then
echo "::error::Installer smoke job failed (status: $r)"
fail=1
Expand Down
44 changes: 44 additions & 0 deletions tests/fixtures/custom_agents/installer_mcp/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT

"""Custom-agent fixture that loads MCP servers via the supported runtime path."""

from __future__ import annotations

import os
from typing import Any, Dict

from gaia.agents.base.agent import Agent
from gaia.agents.base.tools import _TOOL_REGISTRY
from gaia.mcp import MCPClientMixin


class InstallerMCPAgent(Agent, MCPClientMixin):
AGENT_ID = "installer-mcp"
AGENT_NAME = "Installer MCP Fixture"
AGENT_DESCRIPTION = "Installer harness fixture that calls a configured MCP tool"
CONVERSATION_STARTERS = ["Add two numbers through MCP"]

def __init__(self, mcp_config_file: str | None = None, **kwargs):
kwargs.setdefault("skip_lemonade", True)
kwargs.setdefault("silent_mode", True)
kwargs.setdefault("max_steps", 2)
Agent.__init__(self, **kwargs)

config_file = mcp_config_file or os.environ.get("GAIA_TEST_MCP_CONFIG")
if not config_file:
raise ValueError(
"InstallerMCPAgent requires mcp_config_file or GAIA_TEST_MCP_CONFIG"
)
MCPClientMixin.__init__(self, auto_load_config=False, config_file=config_file)
self._snapshot_tools()

def _get_system_prompt(self) -> str:
return "Use the configured MCP tools exactly as requested."

def _register_tools(self) -> None:
_TOOL_REGISTRY.clear()

def process_query(self, user_input: str, **kwargs) -> Dict[str, Any]:
del user_input, kwargs
return self._execute_tool("mcp_dummy_add_two_numbers", {"a": 7, "b": 35})
40 changes: 40 additions & 0 deletions tests/fixtures/custom_agents/installer_no_mcp/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT

"""Custom-agent fixture that exercises a code-only path with no MCP traffic."""

from __future__ import annotations

from typing import Any, Dict

from gaia.agents.base.agent import Agent
from gaia.agents.base.tools import _TOOL_REGISTRY, tool


class InstallerNoMCPAgent(Agent):
AGENT_ID = "installer-no-mcp"
AGENT_NAME = "Installer No MCP Fixture"
AGENT_DESCRIPTION = "Installer harness fixture that uses only local code"
CONVERSATION_STARTERS = ["Run the local calculation"]

def __init__(self, **kwargs):
kwargs.setdefault("skip_lemonade", True)
kwargs.setdefault("silent_mode", True)
kwargs.setdefault("max_steps", 2)
super().__init__(**kwargs)
self._snapshot_tools()

def _get_system_prompt(self) -> str:
return "Use only local tools."

def _register_tools(self) -> None:
_TOOL_REGISTRY.clear()

@tool
def local_double(value: int) -> Dict[str, int]:
"""Double an integer locally."""
return {"doubled": value * 2}

def process_query(self, user_input: str, **kwargs) -> Dict[str, Any]:
del user_input, kwargs
return self._execute_tool("local_double", {"value": 21})
4 changes: 4 additions & 0 deletions tests/fixtures/mcp/dummy_server/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT

"""Deterministic in-tree MCP server fixture for installer/runtime tests."""
80 changes: 80 additions & 0 deletions tests/fixtures/mcp/dummy_server/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT

"""Dummy MCP server fixture.

The server speaks stdio MCP via FastMCP and records each tool call to a JSONL
file when ``GAIA_DUMMY_MCP_LOG`` is set. It is intentionally dependency-light
and deterministic so installer tests do not need npx, network, or credentials.

Usage:
python tests/fixtures/mcp/dummy_server/server.py
"""

from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Any, Dict

from mcp.server.fastmcp import FastMCP


def _record_tool_call(tool: str, arguments: Dict[str, Any], result: Any) -> None:
log_path = os.environ.get("GAIA_DUMMY_MCP_LOG")
if not log_path:
return

path = Path(log_path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(
json.dumps(
{"tool": tool, "arguments": arguments, "result": result},
sort_keys=True,
)
+ "\n"
)


def create_dummy_mcp_server() -> FastMCP:
"""Create the deterministic dummy MCP server."""
mcp = FastMCP(name="GAIA Dummy MCP")

@mcp.tool()
def echo(message: str) -> str:
"""Return the provided message unchanged."""
_record_tool_call("echo", {"message": message}, message)
return message

@mcp.tool()
def add_two_numbers(a: int, b: int) -> Dict[str, int]:
"""Add two integers and return the sum."""
result = {"sum": a + b}
_record_tool_call("add_two_numbers", {"a": a, "b": b}, result)
return result

@mcp.tool()
def mock_search(query: str, limit: int = 3) -> Dict[str, Any]:
"""Return deterministic mock search results for a query."""
result = {
"query": query,
"results": [
{"title": f"{query} result {idx}", "rank": idx}
for idx in range(1, limit + 1)
],
}
_record_tool_call("mock_search", {"query": query, "limit": limit}, result)
return result

return mcp


def main() -> None:
"""Run the dummy MCP server over stdio."""
create_dummy_mcp_server().run(transport="stdio")


if __name__ == "__main__":
main()
46 changes: 46 additions & 0 deletions tests/fixtures/mcp/dummy_server/test_dummy_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT

"""Sanity tests for the dummy MCP server fixture."""

from __future__ import annotations

import json
import sys

from gaia.mcp.client.mcp_client import MCPClient


def _dummy_server_config(log_path):
return {
"command": sys.executable,
"args": ["tests/fixtures/mcp/dummy_server/server.py"],
"env": {"GAIA_DUMMY_MCP_LOG": str(log_path)},
}


def test_dummy_mcp_server_cli_invocation(tmp_path):
log_path = tmp_path / "dummy-mcp.jsonl"
client = MCPClient.from_config("dummy", _dummy_server_config(log_path))

try:
assert client.connect()
tool_names = {tool.name for tool in client.list_tools()}
assert {"echo", "add_two_numbers", "mock_search"} <= tool_names

result = client.call_tool("add_two_numbers", {"a": 20, "b": 22})
assert json.loads(result["content"][0]["text"]) == {"sum": 42}

records = [
json.loads(line)
for line in log_path.read_text(encoding="utf-8").splitlines()
]
assert records == [
{
"tool": "add_two_numbers",
"arguments": {"a": 20, "b": 22},
"result": {"sum": 42},
}
]
finally:
client.disconnect()
Loading
Loading