@rui.branco/sentry-mcp 1.0.4 → 1.0.6
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.
- package/README.md +3 -0
- package/index.js +267 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ When debugging production errors, context switching between Sentry and your code
|
|
|
20
20
|
| Breadcrumbs | Last N events leading up to the error |
|
|
21
21
|
| HTTP Request | Method, URL, headers from the request that triggered the error |
|
|
22
22
|
| Tags & Contexts | Device, browser, OS, user info, and custom tags |
|
|
23
|
+
| Attachments | List and download event attachments (minidumps, screenshots, logs) to disk |
|
|
23
24
|
| Search | Full Sentry search syntax support |
|
|
24
25
|
| Multi-Instance | Support for multiple Sentry organizations/instances |
|
|
25
26
|
|
|
@@ -202,6 +203,8 @@ Use the `instance` parameter on any tool to target a specific instance, or omit
|
|
|
202
203
|
| `sentry_ignore_issue` | Ignore/snooze an issue | `issueId` (required), `instance` |
|
|
203
204
|
| `sentry_search_issues` | Search with custom Sentry query | `query` (required), `instance` |
|
|
204
205
|
| `sentry_assign_issue` | Assign an issue to a user or team | `issueId` (required), `assignee` (required), `instance` |
|
|
206
|
+
| `sentry_list_event_attachments` | List attachments (minidumps, screenshots, logs) stored on an event | `eventId` or `issueId`, `instance` |
|
|
207
|
+
| `sentry_download_event_attachment` | Download an event attachment to disk and return the saved path | `attachmentId` (required), `eventId` or `issueId`, `outputPath`, `instance` |
|
|
205
208
|
| `sentry_list_instances` | List all configured instances | none |
|
|
206
209
|
| `sentry_add_instance` | Add or update an instance at runtime | `name`, `token`, `baseUrl`, `orgSlug` (all required), `projectSlug`, `projects`, `setDefault` |
|
|
207
210
|
| `sentry_remove_instance` | Remove an instance | `name` (required) |
|
package/index.js
CHANGED
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
ListToolsRequestSchema,
|
|
16
16
|
} = require("@modelcontextprotocol/sdk/types.js");
|
|
17
17
|
const fs = require("fs");
|
|
18
|
+
const os = require("os");
|
|
18
19
|
const path = require("path");
|
|
19
20
|
const fetch = require("node-fetch");
|
|
20
21
|
const { spawn, execSync } = require("child_process");
|
|
@@ -154,6 +155,43 @@ async function fetchSentry(apiPath, instance, options = {}) {
|
|
|
154
155
|
return text ? JSON.parse(text) : {};
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
// Same as fetchSentry but returns the raw response body as a Buffer (for
|
|
159
|
+
// binary downloads like event attachments). Keeps fetchSentry's JSON contract intact.
|
|
160
|
+
async function fetchSentryRaw(apiPath, instance, options = {}) {
|
|
161
|
+
const inst = instance || defaultInstance;
|
|
162
|
+
if (!inst) {
|
|
163
|
+
throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
|
|
164
|
+
}
|
|
165
|
+
if (!inst.token) {
|
|
166
|
+
throw new Error(`Sentry API token not configured for instance "${inst.name}".`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const base = (inst.baseUrl || "https://sentry.io").replace(/\/+$/, "");
|
|
170
|
+
const url = `${base}${apiPath}`;
|
|
171
|
+
const { method = "GET" } = options;
|
|
172
|
+
|
|
173
|
+
const response = await fetch(url, {
|
|
174
|
+
method,
|
|
175
|
+
headers: {
|
|
176
|
+
Authorization: `Bearer ${inst.token}`,
|
|
177
|
+
Accept: "*/*",
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
const errorBody = await response.text().catch(() => "");
|
|
183
|
+
let errorMsg = `Sentry API error: ${response.status} ${response.statusText}`;
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(errorBody);
|
|
186
|
+
if (parsed.detail) errorMsg = parsed.detail;
|
|
187
|
+
else if (parsed.message) errorMsg = parsed.message;
|
|
188
|
+
} catch {}
|
|
189
|
+
throw new Error(errorMsg);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return response.buffer();
|
|
193
|
+
}
|
|
194
|
+
|
|
157
195
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
158
196
|
|
|
159
197
|
// Strip noisy/internal fields that waste context but keep everything useful
|
|
@@ -167,7 +205,7 @@ function cleanIssueData(issue) {
|
|
|
167
205
|
}
|
|
168
206
|
|
|
169
207
|
function cleanEventData(event) {
|
|
170
|
-
const skip = new Set(["_meta", "groupID", "
|
|
208
|
+
const skip = new Set(["_meta", "groupID", "dist", "fingerprints"]);
|
|
171
209
|
const cleaned = {};
|
|
172
210
|
for (const [k, v] of Object.entries(event)) {
|
|
173
211
|
if (!skip.has(k) && v != null && v !== "") cleaned[k] = v;
|
|
@@ -175,6 +213,44 @@ function cleanEventData(event) {
|
|
|
175
213
|
return cleaned;
|
|
176
214
|
}
|
|
177
215
|
|
|
216
|
+
// Attachments are event-scoped. Accept a raw eventId (preferred) or resolve
|
|
217
|
+
// the issue's latest event when only an issueId is given.
|
|
218
|
+
async function resolveEventId(args, inst) {
|
|
219
|
+
if (args.eventId) return args.eventId;
|
|
220
|
+
if (args.issueId) {
|
|
221
|
+
const latest = await fetchSentry(
|
|
222
|
+
`/api/0/organizations/${encodeURIComponent(inst.orgSlug)}/issues/${encodeURIComponent(args.issueId)}/events/latest/`,
|
|
223
|
+
inst,
|
|
224
|
+
);
|
|
225
|
+
const id = latest.eventID || latest.id;
|
|
226
|
+
if (!id) throw new Error(`Could not resolve an event for issue ${args.issueId}.`);
|
|
227
|
+
return id;
|
|
228
|
+
}
|
|
229
|
+
throw new Error("Provide either eventId or issueId.");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Sanitize a server-provided attachment filename so it can never escape the
|
|
233
|
+
// target directory (path traversal) or introduce awkward characters.
|
|
234
|
+
function safeAttachmentName(name, fallback) {
|
|
235
|
+
const cleaned = path
|
|
236
|
+
.basename(String(name || fallback))
|
|
237
|
+
.replace(/[^a-zA-Z0-9._ -]/g, "_")
|
|
238
|
+
.replace(/^\.+/, "_")
|
|
239
|
+
.slice(0, 180);
|
|
240
|
+
return cleaned || fallback;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Reduce an id (eventId / attachmentId) to a single safe path segment so it
|
|
244
|
+
// cannot introduce separators or "../" traversal when building the default path.
|
|
245
|
+
function safePathSegment(value, fallback) {
|
|
246
|
+
const cleaned = path
|
|
247
|
+
.basename(String(value == null ? "" : value))
|
|
248
|
+
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
|
249
|
+
.replace(/^\.+/, "_")
|
|
250
|
+
.slice(0, 120);
|
|
251
|
+
return cleaned || fallback;
|
|
252
|
+
}
|
|
253
|
+
|
|
178
254
|
function formatIssueSummary(issue) {
|
|
179
255
|
const meta = issue.metadata || {};
|
|
180
256
|
const lines = [];
|
|
@@ -609,6 +685,83 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
609
685
|
required: ["version"],
|
|
610
686
|
},
|
|
611
687
|
},
|
|
688
|
+
{
|
|
689
|
+
name: "sentry_list_events",
|
|
690
|
+
description:
|
|
691
|
+
"List events for a Sentry issue. Returns event IDs, timestamps, user info, and message/title for each occurrence.",
|
|
692
|
+
inputSchema: {
|
|
693
|
+
type: "object",
|
|
694
|
+
properties: {
|
|
695
|
+
issueId: {
|
|
696
|
+
type: "string",
|
|
697
|
+
description: "Sentry issue ID (numeric).",
|
|
698
|
+
},
|
|
699
|
+
instance: {
|
|
700
|
+
type: "string",
|
|
701
|
+
description:
|
|
702
|
+
"Instance name (for multi-instance setups). Uses default instance if omitted.",
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
required: ["issueId"],
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
name: "sentry_list_event_attachments",
|
|
710
|
+
description:
|
|
711
|
+
"List attachments (minidumps, screenshots, logs, view hierarchies, etc.) stored on a Sentry event. Provide either an eventId (32-char hex) or an issueId (resolves to the issue's latest event). Uses the instance's configured projectSlug.",
|
|
712
|
+
inputSchema: {
|
|
713
|
+
type: "object",
|
|
714
|
+
properties: {
|
|
715
|
+
eventId: {
|
|
716
|
+
type: "string",
|
|
717
|
+
description: "Sentry event ID (32-char hex). Takes precedence over issueId.",
|
|
718
|
+
},
|
|
719
|
+
issueId: {
|
|
720
|
+
type: "string",
|
|
721
|
+
description: "Sentry issue ID (numeric). If eventId is omitted, the issue's latest event is used.",
|
|
722
|
+
},
|
|
723
|
+
instance: {
|
|
724
|
+
type: "string",
|
|
725
|
+
description:
|
|
726
|
+
"Instance name (for multi-instance setups). Uses default instance if omitted.",
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
required: [],
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
name: "sentry_download_event_attachment",
|
|
734
|
+
description:
|
|
735
|
+
"Download a Sentry event attachment to a file on disk and return the saved path. Provide the attachmentId (from sentry_list_event_attachments) plus either an eventId or issueId. By default saves into a temp directory; pass outputPath to choose a file or destination directory.",
|
|
736
|
+
inputSchema: {
|
|
737
|
+
type: "object",
|
|
738
|
+
properties: {
|
|
739
|
+
attachmentId: {
|
|
740
|
+
type: "string",
|
|
741
|
+
description: "Attachment ID to download (from sentry_list_event_attachments).",
|
|
742
|
+
},
|
|
743
|
+
eventId: {
|
|
744
|
+
type: "string",
|
|
745
|
+
description: "Sentry event ID (32-char hex). Takes precedence over issueId.",
|
|
746
|
+
},
|
|
747
|
+
issueId: {
|
|
748
|
+
type: "string",
|
|
749
|
+
description: "Sentry issue ID (numeric). If eventId is omitted, the issue's latest event is used.",
|
|
750
|
+
},
|
|
751
|
+
outputPath: {
|
|
752
|
+
type: "string",
|
|
753
|
+
description:
|
|
754
|
+
"Optional destination. If it is an existing directory (or ends with a slash), the file is saved inside it using the attachment's name; otherwise it is treated as the full file path. Relative paths resolve from the current working directory. Defaults to a temp directory.",
|
|
755
|
+
},
|
|
756
|
+
instance: {
|
|
757
|
+
type: "string",
|
|
758
|
+
description:
|
|
759
|
+
"Instance name (for multi-instance setups). Uses default instance if omitted.",
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
required: ["attachmentId"],
|
|
763
|
+
},
|
|
764
|
+
},
|
|
612
765
|
{
|
|
613
766
|
name: "sentry_list_instances",
|
|
614
767
|
description:
|
|
@@ -1013,6 +1166,119 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1013
1166
|
content: [{ type: "text", text: lines.join("\n") }],
|
|
1014
1167
|
};
|
|
1015
1168
|
|
|
1169
|
+
} else if (name === "sentry_list_events") {
|
|
1170
|
+
const inst = getInstanceByName(args.instance);
|
|
1171
|
+
if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
|
|
1172
|
+
const data = await fetchSentry(
|
|
1173
|
+
`/api/0/organizations/${encodeURIComponent(inst.orgSlug)}/issues/${encodeURIComponent(args.issueId)}/events/`,
|
|
1174
|
+
inst,
|
|
1175
|
+
);
|
|
1176
|
+
if (!data || !data.length) {
|
|
1177
|
+
return {
|
|
1178
|
+
content: [{ type: "text", text: `No events found for issue ${args.issueId}.` }],
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
const lines = data.map((e) => {
|
|
1182
|
+
const user = e.user ? `${e.user.username || e.user.email || e.user.id || "unknown"}` : "N/A";
|
|
1183
|
+
const title = e.title || e.message || "";
|
|
1184
|
+
return `- **${e.eventID || e.id}** | ${e.dateCreated || "N/A"} | User: ${user} | ${title}`;
|
|
1185
|
+
});
|
|
1186
|
+
return {
|
|
1187
|
+
content: [
|
|
1188
|
+
{
|
|
1189
|
+
type: "text",
|
|
1190
|
+
text: `Found ${data.length} event(s) for issue ${args.issueId}:\n\n${lines.join("\n")}`,
|
|
1191
|
+
},
|
|
1192
|
+
],
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
} else if (name === "sentry_list_event_attachments") {
|
|
1196
|
+
const inst = getInstanceByName(args.instance);
|
|
1197
|
+
if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
|
|
1198
|
+
if (!inst.projectSlug) throw new Error("No projectSlug configured for this instance.");
|
|
1199
|
+
const eventId = await resolveEventId(args, inst);
|
|
1200
|
+
const data = await fetchSentry(
|
|
1201
|
+
`/api/0/projects/${encodeURIComponent(inst.orgSlug)}/${encodeURIComponent(inst.projectSlug)}/events/${encodeURIComponent(eventId)}/attachments/`,
|
|
1202
|
+
inst,
|
|
1203
|
+
);
|
|
1204
|
+
if (!data || !data.length) {
|
|
1205
|
+
return {
|
|
1206
|
+
content: [{ type: "text", text: `No attachments found for event ${eventId}.` }],
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
const lines = data.map((a) => {
|
|
1210
|
+
const size = a.size != null ? ` (${(a.size / 1024).toFixed(1)} KB)` : "";
|
|
1211
|
+
const type = a.type ? `, type: ${a.type}` : "";
|
|
1212
|
+
const mime = a.mimetype ? `, mimetype: ${a.mimetype}` : "";
|
|
1213
|
+
return `- ${a.name}${size} [id: ${a.id}${type}${mime}]`;
|
|
1214
|
+
});
|
|
1215
|
+
return {
|
|
1216
|
+
content: [
|
|
1217
|
+
{
|
|
1218
|
+
type: "text",
|
|
1219
|
+
text: `Found ${data.length} attachment(s) for event ${eventId}:\n\n${lines.join("\n")}\n\nDownload one with sentry_download_event_attachment (attachmentId + eventId/issueId).`,
|
|
1220
|
+
},
|
|
1221
|
+
],
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
} else if (name === "sentry_download_event_attachment") {
|
|
1225
|
+
const inst = getInstanceByName(args.instance);
|
|
1226
|
+
if (!inst) throw new Error("No Sentry instance configured. Run: sentry-mcp setup");
|
|
1227
|
+
if (!inst.projectSlug) throw new Error("No projectSlug configured for this instance.");
|
|
1228
|
+
if (!args.attachmentId) throw new Error("attachmentId is required.");
|
|
1229
|
+
const eventId = await resolveEventId(args, inst);
|
|
1230
|
+
|
|
1231
|
+
// Look up metadata (name, size, mimetype) so we save with the real filename.
|
|
1232
|
+
const list = await fetchSentry(
|
|
1233
|
+
`/api/0/projects/${encodeURIComponent(inst.orgSlug)}/${encodeURIComponent(inst.projectSlug)}/events/${encodeURIComponent(eventId)}/attachments/`,
|
|
1234
|
+
inst,
|
|
1235
|
+
);
|
|
1236
|
+
const meta = (list || []).find((a) => String(a.id) === String(args.attachmentId));
|
|
1237
|
+
const safeName = safeAttachmentName(meta && meta.name, `attachment-${args.attachmentId}`);
|
|
1238
|
+
|
|
1239
|
+
const buffer = await fetchSentryRaw(
|
|
1240
|
+
`/api/0/projects/${encodeURIComponent(inst.orgSlug)}/${encodeURIComponent(inst.projectSlug)}/events/${encodeURIComponent(eventId)}/attachments/${encodeURIComponent(args.attachmentId)}/?download`,
|
|
1241
|
+
inst,
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1244
|
+
// Resolve destination: explicit path (file or directory) or default temp dir.
|
|
1245
|
+
let destPath;
|
|
1246
|
+
if (args.outputPath) {
|
|
1247
|
+
const resolved = path.resolve(process.cwd(), args.outputPath);
|
|
1248
|
+
const looksLikeDir =
|
|
1249
|
+
/[\\/]$/.test(args.outputPath) ||
|
|
1250
|
+
(fs.existsSync(resolved) && fs.statSync(resolved).isDirectory());
|
|
1251
|
+
destPath = looksLikeDir ? path.join(resolved, safeName) : resolved;
|
|
1252
|
+
} else {
|
|
1253
|
+
destPath = path.join(
|
|
1254
|
+
os.tmpdir(),
|
|
1255
|
+
"sentry-mcp",
|
|
1256
|
+
"attachments",
|
|
1257
|
+
safePathSegment(eventId, "event"),
|
|
1258
|
+
`${safePathSegment(args.attachmentId, "attachment")}-${safeName}`,
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
1262
|
+
fs.writeFileSync(destPath, buffer);
|
|
1263
|
+
|
|
1264
|
+
const details = {
|
|
1265
|
+
savedPath: destPath,
|
|
1266
|
+
name: (meta && meta.name) || safeName,
|
|
1267
|
+
attachmentId: args.attachmentId,
|
|
1268
|
+
eventId,
|
|
1269
|
+
size: (meta && meta.size != null ? meta.size : buffer.length),
|
|
1270
|
+
mimetype: meta && meta.mimetype ? meta.mimetype : undefined,
|
|
1271
|
+
sha1: meta && meta.sha1 ? meta.sha1 : undefined,
|
|
1272
|
+
};
|
|
1273
|
+
return {
|
|
1274
|
+
content: [
|
|
1275
|
+
{
|
|
1276
|
+
type: "text",
|
|
1277
|
+
text: `Downloaded attachment "${details.name}" (${details.size} bytes) to:\n${destPath}\n\n${JSON.stringify(details, null, 2)}`,
|
|
1278
|
+
},
|
|
1279
|
+
],
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1016
1282
|
} else if (name === "sentry_list_instances") {
|
|
1017
1283
|
if (!instances.length) {
|
|
1018
1284
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rui.branco/sentry-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Sentry MCP server for any MCP-compatible AI client (Claude Code, Codex, Antigravity, Cursor, etc.) - fetch issues, search errors, resolve/ignore issues, get stack traces and breadcrumbs",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|