oops-errors-mcp 0.0.0-stage → 0.1.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/README.md +175 -2
- package/dist/audit.d.ts +42 -0
- package/dist/audit.js +53 -0
- package/dist/audit.js.map +1 -0
- package/dist/client.d.ts +68 -0
- package/dist/client.js +146 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +77 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/issue.d.ts +17 -0
- package/dist/issue.js +42 -0
- package/dist/issue.js.map +1 -0
- package/dist/rbac.d.ts +47 -0
- package/dist/rbac.js +45 -0
- package/dist/rbac.js.map +1 -0
- package/dist/server.d.ts +42 -0
- package/dist/server.js +813 -0
- package/dist/server.js.map +1 -0
- package/dist/stdio.d.ts +2 -0
- package/dist/stdio.js +45 -0
- package/dist/stdio.js.map +1 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +55 -4
package/dist/server.js
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The oops MCP server: tools, resources and prompts over the oops management
|
|
3
|
+
* API (plus the ingest endpoint for `report_error`).
|
|
4
|
+
*
|
|
5
|
+
* Design notes (the parts worth reading before changing anything):
|
|
6
|
+
*
|
|
7
|
+
* - **RBAC, three layers.** The key's scopes (from `GET /apikeys/me`) decide
|
|
8
|
+
* which tools are *registered* (hidden tools are never listed), each handler
|
|
9
|
+
* re-checks its scopes at call time, and the REST API enforces the same
|
|
10
|
+
* scopes independently. See `rbac.ts`.
|
|
11
|
+
* - **Human approval for destructive actions.** `delete_project` and
|
|
12
|
+
* `purge_events` ask the user through MCP *elicitation* when the client
|
|
13
|
+
* supports it (Claude Code, the Inspector). Otherwise they refuse until the
|
|
14
|
+
* model passes `confirm: true`, which forces the model to ask the user first.
|
|
15
|
+
* - **Structured outputs.** Every tool declares an `outputSchema` and returns
|
|
16
|
+
* `structuredContent`, so hosts can validate and chain results without
|
|
17
|
+
* parsing prose. Objects that mirror API responses are *loose* (extra fields
|
|
18
|
+
* allowed) so an additive API change never breaks client-side validation.
|
|
19
|
+
* The text content is a compact, human/agent-readable summary.
|
|
20
|
+
* - **Errors are results, not exceptions.** API errors come back as
|
|
21
|
+
* `isError: true` with the stable error code, so the model can recover
|
|
22
|
+
* (e.g. `limit_reached` → tell the user to upgrade) instead of crashing.
|
|
23
|
+
* - **Everything is audited**, locally (`audit.ts`) and server-side.
|
|
24
|
+
*/
|
|
25
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import { compactArgs } from "./audit.js";
|
|
28
|
+
import { OopsApiError } from "./client.js";
|
|
29
|
+
import { InvalidIssueRef, formatIssueRef, parseIssueRef } from "./issue.js";
|
|
30
|
+
import { TOOL_SCOPES, hasAllScopes } from "./rbac.js";
|
|
31
|
+
import { VERSION } from "./version.js";
|
|
32
|
+
// --------------------------------------------------------------------------- //
|
|
33
|
+
// Shared schema fragments (CONTRACT.md → REST API / Objects)
|
|
34
|
+
// --------------------------------------------------------------------------- //
|
|
35
|
+
const LEVELS = ["fatal", "error", "warning", "info"];
|
|
36
|
+
const STATUSES = ["open", "resolved", "ignored"];
|
|
37
|
+
const STATUS_FILTERS = ["open", "resolved", "ignored", "all"];
|
|
38
|
+
const ISSUE_PERIODS = ["24h", "7d", "30d", "90d", "all"];
|
|
39
|
+
const ISSUE_SORTS = ["last_seen", "first_seen", "events", "users"];
|
|
40
|
+
const STATS_PERIODS = ["today", "yesterday", "24h", "7d", "30d", "90d", "12mo"];
|
|
41
|
+
const INTERVALS = ["hour", "day", "month"];
|
|
42
|
+
const PROJECT_KEY_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
43
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
44
|
+
const projectId = z.uuid().describe("The project's id (UUID), as returned by list_projects or create_project.");
|
|
45
|
+
const issueRef = z
|
|
46
|
+
.union([z.string().min(1).max(64), z.number().int().min(1)])
|
|
47
|
+
.describe('The issue: its uuid, its per-project number (12) or "#12".');
|
|
48
|
+
const levelEnum = z.enum(LEVELS).describe("fatal | error | warning | info");
|
|
49
|
+
const statusEnum = z.enum(STATUSES).describe("open | resolved | ignored. resolved = fixed (reopens automatically if the error comes back); ignored = known noise, stays ignored; open = reopen.");
|
|
50
|
+
const confirmFlag = z
|
|
51
|
+
.boolean()
|
|
52
|
+
.default(false)
|
|
53
|
+
.describe("Set to true only after the user explicitly confirmed this specific destructive action.");
|
|
54
|
+
const pageInput = z.number().int().min(1).default(1).describe("Page number (1-based).");
|
|
55
|
+
const perPageInput = z.number().int().min(1).max(100).default(25).describe("Rows per page (max 100).");
|
|
56
|
+
const eventFilters = {
|
|
57
|
+
level: levelEnum.optional().describe("Only this level."),
|
|
58
|
+
environment: z.string().max(40).optional().describe("Only this environment, e.g. production."),
|
|
59
|
+
release: z.string().max(100).optional().describe("Only this release, e.g. 1.4.2 or a git sha."),
|
|
60
|
+
};
|
|
61
|
+
// Output schemas. `looseObject` = additionalProperties allowed (see header).
|
|
62
|
+
const countsObject = z.looseObject({
|
|
63
|
+
open_issues: z.number(),
|
|
64
|
+
events_24h: z.number(),
|
|
65
|
+
events_30d: z.number(),
|
|
66
|
+
users_30d: z.number(),
|
|
67
|
+
last_event_at: z.string().nullable().optional(),
|
|
68
|
+
});
|
|
69
|
+
const projectObject = z.looseObject({
|
|
70
|
+
id: z.string(),
|
|
71
|
+
key: z.string(),
|
|
72
|
+
name: z.string(),
|
|
73
|
+
description: z.string().nullable().optional(),
|
|
74
|
+
url: z.string().nullable().optional(),
|
|
75
|
+
public_key: z.string().optional(),
|
|
76
|
+
public_key_prefix: z.string(),
|
|
77
|
+
timezone: z.string(),
|
|
78
|
+
allowed_origins: z.array(z.string()),
|
|
79
|
+
ingest_enabled: z.boolean(),
|
|
80
|
+
store_ip: z.boolean().optional(),
|
|
81
|
+
counts: countsObject.optional(),
|
|
82
|
+
created_at: z.string(),
|
|
83
|
+
updated_at: z.string(),
|
|
84
|
+
});
|
|
85
|
+
const frameObject = z.looseObject({
|
|
86
|
+
filename: z.string().nullable().optional(),
|
|
87
|
+
function: z.string().nullable().optional(),
|
|
88
|
+
lineno: z.number().nullable().optional(),
|
|
89
|
+
colno: z.number().nullable().optional(),
|
|
90
|
+
in_app: z.boolean(),
|
|
91
|
+
});
|
|
92
|
+
const breadcrumbObject = z.looseObject({
|
|
93
|
+
ts: z.string().nullable().optional(),
|
|
94
|
+
category: z.string(),
|
|
95
|
+
message: z.string().nullable().optional(),
|
|
96
|
+
level: z.string().nullable().optional(),
|
|
97
|
+
data: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
98
|
+
});
|
|
99
|
+
const userObject = z.looseObject({
|
|
100
|
+
id: z.string().nullable().optional(),
|
|
101
|
+
email: z.string().nullable().optional(),
|
|
102
|
+
name: z.string().nullable().optional(),
|
|
103
|
+
});
|
|
104
|
+
const eventObject = z.looseObject({
|
|
105
|
+
id: z.string(),
|
|
106
|
+
project_id: z.string(),
|
|
107
|
+
issue_id: z.string(),
|
|
108
|
+
issue_number: z.number(),
|
|
109
|
+
ts: z.string(),
|
|
110
|
+
level: z.string(),
|
|
111
|
+
type: z.string().nullable().optional(),
|
|
112
|
+
message: z.string(),
|
|
113
|
+
frames: z.array(frameObject),
|
|
114
|
+
stack_raw: z.string().nullable().optional(),
|
|
115
|
+
url: z.string().nullable().optional(),
|
|
116
|
+
path: z.string().nullable().optional(),
|
|
117
|
+
hostname: z.string().nullable().optional(),
|
|
118
|
+
release: z.string().nullable().optional(),
|
|
119
|
+
environment: z.string(),
|
|
120
|
+
platform: z.string().nullable().optional(),
|
|
121
|
+
sdk: z.string().nullable().optional(),
|
|
122
|
+
user: userObject.nullable().optional(),
|
|
123
|
+
user_hash: z.string(),
|
|
124
|
+
browser: z.string().nullable().optional(),
|
|
125
|
+
os: z.string().nullable().optional(),
|
|
126
|
+
device: z.string().nullable().optional(),
|
|
127
|
+
ip: z.string().nullable().optional(),
|
|
128
|
+
tags: z.record(z.string(), z.string()),
|
|
129
|
+
extra: z.record(z.string(), z.unknown()),
|
|
130
|
+
breadcrumbs: z.array(breadcrumbObject),
|
|
131
|
+
source: z.string(),
|
|
132
|
+
});
|
|
133
|
+
const issueObject = z.looseObject({
|
|
134
|
+
id: z.string(),
|
|
135
|
+
project_id: z.string(),
|
|
136
|
+
number: z.number(),
|
|
137
|
+
fingerprint: z.string(),
|
|
138
|
+
title: z.string(),
|
|
139
|
+
culprit: z.string().nullable().optional(),
|
|
140
|
+
type: z.string().nullable().optional(),
|
|
141
|
+
level: z.string(),
|
|
142
|
+
status: z.string(),
|
|
143
|
+
first_seen: z.string(),
|
|
144
|
+
last_seen: z.string(),
|
|
145
|
+
resolved_at: z.string().nullable().optional(),
|
|
146
|
+
regressed_at: z.string().nullable().optional(),
|
|
147
|
+
event_count: z.number(),
|
|
148
|
+
user_count: z.number(),
|
|
149
|
+
environments: z.array(z.string()),
|
|
150
|
+
releases: z.array(z.string()),
|
|
151
|
+
latest_event_id: z.string().nullable().optional(),
|
|
152
|
+
latest_event: eventObject.nullable().optional(),
|
|
153
|
+
created_at: z.string(),
|
|
154
|
+
updated_at: z.string(),
|
|
155
|
+
});
|
|
156
|
+
const pageOf = (item) => z.looseObject({ items: z.array(item), page: z.number(), per_page: z.number(), total: z.number() });
|
|
157
|
+
const totalsObject = z.looseObject({ events: z.number(), users: z.number(), issues_new: z.number(), issues_open: z.number().optional() });
|
|
158
|
+
const rowObject = z.looseObject({ value: z.string(), events: z.number(), users: z.number() });
|
|
159
|
+
const statsObject = z.looseObject({
|
|
160
|
+
range: z.looseObject({ from: z.string(), to: z.string(), tz: z.string(), interval: z.string() }),
|
|
161
|
+
totals: totalsObject,
|
|
162
|
+
previous: totalsObject,
|
|
163
|
+
series: z.array(z.looseObject({ ts: z.string(), events: z.number(), users: z.number() })),
|
|
164
|
+
breakdowns: z.record(z.string(), z.array(rowObject)),
|
|
165
|
+
});
|
|
166
|
+
const liveObject = z.looseObject({ events: z.number(), window_min: z.number() });
|
|
167
|
+
const usageObject = z.looseObject({
|
|
168
|
+
period_start: z.string(),
|
|
169
|
+
period_end: z.string(),
|
|
170
|
+
events: z.number(),
|
|
171
|
+
events_dropped: z.number(),
|
|
172
|
+
counts: z.looseObject({ projects: z.number(), api_keys: z.number() }),
|
|
173
|
+
limits: z.record(z.string(), z.unknown()),
|
|
174
|
+
});
|
|
175
|
+
const auditEventObject = z.looseObject({
|
|
176
|
+
id: z.string(),
|
|
177
|
+
ts: z.string(),
|
|
178
|
+
actor_type: z.string(),
|
|
179
|
+
actor_id: z.string().nullable().optional(),
|
|
180
|
+
actor_label: z.string().nullable().optional(),
|
|
181
|
+
action: z.string(),
|
|
182
|
+
target_type: z.string().nullable().optional(),
|
|
183
|
+
target_id: z.string().nullable().optional(),
|
|
184
|
+
outcome: z.string(),
|
|
185
|
+
meta: z.record(z.string(), z.unknown()),
|
|
186
|
+
});
|
|
187
|
+
const ingestResultObject = z.looseObject({
|
|
188
|
+
ok: z.boolean(),
|
|
189
|
+
event_id: z.string().optional(),
|
|
190
|
+
issue_id: z.string().optional(),
|
|
191
|
+
issue_number: z.number().optional(),
|
|
192
|
+
reason: z.string().optional(),
|
|
193
|
+
project_id: z.string(),
|
|
194
|
+
});
|
|
195
|
+
function isWrapped(out) {
|
|
196
|
+
return (typeof out.outcome === "string" &&
|
|
197
|
+
typeof out.result === "object" &&
|
|
198
|
+
!("content" in out));
|
|
199
|
+
}
|
|
200
|
+
function structured(value, summary) {
|
|
201
|
+
return {
|
|
202
|
+
content: [{ type: "text", text: summary ?? JSON.stringify(value, null, 2) }],
|
|
203
|
+
structuredContent: value,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function failure(code, message, extra = {}) {
|
|
207
|
+
const detail = Object.keys(extra).length ? ` ${JSON.stringify(extra)}` : "";
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: "text", text: `Error (${code}): ${message}${detail}` }],
|
|
210
|
+
isError: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// --------------------------------------------------------------------------- //
|
|
214
|
+
// Text summaries (what the model reads; the JSON travels in structuredContent)
|
|
215
|
+
// --------------------------------------------------------------------------- //
|
|
216
|
+
function truncate(s, n) {
|
|
217
|
+
const one = s.replace(/\s+/g, " ").trim();
|
|
218
|
+
return one.length > n ? one.slice(0, n - 1) + "…" : one;
|
|
219
|
+
}
|
|
220
|
+
function pct(cur, prev) {
|
|
221
|
+
if (!prev)
|
|
222
|
+
return cur ? "new" : "±0%";
|
|
223
|
+
const d = Math.round(((cur - prev) / prev) * 1000) / 10;
|
|
224
|
+
return `${d > 0 ? "+" : ""}${d}%`;
|
|
225
|
+
}
|
|
226
|
+
function describeProject(p) {
|
|
227
|
+
const c = p.counts
|
|
228
|
+
? `, ${p.counts.open_issues} open issue(s), ${p.counts.events_24h} events/24h, ${p.counts.events_30d} events / ${p.counts.users_30d} users in 30d, last event ${p.counts.last_event_at ?? "never"}`
|
|
229
|
+
: "";
|
|
230
|
+
const state = p.ingest_enabled ? "" : ", ingest PAUSED";
|
|
231
|
+
return `${p.name} (key=${p.key}, id=${p.id}, tz=${p.timezone}${c}${state})`;
|
|
232
|
+
}
|
|
233
|
+
function describeIssue(i) {
|
|
234
|
+
const flags = [i.regressed_at && i.status === "open" ? "REGRESSED" : "", i.resolved_at && i.status === "resolved" ? `resolved ${i.resolved_at}` : ""]
|
|
235
|
+
.filter(Boolean)
|
|
236
|
+
.join(", ");
|
|
237
|
+
const rel = i.releases.length ? ` · releases ${i.releases.slice(-3).join(", ")}` : "";
|
|
238
|
+
const env = i.environments.length ? ` · env ${i.environments.join(", ")}` : "";
|
|
239
|
+
return (`#${i.number} [${i.status}] ${i.level} ${truncate(i.title, 120)}${i.culprit ? ` — ${truncate(i.culprit, 80)}` : ""}` +
|
|
240
|
+
` · ${i.event_count} event(s) / ${i.user_count} user(s) · first ${i.first_seen} · last ${i.last_seen}${env}${rel}${flags ? ` · ${flags}` : ""}` +
|
|
241
|
+
` · id=${i.id}`);
|
|
242
|
+
}
|
|
243
|
+
function topFrame(e) {
|
|
244
|
+
const f = e.frames?.[0];
|
|
245
|
+
if (!f)
|
|
246
|
+
return "";
|
|
247
|
+
const loc = [f.filename, f.lineno != null ? `:${f.lineno}` : "", f.colno != null ? `:${f.colno}` : ""].join("");
|
|
248
|
+
return ` @ ${f.function ?? "?"} (${loc || "?"})`;
|
|
249
|
+
}
|
|
250
|
+
function describeEvent(e, verbose = false) {
|
|
251
|
+
const where = e.path ?? e.url ?? "";
|
|
252
|
+
const ua = [e.browser, e.os, e.device].filter(Boolean).join("/");
|
|
253
|
+
const user = e.user?.id ? ` user=${e.user.id}` : "";
|
|
254
|
+
const head = `${e.ts} ${e.level} ${e.type ? `${e.type}: ` : ""}${truncate(e.message, 160)} (issue #${e.issue_number}, event ${e.id})` +
|
|
255
|
+
`${where ? ` at ${where}` : ""}${topFrame(e)} · ${e.environment}${e.release ? ` ${e.release}` : ""}${ua ? ` · ${ua}` : ""}${user}${e.platform ? ` · ${e.platform}` : ""}${e.ip ? ` ip=${e.ip}` : ""}`;
|
|
256
|
+
if (!verbose)
|
|
257
|
+
return head;
|
|
258
|
+
const lines = [head];
|
|
259
|
+
if (e.frames?.length) {
|
|
260
|
+
lines.push("Stack (top first):");
|
|
261
|
+
for (const f of e.frames.slice(0, 15)) {
|
|
262
|
+
lines.push(` ${f.in_app ? "*" : " "} ${f.function ?? "?"} ${f.filename ?? "?"}${f.lineno != null ? `:${f.lineno}` : ""}${f.colno != null ? `:${f.colno}` : ""}`);
|
|
263
|
+
}
|
|
264
|
+
if (e.frames.length > 15)
|
|
265
|
+
lines.push(` … ${e.frames.length - 15} more frame(s)`);
|
|
266
|
+
}
|
|
267
|
+
else if (e.stack_raw) {
|
|
268
|
+
lines.push("Stack (raw):", truncate(e.stack_raw, 1500));
|
|
269
|
+
}
|
|
270
|
+
if (Object.keys(e.tags ?? {}).length)
|
|
271
|
+
lines.push(`Tags: ${JSON.stringify(e.tags)}`);
|
|
272
|
+
if (Object.keys(e.extra ?? {}).length)
|
|
273
|
+
lines.push(`Extra: ${truncate(JSON.stringify(e.extra), 800)}`);
|
|
274
|
+
if (e.breadcrumbs?.length) {
|
|
275
|
+
lines.push(`Breadcrumbs (last ${Math.min(e.breadcrumbs.length, 10)}):`);
|
|
276
|
+
for (const b of e.breadcrumbs.slice(-10))
|
|
277
|
+
lines.push(` ${b.ts ?? ""} ${b.category}${b.message ? ` ${truncate(b.message, 100)}` : ""}`);
|
|
278
|
+
}
|
|
279
|
+
if (e.user && (e.user.email || e.user.name))
|
|
280
|
+
lines.push(`User: ${[e.user.id, e.user.email, e.user.name].filter(Boolean).join(" ")}`);
|
|
281
|
+
return lines.join("\n");
|
|
282
|
+
}
|
|
283
|
+
function describeTotals(t, prev) {
|
|
284
|
+
const base = `${t.events} events, ${t.users} affected users, ${t.issues_new} new issue(s)${t.issues_open != null ? `, ${t.issues_open} open` : ""}`;
|
|
285
|
+
if (!prev)
|
|
286
|
+
return base;
|
|
287
|
+
return `${base} (vs previous period: ${pct(t.events, prev.events)} events, ${pct(t.users, prev.users)} users, ${pct(t.issues_new, prev.issues_new)} new issues)`;
|
|
288
|
+
}
|
|
289
|
+
function describeRows(rows, n = 6) {
|
|
290
|
+
return rows.slice(0, n).map((r) => `${truncate(r.value, 60)} ${r.events}`).join(", ") || "none";
|
|
291
|
+
}
|
|
292
|
+
function describeStats(s) {
|
|
293
|
+
const b = s.breakdowns;
|
|
294
|
+
const peak = s.series.reduce((acc, p) => (!acc || p.events > acc.events ? p : acc), null);
|
|
295
|
+
return [
|
|
296
|
+
`${s.range.from} → ${s.range.to} (${s.range.tz}, ${s.range.interval} buckets)`,
|
|
297
|
+
`Totals: ${describeTotals(s.totals, s.previous)}.`,
|
|
298
|
+
peak ? `Peak bucket: ${peak.ts} with ${peak.events} events (${s.series.length} buckets).` : "No events in range.",
|
|
299
|
+
`By level (events): ${describeRows(b.level ?? [])}.`,
|
|
300
|
+
`By environment: ${describeRows(b.environment ?? [])}. By release: ${describeRows(b.release ?? [])}.`,
|
|
301
|
+
`Top error types: ${describeRows(b.type ?? [], 8)}.`,
|
|
302
|
+
`Browsers: ${describeRows(b.browser ?? [], 5)}. OS: ${describeRows(b.os ?? [], 5)}. Devices: ${describeRows(b.device ?? [], 4)}.`,
|
|
303
|
+
].join("\n");
|
|
304
|
+
}
|
|
305
|
+
export function buildInstructions(info) {
|
|
306
|
+
return [
|
|
307
|
+
`oops is an error-tracking service. You are acting for account "${info.account.name}" (plan: ${info.account.plan}).`,
|
|
308
|
+
`This API key has role "${info.role}" with scopes: ${info.scopes.join(", ")}. Only tools allowed by those scopes are listed; if a user asks for something outside them, say the key lacks that permission instead of guessing.`,
|
|
309
|
+
"Model: an account has projects (one per app, site or service). A project receives error events through its public key (oops_pub_...), from the browser SDK (/o.js) or the ingest API. The server groups events into issues by fingerprint (type + normalised message + top in-app frame, or a client-provided fingerprint). An issue has a per-project number (#12), a status (open | resolved | ignored), event_count, user_count (distinct affected users), first_seen / last_seen, environments and releases. A resolved issue that gets a new event reopens with regressed_at set; an ignored one stays ignored.",
|
|
310
|
+
"Start with list_projects to find a project id. Triage with list_issues (filters: status, level, environment, release, q, period, sort), read one with get_issue (uuid or #number; includes the latest event with its stack), see occurrences with list_issue_events, inspect one event with get_event, and search raw events with search_events. get_stats gives totals vs the previous period, a time series and breakdowns by level / environment / release / browser / os / device / type; get_live counts the last few minutes.",
|
|
311
|
+
"Change state with update_issue (status: resolved to mark fixed, ignored for known noise, open to reopen) or bulk_update_issues. report_error posts an event into a project you can see, so you can report your own failures. Deleting a project or purging its events is irreversible: those tools require explicit user confirmation.",
|
|
312
|
+
].join("\n");
|
|
313
|
+
}
|
|
314
|
+
export function createOopsServer(opts) {
|
|
315
|
+
const { client, keyInfo, audit } = opts;
|
|
316
|
+
const transport = opts.transport ?? "unknown";
|
|
317
|
+
const allowElicitation = opts.allowElicitation ?? true;
|
|
318
|
+
const scopes = new Set(keyInfo.scopes);
|
|
319
|
+
const actor = { account: keyInfo.account.id, prefix: keyInfo.prefix, role: keyInfo.role };
|
|
320
|
+
const server = new McpServer({ name: "oops", version: VERSION }, { instructions: buildInstructions(keyInfo) });
|
|
321
|
+
const log = (tool, outcome, started, args, extra = {}) => audit.write({
|
|
322
|
+
ts: new Date().toISOString(),
|
|
323
|
+
event: "tool.call",
|
|
324
|
+
transport,
|
|
325
|
+
actor,
|
|
326
|
+
tool,
|
|
327
|
+
outcome,
|
|
328
|
+
duration_ms: Date.now() - started,
|
|
329
|
+
args: compactArgs(args),
|
|
330
|
+
...extra,
|
|
331
|
+
});
|
|
332
|
+
/**
|
|
333
|
+
* Register a tool only if the key's scopes allow it, wrapping the handler
|
|
334
|
+
* with the call-time scope re-check, error mapping and audit logging.
|
|
335
|
+
*/
|
|
336
|
+
function tool(name, config, handler) {
|
|
337
|
+
const required = TOOL_SCOPES[name];
|
|
338
|
+
if (!hasAllScopes(scopes, required))
|
|
339
|
+
return; // layer 1: never listed
|
|
340
|
+
const requiredLabel = required.join(" + ");
|
|
341
|
+
server.registerTool(name, {
|
|
342
|
+
title: config.title,
|
|
343
|
+
description: `${config.description} (requires scope ${requiredLabel})`,
|
|
344
|
+
inputSchema: config.inputSchema,
|
|
345
|
+
outputSchema: config.outputSchema,
|
|
346
|
+
annotations: { title: config.title, ...config.annotations },
|
|
347
|
+
}, (async (args, extra) => {
|
|
348
|
+
const started = Date.now();
|
|
349
|
+
if (!hasAllScopes(scopes, required)) {
|
|
350
|
+
// layer 2: refused even if somehow invoked
|
|
351
|
+
log(name, "denied", started, args, { error: { code: "insufficient_scope", message: requiredLabel } });
|
|
352
|
+
return failure("insufficient_scope", `This key (role ${keyInfo.role}) lacks ${requiredLabel}.`, {
|
|
353
|
+
required_scope: requiredLabel,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
const out = await handler(args, extra);
|
|
358
|
+
if (isWrapped(out)) {
|
|
359
|
+
log(name, out.outcome, started, args);
|
|
360
|
+
return out.result;
|
|
361
|
+
}
|
|
362
|
+
log(name, "ok", started, args);
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
if (err instanceof OopsApiError) {
|
|
367
|
+
// layer 3 (the API said no) or any other API-level failure
|
|
368
|
+
log(name, err.code === "insufficient_scope" ? "denied" : "error", started, args, {
|
|
369
|
+
error: { code: err.code, message: err.message },
|
|
370
|
+
});
|
|
371
|
+
return failure(err.code, err.message, { status: err.status, ...err.extra });
|
|
372
|
+
}
|
|
373
|
+
if (err instanceof InvalidIssueRef) {
|
|
374
|
+
log(name, "error", started, args, { error: { code: "invalid_issue_ref", message: err.message } });
|
|
375
|
+
return failure("invalid_issue_ref", err.message);
|
|
376
|
+
}
|
|
377
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
378
|
+
log(name, "error", started, args, { error: { code: "internal_error", message } });
|
|
379
|
+
return failure("internal_error", message);
|
|
380
|
+
}
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Shared confirmation flow for destructive tools: elicit when possible,
|
|
385
|
+
* otherwise require `confirm: true`. Returns null when approved.
|
|
386
|
+
*/
|
|
387
|
+
async function confirmOrRefuse(confirm, what, toolName, cancelled) {
|
|
388
|
+
if (confirm)
|
|
389
|
+
return null;
|
|
390
|
+
const canAsk = allowElicitation && Boolean(server.server.getClientCapabilities()?.elicitation);
|
|
391
|
+
if (canAsk) {
|
|
392
|
+
const answer = await server.server.elicitInput({
|
|
393
|
+
mode: "form",
|
|
394
|
+
message: `${what} This cannot be undone.`,
|
|
395
|
+
requestedSchema: {
|
|
396
|
+
type: "object",
|
|
397
|
+
properties: { confirm: { type: "boolean", title: "Yes, do it", default: false } },
|
|
398
|
+
required: ["confirm"],
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
if (answer.action === "accept" && answer.content?.confirm === true)
|
|
402
|
+
return null;
|
|
403
|
+
return {
|
|
404
|
+
outcome: "cancelled",
|
|
405
|
+
result: structured(cancelled, "Cancelled by the user. Nothing was changed."),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
outcome: "pending",
|
|
410
|
+
result: failure("confirmation_required", `${what} This is irreversible. Ask the user to confirm, then call ${toolName} again with confirm=true.`),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const pageSummary = (label, p) => `${p.total} ${label} total; page ${p.page} (${p.items.length} of ${p.per_page} per page).`;
|
|
414
|
+
// ------------------------------------------------------------------------- //
|
|
415
|
+
// Projects
|
|
416
|
+
// ------------------------------------------------------------------------- //
|
|
417
|
+
tool("list_projects", {
|
|
418
|
+
title: "List projects",
|
|
419
|
+
description: "List the account's projects, newest first, each with its open-issue count, events in the last 24 h, events and affected users in the last 30 days and the time of the last event. Start here to find a project id for the other tools; pass q to narrow by name, key or URL (filtered client-side).",
|
|
420
|
+
inputSchema: {
|
|
421
|
+
q: z.string().max(100).optional().describe("Case-insensitive substring of the project name, key or URL."),
|
|
422
|
+
},
|
|
423
|
+
outputSchema: z.looseObject({ items: z.array(projectObject), total: z.number() }),
|
|
424
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
425
|
+
}, async ({ q }) => {
|
|
426
|
+
const { items: all } = await client.listProjects();
|
|
427
|
+
const needle = q?.trim().toLowerCase();
|
|
428
|
+
const items = needle
|
|
429
|
+
? all.filter((p) => [p.name, p.key, p.url ?? ""].some((s) => s.toLowerCase().includes(needle)))
|
|
430
|
+
: all;
|
|
431
|
+
return structured({ items, total: items.length }, `${items.length} project(s).\n${items.map(describeProject).join("\n") || "(none)"}`);
|
|
432
|
+
});
|
|
433
|
+
tool("get_project", {
|
|
434
|
+
title: "Get project",
|
|
435
|
+
description: "Fetch one project by id: its public key (for the SDK tag and the ingest API), time zone, allowed origins, ingest / store_ip switches and counts (open issues, events 24h / 30d, users 30d, last event).",
|
|
436
|
+
inputSchema: { project_id: projectId },
|
|
437
|
+
outputSchema: projectObject,
|
|
438
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
439
|
+
}, async ({ project_id }) => {
|
|
440
|
+
const p = await client.getProject(project_id);
|
|
441
|
+
return structured({ ...p }, `${describeProject(p)}\nPublic key: ${p.public_key}\nAllowed origins: ${p.allowed_origins.join(", ") || "any"} · ingest enabled: ${p.ingest_enabled} · raw IPs stored: ${p.store_ip ?? false}`);
|
|
442
|
+
});
|
|
443
|
+
tool("create_project", {
|
|
444
|
+
title: "Create project",
|
|
445
|
+
description: "Create a project (one per app, site or service). Returns the project's public key (oops_pub_...) to put in the SDK tag or the X-Project-Key header of ingest calls. Errors: limit_reached (which=projects) when the plan's project quota is used up, key_taken when the key already exists in this account.",
|
|
446
|
+
inputSchema: {
|
|
447
|
+
name: z.string().min(1).max(100).describe("Human-friendly project name, e.g. the app's name."),
|
|
448
|
+
key: z
|
|
449
|
+
.string()
|
|
450
|
+
.regex(PROJECT_KEY_RE)
|
|
451
|
+
.optional()
|
|
452
|
+
.describe("Slug, unique per account (^[a-z0-9][a-z0-9-]{0,62}$). Defaults to a slug of the name."),
|
|
453
|
+
description: z.string().max(1000).optional(),
|
|
454
|
+
url: z.url().max(2048).optional().describe("The app's public URL."),
|
|
455
|
+
timezone: z.string().max(64).optional().describe("IANA time zone used to bucket days (default UTC)."),
|
|
456
|
+
allowed_origins: z
|
|
457
|
+
.array(z.string().max(253))
|
|
458
|
+
.max(20)
|
|
459
|
+
.optional()
|
|
460
|
+
.describe("Hostnames allowed to report events from the browser (example.com, *.example.com). Empty = any."),
|
|
461
|
+
},
|
|
462
|
+
outputSchema: projectObject,
|
|
463
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
464
|
+
}, async (args) => {
|
|
465
|
+
const p = await client.createProject(args);
|
|
466
|
+
return structured({ ...p }, `Created project ${describeProject(p)}.\nPublic key: ${p.public_key}\nSDK tag: <script defer src="https://oops.3m4.net/o.js" data-key="${p.public_key}"></script>\nIngest: POST /api/ingest with header X-Project-Key: ${p.public_key}`);
|
|
467
|
+
});
|
|
468
|
+
tool("update_project", {
|
|
469
|
+
title: "Update project",
|
|
470
|
+
description: "Change a project's name, description, URL, time zone, allowed origins or switches (ingest_enabled=false pauses intake without touching the snippet, store_ip keeps raw client IPs on events). Only the fields you pass are changed; the key cannot change. Errors: invalid_timezone, invalid_origins.",
|
|
471
|
+
inputSchema: {
|
|
472
|
+
project_id: projectId,
|
|
473
|
+
name: z.string().min(1).max(100).optional(),
|
|
474
|
+
description: z.string().max(1000).optional(),
|
|
475
|
+
url: z.url().max(2048).optional(),
|
|
476
|
+
timezone: z.string().max(64).optional(),
|
|
477
|
+
allowed_origins: z.array(z.string().max(253)).max(20).optional(),
|
|
478
|
+
ingest_enabled: z.boolean().optional().describe("false pauses ingest: events are acknowledged (202) but dropped with reason=paused."),
|
|
479
|
+
store_ip: z.boolean().optional().describe("true keeps the raw client IP on each event (default false: only a daily hash)."),
|
|
480
|
+
},
|
|
481
|
+
outputSchema: projectObject,
|
|
482
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
483
|
+
}, async ({ project_id, ...patch }) => {
|
|
484
|
+
const p = await client.updateProject(project_id, patch);
|
|
485
|
+
return structured({ ...p }, `Updated project ${describeProject(p)}.`);
|
|
486
|
+
});
|
|
487
|
+
tool("rotate_public_key", {
|
|
488
|
+
title: "Rotate project public key",
|
|
489
|
+
description: "Issue a new public key (oops_pub_...) for a project. The OLD KEY STOPS WORKING IMMEDIATELY: every SDK tag and server-side reporter must be updated, so only do this when the user asks to rotate the key (e.g. abuse), and show them the new key.",
|
|
490
|
+
inputSchema: { project_id: projectId },
|
|
491
|
+
outputSchema: projectObject,
|
|
492
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
493
|
+
}, async ({ project_id }) => {
|
|
494
|
+
const p = await client.rotatePublicKey(project_id);
|
|
495
|
+
return structured({ ...p }, `Rotated. New public key for "${p.name}": ${p.public_key}. The previous key is now invalid; update the SDK tag and any X-Project-Key header.`);
|
|
496
|
+
});
|
|
497
|
+
tool("delete_project", {
|
|
498
|
+
title: "Delete project",
|
|
499
|
+
description: "Permanently delete a project with ALL its issues and events. IRREVERSIBLE: its public key stops working. The user must confirm: when the client supports it you will be prompted; otherwise ask the user explicitly and call again with confirm=true. Prefer update_project with ingest_enabled=false to pause instead.",
|
|
500
|
+
inputSchema: { project_id: projectId, confirm: confirmFlag },
|
|
501
|
+
outputSchema: z.looseObject({ deleted: z.boolean(), id: z.string(), key: z.string(), name: z.string() }),
|
|
502
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
503
|
+
}, async ({ project_id, confirm }) => {
|
|
504
|
+
const p = await client.getProject(project_id);
|
|
505
|
+
const n = p.counts ? `${p.counts.open_issues} open issue(s) and ${p.counts.events_30d} event(s) in the last 30 days` : "all its issues and events";
|
|
506
|
+
const refusal = await confirmOrRefuse(confirm, `Delete project "${p.name}" (${p.key}) with ${n} and all its history?`, "delete_project", { deleted: false, id: p.id, key: p.key, name: p.name });
|
|
507
|
+
if (refusal)
|
|
508
|
+
return refusal;
|
|
509
|
+
await client.deleteProject(project_id);
|
|
510
|
+
return structured({ deleted: true, id: p.id, key: p.key, name: p.name }, `Deleted project "${p.name}" (${p.key}).`);
|
|
511
|
+
});
|
|
512
|
+
// ------------------------------------------------------------------------- //
|
|
513
|
+
// Issues and events
|
|
514
|
+
// ------------------------------------------------------------------------- //
|
|
515
|
+
tool("list_issues", {
|
|
516
|
+
title: "List issues",
|
|
517
|
+
description: "Paged list of a project's issues (grouped errors), each with #number, status, level, title, culprit, event and affected-user counts, first / last seen, environments, releases and whether it regressed. Default: open issues seen in the last 30 days, most recent first. Filter by status (open | resolved | ignored | all), level, environment, release, q (substring of title / culprit / type) and period (by last_seen); sort by last_seen, first_seen, events or users. Use sort=users to find what hurts most people.",
|
|
518
|
+
inputSchema: {
|
|
519
|
+
project_id: projectId,
|
|
520
|
+
status: z.enum(STATUS_FILTERS).optional().describe("open (default) | resolved | ignored | all"),
|
|
521
|
+
...eventFilters,
|
|
522
|
+
q: z.string().max(200).optional().describe("Case-insensitive substring of the issue title, culprit or type."),
|
|
523
|
+
period: z.enum(ISSUE_PERIODS).optional().describe("Issues whose last_seen falls in: 24h | 7d | 30d (default) | 90d | all"),
|
|
524
|
+
sort: z.enum(ISSUE_SORTS).optional().describe("last_seen (default) | first_seen | events | users"),
|
|
525
|
+
page: pageInput,
|
|
526
|
+
per_page: perPageInput,
|
|
527
|
+
},
|
|
528
|
+
outputSchema: pageOf(issueObject),
|
|
529
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
530
|
+
}, async ({ project_id, ...params }) => {
|
|
531
|
+
const page = await client.listIssues(project_id, params);
|
|
532
|
+
return structured({ ...page }, `${pageSummary("issue(s)", page)}\n${page.items.map(describeIssue).join("\n") || "(none)"}`);
|
|
533
|
+
});
|
|
534
|
+
tool("get_issue", {
|
|
535
|
+
title: "Get issue",
|
|
536
|
+
description: "One issue in full, by uuid or #number, including its latest event (message, stack frames top-first, URL, release, environment, browser / OS, tags, extra, breadcrumbs, user). The first call to make when the user names an issue (\"#12\", \"issue 12\").",
|
|
537
|
+
inputSchema: { project_id: projectId, issue: issueRef },
|
|
538
|
+
outputSchema: issueObject,
|
|
539
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
540
|
+
}, async ({ project_id, issue }) => {
|
|
541
|
+
const ref = parseIssueRef(issue);
|
|
542
|
+
const i = await client.getIssue(project_id, ref);
|
|
543
|
+
const latest = i.latest_event ? `\nLatest event:\n${describeEvent(i.latest_event, true)}` : "";
|
|
544
|
+
return structured({ ...i }, `${describeIssue(i)}${latest}`);
|
|
545
|
+
});
|
|
546
|
+
tool("list_issue_events", {
|
|
547
|
+
title: "List issue events",
|
|
548
|
+
description: "The occurrences of one issue (uuid or #number), newest first, paged: time, level, message, URL, release, environment, browser / OS / device, user and top frame. Use it to see when an error started, which releases and users it hit, or to pick an event id for get_event.",
|
|
549
|
+
inputSchema: { project_id: projectId, issue: issueRef, page: pageInput, per_page: perPageInput },
|
|
550
|
+
outputSchema: pageOf(eventObject),
|
|
551
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
552
|
+
}, async ({ project_id, issue, page, per_page }) => {
|
|
553
|
+
const ref = parseIssueRef(issue);
|
|
554
|
+
const result = await client.listIssueEvents(project_id, ref, { page, per_page });
|
|
555
|
+
return structured({ ...result }, `Issue ${formatIssueRef(ref)}: ${pageSummary("event(s)", result)}\n${result.items.map((e) => describeEvent(e)).join("\n") || "(none)"}`);
|
|
556
|
+
});
|
|
557
|
+
tool("get_event", {
|
|
558
|
+
title: "Get event",
|
|
559
|
+
description: "One error event in full by id: type, message, stack frames (top first, in-app frames marked), raw stack when frames could not be parsed, URL / path, release, environment, platform, sdk, browser / OS / device, user, tags, extra, breadcrumbs (what happened before the error) and source (sdk | api).",
|
|
560
|
+
inputSchema: {
|
|
561
|
+
project_id: projectId,
|
|
562
|
+
event_id: z.string().min(1).max(32).describe("The event id (a numeric string), from list_issue_events, search_events or get_issue.latest_event_id."),
|
|
563
|
+
},
|
|
564
|
+
outputSchema: eventObject,
|
|
565
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
566
|
+
}, async ({ project_id, event_id }) => {
|
|
567
|
+
const e = await client.getEvent(project_id, event_id);
|
|
568
|
+
return structured({ ...e }, describeEvent(e, true));
|
|
569
|
+
});
|
|
570
|
+
tool("search_events", {
|
|
571
|
+
title: "Search events",
|
|
572
|
+
description: "Raw error events across a project, newest first, paged. Filter by level, environment, release, q (substring of the message), issue (uuid or #number) and period. Use it to find a specific message, to see everything a release produced, or to inspect what else happened around an error.",
|
|
573
|
+
inputSchema: {
|
|
574
|
+
project_id: projectId,
|
|
575
|
+
...eventFilters,
|
|
576
|
+
q: z.string().max(200).optional().describe("Case-insensitive substring of the event message."),
|
|
577
|
+
issue: issueRef.optional().describe('Restrict to one issue: uuid, number or "#12".'),
|
|
578
|
+
period: z.enum(ISSUE_PERIODS).optional().describe("24h | 7d | 30d (default) | 90d | all"),
|
|
579
|
+
page: pageInput,
|
|
580
|
+
per_page: perPageInput,
|
|
581
|
+
},
|
|
582
|
+
outputSchema: pageOf(eventObject),
|
|
583
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
584
|
+
}, async ({ project_id, issue, ...params }) => {
|
|
585
|
+
const result = await client.listEvents(project_id, { ...params, issue: issue === undefined ? undefined : parseIssueRef(issue) });
|
|
586
|
+
return structured({ ...result }, `${pageSummary("event(s)", result)}\n${result.items.map((e) => describeEvent(e)).join("\n") || "(none)"}`);
|
|
587
|
+
});
|
|
588
|
+
tool("update_issue", {
|
|
589
|
+
title: "Update issue status",
|
|
590
|
+
description: "Set an issue's status: resolved (fixed; it reopens by itself and is flagged as regressed if the error comes back), ignored (known noise; stays ignored but keeps counting) or open (reopen). Accepts the uuid or #number. Audited as issue.update.",
|
|
591
|
+
inputSchema: { project_id: projectId, issue: issueRef, status: statusEnum },
|
|
592
|
+
outputSchema: issueObject,
|
|
593
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
594
|
+
}, async ({ project_id, issue, status }) => {
|
|
595
|
+
const ref = parseIssueRef(issue);
|
|
596
|
+
const i = await client.updateIssue(project_id, ref, status);
|
|
597
|
+
const verb = status === "resolved" ? "Resolved" : status === "ignored" ? "Ignored" : "Reopened";
|
|
598
|
+
return structured({ ...i }, `${verb} issue ${describeIssue(i)}`);
|
|
599
|
+
});
|
|
600
|
+
tool("bulk_update_issues", {
|
|
601
|
+
title: "Bulk update issues",
|
|
602
|
+
description: "Set the same status on up to 100 issues at once (uuids or #numbers), e.g. resolve everything from an old release or ignore a batch of noisy warnings. Returns how many were updated. Audited as issues.bulk_update.",
|
|
603
|
+
inputSchema: {
|
|
604
|
+
project_id: projectId,
|
|
605
|
+
issues: z.array(issueRef).min(1).max(100).describe("Issue uuids and/or numbers."),
|
|
606
|
+
status: statusEnum,
|
|
607
|
+
},
|
|
608
|
+
outputSchema: z.looseObject({ updated: z.number(), requested: z.number(), status: z.string() }),
|
|
609
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
610
|
+
}, async ({ project_id, issues, status }) => {
|
|
611
|
+
const ids = issues.map(parseIssueRef);
|
|
612
|
+
const r = await client.bulkUpdateIssues(project_id, ids, status);
|
|
613
|
+
return structured({ ...r, requested: ids.length, status }, `${r.updated} of ${ids.length} issue(s) set to ${status}.`);
|
|
614
|
+
});
|
|
615
|
+
// ------------------------------------------------------------------------- //
|
|
616
|
+
// Stats
|
|
617
|
+
// ------------------------------------------------------------------------- //
|
|
618
|
+
tool("get_stats", {
|
|
619
|
+
title: "Get stats",
|
|
620
|
+
description: "A project's error numbers over a range in one call: totals (events, affected users, new issues, open issues), the previous period for deltas, a time series (hour / day / month buckets in the project's time zone) and the top-10 breakdowns by level, environment, release, browser, os, device and error type. Pass period (today | yesterday | 24h | 7d | 30d | 90d | 12mo) or from/to dates, and optional level / environment / release filters. Use it for \"how is the app doing?\", spike detection and release comparisons.",
|
|
621
|
+
inputSchema: {
|
|
622
|
+
project_id: projectId,
|
|
623
|
+
period: z.enum(STATS_PERIODS).optional().describe("Preset range in the project's time zone (default 7d). Ignored when from/to are given."),
|
|
624
|
+
from: z.string().regex(DATE_RE).optional().describe("Start date YYYY-MM-DD (inclusive, project time zone)."),
|
|
625
|
+
to: z.string().regex(DATE_RE).optional().describe("End date YYYY-MM-DD (inclusive, project time zone)."),
|
|
626
|
+
interval: z.enum(INTERVALS).optional().describe("Bucket size for the series (auto by range when omitted)."),
|
|
627
|
+
...eventFilters,
|
|
628
|
+
},
|
|
629
|
+
outputSchema: statsObject,
|
|
630
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
631
|
+
}, async ({ project_id, ...params }) => {
|
|
632
|
+
const s = await client.stats(project_id, params);
|
|
633
|
+
return structured({ ...s }, describeStats(s));
|
|
634
|
+
});
|
|
635
|
+
tool("get_live", {
|
|
636
|
+
title: "Live error count",
|
|
637
|
+
description: "How many error events the project received in the last few minutes (the server's live window). Cheap; use it to check whether an incident is still ongoing or a deploy just started failing.",
|
|
638
|
+
inputSchema: { project_id: projectId },
|
|
639
|
+
outputSchema: liveObject,
|
|
640
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
641
|
+
}, async ({ project_id }) => {
|
|
642
|
+
const l = await client.live(project_id);
|
|
643
|
+
return structured({ ...l }, `${l.events} event(s) in the last ${l.window_min} minute(s).`);
|
|
644
|
+
});
|
|
645
|
+
tool("purge_events", {
|
|
646
|
+
title: "Purge events",
|
|
647
|
+
description: "Delete ALL stored events and issues of a project. The project and its public key survive and keep receiving new events. IRREVERSIBLE. The user must confirm: when the client supports it you will be prompted; otherwise ask the user explicitly and call again with confirm=true.",
|
|
648
|
+
inputSchema: { project_id: projectId, confirm: confirmFlag },
|
|
649
|
+
outputSchema: z.looseObject({ deleted_events: z.number(), deleted_issues: z.number(), project_id: z.string() }),
|
|
650
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
651
|
+
}, async ({ project_id, confirm }) => {
|
|
652
|
+
const p = await client.getProject(project_id);
|
|
653
|
+
const refusal = await confirmOrRefuse(confirm, `Purge ALL events and issues of project "${p.name}" (${p.key})?`, "purge_events", { deleted_events: 0, deleted_issues: 0, project_id: p.id });
|
|
654
|
+
if (refusal)
|
|
655
|
+
return refusal;
|
|
656
|
+
const r = await client.purgeEvents(project_id);
|
|
657
|
+
return structured({ ...r, project_id: p.id }, `Deleted ${r.deleted_events} event(s) and ${r.deleted_issues} issue(s) of "${p.name}".`);
|
|
658
|
+
});
|
|
659
|
+
// ------------------------------------------------------------------------- //
|
|
660
|
+
// Account
|
|
661
|
+
// ------------------------------------------------------------------------- //
|
|
662
|
+
tool("get_usage", {
|
|
663
|
+
title: "Usage and limits",
|
|
664
|
+
description: "This month's received error events (including the ones dropped past the budget), and project / API key counts against the plan's limits (-1 = unlimited), plus retention. Call this when a tool returns limit_reached, when report_error comes back with reason=limit, or when the dashboard reports dropped events.",
|
|
665
|
+
inputSchema: {},
|
|
666
|
+
outputSchema: usageObject,
|
|
667
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
668
|
+
}, async () => {
|
|
669
|
+
const u = await client.usage();
|
|
670
|
+
const lim = u.limits;
|
|
671
|
+
const fmt = (n) => (n === -1 ? "unlimited" : String(n ?? "?"));
|
|
672
|
+
return structured({ ...u }, `Period ${u.period_start} → ${u.period_end}: ${u.events}/${fmt(lim.events_per_month)} events (${u.events_dropped} dropped over budget), ${u.counts.projects}/${fmt(lim.projects)} projects, ${u.counts.api_keys}/${fmt(lim.api_keys)} API keys, retention ${fmt(lim.retention_days)} days.`);
|
|
673
|
+
});
|
|
674
|
+
tool("get_audit_log", {
|
|
675
|
+
title: "Audit log",
|
|
676
|
+
description: "The account's audit trail, newest first, paged: who did what, when, and whether it was allowed. Actions: project.create|update|delete|rotate_key, issue.update, issues.bulk_update, events.purge, apikey.create|revoke, auth.denied. Filter by action, actor_type (user | api_key), outcome (ok | denied) and since (ISO timestamp).",
|
|
677
|
+
inputSchema: {
|
|
678
|
+
action: z.string().max(60).optional().describe("Exact action, e.g. issue.update, events.purge, auth.denied."),
|
|
679
|
+
actor_type: z.string().max(30).optional().describe("user | api_key"),
|
|
680
|
+
outcome: z.string().max(30).optional().describe("ok | denied"),
|
|
681
|
+
since: z.string().max(40).optional().describe("Only entries at or after this ISO 8601 timestamp."),
|
|
682
|
+
page: pageInput,
|
|
683
|
+
per_page: perPageInput,
|
|
684
|
+
},
|
|
685
|
+
outputSchema: pageOf(auditEventObject),
|
|
686
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
687
|
+
}, async (args) => {
|
|
688
|
+
const result = await client.auditLog(args);
|
|
689
|
+
const lines = result.items.map((e) => `${e.ts} ${e.outcome.padEnd(6)} ${e.action} by ${e.actor_type}${e.actor_label ? `:${e.actor_label}` : ""}${e.target_id ? ` → ${e.target_type}:${e.target_id}` : ""}`);
|
|
690
|
+
return structured({ ...result }, `${pageSummary("audit event(s)", result)}\n${lines.join("\n") || "(none)"}`);
|
|
691
|
+
});
|
|
692
|
+
// ------------------------------------------------------------------------- //
|
|
693
|
+
// Reporting (the agent as an error source)
|
|
694
|
+
// ------------------------------------------------------------------------- //
|
|
695
|
+
tool("report_error", {
|
|
696
|
+
title: "Report an error",
|
|
697
|
+
description: "Report an error (or a message) into a project you can see, so your own failures show up as issues: it fetches the project's public key and posts one event to the ingest API. Give at least type or message; add stack (raw string, parsed server-side) or frames, url, release, environment (default production), platform (default agent), user, tags, extra, breadcrumbs or an explicit fingerprint to control grouping. Returns the event id and the issue #number it landed in, or ok=false with a drop reason (paused | origin | limit) that is not an error on your side.",
|
|
698
|
+
inputSchema: {
|
|
699
|
+
project_id: projectId,
|
|
700
|
+
type: z.string().max(200).optional().describe("Exception class / error name, e.g. TimeoutError. Required unless message is set."),
|
|
701
|
+
message: z.string().max(4096).optional().describe("Error message. Required unless type is set."),
|
|
702
|
+
level: levelEnum.default("error"),
|
|
703
|
+
stack: z.string().max(16_000).optional().describe("Raw stack string (V8, Firefox/Safari or Python traceback format); parsed server-side when frames is absent."),
|
|
704
|
+
frames: z
|
|
705
|
+
.array(z.object({
|
|
706
|
+
filename: z.string().max(1024).optional(),
|
|
707
|
+
function: z.string().max(255).optional(),
|
|
708
|
+
lineno: z.number().int().optional(),
|
|
709
|
+
colno: z.number().int().optional(),
|
|
710
|
+
in_app: z.boolean().default(true),
|
|
711
|
+
}))
|
|
712
|
+
.max(50)
|
|
713
|
+
.optional()
|
|
714
|
+
.describe("Parsed frames, top of the stack first (the frame that threw is frames[0])."),
|
|
715
|
+
url: z.string().max(2048).optional().describe("Where it happened (URL or resource); query string and fragment are dropped on storage."),
|
|
716
|
+
release: z.string().max(100).optional(),
|
|
717
|
+
environment: z.string().max(40).optional().describe("Default production."),
|
|
718
|
+
platform: z.string().max(30).default("agent").describe("The runtime reporting the error: agent (default), python, node, ..."),
|
|
719
|
+
user: z.object({ id: z.string().max(120).optional(), email: z.string().max(255).optional(), name: z.string().max(120).optional() }).optional(),
|
|
720
|
+
tags: z.record(z.string().max(40), z.string().max(200)).optional().describe("Up to 20 short key/value labels, e.g. {\"task\": \"nightly-sync\"}."),
|
|
721
|
+
extra: z.record(z.string(), z.unknown()).optional().describe("Any JSON context (≤ 8 KB). Secret-looking keys are scrubbed server-side."),
|
|
722
|
+
breadcrumbs: z
|
|
723
|
+
.array(z.object({ ts: z.string().optional(), category: z.string().max(40), message: z.string().max(500).optional(), level: z.string().max(10).optional(), data: z.record(z.string(), z.unknown()).optional() }))
|
|
724
|
+
.max(30)
|
|
725
|
+
.optional()
|
|
726
|
+
.describe("What happened before the error, oldest first (last 30 kept)."),
|
|
727
|
+
fingerprint: z.array(z.string().max(100)).max(5).optional().describe("Explicit grouping key; events with the same fingerprint share one issue."),
|
|
728
|
+
ts: z.string().max(40).optional().describe("ISO 8601 time of the error (server time when omitted or skewed > 5 min)."),
|
|
729
|
+
},
|
|
730
|
+
outputSchema: ingestResultObject,
|
|
731
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
732
|
+
}, async ({ project_id, ...event }) => {
|
|
733
|
+
if (!event.type && !event.message)
|
|
734
|
+
return failure("invalid_event", "Give at least one of type or message.");
|
|
735
|
+
const p = await client.getProject(project_id);
|
|
736
|
+
const r = await client.ingest(p.public_key, { ...event, sdk: `oops-errors-mcp/${VERSION}` });
|
|
737
|
+
if (r.ok) {
|
|
738
|
+
return structured({ ...r, project_id: p.id }, `Reported to "${p.name}": issue #${r.issue_number} (id ${r.issue_id}), event ${r.event_id}. Use get_issue with issue=${r.issue_number} to see it.`);
|
|
739
|
+
}
|
|
740
|
+
const why = {
|
|
741
|
+
paused: "the project's ingest is paused (update_project ingest_enabled=true to resume)",
|
|
742
|
+
origin: "the project restricts allowed_origins and this origin is not in the list",
|
|
743
|
+
limit: "the account is over its monthly event budget (see get_usage)",
|
|
744
|
+
};
|
|
745
|
+
return structured({ ...r, project_id: p.id }, `Not stored: ${why[r.reason] ?? r.reason}. The event was acknowledged (202) but dropped.`);
|
|
746
|
+
});
|
|
747
|
+
// ------------------------------------------------------------------------- //
|
|
748
|
+
// Resources (read-only context the host can attach without a tool call)
|
|
749
|
+
// ------------------------------------------------------------------------- //
|
|
750
|
+
if (scopes.has("projects:read")) {
|
|
751
|
+
server.registerResource("projects", "oops://projects", {
|
|
752
|
+
title: "Projects",
|
|
753
|
+
description: "Every project of the account with its open-issue count, recent event counts and last event.",
|
|
754
|
+
mimeType: "application/json",
|
|
755
|
+
}, async (uri) => ({
|
|
756
|
+
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(await client.listProjects(), null, 2) }],
|
|
757
|
+
}));
|
|
758
|
+
server.registerResource("project-summary", new ResourceTemplate("oops://projects/{id}/summary", { list: undefined }), {
|
|
759
|
+
title: "Project summary",
|
|
760
|
+
description: "Open issues, events in the last 24 h and 30 days, affected users in 30 days and the last event time of one project (by project id).",
|
|
761
|
+
mimeType: "application/json",
|
|
762
|
+
}, async (uri, { id }) => ({
|
|
763
|
+
contents: [
|
|
764
|
+
{
|
|
765
|
+
uri: uri.href,
|
|
766
|
+
mimeType: "application/json",
|
|
767
|
+
text: JSON.stringify(await client.summary(String(id)), null, 2),
|
|
768
|
+
},
|
|
769
|
+
],
|
|
770
|
+
}));
|
|
771
|
+
}
|
|
772
|
+
// ------------------------------------------------------------------------- //
|
|
773
|
+
// Prompts (reusable workflows the user can pick from the host UI)
|
|
774
|
+
// ------------------------------------------------------------------------- //
|
|
775
|
+
if (scopes.has("issues:read")) {
|
|
776
|
+
server.registerPrompt("triage", {
|
|
777
|
+
title: "Triage a project's errors",
|
|
778
|
+
description: "Summarise the open issues of a project over a period (new vs regressed, most users affected, by environment and release) and propose what to fix, resolve or ignore first.",
|
|
779
|
+
argsSchema: {
|
|
780
|
+
project: z.string().describe("Project id (UUID), key or name."),
|
|
781
|
+
period: z.string().optional().describe("24h | 7d (default) | 30d | 90d | all"),
|
|
782
|
+
},
|
|
783
|
+
}, ({ project, period }) => {
|
|
784
|
+
const p = period?.trim() || "7d";
|
|
785
|
+
const statsPeriod = p === "all" ? "90d" : p;
|
|
786
|
+
return {
|
|
787
|
+
messages: [
|
|
788
|
+
{
|
|
789
|
+
role: "user",
|
|
790
|
+
content: {
|
|
791
|
+
type: "text",
|
|
792
|
+
text: `Triage the errors of project "${project}" over the last ${p}.
|
|
793
|
+
|
|
794
|
+
1. If "${project}" is not a UUID, call list_projects and pick the project whose key or name matches; use its id below.
|
|
795
|
+
2. Call list_issues with status="open", period="${p}", sort="users", per_page=50, and again with sort="first_seen" if the first page is full, so you see both the most harmful and the newest issues.
|
|
796
|
+
3. Call get_stats with period="${statsPeriod}" for the totals vs the previous period and the breakdowns by environment and release.
|
|
797
|
+
4. For the top 3 issues by user_count call get_issue (issue=#number) to read the latest event's stack and breadcrumbs.
|
|
798
|
+
|
|
799
|
+
Then write a triage report:
|
|
800
|
+
- Headline: events, affected users and new issues over the period vs the previous period, in one sentence.
|
|
801
|
+
- NEW issues (first_seen inside the period) vs REGRESSED issues (regressed_at set) vs long-running ones, as short lists with #number, title, users and events.
|
|
802
|
+
- Which environments and releases are hurting most (from the breakdowns and each issue's environments / releases).
|
|
803
|
+
- Recommended order of work: what to FIX first (most users, regressions, fatal level), what looks already fixed and can be RESOLVED (last_seen old, release no longer deployed), and what is noise that can be IGNORED (info / warning level, third-party scripts, extensions), each with the #numbers.
|
|
804
|
+
Keep it under 300 words, plain language. Do not change any issue status yourself unless the user asks: propose, then act with update_issue or bulk_update_issues only after they confirm.`,
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
],
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
return server;
|
|
812
|
+
}
|
|
813
|
+
//# sourceMappingURL=server.js.map
|