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
24 changes: 24 additions & 0 deletions .taskcluster.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,28 @@ tasks:
name: bugbug http service tests
description: bugbug http service tests

- $mergeDeep:
- { $eval: default_task_definition }
- taskId: { $eval: as_slugid("mcp_tests_task") }
workerType: compute-smaller
payload:
env:
CODECOV_TOKEN: 66162f89-a4d9-420c-84bd-d10f12a428d9
command:
- "/bin/bash"
- "-lcx"
- "git clone --quiet ${repository} &&
cd bugbug &&
git -c advice.detachedHead=false checkout ${head_rev} &&
pip install --disable-pip-version-check --no-cache-dir --progress-bar off . &&
pip install --disable-pip-version-check --no-cache-dir --progress-bar off -r test-requirements.txt &&
pip install --disable-pip-version-check --no-cache-dir --progress-bar off ./services/mcp[dev] &&
pytest --cov=bugbug_mcp services/mcp/tests/ -vvv &&
bash <(curl -s https://codecov.io/bash)"
metadata:
name: bugbug mcp tests
description: bugbug mcp tests

- $mergeDeep:
- { $eval: default_task_definition }
- taskId: { $eval: as_slugid("packaging_test_task") }
Expand Down Expand Up @@ -253,6 +275,7 @@ tasks:
- { $eval: as_slugid("lint_task") }
- { $eval: as_slugid("tests_task") }
- { $eval: as_slugid("http_tests_task") }
- { $eval: as_slugid("mcp_tests_task") }
- { $eval: as_slugid("frontend_build") }
- { $eval: as_slugid("packaging_test_task") }
- { $eval: as_slugid("version_check_task") }
Expand Down Expand Up @@ -283,6 +306,7 @@ tasks:
- { $eval: as_slugid("lint_task") }
- { $eval: as_slugid("version_check_task") }
- { $eval: as_slugid("tests_task") }
- { $eval: as_slugid("mcp_tests_task") }
- { $eval: as_slugid("http_tests_task") }
- { $eval: as_slugid("frontend_build") }
- { $eval: as_slugid("packaging_test_task") }
Expand Down
9 changes: 9 additions & 0 deletions services/mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ llms-txt = [
"beautifulsoup4>=4.13.5",
"requests>=2.32.5",
]
dev = [
"pytest>=9.0.0",
"pytest-asyncio>=1.3.0",
"pytest-mock>=3.15.0",
]

[tool.uv.sources]
bugbug = { git = "https://github.com/mozilla/bugbug", rev = "93c473abdd42f896e95b606d79af076f1bbd5502" }

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
68 changes: 68 additions & 0 deletions services/mcp/src/bugbug_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,74 @@ def get_bugzilla_bug(bug_id: int) -> str:
return SanitizedBug.get(bug_id).to_md()


@mcp.tool()
def bugzilla_quick_search(
search_query: Annotated[
str,
"A quick search string to find bugs. Can include bug numbers, keywords, status, product, component, etc. Examples: 'firefox crash', 'FIXED', 'status:NEW product:Core'",
],
limit: Annotated[int, "Maximum number of bugs to return (default: 20)"] = 20,
) -> str:
"""Search for bugs in Bugzilla using quick search syntax.

Quick search supports shortcuts like bug numbers, keywords, status,
products/components, and combinations of these.

For the full syntax reference, see:
https://bugzilla.mozilla.org/page.cgi?id=quicksearch.html

Returns a formatted list of matching bugs with their ID, status, summary, and link.
"""
from libmozdata.bugzilla import Bugzilla

bugs = []

def bughandler(bug):
bugs.append(bug)

# Use Bugzilla quicksearch API
params = {
"quicksearch": search_query,
"limit": limit,
}

Bugzilla(
params,
include_fields=[
"id",
"status",
"summary",
"product",
"component",
"priority",
"severity",
],
bughandler=bughandler,
).get_data().wait()

if not bugs:
return f"No bugs found matching: {search_query}"

# Format results concisely for LLM consumption
result = f"Found {len(bugs)} bug(s) matching '{search_query}':\n\n"

for bug in bugs:
bug_id = bug["id"]
status = bug.get("status", "N/A")
summary = bug.get("summary", "N/A")
product = bug.get("product", "N/A")
component = bug.get("component", "N/A")
priority = bug.get("priority", "N/A")
severity = bug.get("severity", "N/A")

result += f"Bug {bug_id} [{status}] - {summary}\n"
result += f" Product: {product}::{component}\n"
result += f" Priority: {priority} | Severity: {severity}\n"
result += f" URL: https://bugzilla.mozilla.org/show_bug.cgi?id={bug_id}\n\n"

return result


@mcp.resource(
uri="phabricator://revision/D{revision_id}",
name="Phabricator Revision Content",
Expand Down
137 changes: 137 additions & 0 deletions services/mcp/tests/test_bugzilla_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Tests for Bugzilla MCP tools."""

from unittest.mock import MagicMock

import pytest
from fastmcp.client import Client
from fastmcp.client.transports import FastMCPTransport


@pytest.fixture
async def mcp_client():
"""Create an MCP client for testing."""
from bugbug_mcp.server import mcp

async with Client(mcp) as client:
yield client


def setup_bugzilla_mock(mocker, bugs):
"""Helper to setup Bugzilla mock with given bugs."""
mock_bugzilla_class = mocker.patch("libmozdata.bugzilla.Bugzilla")
mock_instance = MagicMock()

# Store bugs to be returned
mock_instance._bugs = bugs

# When Bugzilla is initialized, capture the bughandler
def mock_init(params, include_fields, bughandler):
mock_instance._bughandler = bughandler
return mock_instance

# When get_data().wait() is called, invoke the handler with bugs
def mock_get_data():
for bug in mock_instance._bugs:
mock_instance._bughandler(bug)
return mock_instance

mock_instance.get_data = mock_get_data
mock_instance.wait = MagicMock()
mock_bugzilla_class.side_effect = mock_init

return mock_bugzilla_class


class TestBugzillaQuickSearch:
"""Test the bugzilla_quick_search tool."""

async def test_quick_search_basic(
self, mocker, mcp_client: Client[FastMCPTransport]
):
"""Test basic quick search functionality."""
mock_bugs = [
{
"id": 123456,
"status": "NEW",
"summary": "Test bug 1",
"product": "Firefox",
"component": "General",
"priority": "P1",
"severity": "S2",
},
{
"id": 789012,
"status": "ASSIGNED",
"summary": "Test bug 2",
"product": "Core",
"component": "DOM",
"priority": "P2",
"severity": "S3",
},
]

mock_bugzilla = setup_bugzilla_mock(mocker, mock_bugs)

result = await mcp_client.call_tool(
name="bugzilla_quick_search",
arguments={"search_query": "firefox crash", "limit": 2},
)

# Verify API call
mock_bugzilla.assert_called_once()
call_args = mock_bugzilla.call_args[0][0]
assert call_args["quicksearch"] == "firefox crash"
assert call_args["limit"] == 2

# Verify result
result_text = result.content[0].text
assert "Found 2 bug(s)" in result_text
assert "Bug 123456 [NEW]" in result_text
assert "Bug 789012 [ASSIGNED]" in result_text
assert "Test bug 1" in result_text
assert "Firefox::General" in result_text
assert "Core::DOM" in result_text

async def test_quick_search_no_results(
self, mocker, mcp_client: Client[FastMCPTransport]
):
"""Test quick search with no results."""
setup_bugzilla_mock(mocker, [])

result = await mcp_client.call_tool(
name="bugzilla_quick_search",
arguments={"search_query": "nonexistent query"},
)

result_text = result.content[0].text
assert "No bugs found matching: nonexistent query" in result_text

async def test_quick_search_custom_limit(
self, mocker, mcp_client: Client[FastMCPTransport]
):
"""Test quick search with custom limit."""
mock_bugzilla = setup_bugzilla_mock(mocker, [])

await mcp_client.call_tool(
name="bugzilla_quick_search",
arguments={"search_query": "test", "limit": 50},
)

call_args = mock_bugzilla.call_args[0][0]
assert call_args["limit"] == 50

async def test_quick_search_handles_missing_fields(
self, mocker, mcp_client: Client[FastMCPTransport]
):
"""Test that missing fields are handled gracefully."""
mock_bugs = [{"id": 123456, "summary": "Test bug"}]
setup_bugzilla_mock(mocker, mock_bugs)

result = await mcp_client.call_tool(
name="bugzilla_quick_search", arguments={"search_query": "test"}
)

result_text = result.content[0].text
assert "Bug 123456" in result_text
assert "Test bug" in result_text
assert "N/A" in result_text