bugzilla-mcp 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ """MCP package for Bugzilla server"""
2
+
3
+ from .tools.bugzilla import (
4
+ bug_info,
5
+ bug_comments,
6
+ add_comment,
7
+ bugs_quicksearch,
8
+ learn_quicksearch_syntax,
9
+ server_url,
10
+ bug_url,
11
+ download_attachments,
12
+ download_attachment,
13
+ bugs_info,
14
+ bugs_comments,
15
+ bugs_analysis_context,
16
+ classify_bugs_heuristics,
17
+ analyze_bugs_statistics,
18
+ create_bug,
19
+ update_bug,
20
+ bug_history,
21
+ bugs_advanced_search,
22
+ bug_dependencies,
23
+ duplicate_chain,
24
+ get_user,
25
+ search_users,
26
+ list_products,
27
+ get_product_components,
28
+ upload_attachment,
29
+ tag_comment,
30
+ bug_attachments,
31
+ get_attachment,
32
+ register_tools,
33
+ )
34
+
35
+ __all__ = [
36
+ "bug_info",
37
+ "bug_comments",
38
+ "add_comment",
39
+ "bugs_quicksearch",
40
+ "learn_quicksearch_syntax",
41
+ "server_url",
42
+ "bug_url",
43
+ "download_attachments",
44
+ "download_attachment",
45
+ "bugs_info",
46
+ "bugs_comments",
47
+ "bugs_analysis_context",
48
+ "classify_bugs_heuristics",
49
+ "analyze_bugs_statistics",
50
+ "create_bug",
51
+ "update_bug",
52
+ "bug_history",
53
+ "bugs_advanced_search",
54
+ "bug_dependencies",
55
+ "duplicate_chain",
56
+ "get_user",
57
+ "search_users",
58
+ "list_products",
59
+ "get_product_components",
60
+ "upload_attachment",
61
+ "tag_comment",
62
+ "bug_attachments",
63
+ "get_attachment",
64
+ "register_tools",
65
+ ]
bugzilla_mcp/cli.py ADDED
@@ -0,0 +1,94 @@
1
+ """`bugzilla-mcp` command and the server builders shared by server.py / server_local.py"""
2
+
3
+ import argparse
4
+ import os
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from dotenv import load_dotenv
8
+ from fastmcp import FastMCP
9
+
10
+ import bugzilla_mcp.utils as utils
11
+ from bugzilla_mcp.middleware import ValidateHeaders
12
+ from bugzilla_mcp.middleware.read_only import ReadOnlyMode, read_only_default
13
+ from bugzilla_mcp.tools.bugzilla import WRITE_TOOL_NAMES, register_tools
14
+ from bugzilla_mcp.utils import Bugzilla
15
+
16
+
17
+ def build_local_server(url: str, api_key: str, read_only: bool | None = None) -> FastMCP:
18
+ """Single-user server: one Bugzilla client for the whole process.
19
+
20
+ It may read and write files on this machine (attachment downloads, uploads
21
+ from a path), because the only user is whoever started it.
22
+ """
23
+ # asyncio.run copies this context into the server's tasks, so every tool sees it
24
+ utils.current_bz.set(Bugzilla(url=url, api_key=api_key, allow_local_files=True))
25
+
26
+ mcp = FastMCP("Bugzilla")
27
+ mcp.add_middleware(ReadOnlyMode(
28
+ WRITE_TOOL_NAMES,
29
+ read_only_default() if read_only is None else read_only,
30
+ allow_header_override=False,
31
+ ))
32
+ register_tools(mcp, local_files=True)
33
+ return mcp
34
+
35
+
36
+ def build_http_server(read_only: bool | None = None) -> FastMCP:
37
+ """Multi-user server: each request brings its own `api_key` / `bugzilla_url` headers.
38
+
39
+ Never touches this machine's disk, and clients may send `read_only: false`.
40
+ """
41
+ mcp = FastMCP("Bugzilla")
42
+ mcp.add_middleware(ValidateHeaders())
43
+ mcp.add_middleware(ReadOnlyMode(
44
+ WRITE_TOOL_NAMES,
45
+ read_only_default() if read_only is None else read_only,
46
+ allow_header_override=True,
47
+ ))
48
+ register_tools(mcp, local_files=False)
49
+ return mcp
50
+
51
+
52
+ def _version() -> str:
53
+ try:
54
+ return version("bugzilla-mcp")
55
+ except PackageNotFoundError: # running from a checkout that isn't installed
56
+ return "unknown"
57
+
58
+
59
+ def main(argv: list[str] | None = None) -> None:
60
+ parser = argparse.ArgumentParser(
61
+ prog="bugzilla-mcp",
62
+ description=(
63
+ "Bugzilla MCP server. stdio (default) reads BUGZILLA_URL and BUGZILLA_API_KEY "
64
+ "from the environment or a .env file; http takes them from each request's headers."
65
+ ),
66
+ )
67
+ parser.add_argument("--transport", choices=["stdio", "http"], default="stdio")
68
+ parser.add_argument("--host", default="127.0.0.1", help="http only (default: 127.0.0.1)")
69
+ parser.add_argument("--port", type=int, default=8000, help="http only (default: 8000)")
70
+ parser.add_argument(
71
+ "--allow-writes",
72
+ action="store_true",
73
+ help="enable tools that change Bugzilla (same as BUGZILLA_READ_ONLY=false)",
74
+ )
75
+ parser.add_argument("--version", action="version", version=f"%(prog)s {_version()}")
76
+ args = parser.parse_args(argv)
77
+
78
+ load_dotenv() # optional .env in the current directory
79
+ read_only = False if args.allow_writes else None # None: BUGZILLA_READ_ONLY decides
80
+
81
+ if args.transport == "stdio":
82
+ # The API key only comes from the environment: command-line arguments
83
+ # show up in process lists and shell history.
84
+ url = os.environ.get("BUGZILLA_URL", "")
85
+ api_key = os.environ.get("BUGZILLA_API_KEY", "")
86
+ if not url or not api_key:
87
+ parser.error("set BUGZILLA_URL and BUGZILLA_API_KEY (environment or .env)")
88
+ build_local_server(url, api_key, read_only).run(transport="stdio", show_banner=False)
89
+ else:
90
+ build_http_server(read_only).run(transport="http", host=args.host, port=args.port)
91
+
92
+
93
+ if __name__ == "__main__":
94
+ main()
@@ -0,0 +1,6 @@
1
+ """Middleware for Bugzilla MCP server"""
2
+
3
+ from .validate_headers import ValidateHeaders
4
+
5
+ __all__ = ["ValidateHeaders"]
6
+
@@ -0,0 +1,55 @@
1
+ """Middleware that hides and blocks write tools unless writes are enabled"""
2
+
3
+ import os
4
+ from fastmcp.exceptions import ToolError
5
+ from fastmcp.server.dependencies import get_http_headers
6
+ from fastmcp.server.middleware import Middleware, MiddlewareContext
7
+
8
+ READ_ONLY_ENV = "BUGZILLA_READ_ONLY"
9
+ READ_ONLY_HEADER = "read_only"
10
+
11
+ _FALSE = {"0", "false", "no", "off"}
12
+
13
+
14
+ def read_only_default() -> bool:
15
+ """Read-only unless BUGZILLA_READ_ONLY is explicitly false/0/no/off"""
16
+ return os.environ.get(READ_ONLY_ENV, "true").strip().lower() not in _FALSE
17
+
18
+
19
+ class ReadOnlyMode(Middleware):
20
+ """Hide write tools from tools/list and refuse calls to them in read-only mode.
21
+
22
+ The mode defaults to `default` (from BUGZILLA_READ_ONLY). On the HTTP server a
23
+ client can override it per request with a `read_only: false` header.
24
+ """
25
+
26
+ def __init__(self, write_tools: set[str], default: bool, allow_header_override: bool):
27
+ self.write_tools = write_tools
28
+ self.default = default
29
+ self.allow_header_override = allow_header_override
30
+
31
+ def _read_only(self) -> bool:
32
+ if self.allow_header_override:
33
+ value = (get_http_headers() or {}).get(READ_ONLY_HEADER)
34
+ if value is not None:
35
+ return value.strip().lower() not in _FALSE
36
+ return self.default
37
+
38
+ async def on_list_tools(self, context: MiddlewareContext, call_next):
39
+ tools = await call_next(context)
40
+ if not self._read_only():
41
+ return tools
42
+ return [t for t in tools if t.name not in self.write_tools]
43
+
44
+ async def on_call_tool(self, context: MiddlewareContext, call_next):
45
+ if context.message.name in self.write_tools and self._read_only():
46
+ how = (
47
+ f"send the `{READ_ONLY_HEADER}: false` header"
48
+ if self.allow_header_override
49
+ else f"set {READ_ONLY_ENV}=false"
50
+ )
51
+ raise ToolError(
52
+ f"`{context.message.name}` changes data, and this server is in read-only mode. "
53
+ f"To enable write tools, {how}."
54
+ )
55
+ return await call_next(context)
@@ -0,0 +1,48 @@
1
+ """Middleware to validate required HTTP headers"""
2
+
3
+ from fastmcp.server.dependencies import get_http_headers
4
+ from fastmcp.server.middleware import Middleware, MiddlewareContext
5
+ from fastmcp.exceptions import ValidationError
6
+ from bugzilla_mcp.utils import Bugzilla
7
+ import bugzilla_mcp.utils as utils
8
+
9
+
10
+ class ValidateHeaders(Middleware):
11
+ """Validate incoming HTTP headers
12
+
13
+ Requires both `api_key` and `bugzilla_url` headers to be present.
14
+ Creates a Bugzilla client scoped to the current request (utils.current_bz)
15
+ and closes it once the request is handled.
16
+ """
17
+
18
+ # on_request, not on_message: since fastmcp 4, on_message also sees notifications
19
+ # (initialized, cancelled, progress), which never need a Bugzilla client.
20
+ async def on_request(self, middleware_context: MiddlewareContext, call_next):
21
+ headers = get_http_headers()
22
+
23
+ # During inspection or when headers are not available, skip validation
24
+ # and use a dummy Bugzilla instance so `fastmcp inspect` works without credentials
25
+ if not headers:
26
+ bz = Bugzilla(url="https://bugzilla.example.com", api_key="inspection-placeholder")
27
+ else:
28
+ if "api_key" not in headers:
29
+ raise ValidationError("`api_key` header is required")
30
+
31
+ if "bugzilla_url" not in headers:
32
+ raise ValidationError("`bugzilla_url` header is required")
33
+
34
+ bugzilla_url = headers["bugzilla_url"]
35
+
36
+ # Normalize URL: add https:// if no protocol is specified
37
+ if bugzilla_url and not bugzilla_url.startswith(("http://", "https://")):
38
+ bugzilla_url = f"https://{bugzilla_url}"
39
+
40
+ bz = Bugzilla(url=bugzilla_url, api_key=headers["api_key"])
41
+
42
+ # all the tools & prompts read this for making api calls
43
+ token = utils.current_bz.set(bz)
44
+ try:
45
+ return await call_next(middleware_context)
46
+ finally:
47
+ utils.current_bz.reset(token)
48
+ await bz.close()
@@ -0,0 +1,22 @@
1
+ """Tools package for Bugzilla MCP server"""
2
+
3
+ from .bugzilla import (
4
+ bug_info,
5
+ bug_comments,
6
+ add_comment,
7
+ bugs_quicksearch,
8
+ learn_quicksearch_syntax,
9
+ server_url,
10
+ bug_url,
11
+ )
12
+
13
+ __all__ = [
14
+ "bug_info",
15
+ "bug_comments",
16
+ "add_comment",
17
+ "bugs_quicksearch",
18
+ "learn_quicksearch_syntax",
19
+ "server_url",
20
+ "bug_url",
21
+ ]
22
+