@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.
- package/index.js +793 -0
- package/package.json +30 -0
- 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
|
+
});
|