@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,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
|
+
}
|