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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ node_modules/
*.log
# Desktop Service Store
*.DS_Store

# VSCode settings
.vscode/
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 ./mcp[dev] &&
pytest --cov=bugbug_mcp 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 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 = { path = "..", editable = true }

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
95 changes: 91 additions & 4 deletions mcp/src/bugbug_mcp/server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""MCP server for Firefox Development."""

import functools
import logging
import os
from pathlib import Path
from typing import Annotated
Expand All @@ -18,7 +19,16 @@
from bugbug.tools.core.platforms.phabricator import PhabricatorPatch
from bugbug.utils import get_secret

os.environ["OPENAI_API_KEY"] = get_secret("OPENAI_API_KEY")
logger = logging.getLogger(__name__)

# Set OPENAI_API_KEY if available (optional for most MCP tools)
try:
os.environ["OPENAI_API_KEY"] = get_secret("OPENAI_API_KEY")
except ValueError:
# API key not found, will skip code review features
logger.warning(
"OPENAI_API_KEY not found. Code review features will be unavailable."
)

mcp = FastMCP("Firefox Development MCP Server")

Expand Down Expand Up @@ -109,6 +119,75 @@ def get_bugzilla_bug(bug_id: int) -> str:
return Bug.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 allows natural language searches and supports various shortcuts:
- Bug numbers: "12345" or "bug 12345"
- Keywords: "crash", "regression"
- Status: "NEW", "FIXED", "ASSIGNED"
- Products/Components: "Firefox", "Core::DOM"
- Combinations: "firefox crash NEW"

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 Expand Up @@ -163,9 +242,17 @@ async def read_fx_doc_section(


def main():
phabricator.set_api_key(
get_secret("PHABRICATOR_URL"), get_secret("PHABRICATOR_TOKEN")
)
# Set Phabricator API key if available (optional)
try:
phabricator.set_api_key(
get_secret("PHABRICATOR_URL"), get_secret("PHABRICATOR_TOKEN")
)
except ValueError:
# Phabricator secrets not available, will skip Phabricator features
logger.warning(
"PHABRICATOR_URL or PHABRICATOR_TOKEN not found. "
"Phabricator features will be unavailable."
)

mcp.run(
transport="streamable-http",
Expand Down
137 changes: 137 additions & 0 deletions 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