@getsnare/mcp 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 +100 -0
- package/dist/bin/snare-mcp.d.ts +2 -0
- package/dist/bin/snare-mcp.js +48 -0
- package/dist/src/client.d.ts +37 -0
- package/dist/src/client.js +137 -0
- package/dist/src/config.d.ts +84 -0
- package/dist/src/config.js +103 -0
- package/dist/src/format.d.ts +126 -0
- package/dist/src/format.js +213 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.js +7 -0
- package/dist/src/prompts.d.ts +18 -0
- package/dist/src/prompts.js +71 -0
- package/dist/src/registry.d.ts +86 -0
- package/dist/src/registry.js +62 -0
- package/dist/src/render.d.ts +101 -0
- package/dist/src/render.js +111 -0
- package/dist/src/scopes.d.ts +20 -0
- package/dist/src/scopes.js +44 -0
- package/dist/src/server.d.ts +44 -0
- package/dist/src/server.js +241 -0
- package/dist/src/tools/events.d.ts +2 -0
- package/dist/src/tools/events.js +215 -0
- package/dist/src/tools/index.d.ts +11 -0
- package/dist/src/tools/index.js +25 -0
- package/dist/src/tools/issues.d.ts +2 -0
- package/dist/src/tools/issues.js +310 -0
- package/dist/src/tools/local.d.ts +2 -0
- package/dist/src/tools/local.js +180 -0
- package/dist/src/tools/memory.d.ts +12 -0
- package/dist/src/tools/memory.js +129 -0
- package/dist/src/tools/snares.d.ts +2 -0
- package/dist/src/tools/snares.js +197 -0
- package/dist/src/tools/workspace.d.ts +12 -0
- package/dist/src/tools/workspace.js +210 -0
- package/dist/src/toolsets.d.ts +17 -0
- package/dist/src/toolsets.js +42 -0
- package/package.json +53 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DASH, bullets, clip, count, duration, facts, label, quantity, stamp, toolText, when } from "../format.js";
|
|
3
|
+
import { readOnly, tool } from "../registry.js";
|
|
4
|
+
/**
|
|
5
|
+
* Occurrences, evidence, traces and the people who hit them.
|
|
6
|
+
*
|
|
7
|
+
* THE VOCABULARY IS FIXED AND THESE TOOLS ARE WHERE IT MATTERS MOST. An ISSUE
|
|
8
|
+
* is the tracked thing. An EVENT is one occurrence of it. A TRACE is the set of
|
|
9
|
+
* events sharing an id, which is one request or one page load. The word "error"
|
|
10
|
+
* does not appear in any name, description or field here, and a test in this
|
|
11
|
+
* package fails the build if it is reintroduced.
|
|
12
|
+
*/
|
|
13
|
+
const issueRef = z.string().describe('The issue, as its key ("ACME-142") or its id.');
|
|
14
|
+
export const eventTools = [
|
|
15
|
+
tool({
|
|
16
|
+
name: "list_issue_events",
|
|
17
|
+
title: "List an issue's occurrences",
|
|
18
|
+
description: "The individual times this issue happened, newest first, one line each. Use it to see WHEN something " +
|
|
19
|
+
"started, whether it is still happening, and which releases it appears in. One line per occurrence and no " +
|
|
20
|
+
"stack traces — for the stack, breadcrumbs and console of one occurrence, call get_event with an id from " +
|
|
21
|
+
"here. A row ending in a replay id has a session recording; get_session_replay takes that id. THIS IS THE " +
|
|
22
|
+
"ONLY PLACE A REPLAY ID COMES FROM.",
|
|
23
|
+
scope: "issues:read",
|
|
24
|
+
toolset: "events",
|
|
25
|
+
input: {
|
|
26
|
+
issue: issueRef,
|
|
27
|
+
release: z.string().optional().describe("Only occurrences from this build."),
|
|
28
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
29
|
+
cursor: z.string().optional(),
|
|
30
|
+
},
|
|
31
|
+
annotations: readOnly(),
|
|
32
|
+
async run(args, { client }) {
|
|
33
|
+
const { issue, ...query } = args;
|
|
34
|
+
const data = await client.get(`/issues/${encodeURIComponent(issue)}/events`, query);
|
|
35
|
+
if (data.events.length === 0)
|
|
36
|
+
return "No occurrences recorded for that issue.";
|
|
37
|
+
// EVERY IDENTIFIER IS LABELLED WITH WHAT IT IDENTIFIES. A bare cuid at
|
|
38
|
+
// the end of a row is a string of characters, and a real model reading a
|
|
39
|
+
// row that ended in one reported it as the trace id — it was the event
|
|
40
|
+
// id, and the two go to different tools. There are no column headings
|
|
41
|
+
// here to disambiguate them, so the word travels with the value.
|
|
42
|
+
const lines = data.events.map((event) => `${stamp(event.at)} ${event.source} ${event.release ?? DASH} ` +
|
|
43
|
+
`${event.endUserId ? `user ${event.endUserId}` : "anonymous"} event ${event.id}` +
|
|
44
|
+
// Only when there is one. A column of dashes under a heading nobody
|
|
45
|
+
// asked for is worse than the fact being absent.
|
|
46
|
+
(event.replayId ? ` replay ${event.replayId}` : "") +
|
|
47
|
+
(event.traceId ? ` trace ${event.traceId}` : ""));
|
|
48
|
+
return toolText(count(data.events.length, data.total, "occurrence"), lines.join("\n"), data.nextCursor ? `More: call again with cursor "${data.nextCursor}".` : null);
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
tool({
|
|
52
|
+
name: "get_event",
|
|
53
|
+
title: "Get one occurrence in full",
|
|
54
|
+
description: "The evidence for a single occurrence: the exception and its stack with source lines where a sourcemap " +
|
|
55
|
+
"resolved them, the breadcrumbs leading up to it, the request, the console output, the tags and the " +
|
|
56
|
+
"platform. This is what to read before saying anything about a cause — it is the difference between a claim " +
|
|
57
|
+
"and a claim somebody can check.",
|
|
58
|
+
scope: "issues:read",
|
|
59
|
+
toolset: "events",
|
|
60
|
+
input: {
|
|
61
|
+
issue: issueRef,
|
|
62
|
+
eventId: z.string().describe("From list_issue_events."),
|
|
63
|
+
},
|
|
64
|
+
annotations: readOnly(),
|
|
65
|
+
async run(args, { client }) {
|
|
66
|
+
const data = await client.get(`/issues/${encodeURIComponent(args.issue)}/events/${encodeURIComponent(args.eventId)}`);
|
|
67
|
+
const event = data.event;
|
|
68
|
+
const stack = event.exception
|
|
69
|
+
? event.exception.frames
|
|
70
|
+
.map((frame) => {
|
|
71
|
+
const where = `${frame.file ?? DASH}:${frame.line ?? DASH}${frame.column ? `:${frame.column}` : ""}`;
|
|
72
|
+
const inApp = frame.inApp ? "" : " (library)";
|
|
73
|
+
const source = frame.code ? `\n ${frame.code.trim()}` : "";
|
|
74
|
+
return ` at ${frame.function ?? "(anonymous)"} — ${where}${inApp}${source}`;
|
|
75
|
+
})
|
|
76
|
+
.join("\n")
|
|
77
|
+
: null;
|
|
78
|
+
return toolText(event.exception?.type
|
|
79
|
+
? `${event.exception.type}: ${clip(event.exception.value, 200)}`
|
|
80
|
+
: clip(event.title, 200), facts([
|
|
81
|
+
["When", when(event.at)],
|
|
82
|
+
["Source", event.source],
|
|
83
|
+
["Release", event.release],
|
|
84
|
+
["Environment", event.environment],
|
|
85
|
+
["Person", event.endUserId],
|
|
86
|
+
["Trace", event.traceId],
|
|
87
|
+
["Session", event.sessionId],
|
|
88
|
+
// Only when it is actually a boolean. `String(undefined)` printed the
|
|
89
|
+
// word "undefined" on every event with no exception attached, which
|
|
90
|
+
// is a fact about JavaScript rather than about the occurrence. Absent
|
|
91
|
+
// means the dash, like every other unknown in this block.
|
|
92
|
+
[
|
|
93
|
+
"Handled",
|
|
94
|
+
typeof event.exception?.handled === "boolean" ? (event.exception.handled ? "Yes" : "No") : null,
|
|
95
|
+
],
|
|
96
|
+
["Request", event.request?.url ? `${event.request.method ?? ""} ${event.request.url}`.trim() : null],
|
|
97
|
+
]), stack ? `Stack\n${stack}` : event.rawStack ? `Stack\n${event.rawStack}` : null, event.breadcrumbs.length > 0
|
|
98
|
+
? `Leading up to it\n${bullets(event.breadcrumbs
|
|
99
|
+
.slice(-20)
|
|
100
|
+
.map((crumb) => `${stamp(crumb.at)} ${crumb.category ?? DASH} ${clip(crumb.message, 160)}`), "")}`
|
|
101
|
+
: null, event.tags.length > 0 ? `Tags\n${event.tags.map(([key, value]) => `${key}=${value}`).join(" ")}` : null, event.consoleLog ? `Console\n${clip(event.consoleLog, 2000)}` : null, event.body ? `Body\n${clip(event.body, 1000)}` : null);
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
tool({
|
|
105
|
+
name: "list_traces",
|
|
106
|
+
title: "List traces",
|
|
107
|
+
description: "One row per request or page load that produced at least one event. A trace answers a question an issue " +
|
|
108
|
+
"cannot: an issue groups the same failure across time, a trace groups DIFFERENT failures within one instant. " +
|
|
109
|
+
"Use it when you suspect several things go wrong together.",
|
|
110
|
+
scope: "issues:read",
|
|
111
|
+
toolset: "events",
|
|
112
|
+
input: {
|
|
113
|
+
projectId: z.string().describe("Required — traces are per project."),
|
|
114
|
+
rangeMinutes: z.number().int().min(1).optional().describe("How far back. Omit for the default window."),
|
|
115
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
116
|
+
},
|
|
117
|
+
annotations: readOnly(),
|
|
118
|
+
async run(args, { client }) {
|
|
119
|
+
const data = await client.get("/traces", args);
|
|
120
|
+
if (data.traces.length === 0)
|
|
121
|
+
return "No traces in that window.";
|
|
122
|
+
return toolText(count(data.traces.length, data.total, "trace"), data.traces
|
|
123
|
+
.map((row) => `${stamp(row.firstSeen)} ${quantity(row.issueCount, "issue")} / ${quantity(row.eventCount, "event")} ` +
|
|
124
|
+
`${row.issueKey ?? DASH} ${clip(row.issueTitle ?? row.url, 80)} trace ${row.traceId}`)
|
|
125
|
+
.join("\n"));
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
tool({
|
|
129
|
+
name: "get_trace",
|
|
130
|
+
title: "Get one trace",
|
|
131
|
+
description: "Everything that happened inside one request, in order, with how long after the first event each one " +
|
|
132
|
+
"occurred. The ordering is usually the diagnosis: knowing the token refresh failed 40ms before the request " +
|
|
133
|
+
"that used it is most of the answer.",
|
|
134
|
+
scope: "issues:read",
|
|
135
|
+
toolset: "events",
|
|
136
|
+
input: {
|
|
137
|
+
traceId: z.string(),
|
|
138
|
+
projectId: z.string().describe("Required — traces are per project."),
|
|
139
|
+
},
|
|
140
|
+
annotations: readOnly(),
|
|
141
|
+
async run(args, { client }) {
|
|
142
|
+
const data = await client.get(`/traces/${encodeURIComponent(args.traceId)}`, { projectId: args.projectId });
|
|
143
|
+
return toolText(`Issues ${data.issueCount} / Events ${data.eventCount}, starting ${when(data.startedAt)}.`, data.spans
|
|
144
|
+
.map((span) => `+${span.offsetMs}ms ${clip(span.title, 90)} ${span.issue ? `→ ${span.issue.key ?? span.issue.id}` : "(not yet grouped)"}`)
|
|
145
|
+
.join("\n"));
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
tool({
|
|
149
|
+
name: "get_session_replay",
|
|
150
|
+
title: "Get a session recording",
|
|
151
|
+
description: "How long a recording is and where a person can watch it. It does NOT return the recording itself — that is " +
|
|
152
|
+
"a stream of DOM mutations, routinely megabytes, and of no use to you. Hand the link to whoever asked.",
|
|
153
|
+
scope: "analytics:read",
|
|
154
|
+
toolset: "events",
|
|
155
|
+
input: { replayId: z.string() },
|
|
156
|
+
annotations: readOnly(),
|
|
157
|
+
async run(args, { client }) {
|
|
158
|
+
const data = await client.get(`/session-replays/${encodeURIComponent(args.replayId)}`);
|
|
159
|
+
return facts([
|
|
160
|
+
["Recorded", when(data.replay.recordedAt)],
|
|
161
|
+
["Length", duration(data.replay.durationSeconds)],
|
|
162
|
+
["Session", data.replay.sessionId],
|
|
163
|
+
["Issue", data.replay.issueKey ?? data.replay.issueId],
|
|
164
|
+
["Watch it", data.replay.url],
|
|
165
|
+
]);
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
tool({
|
|
169
|
+
name: "list_affected_users",
|
|
170
|
+
title: "List the customer's own users",
|
|
171
|
+
description: "People the customer's app has identified, most recently active first. These are NOT members of the Snare " +
|
|
172
|
+
"workspace — for those, use list_members. Use this when somebody asks about a specific customer by name or " +
|
|
173
|
+
"id.",
|
|
174
|
+
scope: "analytics:read",
|
|
175
|
+
toolset: "events",
|
|
176
|
+
input: {
|
|
177
|
+
projectId: z.string().optional(),
|
|
178
|
+
search: z.string().optional().describe("Matches the id and the traits people search by."),
|
|
179
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
180
|
+
cursor: z.string().optional(),
|
|
181
|
+
},
|
|
182
|
+
annotations: readOnly(),
|
|
183
|
+
async run(args, { client }) {
|
|
184
|
+
const data = await client.get("/end-users", args);
|
|
185
|
+
if (data.endUsers.length === 0)
|
|
186
|
+
return "Nobody matches. Identification is opt-in, so a project that never calls identify has none.";
|
|
187
|
+
return toolText(`${quantity(data.endUsers.length, "person", "people")}.`, data.endUsers
|
|
188
|
+
.map((person) => `${person.distinctId} ${quantity(person.issueCount, "issue")} ${quantity(person.sessionCount, "session")} ` +
|
|
189
|
+
`last seen ${stamp(person.lastSeenAt)} person ${person.id}`)
|
|
190
|
+
.join("\n"), data.nextCursor ? `More: call again with cursor "${data.nextCursor}".` : null);
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
tool({
|
|
194
|
+
name: "get_affected_user",
|
|
195
|
+
title: "Get one of the customer's users",
|
|
196
|
+
description: "What one identified person has run into: the issues they hit and how many times each, and their recent " +
|
|
197
|
+
"sessions. Use this when somebody says 'this customer is complaining' — it turns a name into a list of real " +
|
|
198
|
+
"problems, and the counts are theirs rather than the issues' global ones.",
|
|
199
|
+
scope: "analytics:read",
|
|
200
|
+
toolset: "events",
|
|
201
|
+
input: {
|
|
202
|
+
endUserId: z.string().describe("The id from list_affected_users, not the customer's own distinct id."),
|
|
203
|
+
sessionLimit: z.number().int().min(1).max(100).optional(),
|
|
204
|
+
issueLimit: z.number().int().min(1).max(100).optional(),
|
|
205
|
+
},
|
|
206
|
+
annotations: readOnly(),
|
|
207
|
+
async run(args, { client }) {
|
|
208
|
+
const { endUserId, ...query } = args;
|
|
209
|
+
const data = await client.get(`/end-users/${encodeURIComponent(endUserId)}`, query);
|
|
210
|
+
return toolText(`${data.endUser.distinctId} in ${data.endUser.projectName}, last seen ${when(data.endUser.lastSeenAt)}.`, data.issues.length > 0
|
|
211
|
+
? `${count(data.issuesShown, data.issuesTotal, "issue")} they have hit\n${bullets(data.issues.map((row) => `${row.hitCount}× ${row.severity ? label(row.severity) : DASH} ${clip(row.title, 80)} (${row.issueKey ?? row.issueId})`), "")}`
|
|
212
|
+
: "They have not hit any issues.", `${count(data.sessionsShown, data.sessionsTotal, "session")} recorded.`);
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolDef } from "../registry.js";
|
|
2
|
+
/**
|
|
3
|
+
* Every tool this server can offer.
|
|
4
|
+
*
|
|
5
|
+
* ONE FLAT LIST, because everything that reasons about the catalogue — which
|
|
6
|
+
* tools a token unlocks, which sets they belong to, whether any of them
|
|
7
|
+
* contradicts the naming rules — reasons about all of them at once. Grouping
|
|
8
|
+
* lives in each tool's `toolset` field rather than in the shape of this array,
|
|
9
|
+
* so a tool cannot be in two groups or in none.
|
|
10
|
+
*/
|
|
11
|
+
export declare const ALL_TOOLS: ToolDef[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { eventTools } from "./events.js";
|
|
2
|
+
import { issueTools } from "./issues.js";
|
|
3
|
+
import { localTools } from "./local.js";
|
|
4
|
+
import { memoryTools } from "./memory.js";
|
|
5
|
+
import { snareTools } from "./snares.js";
|
|
6
|
+
import { feedbackTools, setupTools, workspaceTools } from "./workspace.js";
|
|
7
|
+
/**
|
|
8
|
+
* Every tool this server can offer.
|
|
9
|
+
*
|
|
10
|
+
* ONE FLAT LIST, because everything that reasons about the catalogue — which
|
|
11
|
+
* tools a token unlocks, which sets they belong to, whether any of them
|
|
12
|
+
* contradicts the naming rules — reasons about all of them at once. Grouping
|
|
13
|
+
* lives in each tool's `toolset` field rather than in the shape of this array,
|
|
14
|
+
* so a tool cannot be in two groups or in none.
|
|
15
|
+
*/
|
|
16
|
+
export const ALL_TOOLS = [
|
|
17
|
+
...issueTools,
|
|
18
|
+
...eventTools,
|
|
19
|
+
...snareTools,
|
|
20
|
+
...localTools,
|
|
21
|
+
...feedbackTools,
|
|
22
|
+
...memoryTools,
|
|
23
|
+
...workspaceTools,
|
|
24
|
+
...setupTools,
|
|
25
|
+
];
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DASH, clip, count, label, quantity, stamp, toolText } from "../format.js";
|
|
3
|
+
import { issueDetail, issueList } from "../render.js";
|
|
4
|
+
import { destructive, idempotentWrite, readOnly, tool, write } from "../registry.js";
|
|
5
|
+
/**
|
|
6
|
+
* Issues: the ones an agent reaches for most.
|
|
7
|
+
*
|
|
8
|
+
* THE DESCRIPTIONS DO REAL WORK HERE. Thirteen tools about the same noun is
|
|
9
|
+
* exactly the shape that makes a model pick the wrong one, so each says when to
|
|
10
|
+
* use it INSTEAD OF its neighbour — list versus search, get versus timeline,
|
|
11
|
+
* update versus assign. That sentence is worth more than any amount of schema.
|
|
12
|
+
*/
|
|
13
|
+
const issueRef = z
|
|
14
|
+
.string()
|
|
15
|
+
.describe('The issue, as its workspace key ("ACME-142") or its id. Keys are what people use.');
|
|
16
|
+
export const issueTools = [
|
|
17
|
+
tool({
|
|
18
|
+
name: "list_issues",
|
|
19
|
+
title: "List issues",
|
|
20
|
+
description: "Browse issues by filter: status, severity, project. Use this to answer questions about a GROUP of issues " +
|
|
21
|
+
'("what is open and critical", "what has Snare fixed this week"). If you already know roughly what the issue ' +
|
|
22
|
+
"is CALLED, use search_issues instead — this tool has no text matching and will make you page through everything.",
|
|
23
|
+
scope: "issues:read",
|
|
24
|
+
toolset: "issues",
|
|
25
|
+
input: {
|
|
26
|
+
projectId: z.string().optional().describe("Narrow to one project. Omit for every project in the workspace."),
|
|
27
|
+
status: z
|
|
28
|
+
.enum(["NEW", "IN_PROGRESS", "FIXED", "REGRESSED", "ATTEMPTED_RECOVERY_DETECTED", "FIXED_MODIFIED"])
|
|
29
|
+
.optional(),
|
|
30
|
+
severity: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]).optional(),
|
|
31
|
+
limit: z.number().int().min(1).max(100).optional().describe("Default 50."),
|
|
32
|
+
cursor: z.string().optional().describe("From a previous call's nextCursor."),
|
|
33
|
+
},
|
|
34
|
+
annotations: readOnly(),
|
|
35
|
+
async run(args, { client }) {
|
|
36
|
+
const data = await client.get("/issues", args);
|
|
37
|
+
const scope = [
|
|
38
|
+
args.severity ? `at ${label(args.severity)} severity` : null,
|
|
39
|
+
args.status ? `with status ${label(args.status)}` : null,
|
|
40
|
+
args.projectId ? "in this project" : "in this workspace",
|
|
41
|
+
]
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.join(" ");
|
|
44
|
+
return toolText(issueList(data.issues, data.total, scope), data.nextCursor ? `More: call again with cursor "${data.nextCursor}".` : null);
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
tool({
|
|
48
|
+
name: "search_issues",
|
|
49
|
+
title: "Search issues",
|
|
50
|
+
description: "Find an issue by words in its title. Use this when you have a name or a phrase and want THE issue " +
|
|
51
|
+
'("the checkout timeout one"). It searches titles only — not comments, stack traces or event bodies — so an ' +
|
|
52
|
+
"empty result means no issue is CALLED that, not that the problem does not exist. For browsing by status or " +
|
|
53
|
+
"severity, use list_issues.",
|
|
54
|
+
scope: "issues:read",
|
|
55
|
+
toolset: "issues",
|
|
56
|
+
input: {
|
|
57
|
+
q: z.string().min(1).describe("Words from the title."),
|
|
58
|
+
projectId: z.string().optional(),
|
|
59
|
+
includeArchived: z
|
|
60
|
+
.boolean()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("Archived and excluded issues are left out unless this is true."),
|
|
63
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
64
|
+
},
|
|
65
|
+
annotations: readOnly(),
|
|
66
|
+
async run(args, { client }) {
|
|
67
|
+
const data = await client.get("/issues/search", args);
|
|
68
|
+
return issueList(data.results, data.total, `matching "${args.q}"`);
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
tool({
|
|
72
|
+
name: "get_issue",
|
|
73
|
+
title: "Get an issue",
|
|
74
|
+
description: "Everything about one issue in a single call: severity, status, who it is assigned to, how many events, how " +
|
|
75
|
+
"many people it has affected, when it started, the latest run and any pull request. Start here for any " +
|
|
76
|
+
"question about a specific issue — you should not need a follow-up call for the basics. For WHO SAID WHAT " +
|
|
77
|
+
"about it, use get_issue_timeline; for individual occurrences, use list_issue_events.",
|
|
78
|
+
scope: "issues:read",
|
|
79
|
+
toolset: "issues",
|
|
80
|
+
input: { issue: issueRef },
|
|
81
|
+
annotations: readOnly(),
|
|
82
|
+
async run(args, { client }) {
|
|
83
|
+
const data = await client.get(`/issues/${encodeURIComponent(args.issue)}`);
|
|
84
|
+
return issueDetail(data.issue);
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
tool({
|
|
88
|
+
name: "get_issue_timeline",
|
|
89
|
+
title: "Get an issue's history",
|
|
90
|
+
description: "Who has worked on this issue and what has happened to it: every comment, every status and severity change " +
|
|
91
|
+
"with who made it, every run and how it ended. Use this to answer 'is somebody already on this', 'why was " +
|
|
92
|
+
"this closed', or 'what did they decide'. Oldest first.",
|
|
93
|
+
scope: "issues:read",
|
|
94
|
+
toolset: "issues",
|
|
95
|
+
input: {
|
|
96
|
+
issue: issueRef,
|
|
97
|
+
limit: z.number().int().min(1).max(100).optional().describe("Default 100. The most recent are kept."),
|
|
98
|
+
},
|
|
99
|
+
annotations: readOnly(),
|
|
100
|
+
async run(args, { client }) {
|
|
101
|
+
const data = await client.get(`/issues/${encodeURIComponent(args.issue)}/timeline`, { limit: args.limit });
|
|
102
|
+
if (data.entries.length === 0)
|
|
103
|
+
return "Nothing has happened to this issue yet.";
|
|
104
|
+
const lines = data.entries.map((entry) => {
|
|
105
|
+
const who = entry.actor.kind === "snare"
|
|
106
|
+
? "Snare"
|
|
107
|
+
: entry.actor.kind === "system"
|
|
108
|
+
? "Snare (automatic)"
|
|
109
|
+
: (entry.actor.name ?? "Someone");
|
|
110
|
+
const via = entry.via ? ` (via ${entry.via})` : "";
|
|
111
|
+
return `${stamp(entry.at)} ${who}${via}: ${clip(entry.text, 400)}`;
|
|
112
|
+
});
|
|
113
|
+
return toolText(count(data.entries.length, data.total, "entry", "entries"), lines.join("\n"),
|
|
114
|
+
// Said rather than left to be inferred. A caller reading the last
|
|
115
|
+
// hundred entries of a busy issue is looking at a window, and reporting
|
|
116
|
+
// "nobody has commented" from it would be wrong.
|
|
117
|
+
data.truncated ? "There is more history than this. Only the most recent entries are shown." : null);
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
tool({
|
|
121
|
+
name: "update_issue",
|
|
122
|
+
title: "Change an issue",
|
|
123
|
+
description: "Set an issue's status, severity or title, or move it to the archive or the exclusion zone. Send only the " +
|
|
124
|
+
"fields you are changing. Setting a value it already has writes nothing and says so, so this is safe to call " +
|
|
125
|
+
"without reading first. To change who is on it, use assign_issue.",
|
|
126
|
+
scope: "issues:write",
|
|
127
|
+
toolset: "issues",
|
|
128
|
+
input: {
|
|
129
|
+
issue: issueRef,
|
|
130
|
+
status: z.enum(["NEW", "IN_PROGRESS", "FIXED", "REGRESSED"]).optional(),
|
|
131
|
+
severity: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]).optional(),
|
|
132
|
+
title: z.string().min(1).max(300).optional(),
|
|
133
|
+
archived: z.boolean().optional().describe("Archiving takes it off the list without saying it is fixed."),
|
|
134
|
+
excluded: z
|
|
135
|
+
.boolean()
|
|
136
|
+
.optional()
|
|
137
|
+
.describe("The exclusion zone is for issues nobody wants Snare to look at. Needs the issues:delete scope."),
|
|
138
|
+
},
|
|
139
|
+
annotations: idempotentWrite(),
|
|
140
|
+
async run(args, { client }) {
|
|
141
|
+
const { issue, ...patch } = args;
|
|
142
|
+
const data = await client.patch(`/issues/${encodeURIComponent(issue)}`, patch);
|
|
143
|
+
return toolText(data.changed.length === 0
|
|
144
|
+
? "Nothing changed — it already had those values."
|
|
145
|
+
: `Changed ${data.changed.join(", ")}.`, issueDetail(data.issue));
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
tool({
|
|
149
|
+
name: "assign_issue",
|
|
150
|
+
title: "Assign an issue",
|
|
151
|
+
description: "Set exactly who is on an issue. This REPLACES the current assignees with the list you send, so include " +
|
|
152
|
+
"everybody who should be on it, and send an empty list to unassign everyone. Accepts email addresses as well " +
|
|
153
|
+
"as user ids. Use list_members to see who is in the workspace.",
|
|
154
|
+
scope: "issues:assign",
|
|
155
|
+
toolset: "issues",
|
|
156
|
+
input: {
|
|
157
|
+
issue: issueRef,
|
|
158
|
+
assignees: z
|
|
159
|
+
.array(z.string())
|
|
160
|
+
.max(50)
|
|
161
|
+
.describe("Emails or user ids. The complete list, not the ones to add."),
|
|
162
|
+
},
|
|
163
|
+
annotations: idempotentWrite(),
|
|
164
|
+
async run(args, { client }) {
|
|
165
|
+
const data = await client.put(`/issues/${encodeURIComponent(args.issue)}/assignees`, { assignees: args.assignees });
|
|
166
|
+
const names = data.issue.assignees.map((person) => person.name ?? person.email);
|
|
167
|
+
return toolText(names.length === 0 ? "Nobody is assigned now." : `Assigned to ${names.join(", ")}.`, issueDetail(data.issue));
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
tool({
|
|
171
|
+
name: "comment_on_issue",
|
|
172
|
+
title: "Comment on an issue",
|
|
173
|
+
description: "Post a comment on an issue's thread. Everyone watching the issue sees it and it appears in the product as " +
|
|
174
|
+
"written by an agent, not by you personally and not by Snare. Use it to record what you found or what you " +
|
|
175
|
+
"did. It is not a way to talk to a running Snare — for that, use send_snare_directive.",
|
|
176
|
+
scope: "issues:comment",
|
|
177
|
+
toolset: "issues",
|
|
178
|
+
input: {
|
|
179
|
+
issue: issueRef,
|
|
180
|
+
body: z.string().min(1).max(10_000),
|
|
181
|
+
replyTo: z.string().optional().describe("The id of a comment to reply to."),
|
|
182
|
+
},
|
|
183
|
+
annotations: write(),
|
|
184
|
+
async run(args, { client }) {
|
|
185
|
+
const data = await client.post(`/issues/${encodeURIComponent(args.issue)}/comments`, { body: args.body, replyTo: args.replyTo });
|
|
186
|
+
return `Posted. Comment ${data.comment.id}.`;
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
tool({
|
|
190
|
+
name: "star_issue",
|
|
191
|
+
title: "Star an issue",
|
|
192
|
+
description: "Flag an issue to come back to. The star is visible to the whole workspace and changes nothing about the " +
|
|
193
|
+
"issue itself — it does not assign, prioritise or notify anybody.",
|
|
194
|
+
scope: "issues:read",
|
|
195
|
+
toolset: "issues",
|
|
196
|
+
input: { issue: issueRef, starred: z.boolean() },
|
|
197
|
+
annotations: idempotentWrite(),
|
|
198
|
+
async run(args, { client }) {
|
|
199
|
+
const data = await client.put(`/issues/${encodeURIComponent(args.issue)}/star`, {
|
|
200
|
+
starred: args.starred,
|
|
201
|
+
});
|
|
202
|
+
return `${data.issue.key} ${args.starred ? "starred" : "unstarred"}.`;
|
|
203
|
+
},
|
|
204
|
+
}),
|
|
205
|
+
tool({
|
|
206
|
+
name: "update_issues",
|
|
207
|
+
title: "Change several issues",
|
|
208
|
+
description: "Apply one change — a status, archiving, or the exclusion zone — to a list of issues at once. Reports which " +
|
|
209
|
+
"ones it applied to and which references it could not find, so check both. Use update_issue for a single one.",
|
|
210
|
+
scope: "issues:write",
|
|
211
|
+
toolset: "issues",
|
|
212
|
+
input: {
|
|
213
|
+
issues: z.array(z.string()).min(1).max(200).describe("Keys or ids."),
|
|
214
|
+
status: z.enum(["NEW", "IN_PROGRESS", "FIXED", "REGRESSED"]).optional(),
|
|
215
|
+
archived: z.boolean().optional(),
|
|
216
|
+
excluded: z.boolean().optional().describe("Needs the issues:delete scope."),
|
|
217
|
+
},
|
|
218
|
+
annotations: idempotentWrite(),
|
|
219
|
+
async run(args, { client }) {
|
|
220
|
+
const data = await client.post("/issues/bulk", args);
|
|
221
|
+
return toolText(`Changed ${data.applied.length} of ${data.total}.`, data.notFound.length > 0 ? `Could not find: ${data.notFound.join(", ")}.` : null);
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
tool({
|
|
225
|
+
name: "merge_issues",
|
|
226
|
+
title: "Merge issues",
|
|
227
|
+
description: "Declare that several issues are the same bug. Their occurrences move onto one surviving issue and the " +
|
|
228
|
+
"others become pointers to it. The oldest survives unless you name one with `into`, because it owns the key " +
|
|
229
|
+
"people have already linked to. THIS CANNOT BE UNDONE — check with list_similar_issues first if you are not " +
|
|
230
|
+
"certain.",
|
|
231
|
+
scope: "issues:group",
|
|
232
|
+
toolset: "issues",
|
|
233
|
+
input: {
|
|
234
|
+
issues: z.array(z.string()).min(2).max(50).describe("Keys or ids. At least two."),
|
|
235
|
+
into: z.string().optional().describe("Which one survives. Must be one of the above. Defaults to the oldest."),
|
|
236
|
+
},
|
|
237
|
+
annotations: destructive(),
|
|
238
|
+
async run(args, { client }) {
|
|
239
|
+
const data = await client.post("/issues/merge", args);
|
|
240
|
+
return toolText(`Merged ${data.mergedIssueIds.length} issues into ${data.issue.key}, moving ${data.movedEventCount} events.`, issueDetail(data.issue));
|
|
241
|
+
},
|
|
242
|
+
}),
|
|
243
|
+
tool({
|
|
244
|
+
name: "split_issue",
|
|
245
|
+
title: "Move an occurrence out of an issue",
|
|
246
|
+
description: "Say that one occurrence does not belong in the issue it was grouped into. It moves to a new issue of its " +
|
|
247
|
+
"own, and Snare's grouping learns from it: the next occurrence that looks like it lands in the new issue " +
|
|
248
|
+
"rather than back here. Use it when you have READ an occurrence and can see it is a different bug. THIS " +
|
|
249
|
+
"CANNOT BE UNDONE by merging them back.",
|
|
250
|
+
scope: "issues:group",
|
|
251
|
+
toolset: "issues",
|
|
252
|
+
input: {
|
|
253
|
+
issue: issueRef,
|
|
254
|
+
eventId: z.string().describe("From list_issue_events."),
|
|
255
|
+
},
|
|
256
|
+
annotations: destructive(),
|
|
257
|
+
async run(args, { client }) {
|
|
258
|
+
const data = await client.post(`/issues/${encodeURIComponent(args.issue)}/split`, { eventId: args.eventId });
|
|
259
|
+
return `Moved that occurrence out of ${data.issue.key} into ${data.newIssue.key}.\n\n${issueDetail(data.newIssue)}`;
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
tool({
|
|
263
|
+
name: "list_similar_issues",
|
|
264
|
+
title: "Find issues like this one",
|
|
265
|
+
description: "Issues that resemble this one, by how alike their occurrences are. Worth calling BEFORE fixing anything: " +
|
|
266
|
+
"the most expensive mistake is fixing a bug that is already fixed, or fixing one instance of a shape that " +
|
|
267
|
+
"appears four times. An empty result means nothing genuinely resembles it, not that the comparison failed.",
|
|
268
|
+
scope: "issues:read",
|
|
269
|
+
toolset: "issues",
|
|
270
|
+
input: { issue: issueRef, limit: z.number().int().min(1).max(20).optional() },
|
|
271
|
+
annotations: readOnly(),
|
|
272
|
+
async run(args, { client }) {
|
|
273
|
+
const data = await client.get(`/issues/${encodeURIComponent(args.issue)}/similar`, { limit: args.limit });
|
|
274
|
+
if (data.similar.length === 0)
|
|
275
|
+
return "Nothing in this workspace closely resembles that issue.";
|
|
276
|
+
return data.similar
|
|
277
|
+
.map((row) => `${row.issueKey ?? row.issueId} ${Math.round(row.similarity * 100)}% alike ${label(row.status)} ${row.severity ? label(row.severity) : DASH} ${clip(row.title, 90)}`)
|
|
278
|
+
.join("\n");
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
tool({
|
|
282
|
+
name: "get_issue_analytics",
|
|
283
|
+
title: "How often, and to how many people",
|
|
284
|
+
description: "Volume and reach for one issue: occurrences per day, distinct affected users, and a breakdown by release " +
|
|
285
|
+
"and source. Use it to decide whether an issue matters, and to spot the release that introduced it. Note " +
|
|
286
|
+
"that affected users counts only people the customer's app has identified, and the response says what share " +
|
|
287
|
+
"that is.",
|
|
288
|
+
scope: "issues:read",
|
|
289
|
+
toolset: "issues",
|
|
290
|
+
input: {
|
|
291
|
+
issue: issueRef,
|
|
292
|
+
days: z.number().int().min(1).max(90).optional().describe("Default 30."),
|
|
293
|
+
},
|
|
294
|
+
annotations: readOnly(),
|
|
295
|
+
async run(args, { client }) {
|
|
296
|
+
const data = await client.get(`/issues/${encodeURIComponent(args.issue)}/analytics`, { days: args.days });
|
|
297
|
+
const identified = data.identifiedShare === null
|
|
298
|
+
? "no occurrences in this window"
|
|
299
|
+
: data.identifiedShare === 0
|
|
300
|
+
? "none of these occurrences identified a person, so the affected-user count is zero for that reason rather than because nobody was affected"
|
|
301
|
+
: `${Math.round(data.identifiedShare * 100)}% of occurrences identified a person`;
|
|
302
|
+
return toolText(`${quantity(data.events, "occurrence")} in the last ${quantity(data.windowDays, "day")}, ` +
|
|
303
|
+
`${quantity(data.affectedUsers, "identified person", "identified people")} affected.`, `Identification: ${identified}.`, `By day\n${data.eventsByDay.map((row) => `${row.day} ${row.events}`).join("\n")}`, data.byRelease.length > 0
|
|
304
|
+
? `By release\n${data.byRelease.map((row) => `${row.release ?? DASH} ${row.events}`).join("\n")}`
|
|
305
|
+
: null, data.bySource.length > 0
|
|
306
|
+
? `By source\n${data.bySource.map((row) => `${row.source} ${row.events}`).join("\n")}`
|
|
307
|
+
: null);
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
310
|
+
];
|