@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,111 @@
1
+ import { DASH, bullets, clip, count, facts, label, percent, usd, when } from "./format.js";
2
+ /**
3
+ * One issue on one line.
4
+ *
5
+ * THE KEY LEADS, because it is the name a person uses and the thing a caller
6
+ * will be given back. Severity next, because it is the first thing anybody
7
+ * sorts by. The title last, because it is the only variable-length part and a
8
+ * column of rows is only scannable if what precedes the ragged part is fixed.
9
+ */
10
+ export function issueLine(issue) {
11
+ const key = issue.key ?? issue.id;
12
+ const severity = issue.severity ? label(issue.severity) : DASH;
13
+ return `${key} ${severity} ${label(issue.status)} ${clip(issue.title, 100)}`;
14
+ }
15
+ export function issueList(issues, total, scope) {
16
+ if (issues.length === 0) {
17
+ // Names the scope that produced it, because the scope is the thing the
18
+ // reader can change. "No issues" is a fact about the filter as often as it
19
+ // is a fact about the workspace.
20
+ return `No issues ${scope}.`;
21
+ }
22
+ return `${count(issues.length, total, "issue")} ${scope}\n\n${issues.map(issueLine).join("\n")}`;
23
+ }
24
+ /** The whole detail card, in the order somebody asks the questions. */
25
+ export function issueDetail(issue) {
26
+ const people = issue.assignees.length === 0
27
+ ? DASH
28
+ : issue.assignees.map((person) => person.name ?? person.email).join(", ");
29
+ const run = issue.latestRun
30
+ ? `${label(issue.latestRun.mode)} run, ${label(issue.latestRun.status)}` +
31
+ (issue.latestRun.stage ? ` at ${label(issue.latestRun.stage)}` : "") +
32
+ (issue.latestRun.confidence !== null ? `, confidence ${percent(issue.latestRun.confidence)}` : "")
33
+ : DASH;
34
+ const state = [
35
+ issue.excluded ? "in the exclusion zone" : null,
36
+ issue.archived ? "archived" : null,
37
+ issue.starred ? "starred" : null,
38
+ ].filter(Boolean);
39
+ return [
40
+ `${issue.key} ${issue.title}`,
41
+ "",
42
+ facts([
43
+ ["Severity", issue.severity ? label(issue.severity) : null],
44
+ ["Status", label(issue.status)],
45
+ ["Assigned to", people],
46
+ ["Events", issue.eventCount],
47
+ ["Affected users", issue.affectedUsers],
48
+ ["First seen", when(issue.firstSeenAt)],
49
+ ["Last seen", when(issue.lastSeenAt)],
50
+ ["Project", issue.projectName ?? issue.projectId ?? null],
51
+ ["Latest run", run],
52
+ ["Also", state.length > 0 ? state.join(", ") : null],
53
+ ["Link", issue.url],
54
+ ]),
55
+ issue.problemSummary ? `\nWhat is happening\n${issue.problemSummary}` : "",
56
+ issue.impactSummary ? `\nWho it affects\n${issue.impactSummary}` : "",
57
+ issue.pullRequests.length > 0
58
+ ? `\nPull requests\n${bullets(issue.pullRequests.map((pr) => `#${pr.number} ${clip(pr.summary, 80)} — ${pr.url}`), "")}`
59
+ : "",
60
+ ]
61
+ .filter(Boolean)
62
+ .join("\n");
63
+ }
64
+ /**
65
+ * A run, with the thing to do about it first.
66
+ *
67
+ * `waitingOn` IS RENDERED AT THE TOP AND AS AN INSTRUCTION, not as a field
68
+ * among fields. A run that has stopped to ask a question will otherwise sit
69
+ * there while an agent polls it, and the two facts that matter — that it is
70
+ * waiting, and which tool answers it — have to be impossible to skim past.
71
+ */
72
+ export function snareRun(run) {
73
+ const heading = run.waitingOn
74
+ ? run.waitingOn.kind === "question"
75
+ ? `WAITING FOR AN ANSWER. Snare asked: "${run.waitingOn.prompt}"\nAnswer it with answer_snare_question (id ${run.waitingOn.id}), or it will proceed on its own default.`
76
+ : `WAITING FOR APPROVAL to ${run.waitingOn.prompt}\nDecide it with decide_snare_approval (id ${run.waitingOn.id}), or it will proceed without.`
77
+ : run.running
78
+ ? `Running${run.stage ? `, at ${label(run.stage)}` : ""}.`
79
+ : `Finished: ${label(run.status)}.`;
80
+ return [
81
+ heading,
82
+ "",
83
+ facts([
84
+ ["Run", run.id],
85
+ ["Issue", `${run.issueTitle} (${run.issueKey ?? run.issueId})`],
86
+ ["Mode", label(run.mode)],
87
+ // Said plainly, because the cost below means something different for each
88
+ // and a reader seeing a near-zero figure on a local run would otherwise
89
+ // conclude the run was free rather than that it was theirs to pay for.
90
+ ["Ran", run.origin === "LOCAL" ? "on this machine" : "in Snare's cloud"],
91
+ ["Stage", run.stage ? `${label(run.stage)}${run.stageIteration ? ` (pass ${run.stageIteration})` : ""}` : null],
92
+ ["Confidence", run.confidence !== null ? percent(run.confidence) : null],
93
+ [
94
+ run.origin === "LOCAL" ? "Snare's own cost" : "Cost",
95
+ run.costUsd !== null ? usd(run.costUsd) : null,
96
+ ],
97
+ ["Started", when(run.startedAt)],
98
+ ["Finished", when(run.completedAt)],
99
+ ["Stopped because", run.haltReason],
100
+ ["Needs a person to", run.escalationAction],
101
+ ]),
102
+ run.filesTouched.length > 0
103
+ ? `\nFiles changed\n${bullets(run.filesTouched, "")}`
104
+ : "",
105
+ run.pullRequest
106
+ ? `\nPull request\n#${run.pullRequest.number} (${label(run.pullRequest.status)}) ${run.pullRequest.url}\n${clip(run.pullRequest.summary, 300)}`
107
+ : "",
108
+ ]
109
+ .filter(Boolean)
110
+ .join("\n");
111
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What a token is allowed to do — a copy, kept honest by a test.
3
+ *
4
+ * WHY A COPY. This package is published to npm and installed on customers'
5
+ * machines. `@snare/types` is a private workspace package, so importing it here
6
+ * would either break `npm install` or force a bundler into a repo that has
7
+ * deliberately never had one. Everything else in this package obeys the same
8
+ * rule: `@modelcontextprotocol/sdk` and `zod` are the only runtime
9
+ * dependencies, and nothing here can reach a database.
10
+ *
11
+ * WHY THE COPY IS SAFE. `scopes-parity.test.ts` imports both this list and the
12
+ * canonical one and asserts they are identical, in order. That test runs in the
13
+ * monorepo and never ships, so drift is a failed build rather than a token that
14
+ * silently loses a capability.
15
+ */
16
+ export declare const API_SCOPES: readonly ["issues:read", "issues:write", "issues:comment", "issues:assign", "issues:group", "issues:delete", "projects:read", "projects:memory", "projects:keys", "analytics:read", "snares:read", "snares:launch", "snares:respond", "snares:local", "feedback:read", "feedback:write", "members:read", "billing:read", "reports:write", "releases:write"];
17
+ export type ApiScope = (typeof API_SCOPES)[number];
18
+ /** Exact match, no hierarchy — the same rule the server enforces. */
19
+ export declare function tokenAllows(scopes: readonly string[], required: ApiScope): boolean;
20
+ export declare function isApiScope(value: string): value is ApiScope;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * What a token is allowed to do — a copy, kept honest by a test.
3
+ *
4
+ * WHY A COPY. This package is published to npm and installed on customers'
5
+ * machines. `@snare/types` is a private workspace package, so importing it here
6
+ * would either break `npm install` or force a bundler into a repo that has
7
+ * deliberately never had one. Everything else in this package obeys the same
8
+ * rule: `@modelcontextprotocol/sdk` and `zod` are the only runtime
9
+ * dependencies, and nothing here can reach a database.
10
+ *
11
+ * WHY THE COPY IS SAFE. `scopes-parity.test.ts` imports both this list and the
12
+ * canonical one and asserts they are identical, in order. That test runs in the
13
+ * monorepo and never ships, so drift is a failed build rather than a token that
14
+ * silently loses a capability.
15
+ */
16
+ export const API_SCOPES = [
17
+ "issues:read",
18
+ "issues:write",
19
+ "issues:comment",
20
+ "issues:assign",
21
+ "issues:group",
22
+ "issues:delete",
23
+ "projects:read",
24
+ "projects:memory",
25
+ "projects:keys",
26
+ "analytics:read",
27
+ "snares:read",
28
+ "snares:launch",
29
+ "snares:respond",
30
+ "snares:local",
31
+ "feedback:read",
32
+ "feedback:write",
33
+ "members:read",
34
+ "billing:read",
35
+ "reports:write",
36
+ "releases:write",
37
+ ];
38
+ /** Exact match, no hierarchy — the same rule the server enforces. */
39
+ export function tokenAllows(scopes, required) {
40
+ return scopes.includes(required);
41
+ }
42
+ export function isApiScope(value) {
43
+ return API_SCOPES.includes(value);
44
+ }
@@ -0,0 +1,44 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { McpConfig } from "./config.js";
3
+ /** What the token turned out to be, learned once at startup. */
4
+ export interface Identity {
5
+ scopes: string[];
6
+ organization: {
7
+ id: string;
8
+ name: string;
9
+ slug: string;
10
+ plan: string;
11
+ } | null;
12
+ }
13
+ /**
14
+ * Why the server could not be built.
15
+ *
16
+ * TWO CAUSES THAT NEED DIFFERENT ANSWERS. `token` means the credential is bad
17
+ * and the person has to make a new one. `unreachable` means Snare could not be
18
+ * called at all, which is a server-side problem and one the caller should
19
+ * retry. The remote transport used to answer 401 for both, on the reasoning
20
+ * that neither is retryable — which is exactly backwards for the second, and a
21
+ * 401 makes an MCP client prompt for re-authentication that can never help.
22
+ */
23
+ export declare class SnareStartupError extends Error {
24
+ readonly reason: "token" | "unreachable";
25
+ constructor(reason: "token" | "unreachable", message: string);
26
+ }
27
+ /**
28
+ * Builds the server this token should see.
29
+ *
30
+ * IT ASKS THE API WHO IT IS FIRST. That one call is what makes the catalogue
31
+ * honest: a tool whose scope the token lacks is never registered, so the model
32
+ * never chooses it, never gets a 403 back, and never has to guess whether that
33
+ * 403 was permanent or a blip. The alternative — register everything and refuse
34
+ * at call time — is a control that cannot say why.
35
+ *
36
+ * A FAILURE HERE IS FATAL AND SAYS SO. If the token is bad or the API is
37
+ * unreachable, there is no useful degraded mode: a server with no tools is
38
+ * indistinguishable from a broken client, and the person is better served by
39
+ * one clear line on stderr.
40
+ */
41
+ export declare function createSnareMcpServer(config: McpConfig, fetchImpl?: typeof globalThis.fetch): Promise<{
42
+ server: McpServer;
43
+ identity: Identity;
44
+ }>;
@@ -0,0 +1,241 @@
1
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { SnareApiError, SnareClient } from "./client.js";
3
+ import { label } from "./format.js";
4
+ import { PROMPTS } from "./prompts.js";
5
+ import { missingScopes, resolveTools } from "./registry.js";
6
+ import { TOOLSET_SUMMARY, TOOLSETS } from "./toolsets.js";
7
+ import { ALL_TOOLS } from "./tools/index.js";
8
+ /**
9
+ * Why the server could not be built.
10
+ *
11
+ * TWO CAUSES THAT NEED DIFFERENT ANSWERS. `token` means the credential is bad
12
+ * and the person has to make a new one. `unreachable` means Snare could not be
13
+ * called at all, which is a server-side problem and one the caller should
14
+ * retry. The remote transport used to answer 401 for both, on the reasoning
15
+ * that neither is retryable — which is exactly backwards for the second, and a
16
+ * 401 makes an MCP client prompt for re-authentication that can never help.
17
+ */
18
+ export class SnareStartupError extends Error {
19
+ reason;
20
+ constructor(reason, message) {
21
+ super(message);
22
+ this.reason = reason;
23
+ this.name = "SnareStartupError";
24
+ }
25
+ }
26
+ /**
27
+ * Builds the server this token should see.
28
+ *
29
+ * IT ASKS THE API WHO IT IS FIRST. That one call is what makes the catalogue
30
+ * honest: a tool whose scope the token lacks is never registered, so the model
31
+ * never chooses it, never gets a 403 back, and never has to guess whether that
32
+ * 403 was permanent or a blip. The alternative — register everything and refuse
33
+ * at call time — is a control that cannot say why.
34
+ *
35
+ * A FAILURE HERE IS FATAL AND SAYS SO. If the token is bad or the API is
36
+ * unreachable, there is no useful degraded mode: a server with no tools is
37
+ * indistinguishable from a broken client, and the person is better served by
38
+ * one clear line on stderr.
39
+ */
40
+ export async function createSnareMcpServer(config, fetchImpl) {
41
+ const client = new SnareClient(config, fetchImpl);
42
+ let identity;
43
+ try {
44
+ identity = await client.get("/me");
45
+ }
46
+ catch (err) {
47
+ if (err instanceof SnareApiError && err.status === 401) {
48
+ throw new SnareStartupError("token", "Snare rejected that token. Check SNARE_API_TOKEN, or make a new one at " +
49
+ `${config.dashboardUrl}/settings/api-tokens.`);
50
+ }
51
+ // The underlying message only when it adds something. The client's own
52
+ // wording for a dead socket is "Could not reach Snare.", so appending it
53
+ // here printed that sentence twice in a row on the one failure a person is
54
+ // most likely to hit — a wrong address in their config.
55
+ const detail = err instanceof Error ? err.message : "";
56
+ const adds = detail && !detail.startsWith("Could not reach Snare");
57
+ throw new SnareStartupError("unreachable", `Could not reach Snare at ${config.baseUrl}.${adds ? ` ${detail}` : ""} Check SNARE_BASE_URL, or try again.`);
58
+ }
59
+ const tools = resolveTools(ALL_TOOLS, config.toolsets, identity.scopes);
60
+ const missing = missingScopes(ALL_TOOLS, config.toolsets, identity.scopes);
61
+ const server = new McpServer({ name: "snare", version: "0.1.0" }, { instructions: instructionsFor(identity, config, tools.length, missing) });
62
+ for (const def of tools) {
63
+ server.registerTool(def.name, {
64
+ title: def.title,
65
+ description: def.description,
66
+ inputSchema: def.input,
67
+ annotations: def.annotations,
68
+ },
69
+ // Cast because each tool declares its own shape and the SDK's callback
70
+ // type is generic over one shape at a time. The shape is enforced by the
71
+ // SDK before this runs, so the argument really is what the tool declared.
72
+ (async (args) => {
73
+ try {
74
+ const text = await def.run(args, { client, config });
75
+ return { content: [{ type: "text", text }] };
76
+ }
77
+ catch (err) {
78
+ // The server's own sentence, marked as an error so the model treats
79
+ // it as a failed call rather than as content. Routes are written to
80
+ // be read — a missing scope names the scope, a conflict says what is
81
+ // in the way — so replacing this with a generic message would throw
82
+ // away the only useful part.
83
+ const message = err instanceof SnareApiError
84
+ ? err.message
85
+ : err instanceof Error
86
+ ? err.message
87
+ : "Something went wrong.";
88
+ return { content: [{ type: "text", text: message }], isError: true };
89
+ }
90
+ }));
91
+ }
92
+ for (const prompt of PROMPTS) {
93
+ server.registerPrompt(prompt.name, {
94
+ title: prompt.title,
95
+ description: prompt.description,
96
+ argsSchema: prompt.argsSchema,
97
+ }, ((args) => ({
98
+ messages: [
99
+ {
100
+ role: "user",
101
+ content: { type: "text", text: prompt.render(args ?? {}) },
102
+ },
103
+ ],
104
+ })));
105
+ }
106
+ registerResources(server, client, identity.scopes);
107
+ return { server, identity };
108
+ }
109
+ /** How many issues the resource listing offers. See `registerResources`. */
110
+ const LISTED_ISSUES = 20;
111
+ /**
112
+ * An addressable issue, so a client that lets somebody attach context by URI
113
+ * can reach the thing people paste links to.
114
+ *
115
+ * A `ResourceTemplate`, NOT the template string. `registerResource` has two
116
+ * overloads and they differ by exactly this: given a string it registers a
117
+ * FIXED resource at that literal URI, so passing `"snare://issue/{key}"`
118
+ * published a resource whose address was those characters, braces included.
119
+ * Nothing matched `snare://issue/ACME-142`, and reading the one that was
120
+ * published asked the API for an issue called `{key}`. It typechecked, it
121
+ * connected, and the stdio verification passed, because that pass only ever
122
+ * looked at tools and prompts. It looks at resources now.
123
+ *
124
+ * IT LISTS A HANDFUL, AND THAT IS NOT DECORATION. `list` was `undefined` on
125
+ * the reasoning that the set is every issue in the workspace and a listing
126
+ * would be a second unpaginated `list_issues` on connect. What that overlooked
127
+ * is where a template is published: `resources/templates/list`, a different
128
+ * call from `resources/list`. A client that only makes the second one — Claude
129
+ * Code is one — shows "No resources found", so the resource existed and
130
+ * nothing could discover it. Watched live: reading `snare://issue/ACME-1`
131
+ * worked while listing resources returned zero.
132
+ *
133
+ * So it lists the twenty most recent issues, which is one bounded call to the
134
+ * same route `list_issues` uses, and keeps the template for every other key.
135
+ * Twenty is an entry point rather than an inventory: a client that wants the
136
+ * rest has a tool for it, and a workspace with four thousand issues must not
137
+ * turn a connect into a four-thousand-row fetch.
138
+ *
139
+ * A FAILED LISTING IS AN EMPTY LISTING. This runs whenever a client feels like
140
+ * it, including at connect, and a workspace that cannot be read right now is
141
+ * not a reason to fail the whole connection.
142
+ *
143
+ * Deliberately thin: it returns what `get_issue` returns. A resource that
144
+ * answered differently from the tool covering the same thing would be a second
145
+ * source of truth for no benefit.
146
+ *
147
+ * NOT REGISTERED AT ALL WITHOUT `issues:read`, for the same reason a tool whose
148
+ * scope is missing is never registered: a resource that is advertised and then
149
+ * refuses is a control that cannot say why.
150
+ */
151
+ function registerResources(server, client, scopes) {
152
+ if (!scopes.includes("issues:read"))
153
+ return;
154
+ server.registerResource("issue", new ResourceTemplate("snare://issue/{key}", {
155
+ list: async () => {
156
+ try {
157
+ const data = await client.get("/issues", { limit: LISTED_ISSUES });
158
+ return {
159
+ resources: data.issues
160
+ // An issue with no key cannot be addressed, so it is not offered.
161
+ .filter((issue) => Boolean(issue.key))
162
+ .map((issue) => ({
163
+ uri: `snare://issue/${issue.key}`,
164
+ name: `${issue.key} ${issue.title}`,
165
+ description: `${label(issue.status)}${issue.severity ? `, ${label(issue.severity)} severity` : ""}.`,
166
+ mimeType: "application/json",
167
+ })),
168
+ };
169
+ }
170
+ catch {
171
+ return { resources: [] };
172
+ }
173
+ },
174
+ }), {
175
+ title: "Snare issue",
176
+ description: "One issue by its key, like snare://issue/ACME-142.",
177
+ mimeType: "application/json",
178
+ }, (async (uri, variables) => {
179
+ const raw = variables.key;
180
+ const key = Array.isArray(raw) ? raw[0] : raw;
181
+ if (!key)
182
+ throw new Error("That URI names no issue. Use snare://issue/ACME-142.");
183
+ const data = await client.get(`/issues/${encodeURIComponent(key)}`);
184
+ return {
185
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(data, null, 2) }],
186
+ };
187
+ }));
188
+ }
189
+ /**
190
+ * What the client is told about this server before anything is called.
191
+ *
192
+ * NAMES WHAT IS MISSING. A model that expected a tool and cannot find it will
193
+ * otherwise say "Snare does not support that", which is wrong and unhelpful.
194
+ * Told which scope is absent, it can say "this token cannot change issues; add
195
+ * issues:write to it" — which is the actual answer.
196
+ *
197
+ * IT ALSO SAYS WHAT THE RESOURCE LIST IS NOT. That list is the twenty most
198
+ * recent issues, and a client shows it without saying so, which reads as the
199
+ * whole workspace. A model that believes it has seen every issue will answer
200
+ * questions about the set from twenty rows. One sentence here is the difference
201
+ * between an entry point and a wrong answer.
202
+ */
203
+ function instructionsFor(identity, config, toolCount, missing) {
204
+ const org = identity.organization;
205
+ const sets = config.toolsets.map((set) => `${set} (${TOOLSET_SUMMARY[set]})`).join(", ");
206
+ const off = TOOLSETS.filter((set) => !config.toolsets.includes(set)).map((set) => `${set} (${TOOLSET_SUMMARY[set]})`);
207
+ return [
208
+ `Snare${org ? ` — the ${org.name} workspace` : ""}.`,
209
+ "",
210
+ "Snare tracks ISSUES. One occurrence of an issue is an EVENT; the set of events sharing an id is a TRACE.",
211
+ "Use those words rather than calling any of them an error.",
212
+ "",
213
+ `${toolCount} tools available, from these sets: ${sets}.`,
214
+ // NAMING WHAT IS OFF, because a model cannot tell "Snare does not do this"
215
+ // from "this set is not switched on", and will say the first. Asked what
216
+ // people were requesting, an agent answered "there is no feature-request or
217
+ // voting surface in Snare" — a false statement about the product, made
218
+ // confidently, because the feedback set was off and nothing said so.
219
+ ...(off.length > 0
220
+ ? [
221
+ `Switched off, but Snare HAS them: ${off.join(", ")}. If somebody asks for one of these, say it needs ` +
222
+ `turning on rather than that Snare cannot do it — SNARE_MCP_TOOLSETS=all, or add the set by name.`,
223
+ ]
224
+ : []),
225
+ missing.length > 0
226
+ ? `Some tools are hidden because this token lacks: ${missing.join(", ")}. If somebody asks for something ` +
227
+ `you cannot do, name the scope rather than saying Snare cannot do it. Tokens are managed at ` +
228
+ `${config.dashboardUrl}/settings/api-tokens.`
229
+ : "This token has every scope the enabled sets need.",
230
+ "",
231
+ ...(identity.scopes.includes("issues:read")
232
+ ? [
233
+ `The resource list holds the ${LISTED_ISSUES} most recent issues, not all of them. Use list_issues or ` +
234
+ "search_issues to reach the rest, and snare://issue/<key> to address any one.",
235
+ "",
236
+ ]
237
+ : []),
238
+ "Two tools spend money from the workspace's plan: launch_snare and start_local_snare. Say so before calling",
239
+ "either, and check get_usage if you are unsure there is allowance left.",
240
+ ].join("\n");
241
+ }
@@ -0,0 +1,2 @@
1
+ import { type ToolDef } from "../registry.js";
2
+ export declare const eventTools: ToolDef[];