Skip to content

Commit 3a5b45c

Browse files
committed
Add Bugzilla quicksearch to MCP server
1 parent 73e9575 commit 3a5b45c

5 files changed

Lines changed: 241 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,6 @@ node_modules/
4949
*.log
5050
# Desktop Service Store
5151
*.DS_Store
52+
53+
# VSCode settings
54+
.vscode/

.taskcluster.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,28 @@ tasks:
137137
name: bugbug http service tests
138138
description: bugbug http service tests
139139

140+
- $mergeDeep:
141+
- { $eval: default_task_definition }
142+
- taskId: { $eval: as_slugid("mcp_tests_task") }
143+
workerType: compute-smaller
144+
payload:
145+
env:
146+
CODECOV_TOKEN: 66162f89-a4d9-420c-84bd-d10f12a428d9
147+
command:
148+
- "/bin/bash"
149+
- "-lcx"
150+
- "git clone --quiet ${repository} &&
151+
cd bugbug &&
152+
git -c advice.detachedHead=false checkout ${head_rev} &&
153+
pip install --disable-pip-version-check --no-cache-dir --progress-bar off . &&
154+
pip install --disable-pip-version-check --no-cache-dir --progress-bar off -r test-requirements.txt &&
155+
pip install --disable-pip-version-check --no-cache-dir --progress-bar off ./services/mcp[dev] &&
156+
pytest --cov=bugbug_mcp services/mcp/tests/ -vvv &&
157+
bash <(curl -s https://codecov.io/bash)"
158+
metadata:
159+
name: bugbug mcp tests
160+
description: bugbug mcp tests
161+
140162
- $mergeDeep:
141163
- { $eval: default_task_definition }
142164
- taskId: { $eval: as_slugid("packaging_test_task") }
@@ -253,6 +275,7 @@ tasks:
253275
- { $eval: as_slugid("lint_task") }
254276
- { $eval: as_slugid("tests_task") }
255277
- { $eval: as_slugid("http_tests_task") }
278+
- { $eval: as_slugid("mcp_tests_task") }
256279
- { $eval: as_slugid("frontend_build") }
257280
- { $eval: as_slugid("packaging_test_task") }
258281
- { $eval: as_slugid("version_check_task") }
@@ -283,6 +306,7 @@ tasks:
283306
- { $eval: as_slugid("lint_task") }
284307
- { $eval: as_slugid("version_check_task") }
285308
- { $eval: as_slugid("tests_task") }
309+
- { $eval: as_slugid("mcp_tests_task") }
286310
- { $eval: as_slugid("http_tests_task") }
287311
- { $eval: as_slugid("frontend_build") }
288312
- { $eval: as_slugid("packaging_test_task") }

services/mcp/pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ llms-txt = [
1414
"beautifulsoup4>=4.13.5",
1515
"requests>=2.32.5",
1616
]
17+
dev = [
18+
"pytest>=9.0.0",
19+
"pytest-asyncio>=1.3.0",
20+
"pytest-mock>=3.15.0",
21+
]
1722

1823
[tool.uv.sources]
1924
bugbug = { git = "https://github.com/mozilla/bugbug", rev = "93c473abdd42f896e95b606d79af076f1bbd5502" }
25+
26+
[tool.pytest.ini_options]
27+
asyncio_mode = "auto"
28+
asyncio_default_fixture_loop_scope = "function"

services/mcp/src/bugbug_mcp/server.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,74 @@ def get_bugzilla_bug(bug_id: int) -> str:
7171
return SanitizedBug.get(bug_id).to_md()
7272

7373

74+
@mcp.tool()
75+
def bugzilla_quick_search(
76+
search_query: Annotated[
77+
str,
78+
"A quick search string to find bugs. Can include bug numbers, keywords, status, product, component, etc. Examples: 'firefox crash', 'FIXED', 'status:NEW product:Core'",
79+
],
80+
limit: Annotated[int, "Maximum number of bugs to return (default: 20)"] = 20,
81+
) -> str:
82+
"""Search for bugs in Bugzilla using quick search syntax.
83+
84+
Quick search supports shortcuts like bug numbers, keywords, status,
85+
products/components, and combinations of these.
86+
87+
For the full syntax reference, see:
88+
https://bugzilla.mozilla.org/page.cgi?id=quicksearch.html
89+
90+
Returns a formatted list of matching bugs with their ID, status, summary, and link.
91+
"""
92+
from libmozdata.bugzilla import Bugzilla
93+
94+
bugs = []
95+
96+
def bughandler(bug):
97+
bugs.append(bug)
98+
99+
# Use Bugzilla quicksearch API
100+
params = {
101+
"quicksearch": search_query,
102+
"limit": limit,
103+
}
104+
105+
Bugzilla(
106+
params,
107+
include_fields=[
108+
"id",
109+
"status",
110+
"summary",
111+
"product",
112+
"component",
113+
"priority",
114+
"severity",
115+
],
116+
bughandler=bughandler,
117+
).get_data().wait()
118+
119+
if not bugs:
120+
return f"No bugs found matching: {search_query}"
121+
122+
# Format results concisely for LLM consumption
123+
result = f"Found {len(bugs)} bug(s) matching '{search_query}':\n\n"
124+
125+
for bug in bugs:
126+
bug_id = bug["id"]
127+
status = bug.get("status", "N/A")
128+
summary = bug.get("summary", "N/A")
129+
product = bug.get("product", "N/A")
130+
component = bug.get("component", "N/A")
131+
priority = bug.get("priority", "N/A")
132+
severity = bug.get("severity", "N/A")
133+
134+
result += f"Bug {bug_id} [{status}] - {summary}\n"
135+
result += f" Product: {product}::{component}\n"
136+
result += f" Priority: {priority} | Severity: {severity}\n"
137+
result += f" URL: https://bugzilla.mozilla.org/show_bug.cgi?id={bug_id}\n\n"
138+
139+
return result
140+
141+
74142
@mcp.resource(
75143
uri="phabricator://revision/D{revision_id}",
76144
name="Phabricator Revision Content",
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Tests for Bugzilla MCP tools."""
2+
3+
from unittest.mock import MagicMock
4+
5+
import pytest
6+
from fastmcp.client import Client
7+
from fastmcp.client.transports import FastMCPTransport
8+
9+
10+
@pytest.fixture
11+
async def mcp_client():
12+
"""Create an MCP client for testing."""
13+
from bugbug_mcp.server import mcp
14+
15+
async with Client(mcp) as client:
16+
yield client
17+
18+
19+
def setup_bugzilla_mock(mocker, bugs):
20+
"""Helper to setup Bugzilla mock with given bugs."""
21+
mock_bugzilla_class = mocker.patch("libmozdata.bugzilla.Bugzilla")
22+
mock_instance = MagicMock()
23+
24+
# Store bugs to be returned
25+
mock_instance._bugs = bugs
26+
27+
# When Bugzilla is initialized, capture the bughandler
28+
def mock_init(params, include_fields, bughandler):
29+
mock_instance._bughandler = bughandler
30+
return mock_instance
31+
32+
# When get_data().wait() is called, invoke the handler with bugs
33+
def mock_get_data():
34+
for bug in mock_instance._bugs:
35+
mock_instance._bughandler(bug)
36+
return mock_instance
37+
38+
mock_instance.get_data = mock_get_data
39+
mock_instance.wait = MagicMock()
40+
mock_bugzilla_class.side_effect = mock_init
41+
42+
return mock_bugzilla_class
43+
44+
45+
class TestBugzillaQuickSearch:
46+
"""Test the bugzilla_quick_search tool."""
47+
48+
async def test_quick_search_basic(
49+
self, mocker, mcp_client: Client[FastMCPTransport]
50+
):
51+
"""Test basic quick search functionality."""
52+
mock_bugs = [
53+
{
54+
"id": 123456,
55+
"status": "NEW",
56+
"summary": "Test bug 1",
57+
"product": "Firefox",
58+
"component": "General",
59+
"priority": "P1",
60+
"severity": "S2",
61+
},
62+
{
63+
"id": 789012,
64+
"status": "ASSIGNED",
65+
"summary": "Test bug 2",
66+
"product": "Core",
67+
"component": "DOM",
68+
"priority": "P2",
69+
"severity": "S3",
70+
},
71+
]
72+
73+
mock_bugzilla = setup_bugzilla_mock(mocker, mock_bugs)
74+
75+
result = await mcp_client.call_tool(
76+
name="bugzilla_quick_search",
77+
arguments={"search_query": "firefox crash", "limit": 2},
78+
)
79+
80+
# Verify API call
81+
mock_bugzilla.assert_called_once()
82+
call_args = mock_bugzilla.call_args[0][0]
83+
assert call_args["quicksearch"] == "firefox crash"
84+
assert call_args["limit"] == 2
85+
86+
# Verify result
87+
result_text = result.content[0].text
88+
assert "Found 2 bug(s)" in result_text
89+
assert "Bug 123456 [NEW]" in result_text
90+
assert "Bug 789012 [ASSIGNED]" in result_text
91+
assert "Test bug 1" in result_text
92+
assert "Firefox::General" in result_text
93+
assert "Core::DOM" in result_text
94+
95+
async def test_quick_search_no_results(
96+
self, mocker, mcp_client: Client[FastMCPTransport]
97+
):
98+
"""Test quick search with no results."""
99+
setup_bugzilla_mock(mocker, [])
100+
101+
result = await mcp_client.call_tool(
102+
name="bugzilla_quick_search",
103+
arguments={"search_query": "nonexistent query"},
104+
)
105+
106+
result_text = result.content[0].text
107+
assert "No bugs found matching: nonexistent query" in result_text
108+
109+
async def test_quick_search_custom_limit(
110+
self, mocker, mcp_client: Client[FastMCPTransport]
111+
):
112+
"""Test quick search with custom limit."""
113+
mock_bugzilla = setup_bugzilla_mock(mocker, [])
114+
115+
await mcp_client.call_tool(
116+
name="bugzilla_quick_search",
117+
arguments={"search_query": "test", "limit": 50},
118+
)
119+
120+
call_args = mock_bugzilla.call_args[0][0]
121+
assert call_args["limit"] == 50
122+
123+
async def test_quick_search_handles_missing_fields(
124+
self, mocker, mcp_client: Client[FastMCPTransport]
125+
):
126+
"""Test that missing fields are handled gracefully."""
127+
mock_bugs = [{"id": 123456, "summary": "Test bug"}]
128+
setup_bugzilla_mock(mocker, mock_bugs)
129+
130+
result = await mcp_client.call_tool(
131+
name="bugzilla_quick_search", arguments={"search_query": "test"}
132+
)
133+
134+
result_text = result.content[0].text
135+
assert "Bug 123456" in result_text
136+
assert "Test bug" in result_text
137+
assert "N/A" in result_text

0 commit comments

Comments
 (0)