@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.
@@ -0,0 +1,210 @@
1
+ import { z } from "zod";
2
+ import { DASH, bullets, clip, count, label, num, quantity, toolText, usd, when } from "../format.js";
3
+ import { readOnly, tool } from "../registry.js";
4
+ /**
5
+ * The workspace itself: projects, people, plan limits, and how things are going.
6
+ *
7
+ * NOT IN THE DEFAULT SET, and that is a judgement about attention rather than
8
+ * about value. Somebody debugging reaches for issues and runs on every turn;
9
+ * they ask what plan they are on roughly never, and a tool schema registered
10
+ * for that is schema the model reads on every turn instead.
11
+ */
12
+ export const workspaceTools = [
13
+ tool({
14
+ name: "get_overview",
15
+ title: "How is the workspace doing",
16
+ description: "Four numbers: what is open and how severe, how many occurrences arrived recently, how many runs are going, " +
17
+ "and how many runs have STOPPED and need a person. The last one is the only one that is a task rather than " +
18
+ "a fact — start there.",
19
+ scope: "analytics:read",
20
+ toolset: "workspace",
21
+ input: {
22
+ projectId: z.string().optional(),
23
+ windowHours: z.number().int().min(1).max(720).optional().describe("For the occurrence count. Default 24."),
24
+ },
25
+ annotations: readOnly(),
26
+ async run(args, { client }) {
27
+ const data = await client.get("/overview", args);
28
+ const severities = Object.entries(data.bySeverity)
29
+ .map(([key, value]) => `${label(key)} ${value}`)
30
+ .join(", ");
31
+ return toolText(data.waiting > 0
32
+ ? `${quantity(data.waiting, "run")} stopped and waiting on a person. ${quantity(data.openIssues, "issue")} open.`
33
+ : `${quantity(data.openIssues, "issue")} open, nothing waiting on a person.`, `Open by severity: ${severities || DASH}`, `Occurrences in the last ${data.windowHours}h: ${data.recentEvents}`, `Runs going right now: ${data.runningSnares}`, `Projects: ${data.projects.map((project) => project.name).join(", ") || DASH}`);
34
+ },
35
+ }),
36
+ tool({
37
+ name: "list_projects",
38
+ title: "List projects",
39
+ description: "The projects this token can talk about, with the repository each one watches. Most other tools take a " +
40
+ "project id, and this is where to get one.",
41
+ scope: "projects:read",
42
+ toolset: "workspace",
43
+ input: {},
44
+ annotations: readOnly(),
45
+ async run(_args, { client }) {
46
+ const data = await client.get("/projects");
47
+ if (data.projects.length === 0)
48
+ return "No projects in this workspace yet.";
49
+ return toolText(count(data.projects.length, data.total, "project"), data.projects.map((project) => `${project.name} ${project.repository ?? DASH} project ${project.id}`).join("\n"));
50
+ },
51
+ }),
52
+ tool({
53
+ name: "get_project",
54
+ title: "Get a project's settings",
55
+ description: "The settings that change what you should DO: which repository and branch a fix lands on, whether a run " +
56
+ "opens a pull request or merges on its own, and which sources are wired up. It does not return the SDK key " +
57
+ "or any integration credential.",
58
+ scope: "projects:read",
59
+ toolset: "workspace",
60
+ input: { projectId: z.string() },
61
+ annotations: readOnly(),
62
+ async run(args, { client }) {
63
+ const data = await client.get(`/projects/${encodeURIComponent(args.projectId)}`);
64
+ const project = data.project;
65
+ return toolText(project.name, `Repository: ${project.repository ?? DASH} (${project.branch})`,
66
+ // Spelled out rather than left as an enum, because the difference
67
+ // between "opens a pull request" and "merges on its own" is the
68
+ // difference between a suggestion and a deployment.
69
+ `When a run finishes: ${project.autonomyMode === "AUTO_DEPLOY"
70
+ ? "it merges the fix itself"
71
+ : project.autonomyMode === "FLAG_ONLY"
72
+ ? "it only flags the issue and changes nothing"
73
+ : "it opens a pull request for somebody to review"}${project.autonomyMode === null ? " (the workspace default, nobody has chosen for this project)" : ""}`, `Wired up: ${project.integrations.map((row) => `${row.type} (${label(row.status)})`).join(", ") || "nothing yet"}`);
74
+ },
75
+ }),
76
+ tool({
77
+ name: "list_members",
78
+ title: "List workspace members",
79
+ description: "Who is in the Snare workspace, for assigning issues and knowing who to escalate to. These are NOT the " +
80
+ "customer's own users — for those, use list_affected_users.",
81
+ scope: "members:read",
82
+ // IN THE ISSUES SET, NOT THE WORKSPACE ONE, because `assign_issue` tells
83
+ // the model to call this and `assign_issue` is on by default. It was not,
84
+ // so an agent asked to assign an issue had nowhere to get a name from. It
85
+ // guessed two — the only name it had seen in a timeline, and the email of
86
+ // the person it was talking to — and both were refused. A tool that another
87
+ // default tool's own description points at has to be default too.
88
+ toolset: "issues",
89
+ input: {},
90
+ annotations: readOnly(),
91
+ async run(_args, { client }) {
92
+ const data = await client.get("/members");
93
+ return toolText(count(data.members.length, data.total, "member"), data.members
94
+ .map((person) => `${person.name ?? person.email} ${person.email}${person.isOwner ? " (owner)" : ""}`)
95
+ .join("\n"));
96
+ },
97
+ }),
98
+ tool({
99
+ name: "get_usage",
100
+ title: "What is left on the plan",
101
+ description: "How much of each allowance this workspace has used and how much is left. Worth checking before " +
102
+ "launch_snare or start_local_snare, both of which spend one — it is better to say 'you have two left' than " +
103
+ "to be refused and have to explain why. Note whether a meter overflows: running out either bills the next " +
104
+ "one or refuses it, and those are opposite outcomes.",
105
+ scope: "billing:read",
106
+ // WITH THE TOOLS THAT SPEND, for the same reason. Both `launch_snare`'s
107
+ // description and the server's own startup instructions tell the model to
108
+ // check this before spending a Snare, and neither could: it was in a set
109
+ // nobody turns on.
110
+ toolset: "snares",
111
+ input: {},
112
+ annotations: readOnly(),
113
+ async run(_args, { client }) {
114
+ const data = await client.get("/usage");
115
+ return toolText(
116
+ // A monthly allowance comes back; a trial's does not. Saying which one
117
+ // this is answers what somebody about to spend a Snare is asking.
118
+ data.window === "LIFETIME"
119
+ ? `On the ${label(data.plan)} plan. These allowances are a one-off grant and do not renew.`
120
+ : `On the ${label(data.plan)} plan. The allowances below reset ${when(data.periodEnd)}.`, bullets(
121
+ // What is LEFT comes first, because that is the number the decision
122
+ // turns on. Used-of-included follows as the detail behind it.
123
+ data.meters.map((meter) => meter.unlimited
124
+ ? `${meter.label}: no limit (${num(meter.used)} used)`
125
+ : `${meter.label}: ${num(meter.remaining)} left of ${num(meter.included)}` +
126
+ (meter.overage > 0 ? `, ${num(meter.overage)} over the allowance` : "")), ""), data.overageUsd > 0 ? `Overage so far: ${usd(data.overageUsd)}` : null);
127
+ },
128
+ }),
129
+ ];
130
+ export const setupTools = [
131
+ tool({
132
+ name: "get_sdk_install_prompt",
133
+ title: "Install the Snare SDK",
134
+ description: "The instructions for wiring Snare's SDK into a codebase, with this project's real key filled in. Follow " +
135
+ "them in the repository you have open. Covers every ecosystem Snare supports, so pick the one that matches " +
136
+ "what you are looking at rather than installing all of them.",
137
+ scope: "projects:keys",
138
+ toolset: "setup",
139
+ input: { projectId: z.string() },
140
+ annotations: readOnly(),
141
+ async run(args, { client }) {
142
+ const data = await client.get(`/projects/${encodeURIComponent(args.projectId)}/install-prompt`);
143
+ return toolText(`Installation instructions for ${data.projectName}.`, data.note ?? null, data.prompt);
144
+ },
145
+ }),
146
+ ];
147
+ export const feedbackTools = [
148
+ tool({
149
+ name: "list_feedback",
150
+ title: "List bug reports and feature requests",
151
+ description: "What the customer's own users have sent in, through the feedback widget or the public board. Bug reports " +
152
+ "can become issues; feature requests have votes and never do. Both come back tagged with `kind`. Spam is " +
153
+ "filtered out unless you ask for it.",
154
+ scope: "feedback:read",
155
+ toolset: "feedback",
156
+ input: {
157
+ projectId: z.string(),
158
+ kind: z.enum(["bug_report", "feature_request"]).optional(),
159
+ status: z.enum(["OPEN", "PLANNED", "SHIPPED", "DECLINED"]).optional().describe("Feature requests only."),
160
+ includeSpam: z.boolean().optional(),
161
+ limit: z.number().int().min(1).max(100).optional(),
162
+ },
163
+ annotations: readOnly(),
164
+ async run(args, { client }) {
165
+ const data = await client.get("/feedback", args);
166
+ if (data.feedback.length === 0)
167
+ return "Nothing has been sent in for that project.";
168
+ return toolText(`${quantity(data.feedback.length, "item")}.`, data.feedback
169
+ .map((item) => item.kind === "feature_request"
170
+ ? `request ${quantity(item.votes ?? 0, "vote")} ${label(item.status ?? "OPEN")} ${clip(item.title, 80)} request ${item.id}`
171
+ : // The issue it was grouped into, by name, so somebody who reads a
172
+ // bug report can go straight to the issue it became.
173
+ `bug ${item.issueKey ?? (item.issueId ? "grouped" : "ungrouped")} ${clip(item.title, 80)} report ${item.id}`)
174
+ .join("\n"));
175
+ },
176
+ }),
177
+ tool({
178
+ name: "get_feature_request",
179
+ title: "Get a feature request",
180
+ description: "One feature request in full: what was asked for, how many people voted for it, and where the team has put " +
181
+ "it. Use it before changing a status, so you can say what you are moving and why rather than acting on a " +
182
+ "line from a list.",
183
+ scope: "feedback:read",
184
+ toolset: "feedback",
185
+ input: { featureRequestId: z.string() },
186
+ annotations: readOnly(),
187
+ async run(args, { client }) {
188
+ const data = await client.get(`/feature-requests/${encodeURIComponent(args.featureRequestId)}`);
189
+ const request = data.featureRequest;
190
+ return toolText(`${request.title} — ${label(request.status)}, ${quantity(request.votes, "vote")}`, request.description ?? "No description.", `Asked for ${when(request.createdAt)}.`);
191
+ },
192
+ }),
193
+ tool({
194
+ name: "update_feature_request_status",
195
+ title: "Move a feature request along",
196
+ description: "Set a feature request to open, planned, shipped or declined. Declining does not delete it — somebody asked " +
197
+ "for this and other people voted for it, and the board should show that the team read it and said no.",
198
+ scope: "feedback:write",
199
+ toolset: "feedback",
200
+ input: {
201
+ featureRequestId: z.string(),
202
+ status: z.enum(["OPEN", "PLANNED", "SHIPPED", "DECLINED"]),
203
+ },
204
+ annotations: { idempotentHint: true, openWorldHint: true },
205
+ async run(args, { client }) {
206
+ const data = await client.patch(`/feature-requests/${encodeURIComponent(args.featureRequestId)}`, { status: args.status });
207
+ return data.changed ? `Moved to ${label(data.status)}.` : `Already ${label(data.status)} — nothing changed.`;
208
+ },
209
+ }),
210
+ ];
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Which tools a caller is shown.
3
+ *
4
+ * A DIFFERENT QUESTION FROM WHAT A TOKEN MAY DO, with a different answer.
5
+ * Scopes decide what is permitted; tool sets decide what is worth putting in
6
+ * front of the model. Every registered tool's name, description and input
7
+ * schema is text the model reads on every turn, so a catalogue of fifty tools
8
+ * costs real context on a conversation that was only ever going to call three
9
+ * of them.
10
+ *
11
+ * The default is four sets — the ones somebody debugging reaches for. The rest
12
+ * are there for callers who want them and cost nothing to the ones who do not.
13
+ */
14
+ export declare const TOOLSETS: readonly ["issues", "events", "snares", "local", "feedback", "memory", "workspace", "setup"];
15
+ export type Toolset = (typeof TOOLSETS)[number];
16
+ /** One line each, for the message the server sends a client at startup. */
17
+ export declare const TOOLSET_SUMMARY: Record<Toolset, string>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Which tools a caller is shown.
3
+ *
4
+ * A DIFFERENT QUESTION FROM WHAT A TOKEN MAY DO, with a different answer.
5
+ * Scopes decide what is permitted; tool sets decide what is worth putting in
6
+ * front of the model. Every registered tool's name, description and input
7
+ * schema is text the model reads on every turn, so a catalogue of fifty tools
8
+ * costs real context on a conversation that was only ever going to call three
9
+ * of them.
10
+ *
11
+ * The default is four sets — the ones somebody debugging reaches for. The rest
12
+ * are there for callers who want them and cost nothing to the ones who do not.
13
+ */
14
+ export const TOOLSETS = [
15
+ /** Reading and changing issues. The core of it. */
16
+ "issues",
17
+ /** Occurrences, evidence, traces, replays and the people who hit them. */
18
+ "events",
19
+ /** Launching, watching, steering and stopping runs in the cloud. */
20
+ "snares",
21
+ /** Running a Snare on this machine, with the caller's own agent. */
22
+ "local",
23
+ /** Bug reports and feature requests from the customer's own users. */
24
+ "feedback",
25
+ /** Project rules, standing instructions and what Snare has learned. */
26
+ "memory",
27
+ /** Projects, people, plan limits, and how the workspace is doing. */
28
+ "workspace",
29
+ /** Installing the SDK. */
30
+ "setup",
31
+ ];
32
+ /** One line each, for the message the server sends a client at startup. */
33
+ export const TOOLSET_SUMMARY = {
34
+ issues: "read and change issues",
35
+ events: "occurrences, evidence, traces and affected users",
36
+ snares: "launch, watch, steer and stop fix runs",
37
+ local: "run a Snare here, on your own agent",
38
+ feedback: "bug reports and feature requests",
39
+ memory: "project rules, instructions and what Snare has learned",
40
+ workspace: "projects, people, plan limits and workspace health",
41
+ setup: "install the SDK",
42
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@getsnare/mcp",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for Snare. Read and act on issues, events, traces and fix runs from any MCP client, and run a Snare on your own machine.",
5
+ "homepage": "https://snare.dev",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "snare",
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "issue-tracking",
12
+ "ai-agent",
13
+ "claude",
14
+ "cursor"
15
+ ],
16
+ "type": "module",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "main": "./dist/src/index.js",
21
+ "types": "./dist/src/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/src/index.d.ts",
25
+ "default": "./dist/src/index.js"
26
+ }
27
+ },
28
+ "bin": {
29
+ "snare-mcp": "./dist/bin/snare-mcp.js"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.json",
40
+ "dev": "tsc -p tsconfig.json --watch",
41
+ "typecheck": "tsc -p tsconfig.json --noEmit"
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.29.0",
45
+ "zod": "^3.24.1"
46
+ },
47
+ "devDependencies": {
48
+ "@snare/types": "workspace:*",
49
+ "@types/node": "^22.10.0",
50
+ "tsx": "^4.19.2",
51
+ "typescript": "^5.7.0"
52
+ }
53
+ }