@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,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a record into the text a model reads.
|
|
3
|
+
*
|
|
4
|
+
* THIS FILE IS WHY THE TOOLS ARE USABLE. The difference between a tool an agent
|
|
5
|
+
* uses well and one it uses badly is mostly the shape of what comes back. Raw
|
|
6
|
+
* JSON rows make a model rebuild the domain on every call: it has to work out
|
|
7
|
+
* that `IN_PROGRESS` is a status, that `null` in one field means nobody and in
|
|
8
|
+
* another means zero, and that 11747 is seconds. Every one of those is a chance
|
|
9
|
+
* to get it wrong in a sentence somebody then reads.
|
|
10
|
+
*
|
|
11
|
+
* So every response is text, led by one line saying what it is, with the
|
|
12
|
+
* structured facts underneath in a fixed shape. The rules below are the same
|
|
13
|
+
* ones the product's own UI follows, for the same reasons — they are in
|
|
14
|
+
* CLAUDE.md and they are not arbitrary.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* No value, and visibly not zero.
|
|
18
|
+
*
|
|
19
|
+
* An em dash rather than "none", "null" or an empty string: a reader (human or
|
|
20
|
+
* model) has to be able to tell "nobody is assigned" from "zero people are
|
|
21
|
+
* assigned", and every text form of absence collides with some real value
|
|
22
|
+
* somewhere.
|
|
23
|
+
*/
|
|
24
|
+
export const DASH = "—";
|
|
25
|
+
/** Sentence case for an enum. `IN_PROGRESS` is a column value, "In progress" is language. */
|
|
26
|
+
export function label(value) {
|
|
27
|
+
if (!value)
|
|
28
|
+
return DASH;
|
|
29
|
+
const spaced = value.replace(/_/g, " ").toLowerCase();
|
|
30
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A duration in human units, never a clock and never raw seconds.
|
|
34
|
+
*
|
|
35
|
+
* Two units at most: "3 hrs 15 min", not "3 hrs 15 min 47 s". The third unit is
|
|
36
|
+
* always noise at the scale the second one is already showing, and a reader
|
|
37
|
+
* skimming a list stops at the first two anyway.
|
|
38
|
+
*/
|
|
39
|
+
export function duration(seconds) {
|
|
40
|
+
if (seconds === null || seconds === undefined || Number.isNaN(seconds))
|
|
41
|
+
return DASH;
|
|
42
|
+
if (seconds < 0)
|
|
43
|
+
return DASH;
|
|
44
|
+
const units = [
|
|
45
|
+
[86_400, "d"],
|
|
46
|
+
[3_600, "hrs"],
|
|
47
|
+
[60, "min"],
|
|
48
|
+
[1, "s"],
|
|
49
|
+
];
|
|
50
|
+
const parts = [];
|
|
51
|
+
let left = Math.floor(seconds);
|
|
52
|
+
for (const [size, name] of units) {
|
|
53
|
+
if (left >= size || (parts.length === 0 && size === 1)) {
|
|
54
|
+
const amount = Math.floor(left / size);
|
|
55
|
+
if (amount > 0 || parts.length === 0) {
|
|
56
|
+
parts.push(`${amount} ${name}`);
|
|
57
|
+
left -= amount * size;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (parts.length === 2)
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
return parts.join(" ");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* How far away a moment is, in either direction.
|
|
67
|
+
*
|
|
68
|
+
* The ISO timestamp is always alongside it: this is the part somebody reads,
|
|
69
|
+
* that is the part they compute with.
|
|
70
|
+
*
|
|
71
|
+
* THE FUTURE IS A REAL CASE HERE and used to be clamped to zero, which rendered
|
|
72
|
+
* every one of them as "just now". Not everything this formats has happened:
|
|
73
|
+
* a billing period ends, a trial expires, a question's window closes. "This
|
|
74
|
+
* period ends 2026-10-07 (just now)" is a sentence that contradicts itself, and
|
|
75
|
+
* the reader it misleads is the one deciding whether they can afford to start
|
|
76
|
+
* a run.
|
|
77
|
+
*/
|
|
78
|
+
export function ago(iso, now = new Date()) {
|
|
79
|
+
if (!iso)
|
|
80
|
+
return DASH;
|
|
81
|
+
const at = Date.parse(iso);
|
|
82
|
+
if (Number.isNaN(at))
|
|
83
|
+
return DASH;
|
|
84
|
+
const seconds = Math.floor((now.getTime() - at) / 1000);
|
|
85
|
+
if (Math.abs(seconds) < 45)
|
|
86
|
+
return "just now";
|
|
87
|
+
return seconds > 0 ? `${duration(seconds)} ago` : `in ${duration(-seconds)}`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A moment, to the minute, without the machine punctuation.
|
|
91
|
+
*
|
|
92
|
+
* `2026-09-06 08:52` rather than `2026-09-06T08:52:58.296Z`. The `T`, the
|
|
93
|
+
* seconds and the milliseconds are three pieces of precision no answer about an
|
|
94
|
+
* issue has ever needed, and in a column of forty rows they are most of the
|
|
95
|
+
* width. Always UTC, because a server-rendered local time is a time in a
|
|
96
|
+
* timezone the reader cannot see.
|
|
97
|
+
*/
|
|
98
|
+
export function stamp(iso) {
|
|
99
|
+
if (!iso)
|
|
100
|
+
return DASH;
|
|
101
|
+
const at = new Date(iso);
|
|
102
|
+
if (Number.isNaN(at.getTime()))
|
|
103
|
+
return DASH;
|
|
104
|
+
return at.toISOString().slice(0, 16).replace("T", " ");
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A moment AND how long ago it was: `2026-09-06 08:52 UTC (1 d ago)`.
|
|
108
|
+
*
|
|
109
|
+
* For detail cards, where there is room for both and both get used. "Last seen"
|
|
110
|
+
* answers two different questions — is this still happening, and exactly when
|
|
111
|
+
* did it last happen — and a bare timestamp only answers the second, leaving
|
|
112
|
+
* the model to do date arithmetic in prose. It gets that wrong often enough
|
|
113
|
+
* that giving it the answer is worth the twelve characters.
|
|
114
|
+
*
|
|
115
|
+
* `ago()` alone is not enough here for the opposite reason: "3 d ago" cannot be
|
|
116
|
+
* quoted into a report, correlated with a deploy, or compared with anything.
|
|
117
|
+
*/
|
|
118
|
+
export function when(iso, now = new Date()) {
|
|
119
|
+
if (!iso)
|
|
120
|
+
return DASH;
|
|
121
|
+
const at = new Date(iso);
|
|
122
|
+
if (Number.isNaN(at.getTime()))
|
|
123
|
+
return DASH;
|
|
124
|
+
return `${stamp(iso)} UTC (${ago(iso, now)})`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A quantity that agrees with its noun: "1 occurrence", "4 occurrences".
|
|
128
|
+
*
|
|
129
|
+
* `count()` above covers "12 of 340 issues". This covers the plain case, which
|
|
130
|
+
* was being written inline as `${n} issues` in six places and produced
|
|
131
|
+
* "1 occurrences", "1 issues" and "1 sessions" in real output. A model reading
|
|
132
|
+
* that is fine; a person reading the sentence the model then writes is reading
|
|
133
|
+
* a mistake the product made, and the fix is one function rather than six
|
|
134
|
+
* ternaries nobody will remember to add the seventh time.
|
|
135
|
+
*/
|
|
136
|
+
export function quantity(amount, noun, plural) {
|
|
137
|
+
return `${amount} ${amount === 1 ? noun : (plural ?? `${noun}s`)}`;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* A count that says what it is out of.
|
|
141
|
+
*
|
|
142
|
+
* "12 of 340 issues", never "12". A list that does not say what it is a slice of
|
|
143
|
+
* is a list whose answer changes meaning depending on something the reader
|
|
144
|
+
* cannot see, and an agent reporting "there are 12 open issues" from a page of
|
|
145
|
+
* 12 has said something false.
|
|
146
|
+
*/
|
|
147
|
+
export function count(shown, total, noun, plural) {
|
|
148
|
+
const word = total === 1 ? noun : (plural ?? `${noun}s`);
|
|
149
|
+
return `${shown} of ${total} ${word}`;
|
|
150
|
+
}
|
|
151
|
+
/** `Label: value`, with absence rendered as the dash rather than omitted. */
|
|
152
|
+
export function line(name, value) {
|
|
153
|
+
if (value === null || value === undefined || value === "")
|
|
154
|
+
return `${name}: ${DASH}`;
|
|
155
|
+
return `${name}: ${value}`;
|
|
156
|
+
}
|
|
157
|
+
/** A block of `Label: value` lines, blanks dropped, in the order given. */
|
|
158
|
+
export function facts(entries) {
|
|
159
|
+
return entries.map(([name, value]) => line(name, value)).join("\n");
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* A tool's whole answer: one summary line, a blank, then the body.
|
|
163
|
+
*
|
|
164
|
+
* The summary exists so a model that reads nothing else still knows what it got.
|
|
165
|
+
* It is also what a client renders in a collapsed tool-call row, which is the
|
|
166
|
+
* only thing many readers will ever see of this.
|
|
167
|
+
*/
|
|
168
|
+
export function toolText(summary, ...blocks) {
|
|
169
|
+
const body = blocks.filter((block) => Boolean(block && block.trim()));
|
|
170
|
+
return body.length === 0 ? summary : `${summary}\n\n${body.join("\n\n")}`;
|
|
171
|
+
}
|
|
172
|
+
/** A bulleted list, or a sentence saying there is nothing. */
|
|
173
|
+
export function bullets(items, emptyMessage) {
|
|
174
|
+
return items.length === 0 ? emptyMessage : items.map((item) => `- ${item}`).join("\n");
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Truncates a long free-text field for a list row.
|
|
178
|
+
*
|
|
179
|
+
* A stack trace or a comment thread pasted whole into a fifty-row listing is
|
|
180
|
+
* the single easiest way to fill a context window with something nobody asked
|
|
181
|
+
* for. Detail calls return the whole thing; lists return this.
|
|
182
|
+
*/
|
|
183
|
+
export function clip(text, max = 200) {
|
|
184
|
+
if (!text)
|
|
185
|
+
return DASH;
|
|
186
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
187
|
+
return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
|
|
188
|
+
}
|
|
189
|
+
/** A percentage from a 0-1 score, or the dash. Confidence and similarity both use this. */
|
|
190
|
+
export function percent(value) {
|
|
191
|
+
if (value === null || value === undefined || Number.isNaN(value))
|
|
192
|
+
return DASH;
|
|
193
|
+
return `${Math.round(value * 100)}%`;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* A whole number with thousands separators.
|
|
197
|
+
*
|
|
198
|
+
* `50000` and `49953` next to each other in one sentence are two five-digit
|
|
199
|
+
* runs a reader has to count digits to tell apart, and a model asked to
|
|
200
|
+
* summarise "47 of 50000 used, 49953 left" reported it back as "49,953 of 50k
|
|
201
|
+
* used" — the opposite of the truth, on a line about somebody's bill.
|
|
202
|
+
*/
|
|
203
|
+
export function num(value) {
|
|
204
|
+
if (value === null || value === undefined || Number.isNaN(value))
|
|
205
|
+
return DASH;
|
|
206
|
+
return value.toLocaleString("en-US");
|
|
207
|
+
}
|
|
208
|
+
/** Money, always with the unit, because a bare number here reads as anything. */
|
|
209
|
+
export function usd(value) {
|
|
210
|
+
if (value === null || value === undefined || Number.isNaN(value))
|
|
211
|
+
return DASH;
|
|
212
|
+
return `$${value.toFixed(2)}`;
|
|
213
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createSnareMcpServer, SnareStartupError, type Identity } from "./server.js";
|
|
2
|
+
export { buildConfig, ConfigError, DEFAULT_BASE_URL, DEFAULT_TOOLSETS, parseToolsets, type McpConfig } from "./config.js";
|
|
3
|
+
export { SnareApiError, SnareClient } from "./client.js";
|
|
4
|
+
export { API_SCOPES, isApiScope, tokenAllows, type ApiScope } from "./scopes.js";
|
|
5
|
+
export { TOOLSETS, TOOLSET_SUMMARY, type Toolset } from "./toolsets.js";
|
|
6
|
+
export { ALL_TOOLS } from "./tools/index.js";
|
|
7
|
+
export { missingScopes, resolveTools, type ToolDef } from "./registry.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createSnareMcpServer, SnareStartupError } from "./server.js";
|
|
2
|
+
export { buildConfig, ConfigError, DEFAULT_BASE_URL, DEFAULT_TOOLSETS, parseToolsets } from "./config.js";
|
|
3
|
+
export { SnareApiError, SnareClient } from "./client.js";
|
|
4
|
+
export { API_SCOPES, isApiScope, tokenAllows } from "./scopes.js";
|
|
5
|
+
export { TOOLSETS, TOOLSET_SUMMARY } from "./toolsets.js";
|
|
6
|
+
export { ALL_TOOLS } from "./tools/index.js";
|
|
7
|
+
export { missingScopes, resolveTools } from "./registry.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Slash commands, in the clients that surface prompts.
|
|
4
|
+
*
|
|
5
|
+
* WHY THESE THREE. A prompt is worth writing when the useful thing to do is a
|
|
6
|
+
* SEQUENCE the model would not assemble on its own. Fixing an issue locally is
|
|
7
|
+
* a loop with a shape; triage is a pass over a list with a decision at each row;
|
|
8
|
+
* explaining is the one where the temptation is to answer before reading the
|
|
9
|
+
* evidence. Everything else is a single tool call and does not need a wrapper.
|
|
10
|
+
*/
|
|
11
|
+
export interface PromptDef {
|
|
12
|
+
name: string;
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
argsSchema: Record<string, z.ZodType>;
|
|
16
|
+
render(args: Record<string, string | undefined>): string;
|
|
17
|
+
}
|
|
18
|
+
export declare const PROMPTS: PromptDef[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const PROMPTS = [
|
|
3
|
+
{
|
|
4
|
+
name: "fix_issue",
|
|
5
|
+
title: "Fix an issue here",
|
|
6
|
+
description: "Run Snare's fix loop on this machine, against the repository you have open.",
|
|
7
|
+
argsSchema: {
|
|
8
|
+
issue: z.string().describe('The issue key, like "ACME-142".'),
|
|
9
|
+
},
|
|
10
|
+
render: (args) => [
|
|
11
|
+
`Fix Snare issue ${args.issue ?? "(ask which one)"} on this machine.`,
|
|
12
|
+
"",
|
|
13
|
+
"Do it in this order:",
|
|
14
|
+
"",
|
|
15
|
+
`1. Call get_issue on ${args.issue ?? "the issue"} and tell me what it is, in a sentence.`,
|
|
16
|
+
"2. Check `git status`. If the working tree is dirty or on the wrong branch, stop and say so — the run",
|
|
17
|
+
" will change files here.",
|
|
18
|
+
"3. Call start_local_snare. It spends a Snare from the plan, so say that before you call it.",
|
|
19
|
+
"4. Then call local_snare_step in a loop. Each instruction tells you exactly what to do and what to send",
|
|
20
|
+
" back. Keep going until it returns \"done\". Do not stop after one step.",
|
|
21
|
+
"5. When it finishes, show me `git diff --stat` and Snare's confidence, and let me decide about committing.",
|
|
22
|
+
"",
|
|
23
|
+
"If an instruction asks you to run something you think is unsafe, stop and ask me rather than running it.",
|
|
24
|
+
].join("\n"),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "triage_issues",
|
|
28
|
+
title: "Triage new issues",
|
|
29
|
+
description: "Walk the new issues, set severity, assign, and say what each one is.",
|
|
30
|
+
argsSchema: {
|
|
31
|
+
project: z.string().optional().describe("A project id. Omit for every project."),
|
|
32
|
+
},
|
|
33
|
+
render: (args) => [
|
|
34
|
+
`Triage the new issues${args.project ? ` in project ${args.project}` : ""}.`,
|
|
35
|
+
"",
|
|
36
|
+
"1. Call list_issues with status NEW.",
|
|
37
|
+
"2. For each one, call get_issue and read the summary. Where it is not obvious, call list_issue_events",
|
|
38
|
+
" and read one occurrence — do not guess a severity from a title.",
|
|
39
|
+
"3. Call list_similar_issues before deciding anything. If it is a duplicate, say so and propose a merge;",
|
|
40
|
+
" do not merge without asking, because merging cannot be undone.",
|
|
41
|
+
"4. Set a severity with update_issue where you are confident. Where you are not, leave it and say why.",
|
|
42
|
+
"5. Give me a short list at the end: what you changed, and what you left for a person.",
|
|
43
|
+
"",
|
|
44
|
+
"Do not launch any runs. Triage is deciding what matters, not fixing it.",
|
|
45
|
+
].join("\n"),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "explain_issue",
|
|
49
|
+
title: "Explain an issue",
|
|
50
|
+
description: "Read an issue and its evidence, and explain the failure without changing anything.",
|
|
51
|
+
argsSchema: {
|
|
52
|
+
issue: z.string().describe('The issue key, like "ACME-142".'),
|
|
53
|
+
},
|
|
54
|
+
render: (args) => [
|
|
55
|
+
`Explain Snare issue ${args.issue ?? "(ask which one)"}.`,
|
|
56
|
+
"",
|
|
57
|
+
"Read before you conclude:",
|
|
58
|
+
"",
|
|
59
|
+
"1. get_issue for what it is and how big it is.",
|
|
60
|
+
"2. get_issue_analytics for whether it is getting worse and which release it started in.",
|
|
61
|
+
"3. list_issue_events, then get_event on one — read the actual stack and breadcrumbs.",
|
|
62
|
+
"4. get_issue_timeline for what anybody has already worked out about it.",
|
|
63
|
+
"",
|
|
64
|
+
"Then explain what is failing and why, citing the file and line from the stack. If the evidence does not",
|
|
65
|
+
"support a cause, say that instead of picking the most likely one — a confident wrong answer here costs",
|
|
66
|
+
"more than no answer.",
|
|
67
|
+
"",
|
|
68
|
+
"Change nothing. No status, no severity, no comment, no run.",
|
|
69
|
+
].join("\n"),
|
|
70
|
+
},
|
|
71
|
+
];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ZodRawShape, z } from "zod";
|
|
2
|
+
import type { SnareClient } from "./client.js";
|
|
3
|
+
import type { McpConfig } from "./config.js";
|
|
4
|
+
import type { ApiScope } from "./scopes.js";
|
|
5
|
+
import type { Toolset } from "./toolsets.js";
|
|
6
|
+
/**
|
|
7
|
+
* What a tool is, in this package.
|
|
8
|
+
*
|
|
9
|
+
* A PLAIN OBJECT RATHER THAN A CALL TO THE SDK, so the catalogue can be
|
|
10
|
+
* inspected before anything is registered. That is what lets the tests assert
|
|
11
|
+
* things about all fifty at once — that every one names a real scope, sits in
|
|
12
|
+
* exactly one set, and carries the right annotations — rather than each tool
|
|
13
|
+
* being a side effect that happened during startup.
|
|
14
|
+
*/
|
|
15
|
+
/** Hints a client uses to decide whether to ask the human before calling. */
|
|
16
|
+
export interface ToolAnnotations {
|
|
17
|
+
/** Reads and changes nothing. A client may auto-approve these. */
|
|
18
|
+
readOnlyHint?: boolean;
|
|
19
|
+
/** Not undone by calling the opposite tool. A client should ask. */
|
|
20
|
+
destructiveHint?: boolean;
|
|
21
|
+
/** Calling it twice has the same effect as once. */
|
|
22
|
+
idempotentHint?: boolean;
|
|
23
|
+
/** Reaches a hosted service rather than a closed world. True for all of these. */
|
|
24
|
+
openWorldHint?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface ToolContext {
|
|
27
|
+
client: SnareClient;
|
|
28
|
+
config: McpConfig;
|
|
29
|
+
}
|
|
30
|
+
export interface ToolDef<Shape extends ZodRawShape = ZodRawShape> {
|
|
31
|
+
name: string;
|
|
32
|
+
/** A short human title for a client's tool list. */
|
|
33
|
+
title: string;
|
|
34
|
+
/**
|
|
35
|
+
* Written for a model deciding whether to call THIS one rather than the
|
|
36
|
+
* neighbour that sounds similar. With a catalogue this size the failure mode
|
|
37
|
+
* is never a missing tool, it is four plausible ones — so every description
|
|
38
|
+
* says when to reach for it instead of the others.
|
|
39
|
+
*/
|
|
40
|
+
description: string;
|
|
41
|
+
scope: ApiScope;
|
|
42
|
+
toolset: Toolset;
|
|
43
|
+
input: Shape;
|
|
44
|
+
annotations: ToolAnnotations;
|
|
45
|
+
run(args: z.objectOutputType<Shape, z.ZodTypeAny>, context: ToolContext): Promise<string>;
|
|
46
|
+
}
|
|
47
|
+
/** Convenience for declaring a tool without losing the shape's inference. */
|
|
48
|
+
export declare function tool<Shape extends ZodRawShape>(def: ToolDef<Shape>): ToolDef<ZodRawShape>;
|
|
49
|
+
/**
|
|
50
|
+
* Which tools this caller actually gets.
|
|
51
|
+
*
|
|
52
|
+
* SET FIRST, THEN SCOPE. A tool the caller has not enabled is not theirs to see
|
|
53
|
+
* even if their token would allow it, and a tool their token cannot use is
|
|
54
|
+
* noise in every prompt for the rest of the session.
|
|
55
|
+
*
|
|
56
|
+
* HIDDEN, NOT DISABLED. A tool that is registered and then refuses is a control
|
|
57
|
+
* that cannot say why — the model calls it, gets a 403, and has no way to tell
|
|
58
|
+
* that from a transient failure. Leaving it out and naming the missing scope in
|
|
59
|
+
* the server's instructions gives the model something it can actually say to
|
|
60
|
+
* the person. This is CLAUDE.md's "a control nobody can use has to say why",
|
|
61
|
+
* applied to a machine reader.
|
|
62
|
+
*/
|
|
63
|
+
export declare function resolveTools(all: readonly ToolDef[], enabled: readonly Toolset[], granted: readonly string[]): ToolDef[];
|
|
64
|
+
/** The scopes a caller would need to unlock everything they have enabled but cannot use. */
|
|
65
|
+
export declare function missingScopes(all: readonly ToolDef[], enabled: readonly Toolset[], granted: readonly string[]): ApiScope[];
|
|
66
|
+
/**
|
|
67
|
+
* Annotation defaults by name, so the whole catalogue cannot disagree with
|
|
68
|
+
* itself.
|
|
69
|
+
*
|
|
70
|
+
* A tool called `list_*`, `get_*` or `search_*` reads. Anything else does not.
|
|
71
|
+
* Stating it once here and asserting it in a test is what stops the fortieth
|
|
72
|
+
* tool being marked read-only because it was copied from the one above it.
|
|
73
|
+
*/
|
|
74
|
+
export declare function readOnly(): ToolAnnotations;
|
|
75
|
+
/** Sets a value rather than appending one, and is safe to repeat. */
|
|
76
|
+
export declare function idempotentWrite(): ToolAnnotations;
|
|
77
|
+
/**
|
|
78
|
+
* Appends, spends money, or is visible to somebody else's team.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately NOT idempotent even where the underlying call is: a client that
|
|
81
|
+
* auto-approves idempotent tools should stop at anything that costs a Snare or
|
|
82
|
+
* puts a sentence on a thread other people read.
|
|
83
|
+
*/
|
|
84
|
+
export declare function write(): ToolAnnotations;
|
|
85
|
+
/** Not undone by calling the opposite tool. Four tools qualify. */
|
|
86
|
+
export declare function destructive(): ToolAnnotations;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { tokenAllows } from "./scopes.js";
|
|
2
|
+
/** Convenience for declaring a tool without losing the shape's inference. */
|
|
3
|
+
export function tool(def) {
|
|
4
|
+
return def;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Which tools this caller actually gets.
|
|
8
|
+
*
|
|
9
|
+
* SET FIRST, THEN SCOPE. A tool the caller has not enabled is not theirs to see
|
|
10
|
+
* even if their token would allow it, and a tool their token cannot use is
|
|
11
|
+
* noise in every prompt for the rest of the session.
|
|
12
|
+
*
|
|
13
|
+
* HIDDEN, NOT DISABLED. A tool that is registered and then refuses is a control
|
|
14
|
+
* that cannot say why — the model calls it, gets a 403, and has no way to tell
|
|
15
|
+
* that from a transient failure. Leaving it out and naming the missing scope in
|
|
16
|
+
* the server's instructions gives the model something it can actually say to
|
|
17
|
+
* the person. This is CLAUDE.md's "a control nobody can use has to say why",
|
|
18
|
+
* applied to a machine reader.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveTools(all, enabled, granted) {
|
|
21
|
+
const sets = new Set(enabled);
|
|
22
|
+
return all.filter((def) => sets.has(def.toolset) && tokenAllows(granted, def.scope));
|
|
23
|
+
}
|
|
24
|
+
/** The scopes a caller would need to unlock everything they have enabled but cannot use. */
|
|
25
|
+
export function missingScopes(all, enabled, granted) {
|
|
26
|
+
const sets = new Set(enabled);
|
|
27
|
+
const missing = new Set();
|
|
28
|
+
for (const def of all) {
|
|
29
|
+
if (sets.has(def.toolset) && !tokenAllows(granted, def.scope))
|
|
30
|
+
missing.add(def.scope);
|
|
31
|
+
}
|
|
32
|
+
return [...missing].sort();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Annotation defaults by name, so the whole catalogue cannot disagree with
|
|
36
|
+
* itself.
|
|
37
|
+
*
|
|
38
|
+
* A tool called `list_*`, `get_*` or `search_*` reads. Anything else does not.
|
|
39
|
+
* Stating it once here and asserting it in a test is what stops the fortieth
|
|
40
|
+
* tool being marked read-only because it was copied from the one above it.
|
|
41
|
+
*/
|
|
42
|
+
export function readOnly() {
|
|
43
|
+
return { readOnlyHint: true, openWorldHint: true };
|
|
44
|
+
}
|
|
45
|
+
/** Sets a value rather than appending one, and is safe to repeat. */
|
|
46
|
+
export function idempotentWrite() {
|
|
47
|
+
return { idempotentHint: true, openWorldHint: true };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Appends, spends money, or is visible to somebody else's team.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately NOT idempotent even where the underlying call is: a client that
|
|
53
|
+
* auto-approves idempotent tools should stop at anything that costs a Snare or
|
|
54
|
+
* puts a sentence on a thread other people read.
|
|
55
|
+
*/
|
|
56
|
+
export function write() {
|
|
57
|
+
return { openWorldHint: true };
|
|
58
|
+
}
|
|
59
|
+
/** Not undone by calling the opposite tool. Four tools qualify. */
|
|
60
|
+
export function destructive() {
|
|
61
|
+
return { destructiveHint: true, openWorldHint: true };
|
|
62
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes the API returns, rendered for a model.
|
|
3
|
+
*
|
|
4
|
+
* TYPED LOOSELY ON PURPOSE. These mirror `/api/v1` responses, and duplicating
|
|
5
|
+
* the server's exact types here would be a second definition to keep in step
|
|
6
|
+
* for no benefit — nothing in this package constructs one, it only reads what
|
|
7
|
+
* came back. What matters is that every field this file names actually exists,
|
|
8
|
+
* which the real-client verification exercises end to end.
|
|
9
|
+
*/
|
|
10
|
+
export interface IssueSummary {
|
|
11
|
+
id: string;
|
|
12
|
+
key?: string | null;
|
|
13
|
+
title: string;
|
|
14
|
+
status: string;
|
|
15
|
+
severity: string | null;
|
|
16
|
+
projectId?: string;
|
|
17
|
+
projectName?: string | null;
|
|
18
|
+
createdAt?: string;
|
|
19
|
+
updatedAt?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface IssueDetail extends IssueSummary {
|
|
22
|
+
key: string;
|
|
23
|
+
assignees: Array<{
|
|
24
|
+
id: string;
|
|
25
|
+
name: string | null;
|
|
26
|
+
email: string;
|
|
27
|
+
}>;
|
|
28
|
+
eventCount: number;
|
|
29
|
+
affectedUsers: number;
|
|
30
|
+
firstSeenAt: string | null;
|
|
31
|
+
lastSeenAt: string | null;
|
|
32
|
+
starred: boolean;
|
|
33
|
+
archived: boolean;
|
|
34
|
+
excluded: boolean;
|
|
35
|
+
problemSummary: string | null;
|
|
36
|
+
impactSummary: string | null;
|
|
37
|
+
latestRun: {
|
|
38
|
+
id: string;
|
|
39
|
+
mode: string;
|
|
40
|
+
status: string;
|
|
41
|
+
stage: string | null;
|
|
42
|
+
confidence: number | null;
|
|
43
|
+
} | null;
|
|
44
|
+
pullRequests: Array<{
|
|
45
|
+
url: string;
|
|
46
|
+
number: number;
|
|
47
|
+
summary: string;
|
|
48
|
+
}>;
|
|
49
|
+
url: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* One issue on one line.
|
|
53
|
+
*
|
|
54
|
+
* THE KEY LEADS, because it is the name a person uses and the thing a caller
|
|
55
|
+
* will be given back. Severity next, because it is the first thing anybody
|
|
56
|
+
* sorts by. The title last, because it is the only variable-length part and a
|
|
57
|
+
* column of rows is only scannable if what precedes the ragged part is fixed.
|
|
58
|
+
*/
|
|
59
|
+
export declare function issueLine(issue: IssueSummary): string;
|
|
60
|
+
export declare function issueList(issues: readonly IssueSummary[], total: number, scope: string): string;
|
|
61
|
+
/** The whole detail card, in the order somebody asks the questions. */
|
|
62
|
+
export declare function issueDetail(issue: IssueDetail): string;
|
|
63
|
+
export interface SnareRun {
|
|
64
|
+
id: string;
|
|
65
|
+
issueId: string;
|
|
66
|
+
issueKey: string | null;
|
|
67
|
+
issueTitle: string;
|
|
68
|
+
mode: string;
|
|
69
|
+
origin: string;
|
|
70
|
+
status: string;
|
|
71
|
+
running: boolean;
|
|
72
|
+
stage: string | null;
|
|
73
|
+
stageIteration: number | null;
|
|
74
|
+
confidence: number | null;
|
|
75
|
+
costUsd: number | null;
|
|
76
|
+
filesTouched: string[];
|
|
77
|
+
haltReason: string | null;
|
|
78
|
+
escalationAction: string | null;
|
|
79
|
+
startedAt: string;
|
|
80
|
+
completedAt: string | null;
|
|
81
|
+
waitingOn: {
|
|
82
|
+
kind: "question" | "approval";
|
|
83
|
+
id: string;
|
|
84
|
+
prompt: string;
|
|
85
|
+
} | null;
|
|
86
|
+
pullRequest: {
|
|
87
|
+
url: string;
|
|
88
|
+
number: number;
|
|
89
|
+
summary: string;
|
|
90
|
+
status: string;
|
|
91
|
+
} | null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A run, with the thing to do about it first.
|
|
95
|
+
*
|
|
96
|
+
* `waitingOn` IS RENDERED AT THE TOP AND AS AN INSTRUCTION, not as a field
|
|
97
|
+
* among fields. A run that has stopped to ask a question will otherwise sit
|
|
98
|
+
* there while an agent polls it, and the two facts that matter — that it is
|
|
99
|
+
* waiting, and which tool answers it — have to be impossible to skim past.
|
|
100
|
+
*/
|
|
101
|
+
export declare function snareRun(run: SnareRun): string;
|