@rui.branco/sentry-mcp 1.0.0

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/index.js +793 -0
  2. package/package.json +30 -0
  3. package/setup.js +266 -0
package/index.js ADDED
@@ -0,0 +1,793 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Handle setup command
4
+ if (process.argv[2] === "setup") {
5
+ require("./setup.js");
6
+ return;
7
+ }
8
+
9
+ const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
10
+ const {
11
+ StdioServerTransport,
12
+ } = require("@modelcontextprotocol/sdk/server/stdio.js");
13
+ const {
14
+ CallToolRequestSchema,
15
+ ListToolsRequestSchema,
16
+ } = require("@modelcontextprotocol/sdk/types.js");
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+ const fetch = require("node-fetch");
20
+ const { spawn, execSync } = require("child_process");
21
+
22
+ // Auto-update: check GitHub for new commits, install in background
23
+ const GITHUB_REPO = "rui-branco/sentry-mcp";
24
+ const INSTALLED_SHA_FILE = path.join(__dirname, ".installed-sha");
25
+ try {
26
+ const localSha = fs.existsSync(INSTALLED_SHA_FILE)
27
+ ? fs.readFileSync(INSTALLED_SHA_FILE, "utf-8").trim()
28
+ : "";
29
+ const remoteSha = execSync(
30
+ `git ls-remote https://github.com/${GITHUB_REPO}.git HEAD`,
31
+ { stdio: "pipe", timeout: 5000 },
32
+ )
33
+ .toString()
34
+ .split("\t")[0]
35
+ .trim();
36
+ if (remoteSha && remoteSha !== localSha) {
37
+ const child = spawn(
38
+ "sh",
39
+ [
40
+ "-c",
41
+ `npm install -g git+ssh://git@github.com/${GITHUB_REPO}.git && echo "${remoteSha}" > "${INSTALLED_SHA_FILE}"`,
42
+ ],
43
+ { stdio: "ignore", detached: true },
44
+ );
45
+ child.unref();
46
+ }
47
+ } catch {}
48
+
49
+ // ── Config ──────────────────────────────────────────────────────────────────
50
+
51
+ const configDir = path.join(process.env.HOME, ".config/sentry-mcp");
52
+ const configPath = path.join(configDir, "config.json");
53
+
54
+ function loadConfigFile() {
55
+ try {
56
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ // Load config (supports single-instance and multi-instance formats)
63
+ const rawConfig = loadConfigFile();
64
+
65
+ // Normalize config to multi-instance format
66
+ let instances;
67
+ if (rawConfig.instances) {
68
+ instances = rawConfig.instances.map((inst) => ({
69
+ ...inst,
70
+ projects: (inst.projects || []).map((p) => p.toLowerCase()),
71
+ }));
72
+ } else if (rawConfig.token) {
73
+ // Old single-instance format — wrap as array
74
+ instances = [
75
+ {
76
+ name: "default",
77
+ token: rawConfig.token,
78
+ baseUrl: (rawConfig.baseUrl || "https://sentry.io").replace(/\/+$/, ""),
79
+ orgSlug: rawConfig.orgSlug,
80
+ projectSlug: rawConfig.projectSlug || "",
81
+ projects: [],
82
+ },
83
+ ];
84
+ } else {
85
+ instances = [];
86
+ }
87
+
88
+ // Resolve default instance
89
+ const defaultInstance =
90
+ (rawConfig.defaultInstance &&
91
+ instances.find((i) => i.name === rawConfig.defaultInstance)) ||
92
+ instances[0] ||
93
+ null;
94
+
95
+ // Instance resolution helpers
96
+ function getInstanceForProject(projectSlug) {
97
+ if (!projectSlug) return defaultInstance;
98
+ const lower = projectSlug.toLowerCase();
99
+ return (
100
+ instances.find((i) => i.projects.includes(lower)) ||
101
+ instances.find((i) => i.projectSlug === projectSlug) ||
102
+ defaultInstance
103
+ );
104
+ }
105
+
106
+ function getInstanceByName(name) {
107
+ if (!name) return defaultInstance;
108
+ return instances.find((i) => i.name === name) || defaultInstance;
109
+ }
110
+
111
+ // ── Sentry API ──────────────────────────────────────────────────────────────
112
+
113
+ async function fetchSentry(apiPath, instance, options = {}) {
114
+ const inst = instance || defaultInstance;
115
+ if (!inst) {
116
+ throw new Error(
117
+ "No Sentry instance configured. Run: sentry-mcp setup",
118
+ );
119
+ }
120
+ if (!inst.token) {
121
+ throw new Error(`Sentry API token not configured for instance "${inst.name}".`);
122
+ }
123
+
124
+ const base = (inst.baseUrl || "https://sentry.io").replace(/\/+$/, "");
125
+ const url = `${base}${apiPath}`;
126
+ const { method = "GET", body } = options;
127
+
128
+ const headers = {
129
+ Authorization: `Bearer ${inst.token}`,
130
+ Accept: "application/json",
131
+ };
132
+ if (body) {
133
+ headers["Content-Type"] = "application/json";
134
+ }
135
+
136
+ const response = await fetch(url, {
137
+ method,
138
+ headers,
139
+ body: body ? JSON.stringify(body) : undefined,
140
+ });
141
+
142
+ if (!response.ok) {
143
+ const errorBody = await response.text().catch(() => "");
144
+ let errorMsg = `Sentry API error: ${response.status} ${response.statusText}`;
145
+ try {
146
+ const parsed = JSON.parse(errorBody);
147
+ if (parsed.detail) errorMsg = parsed.detail;
148
+ else if (parsed.message) errorMsg = parsed.message;
149
+ } catch {}
150
+ throw new Error(errorMsg);
151
+ }
152
+
153
+ const text = await response.text();
154
+ return text ? JSON.parse(text) : {};
155
+ }
156
+
157
+ // ── Helpers ─────────────────────────────────────────────────────────────────
158
+
159
+ function formatIssue(issue) {
160
+ const meta = issue.metadata || {};
161
+ const lines = [];
162
+ lines.push(`**${issue.title}**`);
163
+ lines.push(`- ID: ${issue.id} | Short ID: ${issue.shortId || "N/A"}`);
164
+ lines.push(`- Level: ${issue.level || "error"} | Status: ${issue.status || "unresolved"}`);
165
+ lines.push(`- Events: ${issue.count || 0} | Users: ${issue.userCount || 0}`);
166
+ if (issue.culprit) lines.push(`- Culprit: ${issue.culprit}`);
167
+ if (meta.filename) lines.push(`- File: ${meta.filename}${meta.function ? " in " + meta.function : ""}`);
168
+ if (issue.firstSeen) lines.push(`- First seen: ${issue.firstSeen}`);
169
+ if (issue.lastSeen) lines.push(`- Last seen: ${issue.lastSeen}`);
170
+ if (issue.permalink) lines.push(`- URL: ${issue.permalink}`);
171
+ if (issue.assignedTo) {
172
+ const a = issue.assignedTo;
173
+ lines.push(`- Assigned to: ${a.name || a.email || a.id || "unknown"}`);
174
+ }
175
+ return lines.join("\n");
176
+ }
177
+
178
+ function formatEvent(event) {
179
+ const lines = [];
180
+
181
+ // Stack traces
182
+ const exceptions =
183
+ event.entries?.find((e) => e.type === "exception")?.data?.values || [];
184
+ if (exceptions.length) {
185
+ lines.push("## Stack Trace\n");
186
+ for (const ex of exceptions) {
187
+ if (ex.type || ex.value) lines.push(`**${ex.type || "Exception"}:** ${ex.value || ""}\n`);
188
+ const frames = ex.stacktrace?.frames;
189
+ if (frames?.length) {
190
+ lines.push("```");
191
+ for (const f of frames) {
192
+ const loc = f.lineNo ? `:${f.lineNo}${f.colNo ? ":" + f.colNo : ""}` : "";
193
+ const marker = f.inApp ? "-> " : " ";
194
+ lines.push(`${marker}${f.function || "?"} (${f.filename || "?"}${loc})`);
195
+ }
196
+ lines.push("```\n");
197
+ }
198
+ }
199
+ }
200
+
201
+ // Breadcrumbs
202
+ const breadcrumbs =
203
+ event.entries?.find((e) => e.type === "breadcrumbs")?.data?.values || [];
204
+ if (breadcrumbs.length) {
205
+ const last = breadcrumbs.slice(-20);
206
+ lines.push(`## Breadcrumbs (last ${last.length})\n`);
207
+ for (const bc of last) {
208
+ const msg = bc.message || bc.data?.url || bc.data?.to || "";
209
+ lines.push(`- **${bc.category || "?"}** [${bc.level || "info"}] ${msg}`);
210
+ }
211
+ lines.push("");
212
+ }
213
+
214
+ // HTTP Request
215
+ const httpReq =
216
+ event.entries?.find((e) => e.type === "request")?.data;
217
+ if (httpReq) {
218
+ lines.push("## HTTP Request\n");
219
+ lines.push(`\`${httpReq.method || "GET"} ${httpReq.url || ""}\``);
220
+ if (httpReq.headers) {
221
+ lines.push("\n```");
222
+ for (const [k, v] of httpReq.headers) {
223
+ lines.push(`${k}: ${v}`);
224
+ }
225
+ lines.push("```");
226
+ }
227
+ lines.push("");
228
+ }
229
+
230
+ // Tags
231
+ if (event.tags?.length) {
232
+ lines.push("## Tags\n");
233
+ lines.push("| Key | Value |");
234
+ lines.push("|-----|-------|");
235
+ for (const t of event.tags) {
236
+ lines.push(`| ${t.key} | ${t.value} |`);
237
+ }
238
+ lines.push("");
239
+ }
240
+
241
+ // Contexts
242
+ if (event.contexts) {
243
+ lines.push("## Contexts\n");
244
+ for (const [name, ctx] of Object.entries(event.contexts)) {
245
+ if (name === "trace" || name === "replay" || !ctx || typeof ctx !== "object") continue;
246
+ const entries = Object.entries(ctx).filter(([k, v]) => k !== "type" && v != null && v !== "");
247
+ if (!entries.length) continue;
248
+ const title = name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, " ");
249
+ lines.push(`**${title}:**`);
250
+ for (const [k, v] of entries.slice(0, 15)) {
251
+ const display = typeof v === "object" ? JSON.stringify(v) : String(v);
252
+ lines.push(`- ${k}: ${display}`);
253
+ }
254
+ lines.push("");
255
+ }
256
+ }
257
+
258
+ // User
259
+ if (event.user) {
260
+ const u = event.user;
261
+ lines.push("## User\n");
262
+ lines.push(`${u.username || u.name || u.email || u.id || "unknown"}${u.email ? " (" + u.email + ")" : ""}`);
263
+ if (u.ip_address) lines.push(`IP: ${u.ip_address}`);
264
+ lines.push("");
265
+ }
266
+
267
+ return lines.join("\n") || "No event data available.";
268
+ }
269
+
270
+ // ── MCP Server ──────────────────────────────────────────────────────────────
271
+
272
+ const server = new Server(
273
+ { name: "sentry-mcp", version: "1.0.0" },
274
+ { capabilities: { tools: {} } },
275
+ );
276
+
277
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
278
+ return {
279
+ tools: [
280
+ {
281
+ name: "sentry_list_projects",
282
+ description:
283
+ "List all Sentry projects accessible by the configured token. When multiple instances are configured, lists projects from each.",
284
+ inputSchema: {
285
+ type: "object",
286
+ properties: {
287
+ instance: {
288
+ type: "string",
289
+ description:
290
+ "Instance name (for multi-instance setups). Uses default instance if omitted.",
291
+ },
292
+ },
293
+ required: [],
294
+ },
295
+ },
296
+ {
297
+ name: "sentry_get_issues",
298
+ description:
299
+ "Get unresolved issues for a Sentry project. Returns a list of issues with their metadata.",
300
+ inputSchema: {
301
+ type: "object",
302
+ properties: {
303
+ query: {
304
+ type: "string",
305
+ description:
306
+ 'Sentry search query (default: "is:unresolved"). Supports Sentry search syntax.',
307
+ },
308
+ instance: {
309
+ type: "string",
310
+ description:
311
+ "Instance name (for multi-instance setups). Uses default instance if omitted.",
312
+ },
313
+ },
314
+ required: [],
315
+ },
316
+ },
317
+ {
318
+ name: "sentry_get_issue",
319
+ description:
320
+ "Get full details for a specific Sentry issue by its numeric ID.",
321
+ inputSchema: {
322
+ type: "object",
323
+ properties: {
324
+ issueId: {
325
+ type: "string",
326
+ description: "Sentry issue ID (numeric).",
327
+ },
328
+ instance: {
329
+ type: "string",
330
+ description:
331
+ "Instance name (for multi-instance setups). Uses default instance if omitted.",
332
+ },
333
+ },
334
+ required: ["issueId"],
335
+ },
336
+ },
337
+ {
338
+ name: "sentry_get_latest_event",
339
+ description:
340
+ "Get the latest event for a Sentry issue — includes full stack trace, breadcrumbs, HTTP request, contexts, and tags.",
341
+ inputSchema: {
342
+ type: "object",
343
+ properties: {
344
+ issueId: {
345
+ type: "string",
346
+ description: "Sentry issue ID (numeric).",
347
+ },
348
+ instance: {
349
+ type: "string",
350
+ description:
351
+ "Instance name (for multi-instance setups). Uses default instance if omitted.",
352
+ },
353
+ },
354
+ required: ["issueId"],
355
+ },
356
+ },
357
+ {
358
+ name: "sentry_resolve_issue",
359
+ description: "Mark a Sentry issue as resolved.",
360
+ inputSchema: {
361
+ type: "object",
362
+ properties: {
363
+ issueId: {
364
+ type: "string",
365
+ description: "Sentry issue ID (numeric).",
366
+ },
367
+ instance: {
368
+ type: "string",
369
+ description:
370
+ "Instance name (for multi-instance setups). Uses default instance if omitted.",
371
+ },
372
+ },
373
+ required: ["issueId"],
374
+ },
375
+ },
376
+ {
377
+ name: "sentry_unresolve_issue",
378
+ description: "Reopen a resolved Sentry issue.",
379
+ inputSchema: {
380
+ type: "object",
381
+ properties: {
382
+ issueId: {
383
+ type: "string",
384
+ description: "Sentry issue ID (numeric).",
385
+ },
386
+ instance: {
387
+ type: "string",
388
+ description:
389
+ "Instance name (for multi-instance setups). Uses default instance if omitted.",
390
+ },
391
+ },
392
+ required: ["issueId"],
393
+ },
394
+ },
395
+ {
396
+ name: "sentry_ignore_issue",
397
+ description: "Ignore/snooze a Sentry issue.",
398
+ inputSchema: {
399
+ type: "object",
400
+ properties: {
401
+ issueId: {
402
+ type: "string",
403
+ description: "Sentry issue ID (numeric).",
404
+ },
405
+ instance: {
406
+ type: "string",
407
+ description:
408
+ "Instance name (for multi-instance setups). Uses default instance if omitted.",
409
+ },
410
+ },
411
+ required: ["issueId"],
412
+ },
413
+ },
414
+ {
415
+ name: "sentry_search_issues",
416
+ description:
417
+ "Search Sentry issues with a custom query. Supports full Sentry search syntax.",
418
+ inputSchema: {
419
+ type: "object",
420
+ properties: {
421
+ query: {
422
+ type: "string",
423
+ description:
424
+ 'Sentry search query (e.g. "is:unresolved level:error", "TypeError", "assigned:me").',
425
+ },
426
+ instance: {
427
+ type: "string",
428
+ description:
429
+ "Instance name (for multi-instance setups). Uses default instance if omitted.",
430
+ },
431
+ },
432
+ required: ["query"],
433
+ },
434
+ },
435
+ {
436
+ name: "sentry_assign_issue",
437
+ description: "Assign a Sentry issue to a user or team.",
438
+ inputSchema: {
439
+ type: "object",
440
+ properties: {
441
+ issueId: {
442
+ type: "string",
443
+ description: "Sentry issue ID (numeric).",
444
+ },
445
+ assignee: {
446
+ type: "string",
447
+ description:
448
+ 'Username, email, or team identifier to assign to (e.g. "username:john", "team:backend").',
449
+ },
450
+ instance: {
451
+ type: "string",
452
+ description:
453
+ "Instance name (for multi-instance setups). Uses default instance if omitted.",
454
+ },
455
+ },
456
+ required: ["issueId", "assignee"],
457
+ },
458
+ },
459
+ {
460
+ name: "sentry_list_instances",
461
+ description:
462
+ "List all configured Sentry instances and which is the default.",
463
+ inputSchema: {
464
+ type: "object",
465
+ properties: {},
466
+ required: [],
467
+ },
468
+ },
469
+ {
470
+ name: "sentry_add_instance",
471
+ description:
472
+ "Add or update a Sentry instance at runtime. Persists to config file.",
473
+ inputSchema: {
474
+ type: "object",
475
+ properties: {
476
+ name: {
477
+ type: "string",
478
+ description: "Instance name (e.g. 'work', 'personal').",
479
+ },
480
+ token: {
481
+ type: "string",
482
+ description: "Sentry API auth token (Bearer token).",
483
+ },
484
+ baseUrl: {
485
+ type: "string",
486
+ description:
487
+ "Sentry base URL (e.g. 'https://sentry.io' or 'https://my-org.sentry.io').",
488
+ },
489
+ orgSlug: {
490
+ type: "string",
491
+ description: "Sentry organization slug.",
492
+ },
493
+ projectSlug: {
494
+ type: "string",
495
+ description:
496
+ "Default Sentry project slug for this instance (optional).",
497
+ },
498
+ projects: {
499
+ type: "array",
500
+ items: { type: "string" },
501
+ description:
502
+ "Project slugs to route to this instance (for multi-instance resolution).",
503
+ },
504
+ setDefault: {
505
+ type: "boolean",
506
+ description: "Set this as the default instance.",
507
+ },
508
+ },
509
+ required: ["name", "token", "baseUrl", "orgSlug"],
510
+ },
511
+ },
512
+ {
513
+ name: "sentry_remove_instance",
514
+ description: "Remove a Sentry instance from the configuration.",
515
+ inputSchema: {
516
+ type: "object",
517
+ properties: {
518
+ name: {
519
+ type: "string",
520
+ description: "Instance name to remove.",
521
+ },
522
+ },
523
+ required: ["name"],
524
+ },
525
+ },
526
+ ],
527
+ };
528
+ });
529
+
530
+ // ── Tool handler ────────────────────────────────────────────────────────────
531
+
532
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
533
+ const { name, arguments: args } = request.params;
534
+
535
+ try {
536
+ if (name === "sentry_list_projects") {
537
+ const inst = getInstanceByName(args.instance);
538
+ if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
539
+ const data = await fetchSentry(
540
+ `/api/0/organizations/${encodeURIComponent(inst.orgSlug)}/projects/`,
541
+ inst,
542
+ );
543
+ if (!data || !data.length) {
544
+ return {
545
+ content: [{ type: "text", text: "No projects found." }],
546
+ };
547
+ }
548
+ const lines = data.map(
549
+ (p) =>
550
+ `- **${p.name}** (slug: ${p.slug}, platform: ${p.platform || "N/A"}, id: ${p.id})`,
551
+ );
552
+ return {
553
+ content: [
554
+ {
555
+ type: "text",
556
+ text: `Found ${data.length} project(s) in org "${inst.orgSlug}":\n\n${lines.join("\n")}`,
557
+ },
558
+ ],
559
+ };
560
+
561
+ } else if (name === "sentry_get_issues") {
562
+ const inst = getInstanceByName(args.instance);
563
+ if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
564
+ if (!inst.projectSlug) throw new Error("No projectSlug configured for this instance. Use sentry_search_issues with org-level search, or configure a projectSlug.");
565
+ const query = args.query || "is:unresolved";
566
+ const params = new URLSearchParams({ query });
567
+ const data = await fetchSentry(
568
+ `/api/0/projects/${encodeURIComponent(inst.orgSlug)}/${encodeURIComponent(inst.projectSlug)}/issues/?${params}`,
569
+ inst,
570
+ );
571
+ if (!data || !data.length) {
572
+ return {
573
+ content: [{ type: "text", text: `No issues found for query: ${query}` }],
574
+ };
575
+ }
576
+ const formatted = data.map(formatIssue);
577
+ return {
578
+ content: [
579
+ {
580
+ type: "text",
581
+ text: `Found ${data.length} issue(s) for "${query}":\n\n${formatted.join("\n\n---\n\n")}`,
582
+ },
583
+ ],
584
+ };
585
+
586
+ } else if (name === "sentry_get_issue") {
587
+ const inst = getInstanceByName(args.instance);
588
+ if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
589
+ const data = await fetchSentry(
590
+ `/api/0/organizations/${encodeURIComponent(inst.orgSlug)}/issues/${encodeURIComponent(args.issueId)}/`,
591
+ inst,
592
+ );
593
+ return {
594
+ content: [{ type: "text", text: formatIssue(data) }],
595
+ };
596
+
597
+ } else if (name === "sentry_get_latest_event") {
598
+ const inst = getInstanceByName(args.instance);
599
+ if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
600
+ const data = await fetchSentry(
601
+ `/api/0/organizations/${encodeURIComponent(inst.orgSlug)}/issues/${encodeURIComponent(args.issueId)}/events/latest/`,
602
+ inst,
603
+ );
604
+ return {
605
+ content: [{ type: "text", text: formatEvent(data) }],
606
+ };
607
+
608
+ } else if (name === "sentry_resolve_issue") {
609
+ const inst = getInstanceByName(args.instance);
610
+ if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
611
+ await fetchSentry(
612
+ `/api/0/organizations/${encodeURIComponent(inst.orgSlug)}/issues/${encodeURIComponent(args.issueId)}/`,
613
+ inst,
614
+ { method: "PUT", body: { status: "resolved" } },
615
+ );
616
+ return {
617
+ content: [{ type: "text", text: `Issue ${args.issueId} marked as resolved.` }],
618
+ };
619
+
620
+ } else if (name === "sentry_unresolve_issue") {
621
+ const inst = getInstanceByName(args.instance);
622
+ if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
623
+ await fetchSentry(
624
+ `/api/0/organizations/${encodeURIComponent(inst.orgSlug)}/issues/${encodeURIComponent(args.issueId)}/`,
625
+ inst,
626
+ { method: "PUT", body: { status: "unresolved" } },
627
+ );
628
+ return {
629
+ content: [{ type: "text", text: `Issue ${args.issueId} reopened (unresolved).` }],
630
+ };
631
+
632
+ } else if (name === "sentry_ignore_issue") {
633
+ const inst = getInstanceByName(args.instance);
634
+ if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
635
+ await fetchSentry(
636
+ `/api/0/organizations/${encodeURIComponent(inst.orgSlug)}/issues/${encodeURIComponent(args.issueId)}/`,
637
+ inst,
638
+ { method: "PUT", body: { status: "ignored" } },
639
+ );
640
+ return {
641
+ content: [{ type: "text", text: `Issue ${args.issueId} ignored.` }],
642
+ };
643
+
644
+ } else if (name === "sentry_search_issues") {
645
+ const inst = getInstanceByName(args.instance);
646
+ if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
647
+ // Use project-scoped if projectSlug is available, otherwise org-scoped
648
+ let apiPath;
649
+ if (inst.projectSlug) {
650
+ const params = new URLSearchParams({ query: args.query });
651
+ apiPath = `/api/0/projects/${encodeURIComponent(inst.orgSlug)}/${encodeURIComponent(inst.projectSlug)}/issues/?${params}`;
652
+ } else {
653
+ const params = new URLSearchParams({ query: args.query });
654
+ apiPath = `/api/0/organizations/${encodeURIComponent(inst.orgSlug)}/issues/?${params}`;
655
+ }
656
+ const data = await fetchSentry(apiPath, inst);
657
+ if (!data || !data.length) {
658
+ return {
659
+ content: [{ type: "text", text: `No issues found for query: ${args.query}` }],
660
+ };
661
+ }
662
+ const formatted = data.map(formatIssue);
663
+ return {
664
+ content: [
665
+ {
666
+ type: "text",
667
+ text: `Found ${data.length} issue(s) for "${args.query}":\n\n${formatted.join("\n\n---\n\n")}`,
668
+ },
669
+ ],
670
+ };
671
+
672
+ } else if (name === "sentry_assign_issue") {
673
+ const inst = getInstanceByName(args.instance);
674
+ if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
675
+ await fetchSentry(
676
+ `/api/0/organizations/${encodeURIComponent(inst.orgSlug)}/issues/${encodeURIComponent(args.issueId)}/`,
677
+ inst,
678
+ { method: "PUT", body: { assignedTo: args.assignee } },
679
+ );
680
+ return {
681
+ content: [
682
+ { type: "text", text: `Issue ${args.issueId} assigned to ${args.assignee}.` },
683
+ ],
684
+ };
685
+
686
+ } else if (name === "sentry_list_instances") {
687
+ if (!instances.length) {
688
+ return {
689
+ content: [{ type: "text", text: "No Sentry instances configured. Run: sentry-mcp setup" }],
690
+ };
691
+ }
692
+ const lines = instances.map((inst) => {
693
+ const isDefault = defaultInstance && inst.name === defaultInstance.name ? " (default)" : "";
694
+ const projs = inst.projects?.length ? ` [${inst.projects.join(", ")}]` : "";
695
+ return `- **${inst.name}**${isDefault}: ${inst.baseUrl} (org: ${inst.orgSlug}${inst.projectSlug ? ", project: " + inst.projectSlug : ""})${projs}`;
696
+ });
697
+ return {
698
+ content: [
699
+ {
700
+ type: "text",
701
+ text: `${instances.length} instance(s) configured:\n\n${lines.join("\n")}`,
702
+ },
703
+ ],
704
+ };
705
+
706
+ } else if (name === "sentry_add_instance") {
707
+ const config = loadConfigFile();
708
+ if (!config.instances) config.instances = [];
709
+
710
+ const instance = {
711
+ name: args.name,
712
+ token: args.token,
713
+ baseUrl: (args.baseUrl || "https://sentry.io").replace(/\/+$/, ""),
714
+ orgSlug: args.orgSlug,
715
+ projectSlug: args.projectSlug || "",
716
+ projects: (args.projects || []).map((p) => p.toLowerCase()),
717
+ };
718
+
719
+ const idx = config.instances.findIndex((i) => i.name === args.name);
720
+ if (idx >= 0) {
721
+ config.instances[idx] = instance;
722
+ } else {
723
+ config.instances.push(instance);
724
+ }
725
+
726
+ if (args.setDefault || !config.defaultInstance) {
727
+ config.defaultInstance = args.name;
728
+ }
729
+
730
+ if (!fs.existsSync(configDir)) {
731
+ fs.mkdirSync(configDir, { recursive: true });
732
+ }
733
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
734
+
735
+ // Update runtime state
736
+ const normalized = { ...instance, projects: instance.projects };
737
+ const runtimeIdx = instances.findIndex((i) => i.name === args.name);
738
+ if (runtimeIdx >= 0) {
739
+ instances[runtimeIdx] = normalized;
740
+ } else {
741
+ instances.push(normalized);
742
+ }
743
+
744
+ return {
745
+ content: [
746
+ {
747
+ type: "text",
748
+ text: `Instance "${args.name}" saved.${args.setDefault ? " Set as default." : ""}`,
749
+ },
750
+ ],
751
+ };
752
+
753
+ } else if (name === "sentry_remove_instance") {
754
+ const config = loadConfigFile();
755
+ if (!config.instances) {
756
+ return {
757
+ content: [{ type: "text", text: "No instances configured." }],
758
+ };
759
+ }
760
+
761
+ config.instances = config.instances.filter((i) => i.name !== args.name);
762
+ if (config.defaultInstance === args.name) {
763
+ config.defaultInstance = config.instances[0]?.name || null;
764
+ }
765
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
766
+
767
+ // Update runtime state
768
+ const runtimeIdx = instances.findIndex((i) => i.name === args.name);
769
+ if (runtimeIdx >= 0) instances.splice(runtimeIdx, 1);
770
+
771
+ return {
772
+ content: [
773
+ { type: "text", text: `Instance "${args.name}" removed.` },
774
+ ],
775
+ };
776
+
777
+ } else {
778
+ throw new Error(`Unknown tool: ${name}`);
779
+ }
780
+ } catch (error) {
781
+ return {
782
+ content: [{ type: "text", text: `Error: ${error.message}` }],
783
+ isError: true,
784
+ };
785
+ }
786
+ });
787
+
788
+ async function main() {
789
+ const transport = new StdioServerTransport();
790
+ await server.connect(transport);
791
+ }
792
+
793
+ main().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@rui.branco/sentry-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Sentry MCP server for Claude Code - fetch issues, search errors, resolve/ignore issues, get stack traces and breadcrumbs",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "sentry-mcp": "./index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "setup.js"
12
+ ],
13
+ "scripts": {
14
+ "setup": "node setup.js"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "sentry",
20
+ "claude",
21
+ "claude-code",
22
+ "error-tracking"
23
+ ],
24
+ "author": "Rui Branco",
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.25.3",
28
+ "node-fetch": "^2.7.0"
29
+ }
30
+ }
package/setup.js ADDED
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+
3
+ const readline = require("readline");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+
7
+ const configDir = path.join(process.env.HOME, ".config/sentry-mcp");
8
+ const configPath = path.join(configDir, "config.json");
9
+
10
+ // Check for command line arguments
11
+ let args = process.argv.slice(2);
12
+ // Skip "setup" arg if called via index.js
13
+ if (args[0] === "setup") args = args.slice(1);
14
+
15
+ // Load existing config if present
16
+ function loadConfig() {
17
+ try {
18
+ if (fs.existsSync(configPath)) {
19
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
20
+ }
21
+ } catch {}
22
+ return null;
23
+ }
24
+
25
+ function saveConfig(config) {
26
+ if (!fs.existsSync(configDir)) {
27
+ fs.mkdirSync(configDir, { recursive: true });
28
+ }
29
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
30
+ }
31
+
32
+ // Non-interactive: setup add <name> <token> <baseUrl> <orgSlug> [projectSlug] [projects...]
33
+ if (args[0] === "add" && args.length >= 4) {
34
+ const [, name, token, baseUrl, orgSlug, projectSlug, ...projectArgs] = args;
35
+ const projects = projectArgs.length > 0
36
+ ? projectArgs.join(",").split(",").map((p) => p.trim().toLowerCase()).filter(Boolean)
37
+ : [];
38
+
39
+ const config = loadConfig() || {};
40
+
41
+ // Migrate old format if needed
42
+ if (config.token && !config.instances) {
43
+ config.instances = [{
44
+ name: "default",
45
+ token: config.token,
46
+ baseUrl: config.baseUrl,
47
+ orgSlug: config.orgSlug,
48
+ projectSlug: config.projectSlug || "",
49
+ projects: [],
50
+ }];
51
+ config.defaultInstance = "default";
52
+ delete config.token;
53
+ delete config.baseUrl;
54
+ delete config.orgSlug;
55
+ delete config.projectSlug;
56
+ }
57
+
58
+ if (!config.instances) config.instances = [];
59
+
60
+ // Replace or add instance
61
+ const existing = config.instances.findIndex((i) => i.name === name);
62
+ const instance = {
63
+ name,
64
+ token,
65
+ baseUrl: baseUrl.replace(/\/+$/, ""),
66
+ orgSlug,
67
+ projectSlug: projectSlug || "",
68
+ projects,
69
+ };
70
+ if (existing >= 0) {
71
+ config.instances[existing] = instance;
72
+ } else {
73
+ config.instances.push(instance);
74
+ }
75
+
76
+ if (!config.defaultInstance) config.defaultInstance = name;
77
+
78
+ saveConfig(config);
79
+ console.log(`Instance "${name}" saved to ${configPath}`);
80
+ if (projects.length > 0) {
81
+ console.log(`Projects: ${projects.join(", ")}`);
82
+ }
83
+ process.exit(0);
84
+ }
85
+
86
+ // Non-interactive: setup remove <name>
87
+ if (args[0] === "remove" && args.length >= 2) {
88
+ const name = args[1];
89
+ const config = loadConfig();
90
+
91
+ if (!config || !config.instances) {
92
+ console.error("No multi-instance config found.");
93
+ process.exit(1);
94
+ }
95
+
96
+ config.instances = config.instances.filter((i) => i.name !== name);
97
+ if (config.defaultInstance === name) {
98
+ config.defaultInstance = config.instances[0]?.name || null;
99
+ }
100
+
101
+ saveConfig(config);
102
+ console.log(`Instance "${name}" removed.`);
103
+ process.exit(0);
104
+ }
105
+
106
+ // Non-interactive: setup <token> <baseUrl> <orgSlug> [projectSlug]
107
+ if (args.length >= 3 && args[0] !== "add" && args[0] !== "remove") {
108
+ const [token, baseUrl, orgSlug, projectSlug] = args;
109
+ saveConfig({
110
+ token,
111
+ baseUrl: baseUrl.replace(/\/+$/, ""),
112
+ orgSlug,
113
+ projectSlug: projectSlug || "",
114
+ });
115
+ console.log(`Config saved to ${configPath}`);
116
+ process.exit(0);
117
+ }
118
+
119
+ // Interactive mode
120
+ const rl = readline.createInterface({
121
+ input: process.stdin,
122
+ output: process.stdout,
123
+ });
124
+
125
+ function ask(question) {
126
+ return new Promise((resolve) => {
127
+ rl.question(question, resolve);
128
+ });
129
+ }
130
+
131
+ async function setup() {
132
+ const existing = loadConfig();
133
+ const hasInstances = existing?.instances?.length > 0;
134
+ const isOldFormat = existing?.token && !existing?.instances;
135
+
136
+ console.log("\n=== Sentry MCP Setup ===\n");
137
+
138
+ if (hasInstances || isOldFormat) {
139
+ if (isOldFormat) {
140
+ console.log(`Existing single-instance config found (${existing.baseUrl})\n`);
141
+ } else {
142
+ console.log("Existing instances:");
143
+ for (const inst of existing.instances) {
144
+ const isDefault = inst.name === existing.defaultInstance ? " (default)" : "";
145
+ const projs = inst.projects?.length > 0 ? ` [${inst.projects.join(", ")}]` : "";
146
+ console.log(` - ${inst.name}${isDefault}: ${inst.baseUrl} (org: ${inst.orgSlug})${projs}`);
147
+ }
148
+ console.log();
149
+ }
150
+
151
+ const action = await ask("Add new instance, or fresh setup? (add/fresh): ");
152
+ if (action.trim().toLowerCase() === "fresh") {
153
+ // Fall through to single setup
154
+ } else {
155
+ return await addInstance(existing);
156
+ }
157
+ }
158
+
159
+ console.log("To get your Sentry API token:");
160
+ console.log("1. Go to https://sentry.io/settings/account/api/auth-tokens/");
161
+ console.log(" (or your self-hosted Sentry Settings > API > Auth Tokens)");
162
+ console.log("2. Create a new token with scopes: event:read, project:read, org:read, member:read");
163
+ console.log("3. Copy the token\n");
164
+
165
+ const token = await ask("Sentry API token: ");
166
+ const baseUrl = await ask("Sentry base URL (e.g., https://sentry.io or https://my-org.sentry.io): ");
167
+ const orgSlug = await ask("Organization slug: ");
168
+ const projectSlug = await ask("Default project slug (optional, press Enter to skip): ");
169
+
170
+ saveConfig({
171
+ token: token.trim(),
172
+ baseUrl: baseUrl.trim().replace(/\/+$/, ""),
173
+ orgSlug: orgSlug.trim(),
174
+ projectSlug: projectSlug.trim() || "",
175
+ });
176
+ console.log(`\nConfig saved to ${configPath}`);
177
+
178
+ printSetupComplete();
179
+ rl.close();
180
+ }
181
+
182
+ async function addInstance(config) {
183
+ // Migrate old format if needed
184
+ if (config.token && !config.instances) {
185
+ const oldName = await ask("Name for your existing instance (e.g., work): ");
186
+ const oldProjects = await ask("Project slugs for existing instance (comma-separated): ");
187
+ config = {
188
+ instances: [{
189
+ name: oldName.trim() || "default",
190
+ token: config.token,
191
+ baseUrl: config.baseUrl,
192
+ orgSlug: config.orgSlug,
193
+ projectSlug: config.projectSlug || "",
194
+ projects: oldProjects.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean),
195
+ }],
196
+ defaultInstance: oldName.trim() || "default",
197
+ };
198
+ }
199
+
200
+ if (!config.instances) config.instances = [];
201
+
202
+ console.log("\n--- Add New Instance ---\n");
203
+ console.log("To get your Sentry API token:");
204
+ console.log("1. Go to https://sentry.io/settings/account/api/auth-tokens/");
205
+ console.log(" (or your self-hosted Sentry Settings > API > Auth Tokens)");
206
+ console.log("2. Create a new token with scopes: event:read, project:read, org:read, member:read");
207
+ console.log("3. Copy the token\n");
208
+
209
+ const name = await ask("Instance name (e.g., personal): ");
210
+ const token = await ask("Sentry API token: ");
211
+ const baseUrl = await ask("Sentry base URL (e.g., https://sentry.io or https://my-org.sentry.io): ");
212
+ const orgSlug = await ask("Organization slug: ");
213
+ const projectSlug = await ask("Default project slug (optional, press Enter to skip): ");
214
+ const projectsInput = await ask("Project slugs to route to this instance (comma-separated, optional): ");
215
+ const projects = projectsInput.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
216
+
217
+ const instance = {
218
+ name: name.trim(),
219
+ token: token.trim(),
220
+ baseUrl: baseUrl.trim().replace(/\/+$/, ""),
221
+ orgSlug: orgSlug.trim(),
222
+ projectSlug: projectSlug.trim() || "",
223
+ projects,
224
+ };
225
+
226
+ // Replace or add
227
+ const idx = config.instances.findIndex((i) => i.name === instance.name);
228
+ if (idx >= 0) {
229
+ config.instances[idx] = instance;
230
+ } else {
231
+ config.instances.push(instance);
232
+ }
233
+
234
+ if (!config.defaultInstance) config.defaultInstance = instance.name;
235
+
236
+ const setDefault = await ask(`Set "${instance.name}" as default? (y/N): `);
237
+ if (setDefault.trim().toLowerCase() === "y") {
238
+ config.defaultInstance = instance.name;
239
+ }
240
+
241
+ saveConfig(config);
242
+ console.log(`\nInstance "${instance.name}" saved to ${configPath}`);
243
+
244
+ console.log("\nAll instances:");
245
+ for (const inst of config.instances) {
246
+ const isDefault = inst.name === config.defaultInstance ? " (default)" : "";
247
+ const projs = inst.projects?.length > 0 ? ` [${inst.projects.join(", ")}]` : "";
248
+ console.log(` - ${inst.name}${isDefault}: ${inst.baseUrl} (org: ${inst.orgSlug})${projs}`);
249
+ }
250
+
251
+ printSetupComplete();
252
+ rl.close();
253
+ }
254
+
255
+ function printSetupComplete() {
256
+ console.log("\n=== Setup Complete ===");
257
+ console.log("\nIf you haven't already, add to Claude Code with:\n");
258
+ console.log(" claude mcp add --transport stdio sentry -- npx -y @rui.branco/sentry-mcp");
259
+ console.log("\nThen restart Claude Code and run /mcp to verify.");
260
+ }
261
+
262
+ setup().catch((e) => {
263
+ console.error("Setup failed:", e.message);
264
+ rl.close();
265
+ process.exit(1);
266
+ });