bugcap 0.2.2__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.
bugcap/logs.py ADDED
@@ -0,0 +1,74 @@
1
+ """Logging for `bugcap mcp-serve`.
2
+
3
+ stdout carries the MCP protocol, so nothing here ever writes to it: records go to a rotating
4
+ file in the per-user data dir and to stderr."""
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+ import sys
10
+ from logging.handlers import RotatingFileHandler
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from . import config
15
+ from .paths import data_dir
16
+
17
+ LEVELS = {"debug": logging.DEBUG, "info": logging.INFO, "warning": logging.WARNING, "error": logging.ERROR}
18
+ FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
19
+ LOGGER_NAMES = ("bugcap.mcp", "mcp") # ours, and the MCP SDK's own (protocol-level) messages
20
+ _MARK = "_bugcap_handler"
21
+
22
+
23
+ def default_log_path() -> Path:
24
+ path = data_dir() / "logs"
25
+ path.mkdir(parents=True, exist_ok=True)
26
+ return path / "mcp-server.log"
27
+
28
+
29
+ def resolve_level(explicit: Optional[str] = None) -> int:
30
+ """Flag, then BUGCAP_LOG_LEVEL, then `[log] level` in config.toml, then INFO."""
31
+ for candidate in (explicit, os.environ.get("BUGCAP_LOG_LEVEL"), config.get("log.level")):
32
+ if candidate and str(candidate).strip().lower() in LEVELS:
33
+ return LEVELS[str(candidate).strip().lower()]
34
+ return logging.INFO
35
+
36
+
37
+ def resolve_path(explicit: Optional[str] = None) -> Path:
38
+ for candidate in (explicit, os.environ.get("BUGCAP_LOG_FILE"), config.get("log.file")):
39
+ if candidate:
40
+ return Path(str(candidate)).expanduser()
41
+ return default_log_path()
42
+
43
+
44
+ def setup_logging(level: Optional[str] = None, path: Optional[str] = None) -> tuple[logging.Logger, Optional[Path]]:
45
+ """Configure file + stderr logging (idempotent). Returns the logger and the log file
46
+ actually in use (None if the file could not be opened and only stderr is used)."""
47
+ numeric = resolve_level(level)
48
+ target: Optional[Path] = resolve_path(path)
49
+ formatter = logging.Formatter(FORMAT)
50
+ handlers: list[logging.Handler] = []
51
+ try:
52
+ target.parent.mkdir(parents=True, exist_ok=True)
53
+ file_handler = RotatingFileHandler(target, maxBytes=1_000_000, backupCount=3, encoding="utf-8")
54
+ file_handler.setFormatter(formatter)
55
+ handlers.append(file_handler)
56
+ except OSError as exc:
57
+ print(f"bugcap mcp-serve: cannot write the log file {target}: {exc}; logging to stderr only", file=sys.stderr)
58
+ target = None
59
+ stream = logging.StreamHandler(sys.stderr) # never stdout: it carries the protocol
60
+ stream.setFormatter(formatter)
61
+ handlers.append(stream)
62
+ for handler in handlers:
63
+ setattr(handler, _MARK, True)
64
+
65
+ for name in LOGGER_NAMES:
66
+ logger = logging.getLogger(name)
67
+ for old in [h for h in logger.handlers if getattr(h, _MARK, False)]:
68
+ logger.removeHandler(old)
69
+ old.close()
70
+ for handler in handlers:
71
+ logger.addHandler(handler)
72
+ logger.setLevel(numeric)
73
+ logger.propagate = False # the SDK installs its own root handler; avoid duplicates
74
+ return logging.getLogger("bugcap.mcp"), target
bugcap/mcp_server.py ADDED
@@ -0,0 +1,221 @@
1
+ """stdio MCP server built on the official `mcp` SDK (optional extra `bugcap[mcp]`).
2
+
3
+ The SDK is imported lazily so the core CLI works without it; `bugcap mcp-serve`
4
+ without the extra fails with a clear install hint. Tool logic lives in `agent_api`."""
5
+ from __future__ import annotations
6
+
7
+ import functools
8
+ import json
9
+ import logging
10
+ import os
11
+ import sys
12
+ import time
13
+ from typing import Optional
14
+
15
+ from . import agent_api, logs
16
+ from .paths import db_path
17
+ from .store import Store
18
+
19
+ log = logging.getLogger("bugcap.mcp")
20
+
21
+ INSTALL_HINT = (
22
+ "The MCP server needs the 'mcp' extra: pipx install 'bugcap[mcp]' "
23
+ "(or pipx inject bugcap mcp)."
24
+ )
25
+
26
+
27
+ class MCPUnavailable(RuntimeError):
28
+ pass
29
+
30
+
31
+ def _image_format(mime: str) -> str:
32
+ return {
33
+ "image/png": "png",
34
+ "image/jpeg": "jpeg",
35
+ "image/gif": "gif",
36
+ "image/webp": "webp",
37
+ "image/bmp": "bmp",
38
+ }.get(mime, "png")
39
+
40
+
41
+ def _version() -> str:
42
+ try:
43
+ from importlib.metadata import version
44
+
45
+ return version("bugcap")
46
+ except Exception: # not installed (running from a checkout)
47
+ return "unknown"
48
+
49
+
50
+ def _problem(result) -> Optional[str]:
51
+ """The error text of a tool result, if it reports one (tools return JSON strings or content lists)."""
52
+ first = result[0] if isinstance(result, list) and result else result
53
+ if not isinstance(first, str):
54
+ return None
55
+ try:
56
+ data = json.loads(first)
57
+ except ValueError:
58
+ return None
59
+ if isinstance(data, dict):
60
+ if data.get("error"):
61
+ return f"{data.get('code', 'error')}: {data['error']}"
62
+ if data.get("status") in ("error", "cancelled", "non_interactive"):
63
+ return f"{data['status']}: {data.get('message', '')}"
64
+ return None
65
+
66
+
67
+ def _logged(fn):
68
+ """Log each tool call: name and argument names (values only at DEBUG, since notes can be
69
+ private), outcome and duration, and any exception with its traceback."""
70
+
71
+ @functools.wraps(fn)
72
+ def wrapper(*args, **kwargs):
73
+ name = fn.__name__
74
+ started = time.monotonic()
75
+ log.info("tool %s called (arguments: %s)", name, ", ".join(sorted(kwargs)) or "none")
76
+ if log.isEnabledFor(logging.DEBUG):
77
+ log.debug("tool %s arguments: %.500r", name, kwargs)
78
+ try:
79
+ result = fn(*args, **kwargs)
80
+ except Exception:
81
+ log.exception("tool %s failed after %d ms", name, (time.monotonic() - started) * 1000)
82
+ raise
83
+ millis = (time.monotonic() - started) * 1000
84
+ problem = _problem(result)
85
+ if problem:
86
+ log.warning("tool %s returned an error after %d ms: %s", name, millis, problem)
87
+ else:
88
+ log.info("tool %s ok in %d ms", name, millis)
89
+ return result
90
+
91
+ return wrapper
92
+
93
+
94
+ def _log_client_initialize() -> None:
95
+ """Log the client's initialize request (name, version, protocol). Best effort: it relies on
96
+ an SDK internal, so a changed SDK only costs this one log line."""
97
+ try:
98
+ from mcp import types
99
+ from mcp.server.session import ServerSession
100
+ except ImportError: # pragma: no cover
101
+ return
102
+ original = getattr(ServerSession, "_received_request", None)
103
+ if original is None or getattr(original, "_bugcap_wrapped", False):
104
+ return
105
+
106
+ async def received(self, responder):
107
+ try:
108
+ request = responder.request.root
109
+ if isinstance(request, types.InitializeRequest):
110
+ params = request.params
111
+ log.info(
112
+ "client initialize: %s %s (protocol %s)",
113
+ params.clientInfo.name, params.clientInfo.version, params.protocolVersion,
114
+ )
115
+ except Exception: # never let logging break the protocol
116
+ log.debug("could not log the initialize request", exc_info=True)
117
+ return await original(self, responder)
118
+
119
+ received._bugcap_wrapped = True # type: ignore[attr-defined]
120
+ ServerSession._received_request = received # type: ignore[method-assign]
121
+
122
+
123
+ def build_server():
124
+ """Construct the FastMCP server with the tools registered."""
125
+ try:
126
+ from mcp.server.fastmcp import FastMCP, Image
127
+ except ImportError as exc: # pragma: no cover - exercised via subprocess test
128
+ raise MCPUnavailable(INSTALL_HINT) from exc
129
+
130
+ server = FastMCP("bugcap")
131
+ _log_client_initialize()
132
+
133
+ def tool():
134
+ def register(fn):
135
+ return server.tool()(_logged(fn))
136
+ return register
137
+
138
+ @tool()
139
+ def list_reports(all: bool = False, status: Optional[str] = None, limit: Optional[int] = None) -> str:
140
+ """List bug reports (current repo by default; `all` for every repo)."""
141
+ with Store() as store:
142
+ return json.dumps(agent_api.list_reports(store, all=all, status=status, limit=limit))
143
+
144
+ @tool()
145
+ def get_report(id: int):
146
+ """Fetch a report's metadata plus each attached image as image content."""
147
+ with Store() as store:
148
+ out = agent_api.get_report(store, id)
149
+ if "error" in out:
150
+ return json.dumps(out)
151
+ content = [json.dumps(out["report"])]
152
+ for img in out["images"]:
153
+ content.append(Image(data=img["bytes"], format=_image_format(img["mime"])))
154
+ return content
155
+
156
+ @tool()
157
+ def request_screenshot(
158
+ report_id: Optional[int] = None,
159
+ issue: Optional[str] = None,
160
+ message: Optional[str] = None,
161
+ timeout_seconds: int = 300,
162
+ note: Optional[str] = None,
163
+ ):
164
+ """Ask the user to capture a screenshot for a report or issue (blocks when a UI exists).
165
+ `note` is appended to the report's notes, tied to the new image."""
166
+ with Store() as store:
167
+ out = agent_api.request_screenshot(
168
+ store, report_id=report_id, issue=issue, message=message, timeout_seconds=timeout_seconds, note=note
169
+ )
170
+ image = out.pop("image", None)
171
+ content = [json.dumps(out)]
172
+ if image:
173
+ content.append(Image(data=image["bytes"], format=_image_format(image["mime"])))
174
+ return content
175
+
176
+ @tool()
177
+ def pull_issues(repo: Optional[str] = None, labels: Optional[list[str]] = None, limit: int = 30) -> str:
178
+ """Import GitHub issues as reports (same as `bugcap github pull`)."""
179
+ with Store() as store:
180
+ return json.dumps(agent_api.pull_issues(store, repo=repo, labels=labels, limit=limit))
181
+
182
+ @tool()
183
+ def attach_image(id: int, sources: list[str], labels: Optional[list[Optional[str]]] = None) -> str:
184
+ """Attach images (file paths, globs or http(s) URLs) to a report, with optional labels
185
+ (one per source, null for none). Notes can then refer to them as @1 or @label."""
186
+ with Store() as store:
187
+ return json.dumps(agent_api.attach_image(store, id, sources, labels))
188
+
189
+ @tool()
190
+ def update_notes(id: int, notes: str) -> str:
191
+ """Replace a report's notes. @1 / @label references must point at the report's images."""
192
+ with Store() as store:
193
+ return json.dumps(agent_api.update_notes(store, id, notes))
194
+
195
+ return server
196
+
197
+
198
+ def run(log_level: Optional[str] = None, log_file: Optional[str] = None) -> None:
199
+ """Run the stdio server (blocking). Raises MCPUnavailable if the SDK is missing.
200
+ Logs startup, client initialize, every tool call and any crash (see `logs.py`)."""
201
+ _, target = logs.setup_logging(log_level, log_file)
202
+ if target is not None:
203
+ print(f"bugcap mcp-serve: logging to {target}", file=sys.stderr, flush=True)
204
+ log.info(
205
+ "starting bugcap %s MCP server (python %s, database %s, pid %s)",
206
+ _version(), sys.version.split()[0], db_path(), os.getpid(),
207
+ )
208
+ try:
209
+ server = build_server()
210
+ except MCPUnavailable as exc:
211
+ log.error("cannot start: %s", exc)
212
+ raise
213
+ try:
214
+ server.run()
215
+ except KeyboardInterrupt:
216
+ log.info("interrupted; shutting down")
217
+ except Exception:
218
+ log.exception("server crashed")
219
+ raise
220
+ finally:
221
+ log.info("server stopped")
bugcap/paths.py ADDED
@@ -0,0 +1,116 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+
6
+ def _home_override() -> Path | None:
7
+ value = os.environ.get("BUGCAP_HOME")
8
+ return Path(value) if value else None
9
+
10
+
11
+ def _data_dir_path() -> Path:
12
+ """The data directory location, without creating it."""
13
+ override = _home_override()
14
+ if override:
15
+ path = override / "data"
16
+ elif sys.platform == "win32":
17
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
18
+ path = Path(base) / "bugcap"
19
+ elif sys.platform == "darwin":
20
+ path = Path.home() / "Library" / "Application Support" / "bugcap"
21
+ else:
22
+ base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
23
+ path = Path(base) / "bugcap"
24
+ return path
25
+
26
+
27
+ def data_dir() -> Path:
28
+ path = _data_dir_path()
29
+ path.mkdir(parents=True, exist_ok=True)
30
+ return path
31
+
32
+
33
+ def config_dir() -> Path:
34
+ override = _home_override()
35
+ if override:
36
+ path = override / "config"
37
+ elif sys.platform == "win32":
38
+ base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
39
+ path = Path(base) / "bugcap"
40
+ elif sys.platform == "darwin":
41
+ path = Path.home() / "Library" / "Application Support" / "bugcap"
42
+ else:
43
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
44
+ path = Path(base) / "bugcap"
45
+ path.mkdir(parents=True, exist_ok=True)
46
+ return path
47
+
48
+
49
+ def images_dir() -> Path:
50
+ path = data_dir() / "images"
51
+ path.mkdir(parents=True, exist_ok=True)
52
+ return path
53
+
54
+
55
+ def media_dir() -> Path:
56
+ path = data_dir() / "media"
57
+ path.mkdir(parents=True, exist_ok=True)
58
+ return path
59
+
60
+
61
+ def drafts_dir() -> Path:
62
+ path = data_dir() / "drafts"
63
+ path.mkdir(parents=True, exist_ok=True)
64
+ return path
65
+
66
+
67
+ _STORE_DIRS = ("images", "media")
68
+
69
+
70
+ def to_data_relative(path) -> str:
71
+ """Path relative to the data dir (posix style) if it lies under images/ or media/;
72
+ otherwise the path unchanged (legacy absolute paths)."""
73
+ raw = str(path)
74
+ root = _data_dir_path()
75
+ try:
76
+ real = Path(os.path.realpath(raw))
77
+ for name in _STORE_DIRS:
78
+ base = Path(os.path.realpath(root / name))
79
+ if real == base or base in real.parents:
80
+ return real.relative_to(Path(os.path.realpath(root))).as_posix()
81
+ except (OSError, ValueError):
82
+ pass
83
+ return raw
84
+
85
+
86
+ def resolve_data_path(rel) -> Path:
87
+ """Absolute path for a stored data-relative path; ValueError unless its realpath lies
88
+ inside images/ or media/ (blocks traversal, absolute paths and escaping symlinks)."""
89
+ raw = str(rel)
90
+ if not raw or os.path.isabs(raw) or "\x00" in raw:
91
+ raise ValueError(f"path outside the media store: {raw!r}")
92
+ root = _data_dir_path()
93
+ candidate = Path(os.path.realpath(root / raw))
94
+ for name in _STORE_DIRS:
95
+ base = Path(os.path.realpath(root / name))
96
+ if candidate != base and base in candidate.parents:
97
+ return candidate
98
+ raise ValueError(f"path outside the media store: {raw!r}")
99
+
100
+
101
+ def absolute_stored_path(stored: str) -> str:
102
+ """Absolute filesystem path for a stored media path (relative ones live in the data dir)."""
103
+ if os.path.isabs(stored):
104
+ return stored
105
+ first = stored.replace("\\", "/").split("/", 1)[0]
106
+ if first in _STORE_DIRS:
107
+ return str(_data_dir_path() / stored)
108
+ return stored
109
+
110
+
111
+ def db_path() -> Path:
112
+ return data_dir() / "bugcap.db"
113
+
114
+
115
+ def config_path() -> Path:
116
+ return config_dir() / "config.toml"