@arwars/simplebug-mcp 0.1.1

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.
Files changed (3) hide show
  1. package/README.md +75 -0
  2. package/dist/index.js +386 -0
  3. package/package.json +38 -0
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @arwars/simplebug-mcp
2
+
3
+ MCP server for [SimpleBug](https://simplebug.example.com). It lets an AI agent pull
4
+ everything captured in a bug report — reproduction steps (incl. keyboard),
5
+ console logs, network requests (failures flagged), device info, and video frames
6
+ — so it can diagnose and fix the reported bug.
7
+
8
+ It's a standard **MCP stdio server**, so it works in any MCP client: Claude Code,
9
+ OpenClaw, Antigravity CLI, Cursor, Windsurf, etc.
10
+
11
+ ## Requirements
12
+
13
+ - **Node.js ≥ 20**
14
+ - **ffmpeg** / **ffprobe** on `PATH` (only needed for the video-frame tool)
15
+ - A **SimpleBug API key** — generate your own in the SimpleBug web app under
16
+ **Settings → API Keys**. Keys are per-user with per-organization permissions,
17
+ so you only see reports for the orgs you belong to.
18
+
19
+ ## Configuration
20
+
21
+ | Env var | Required | Default |
22
+ | --- | --- | --- |
23
+ | `SIMPLEBUG_API_KEY` | yes | — |
24
+ | `SIMPLEBUG_URL` | no | `https://simplebug.example.com` |
25
+
26
+ ## Install
27
+
28
+ ### Claude Code
29
+
30
+ ```bash
31
+ claude mcp add simplebug -s user \
32
+ --env SIMPLEBUG_API_KEY=sbk_your_key \
33
+ --env SIMPLEBUG_URL=https://simplebug.example.com \
34
+ -- npx -y @arwars/simplebug-mcp
35
+ ```
36
+
37
+ ### OpenClaw / Antigravity CLI / Cursor / generic MCP client
38
+
39
+ Add an MCP server entry:
40
+
41
+ ```jsonc
42
+ {
43
+ "mcpServers": {
44
+ "simplebug": {
45
+ "command": "npx",
46
+ "args": ["-y", "@arwars/simplebug-mcp"],
47
+ "env": {
48
+ "SIMPLEBUG_API_KEY": "sbk_your_key",
49
+ "SIMPLEBUG_URL": "https://simplebug.example.com"
50
+ }
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ ## Tools
57
+
58
+ - **`get_bug_report(report)`** — full report: metadata, reproduction steps
59
+ (incl. keyboard), console logs, network requests (failures flagged), video URL,
60
+ device. Accepts a share link or report id.
61
+ - **`list_bug_reports(limit?, status?)`** — browse recent reports you can access.
62
+ - **`get_network_request(report, request_id)`** — full headers/bodies of one
63
+ captured request.
64
+ - **`get_bug_report_screenshots(report, at_seconds?)`** — extract video frames to
65
+ local image files and return their paths + video time + nearest reproduction
66
+ step, so you can inspect visual bugs with your own vision tool. Targeted by
67
+ default (a few orientation frames); pass `at_seconds` to zoom into a moment.
68
+ Never decodes the whole video.
69
+
70
+ ## Typical flow
71
+
72
+ 1. `get_bug_report` to read the steps / errors and form a hypothesis.
73
+ 2. `get_bug_report_screenshots` with `at_seconds` at the suspected moment(s);
74
+ look at the returned frame paths with your own vision tool and compare a few.
75
+ 3. Fix the bug.
package/dist/index.js ADDED
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { execFileSync } from "node:child_process";
5
+ import {
6
+ mkdirSync,
7
+ readdirSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { createORPCClient } from "@orpc/client";
17
+ import { RPCLink } from "@orpc/client/fetch";
18
+ import { z } from "zod";
19
+ var BASE_URL = (process.env.SIMPLEBUG_URL ?? "https://simplebug.example.com").replace(/\/+$/, "");
20
+ var API_KEY = process.env.SIMPLEBUG_API_KEY ?? "";
21
+ var link = new RPCLink({
22
+ url: `${BASE_URL}/rpc`,
23
+ headers: () => API_KEY ? { "x-api-key": API_KEY } : {}
24
+ });
25
+ var client = createORPCClient(link);
26
+ function parseReportId(input) {
27
+ const trimmed = input.trim();
28
+ const slug = trimmed.match(/\/s\/([^/?#]+)/);
29
+ if (slug?.[1]) {
30
+ return slug[1];
31
+ }
32
+ try {
33
+ const segment = new URL(trimmed).pathname.split("/").filter(Boolean).pop();
34
+ if (segment) {
35
+ return segment;
36
+ }
37
+ } catch {}
38
+ return trimmed;
39
+ }
40
+ function asRecord(value) {
41
+ return value && typeof value === "object" ? value : {};
42
+ }
43
+ function str(value) {
44
+ return typeof value === "string" && value.length > 0 ? value : undefined;
45
+ }
46
+ function fmtOffset(offset) {
47
+ if (typeof offset !== "number" || offset < 0) {
48
+ return "--:--";
49
+ }
50
+ const totalSeconds = Math.floor(offset / 1000);
51
+ const m = Math.floor(totalSeconds / 60);
52
+ const s = totalSeconds % 60;
53
+ const ms = Math.floor(offset % 1000);
54
+ return `${m}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(3, "0")}`;
55
+ }
56
+ function describeAction(action) {
57
+ const meta = asRecord(action.metadata);
58
+ const target = str(action.target);
59
+ let detail = "";
60
+ switch (action.type) {
61
+ case "keydown": {
62
+ const key = str(meta.key) ?? "?";
63
+ const mods = str(meta.modifiers);
64
+ detail = `press ${mods ? `${mods}+${key}` : key}`;
65
+ break;
66
+ }
67
+ case "click":
68
+ detail = `click ${str(meta.label) ?? target ?? "element"}`;
69
+ break;
70
+ case "input":
71
+ detail = `type into ${target ?? "field"}`;
72
+ break;
73
+ case "change":
74
+ detail = `change ${target ?? "field"}`;
75
+ break;
76
+ case "navigation":
77
+ detail = `navigate ${str(meta.path) ?? str(meta.url) ?? ""}`.trim();
78
+ break;
79
+ default:
80
+ detail = `${action.type}${target ? ` on ${target}` : ""}`;
81
+ }
82
+ return `[${fmtOffset(action.offset)}] ${detail}`;
83
+ }
84
+ function toText(text) {
85
+ return { content: [{ type: "text", text }] };
86
+ }
87
+ function toError(error) {
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ const hint = /unauthorized|forbidden|not.?found|401|403|404/i.test(message) ? " (check SIMPLEBUG_API_KEY, and that the service user is a member of the report's organization)" : "";
90
+ return {
91
+ isError: true,
92
+ content: [{ type: "text", text: `Error: ${message}${hint}` }]
93
+ };
94
+ }
95
+ function videoDurationSeconds(videoPath) {
96
+ try {
97
+ const out = execFileSync("ffprobe", [
98
+ "-v",
99
+ "error",
100
+ "-select_streams",
101
+ "v:0",
102
+ "-show_entries",
103
+ "packet=pts_time",
104
+ "-of",
105
+ "csv=p=0",
106
+ videoPath
107
+ ], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
108
+ const times = out.trim().split(`
109
+ `).map((line) => Number.parseFloat(line)).filter((n) => Number.isFinite(n));
110
+ return times.length ? Math.max(...times) : 0;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+ function extractFrameJpeg(videoPath, offsetSeconds, outPath, scaleWidth = 1024) {
116
+ try {
117
+ execFileSync("ffmpeg", [
118
+ "-y",
119
+ "-ss",
120
+ String(Math.max(0, offsetSeconds)),
121
+ "-i",
122
+ videoPath,
123
+ "-frames:v",
124
+ "1",
125
+ "-vf",
126
+ `scale=${scaleWidth}:-1`,
127
+ "-q:v",
128
+ "4",
129
+ outPath
130
+ ], { stdio: "ignore" });
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+ function pickDefaultTimestamps(duration, errorTimes) {
137
+ const d = duration > 0 ? duration : 30;
138
+ const round = (n) => Math.round(n * 10) / 10;
139
+ const picks = new Set;
140
+ for (const t of errorTimes) {
141
+ if (picks.size >= 4) {
142
+ break;
143
+ }
144
+ picks.add(round(Math.max(0, Math.min(t, d))));
145
+ }
146
+ picks.add(0);
147
+ picks.add(round(Math.max(0, d - 0.1)));
148
+ if (picks.size < 3) {
149
+ picks.add(round(d / 2));
150
+ }
151
+ return Array.from(picks).sort((a, b) => a - b).slice(0, 5);
152
+ }
153
+ function nearestEventLabel(timeS, events) {
154
+ let bestText = null;
155
+ let bestDelta = Number.POSITIVE_INFINITY;
156
+ const consider = (offset, text2) => {
157
+ if (typeof offset !== "number" || offset < 0) {
158
+ return;
159
+ }
160
+ const delta = Math.abs(offset / 1000 - timeS);
161
+ if (delta < bestDelta) {
162
+ bestDelta = delta;
163
+ bestText = text2;
164
+ }
165
+ };
166
+ for (const action of events.actions) {
167
+ consider(action.offset, describeAction(action).replace(/^\[[^\]]*\]\s*/, ""));
168
+ }
169
+ for (const log of events.logs) {
170
+ consider(log.offset, `${log.level.toUpperCase()}: ${log.message}`);
171
+ }
172
+ if (bestText === null || bestDelta > 2.5) {
173
+ return "(no nearby step)";
174
+ }
175
+ const text = bestText;
176
+ return text.length > 90 ? `${text.slice(0, 90)}…` : text;
177
+ }
178
+ var FRAMES_ROOT = join(tmpdir(), "simplebug-frames");
179
+ var FRAMES_TTL_MS = 60 * 60 * 1000;
180
+ function reapOldFrames() {
181
+ try {
182
+ for (const name of readdirSync(FRAMES_ROOT)) {
183
+ const path = join(FRAMES_ROOT, name);
184
+ try {
185
+ if (Date.now() - statSync(path).mtimeMs > FRAMES_TTL_MS) {
186
+ rmSync(path, { recursive: true, force: true });
187
+ }
188
+ } catch {}
189
+ }
190
+ } catch {}
191
+ }
192
+ function imageExt(url) {
193
+ const lower = url.toLowerCase();
194
+ if (lower.includes(".png")) {
195
+ return "png";
196
+ }
197
+ if (lower.includes(".webp")) {
198
+ return "webp";
199
+ }
200
+ return "jpg";
201
+ }
202
+ async function renderBugReport(reportInput) {
203
+ const id = parseReportId(reportInput);
204
+ const [report, events, network] = await Promise.all([
205
+ client.bugReport.getById({ id }),
206
+ client.bugReport.getDebuggerEvents({ id }).catch(() => ({
207
+ actions: [],
208
+ logs: []
209
+ })),
210
+ client.bugReport.getNetworkRequests({ id, page: 1, perPage: 100 }).catch(() => ({ items: [], pagination: undefined }))
211
+ ]);
212
+ const device = asRecord(report.deviceInfo);
213
+ const lines = [];
214
+ lines.push(`# Bug report: ${report.title ?? "(untitled)"}`);
215
+ lines.push(`- id: ${report.id}`);
216
+ lines.push(`- link: ${BASE_URL}/s/${report.id}`);
217
+ lines.push(`- status: ${report.status} | priority: ${report.priority}${report.tags?.length ? ` | tags: ${report.tags.join(", ")}` : ""}`);
218
+ lines.push(`- organization: ${report.organization?.name ?? "?"} | reporter: ${report.reporter?.name ?? "?"} | created: ${report.createdAt}`);
219
+ if (report.url) {
220
+ lines.push(`- page url: ${report.url}`);
221
+ }
222
+ const deviceBits = [str(device.browser), str(device.os), str(device.viewport)].filter(Boolean).join(" | ");
223
+ if (deviceBits) {
224
+ lines.push(`- device: ${deviceBits}`);
225
+ }
226
+ if (report.attachmentUrl) {
227
+ lines.push(`- ${report.attachmentType ?? "attachment"}: ${report.attachmentUrl}`);
228
+ }
229
+ lines.push(`
230
+ ## Description`);
231
+ lines.push(report.description?.trim() || "(none provided)");
232
+ lines.push(`
233
+ ## Reproduction steps (${events.actions.length})`);
234
+ if (events.actions.length === 0) {
235
+ lines.push("(no user actions captured)");
236
+ } else {
237
+ events.actions.forEach((action, index) => {
238
+ lines.push(`${index + 1}. ${describeAction(action)}`);
239
+ });
240
+ }
241
+ const errorLogs = events.logs.filter((entry) => entry.level === "error" || entry.level === "warn");
242
+ lines.push(`
243
+ ## Console logs (${events.logs.length}, ${errorLogs.length} error/warn)`);
244
+ if (events.logs.length === 0) {
245
+ lines.push("(no console logs captured)");
246
+ } else {
247
+ for (const entry of events.logs) {
248
+ lines.push(`- [${fmtOffset(entry.offset)}] ${entry.level.toUpperCase()}: ${entry.message}`);
249
+ }
250
+ }
251
+ const items = network.items ?? [];
252
+ const failed = items.filter((req) => typeof req.status === "number" && req.status >= 400);
253
+ lines.push(`
254
+ ## Network requests (${items.length} shown${failed.length ? `, ${failed.length} failed` : ""})`);
255
+ if (items.length === 0) {
256
+ lines.push("(no network requests captured)");
257
+ } else {
258
+ for (const req of items) {
259
+ const flag = typeof req.status === "number" && req.status >= 400 ? " ⚠️" : "";
260
+ lines.push(`- ${req.method} ${req.status ?? "—"} ${req.url}${typeof req.duration === "number" ? ` (${req.duration}ms)` : ""}${flag}`);
261
+ }
262
+ }
263
+ return lines.join(`
264
+ `);
265
+ }
266
+ var server = new McpServer({ name: "simplebug", version: "0.1.0" });
267
+ server.registerTool("get_bug_report", {
268
+ title: "Get SimpleBug report",
269
+ description: "Fetch every captured detail of a SimpleBug bug report (metadata, " + "reproduction steps including keyboard input, console logs, network " + "requests with failures highlighted, video URL, device) so you can " + "diagnose and fix the reported bug. Accepts a share link or a report id.",
270
+ inputSchema: {
271
+ report: z.string().describe("SimpleBug share link (https://simplebug.example.com/s/<id>) or the report id")
272
+ }
273
+ }, async ({ report }) => {
274
+ try {
275
+ return toText(await renderBugReport(report));
276
+ } catch (error) {
277
+ return toError(error);
278
+ }
279
+ });
280
+ server.registerTool("list_bug_reports", {
281
+ title: "List SimpleBug reports",
282
+ description: "List recent bug reports the service account can access (id, title, " + "status, link), to browse before fetching one in full.",
283
+ inputSchema: {
284
+ limit: z.number().int().min(1).max(50).optional().describe("max 50"),
285
+ status: z.string().optional().describe("filter by status, e.g. open")
286
+ }
287
+ }, async ({ limit, status }) => {
288
+ try {
289
+ const result = await client.bugReport.list({
290
+ perPage: limit ?? 20,
291
+ page: 1,
292
+ ...status ? { status: [status] } : {}
293
+ });
294
+ const items = result.items ?? [];
295
+ if (items.length === 0) {
296
+ return toText("No bug reports found.");
297
+ }
298
+ return toText(items.map((item) => `- ${item.status} · ${item.title ?? "(untitled)"} · ${BASE_URL}/s/${item.id}`).join(`
299
+ `));
300
+ } catch (error) {
301
+ return toError(error);
302
+ }
303
+ });
304
+ server.registerTool("get_network_request", {
305
+ title: "Get a network request payload",
306
+ description: "Fetch the full request/response payload (headers + bodies) of one " + "captured network request in a report, for deep debugging.",
307
+ inputSchema: {
308
+ report: z.string().describe("share link or report id"),
309
+ request_id: z.string().describe("the network request id")
310
+ }
311
+ }, async ({ report, request_id }) => {
312
+ try {
313
+ const payload = await client.bugReport.getNetworkRequestPayload({
314
+ id: parseReportId(report),
315
+ requestId: request_id
316
+ });
317
+ return toText(JSON.stringify(payload, null, 2));
318
+ } catch (error) {
319
+ return toError(error);
320
+ }
321
+ });
322
+ server.registerTool("get_bug_report_screenshots", {
323
+ title: "See the bug — extract report video frames / screenshot",
324
+ description: "Inspect a bug report's screen recording (or screenshot) visually and " + "EFFICIENTLY. Recommended workflow: first read the report (get_bug_report) " + "to see the reproduction steps and form a hypothesis about WHEN the bug " + "appears, then call this with at_seconds=[…] at those step times to extract " + "ONLY those frames. With no at_seconds it returns just a few orientation " + "frames (any error/warn moments + start/end) — it does NOT sweep the whole " + "clip, and it NEVER decodes the full video (each frame is one cheap seek). " + "Frames are written to LOCAL image files; their PATHS + video time + " + "nearest reproduction step are returned. Analyse a frame with your own " + "vision/image tool (image_source = the returned path) or open the path. " + "For focus/animation or other temporal bugs, request a few timestamps " + "around the suspected step and COMPARE them. Only widen the timestamps if " + "you cannot localise the bug from a few. NOTE: frame files are local to the " + "machine running this server (co-located vision tools only).",
325
+ inputSchema: {
326
+ report: z.string().describe("share link or report id"),
327
+ at_seconds: z.array(z.number().min(0)).max(8).optional().describe("video timestamps (seconds) to extract — target the suspected bug moment(s) from the steps. Default = a few orientation frames (error/warn + start/end), NOT a full sweep.")
328
+ }
329
+ }, async ({ report, at_seconds }) => {
330
+ try {
331
+ const id = parseReportId(report);
332
+ const [meta, events] = await Promise.all([
333
+ client.bugReport.getById({ id }),
334
+ client.bugReport.getDebuggerEvents({ id }).catch(() => ({ actions: [], logs: [] }))
335
+ ]);
336
+ if (!meta.attachmentUrl) {
337
+ return toText("This report has no video or screenshot attachment.");
338
+ }
339
+ mkdirSync(FRAMES_ROOT, { recursive: true });
340
+ reapOldFrames();
341
+ const workDir = join(FRAMES_ROOT, id);
342
+ rmSync(workDir, { recursive: true, force: true });
343
+ mkdirSync(workDir, { recursive: true });
344
+ const response = await fetch(meta.attachmentUrl);
345
+ if (!response.ok) {
346
+ return toText(`Could not download attachment (HTTP ${response.status}).`);
347
+ }
348
+ const bytes = Buffer.from(await response.arrayBuffer());
349
+ if (meta.attachmentType === "screenshot") {
350
+ const shotPath = join(workDir, `screenshot.${imageExt(meta.attachmentUrl)}`);
351
+ writeFileSync(shotPath, bytes);
352
+ return toText(`Screenshot of "${meta.title ?? id}" saved locally:
353
+ ${shotPath}
354
+
355
+ ` + `Analyse it with your own vision/image tool (image_source=${shotPath}).`);
356
+ }
357
+ const videoPath = join(workDir, "video.webm");
358
+ writeFileSync(videoPath, bytes);
359
+ const duration = videoDurationSeconds(videoPath);
360
+ const errorTimes = events.logs.filter((log) => log.level === "error" || log.level === "warn").map((log) => (typeof log.offset === "number" ? log.offset : -1) / 1000).filter((t) => t >= 0);
361
+ const detailed = Boolean(at_seconds?.length);
362
+ const times = detailed ? at_seconds.slice(0, 8) : pickDefaultTimestamps(duration, errorTimes);
363
+ const lines = [];
364
+ let frameIndex = 0;
365
+ for (const t of times) {
366
+ const clamped = Math.max(0, duration ? Math.min(t, duration - 0.05) : t);
367
+ const outPath = join(workDir, `frame-${String(frameIndex++).padStart(2, "0")}-t${clamped.toFixed(1)}s.jpg`);
368
+ if (!extractFrameJpeg(videoPath, clamped, outPath, 1024)) {
369
+ continue;
370
+ }
371
+ lines.push(`- t=${clamped.toFixed(1)}s — ${outPath} — ${nearestEventLabel(clamped, events)}`);
372
+ }
373
+ if (lines.length === 0) {
374
+ return toText("Could not extract any frames (the clip may be unplayable).");
375
+ }
376
+ return toText(`Extracted ${lines.length} frames from "${meta.title ?? id}" (clip ≈ ${duration ? duration.toFixed(1) : "?"}s) to local files. ` + (detailed ? "At your requested timestamps. " : "A few orientation frames only (error/warn + start/end) — not a " + "full sweep. ") + `Each line is: video time — file path — nearest reproduction step/log.
377
+
378
+ ` + `${lines.join(`
379
+ `)}
380
+
381
+ ` + "Analyse a frame with your own vision/image tool (image_source=<path>, " + "these are local files on this machine) or open the path directly. To " + "pinpoint the bug, call again with at_seconds=[…] at the suspected " + "step's time (and compare a few nearby frames for focus/animation bugs) " + "— extract only what you need.");
382
+ } catch (error) {
383
+ return toError(error);
384
+ }
385
+ });
386
+ await server.connect(new StdioServerTransport);
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@arwars/simplebug-mcp",
3
+ "version": "0.1.1",
4
+ "description": "MCP server for SimpleBug — lets AI agents read bug reports (reproduction steps, console logs, network, video frames) to diagnose and fix bugs.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "simplebug-mcp": "dist/index.js"
9
+ },
10
+ "files": ["dist", "README.md"],
11
+ "engines": {
12
+ "node": ">=20"
13
+ },
14
+ "keywords": ["mcp", "model-context-protocol", "simplebug", "bug-reports", "ai"],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ArWars/SimpleBug.git",
18
+ "directory": "apps/mcp"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "build": "bun build ./src/index.ts --target=node --format=esm --outfile=dist/index.js --external @modelcontextprotocol/sdk --external @orpc/client --external zod",
25
+ "prepublishOnly": "bun run build",
26
+ "start": "bun run ./src/index.ts",
27
+ "check-types": "tsc --noEmit -p tsconfig.json"
28
+ },
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "1.26.0",
31
+ "@orpc/client": "^1.12.2",
32
+ "zod": "^4.1.13"
33
+ },
34
+ "devDependencies": {
35
+ "@simplebug/api": "workspace:*",
36
+ "typescript": "^5"
37
+ }
38
+ }